Files
LangBot/web/tests/e2e/provider-delete-footer.spec.ts
T
Hyu 0f216a0d4d feat(provider): support Codex subscriptions with ChatGPT sign-in (#2513)
* feat(provider): support Codex subscriptions with ChatGPT sign-in

* style: format Codex live integration test

* fix(provider): preserve Codex identity in temporary model tests

* fix(web): portal provider selector without dialog overflow

* fix(web): allow native scrolling in provider dropdown

* fix(provider): surface safe Codex quota and upstream errors

* fix(web): provide reliable Codex copy feedback in dialogs

* feat(provider): confirm cascade deletion from edit dialog

* fix(persistence): discard connections after failed commit

* fix(web): polish provider loading and confirmation motion

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-06 23:31:02 +08:00

341 lines
12 KiB
TypeScript

import { expect, test, type Page, type Route } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
// UI fixtures only: no real provider/model deletion or subscription authentication.
async function fixture(page: Page, requester = 'openai', empty = false) {
await installLangBotApiMocks(page, { authenticated: true });
const provider = {
uuid: 'provider-delete-fixture',
name: 'Delete fixture provider',
requester,
base_url: 'https://example.test/v1',
api_keys: [],
llm_count: empty ? 0 : 1,
embedding_count: empty ? 0 : 1,
rerank_count: empty ? 0 : 1,
};
const state = {
deleted: false,
fail: false,
deletes: [] as string[],
reads: [] as string[],
release: undefined as (() => void) | undefined,
hold: false,
};
const ok = (route: Route, data: unknown) =>
route.fulfill({ json: { code: 0, data } });
await page.route('**/api/v1/provider/**', async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
const method = route.request().method();
if (method === 'DELETE') {
state.deletes.push(path + url.search);
if (state.hold)
await new Promise<void>((resolve) => {
state.release = resolve;
});
if (state.fail)
return route.fulfill({
status: 409,
json: { code: 409, msg: 'Fixture deletion blocked; try again.' },
});
state.deleted = true;
return ok(route, {});
}
if (path.endsWith('/icon'))
return route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
});
if (path.endsWith('/requesters'))
return ok(route, {
requesters: ['openai', 'openai-codex'].map((name) => ({
name,
label: { en_US: name },
description: { en_US: '' },
spec: {
provider_category: 'manufacturer',
support_type: ['llm', 'embedding', 'rerank'],
config: [],
},
})),
});
if (method === 'GET') state.reads.push(path + url.search);
if (path.endsWith('/providers'))
return ok(route, { providers: state.deleted ? [] : [provider] });
if (path.endsWith('/codex/status'))
return ok(route, {
status: 'connected',
connected: true,
expires_at: null,
});
if (path.includes('/models/')) {
const type = path.split('/').pop();
return ok(route, {
models: state.deleted
? []
: [
{
uuid: `fixture-${type}`,
name: `Fixture ${type} model`,
provider_uuid: provider.uuid,
provider,
abilities: [],
extra_args: {},
},
],
});
}
if (path.endsWith(provider.uuid)) return ok(route, { provider });
return ok(route, {});
});
await page.goto('/home/bots');
await page.getByRole('button', { name: 'Models', exact: true }).click();
return state;
}
const editDialog = (page: Page) =>
page.locator('[role="dialog"]').filter({
has: page.locator('[data-slot="dialog-title"]', {
hasText: /^Edit Provider$/,
}),
});
async function edit(page: Page) {
const card = page
.locator('[data-slot="card"]')
.filter({ hasText: 'Delete fixture provider' });
await card.getByRole('button', { name: 'Expand', exact: true }).click();
await expect(
card.getByText('Fixture llm model', { exact: true }),
).toBeVisible();
await card
.locator('button')
.filter({ has: page.locator('svg.lucide-settings') })
.click();
await expect(editDialog(page).locator('input[name="name"]')).toHaveValue(
'Delete fixture provider',
);
}
for (const width of [1280, 320]) {
test(`confirmation stays centered throughout entry (${width}px)`, async ({
page,
}) => {
const state = await fixture(page);
await edit(page);
await page.setViewportSize({ width, height: 900 });
// Trigger without Playwright's post-click wait so the browser animation is
// still live. Sample its actual keyframes, not only the final screenshot.
await editDialog(page)
.getByRole('button', { name: 'Delete', exact: true })
.evaluate((el) => (el as HTMLButtonElement).click());
const confirmation = page.getByRole('alertdialog');
for (const phase of ['entry']) {
const samples = await confirmation.evaluate(async (el) => {
const animations = el.getAnimations();
if (!animations.length)
throw new Error('Expected the real dialog animation');
await Promise.all(animations.map((a) => a.ready));
animations.forEach((a) => a.pause());
const samples = [0, 0.25, 0.5, 0.75, 0.99].map((fraction) => {
animations.forEach((a) => {
a.currentTime = Number(a.effect!.getTiming().duration) * fraction;
});
const r = el.getBoundingClientRect();
return {
x: r.x + r.width / 2,
y: r.y + r.height / 2,
left: r.left,
right: r.right,
};
});
animations.forEach((a) => a.finish());
return samples;
});
for (const sample of samples) {
expect(
Math.abs(sample.x - width / 2),
`${phase} horizontal center`,
).toBeLessThan(1);
expect(
Math.abs(sample.y - 450),
`${phase} vertical center`,
).toBeLessThan(1);
expect(sample.left).toBeGreaterThanOrEqual(0);
expect(sample.right).toBeLessThanOrEqual(width);
}
}
await confirmation
.getByRole('button', { name: 'Cancel', exact: true })
.click();
await expect(confirmation).toHaveCount(0);
expect(state.deletes).toEqual([]);
});
}
for (const requester of ['openai', 'openai-codex']) {
for (const width of [1280, 320]) {
test(`footer deletion confirmation cancellation and geometry (${requester}, ${width}px)`, async ({
page,
}) => {
const state = await fixture(page, requester);
await edit(page);
await page.setViewportSize({ width, height: 900 });
const dialog = editDialog(page);
const footer = dialog.locator('[data-slot="dialog-footer"]');
const remove = footer.getByRole('button', {
name: 'Delete',
exact: true,
});
await expect(remove).toBeVisible();
for (const button of await footer.getByRole('button').all()) {
await expect(button).toBeInViewport({ ratio: 1 });
const box = await button.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
}
const left = await remove.boundingBox();
const cancel = await footer
.getByRole('button', { name: 'Cancel', exact: true })
.boundingBox();
expect(left!.x + left!.width).toBeLessThan(cancel!.x);
await remove.click();
const confirmation = page.getByRole('alertdialog');
await expect(confirmation).toContainText('this provider and ALL models');
await expect(confirmation).toContainText('cannot be undone');
await expect(confirmation).toBeInViewport({ ratio: 1 });
await confirmation.evaluate(async (element) => {
await Promise.all(
element.getAnimations().map((animation) => animation.finished),
);
});
const box = await confirmation.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
await confirmation
.getByRole('button', { name: 'Cancel', exact: true })
.click();
await expect(confirmation).toHaveCount(0);
await expect(dialog).toBeVisible();
expect(state.deletes).toEqual([]);
});
}
test(`one awaited cascade request refreshes providers and clears models (${requester})`, async ({
page,
}) => {
const state = await fixture(page, requester);
await edit(page);
state.hold = true;
await editDialog(page)
.getByRole('button', { name: 'Delete', exact: true })
.click();
const confirmation = page.getByRole('alertdialog');
await confirmation
.getByRole('button', { name: 'Delete', exact: true })
.click();
await expect.poll(() => state.deletes.length).toBe(1);
await expect(
confirmation.getByRole('button', { name: 'Delete', exact: true }),
).toBeDisabled();
await expect(
confirmation.getByRole('button', { name: 'Cancel', exact: true }),
).toBeDisabled();
await expect(
editDialog(page).getByRole('button', {
name: requester === 'openai' ? 'Save' : 'Done',
exact: true,
includeHidden: true,
}),
).toBeDisabled();
await page.keyboard.press('Escape');
await expect(confirmation).toBeVisible();
state.reads = [];
state.release!();
await expect(editDialog(page)).toHaveCount(0);
await expect(
page.getByText('Delete fixture provider', { exact: true }),
).toHaveCount(0);
await expect(
page.getByText('Fixture llm model', { exact: true }),
).toHaveCount(0);
expect(state.deletes).toEqual([
'/api/v1/provider/providers/provider-delete-fixture?cascade=true',
]);
expect(state.reads).toContain('/api/v1/provider/providers');
});
}
test('failed cascade retains readable error and can retry', async ({
page,
}) => {
const state = await fixture(page);
await edit(page);
state.fail = true;
await editDialog(page)
.getByRole('button', { name: 'Delete', exact: true })
.click();
const confirmation = page.getByRole('alertdialog');
await confirmation
.getByRole('button', { name: 'Delete', exact: true })
.click();
await expect(confirmation.getByRole('alert')).toContainText(
'Fixture deletion blocked; try again.',
);
await expect(
confirmation.getByRole('button', { name: 'Delete', exact: true }),
).toBeEnabled();
await expect(editDialog(page)).toBeVisible();
state.fail = false;
await confirmation
.getByRole('button', { name: 'Delete', exact: true })
.click();
await expect(editDialog(page)).toHaveCount(0);
expect(state.deletes).toHaveLength(2);
});
test('new providers do not expose footer deletion', async ({ page }) => {
const state = await fixture(page);
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
await expect(
page
.getByRole('dialog', { name: 'Add Provider', exact: true })
.getByRole('button', { name: 'Delete', exact: true }),
).toHaveCount(0);
expect(state.deletes).toEqual([]);
});
test('system-managed provider has no edit or delete entry', async ({
page,
}) => {
const state = await fixture(page, 'space-chat-completions');
const card = page
.locator('[data-slot="card"]')
.filter({ hasText: 'Delete fixture provider' });
await expect(card).toBeVisible();
await expect(card.locator('svg.lucide-settings')).toHaveCount(0);
await expect(card.locator('svg.lucide-trash-2')).toHaveCount(0);
expect(state.deletes).toEqual([]);
});
test('existing empty-provider card delete keeps its non-cascade request', async ({
page,
}) => {
const state = await fixture(page, 'openai', true);
const card = page
.locator('[data-slot="card"]')
.filter({ hasText: 'Delete fixture provider' });
await card
.locator('button')
.filter({ has: page.locator('svg.lucide-trash-2') })
.click();
await expect(
page.getByText('Are you sure you want to delete this provider?', {
exact: true,
}),
).toBeVisible();
await page.getByRole('button', { name: 'Delete', exact: true }).click();
await expect(card).toHaveCount(0);
expect(state.deletes).toEqual([
'/api/v1/provider/providers/provider-delete-fixture',
]);
});