Files
LangBot/web/tests/e2e/provider-edit-loading.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

263 lines
9.2 KiB
TypeScript

import { expect, test, type Page, type Route } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
// All API traffic is intercepted; no real provider secrets or mutations.
async function fixture(page: Page, requester = 'openai') {
await installLangBotApiMocks(page, { authenticated: true });
const providers = ['alpha', 'beta'].map((id) => ({
uuid: `loading-${id}`,
name: `Loading fixture ${id}`,
requester,
base_url: `https://${id}.example.test/v1`,
api_keys: [`fixture-key-${id}`],
llm_count: 0,
embedding_count: 0,
rerank_count: 0,
}));
const state = {
hold: '' as '' | 'detail' | 'requesters',
fail: '' as '' | 'detail' | 'requesters',
held: [] as { release: () => void; finished: Promise<void> }[],
reads: [] as string[],
mutations: [] as string[],
errors: [] as string[],
};
page.on('pageerror', (error) => state.errors.push(error.message));
const ok = (route: Route, data: unknown) =>
route.fulfill({ json: { code: 0, data } });
await page.route('**/api/v1/provider/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (route.request().method() !== 'GET') {
state.mutations.push(route.request().method() + ' ' + path);
return ok(route, {});
}
if (path.endsWith('/icon'))
return route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
});
state.reads.push(path);
const provider = providers.find((p) => path.endsWith('/' + p.uuid));
const dependency = path.endsWith('/requesters')
? 'requesters'
: provider
? 'detail'
: '';
const fail = dependency && state.fail === dependency;
let finish: (() => void) | undefined;
if (dependency && state.hold === dependency) {
const finished = new Promise<void>((resolve) => {
finish = resolve;
});
await new Promise<void>((release) =>
state.held.push({ release, finished }),
);
}
try {
if (fail)
return await route.fulfill({
status: 503,
json: { code: 503, msg: `Fixture ${dependency} unavailable` },
});
if (dependency === 'requesters')
return await ok(route, {
requesters: [
{
name: requester,
label: {
en_US:
requester === 'openai' ? 'OpenAI fixture' : 'Codex fixture',
},
description: { en_US: '' },
spec: {
provider_category: 'manufacturer',
support_type: ['llm'],
config: [],
},
},
],
});
if (provider) return await ok(route, { provider });
if (path.endsWith('/providers')) return await ok(route, { providers });
if (path.endsWith('/codex/status'))
return await ok(route, {
status: 'connected',
connected: true,
expires_at: null,
});
return await ok(route, { models: [] });
} finally {
finish?.();
}
});
await page.goto('/home/bots');
await page.getByRole('button', { name: 'Models', exact: true }).click();
await expect(
page.getByText(providers[0].name, { exact: true }),
).toBeVisible();
// Let the panel's independent requester-support read finish before gating the form.
await expect
.poll(() => state.reads.filter((p) => p.endsWith('/requesters')).length)
.toBeGreaterThanOrEqual(1);
return state;
}
const dialog = (page: Page) =>
page.getByRole('dialog', { name: 'Edit Provider', exact: true });
const editButton = (page: Page, id = 'alpha') =>
page
.locator('[data-slot="card"]')
.filter({ hasText: `Loading fixture ${id}` })
.locator('button')
.filter({ has: page.locator('svg.lucide-settings') });
async function expectLoading(page: Page) {
const form = dialog(page);
await expect(form.getByRole('status')).toContainText('Loading...');
await expect(
form.getByRole('status').locator('svg.animate-spin'),
).toBeVisible();
await expect(form.locator('input')).toHaveCount(0);
await expect(
form.getByRole('button', { name: /^(Save|Done|Delete)$/ }),
).toHaveCount(0);
await expect(
form.getByRole('button', { name: 'Cancel', exact: true }),
).toBeEnabled();
}
async function expectReady(page: Page, id = 'alpha', requester = 'openai') {
const form = dialog(page);
await expect(form.locator('input[name="name"]')).toHaveValue(
`Loading fixture ${id}`,
);
await expect(
form.getByRole('status', { name: 'Loading...', exact: true }),
).toHaveCount(0);
await expect(
form.getByRole('button', { name: 'Delete', exact: true }),
).toBeEnabled();
await expect(
form.getByRole('button', {
name: requester === 'openai' ? 'Save' : 'Done',
exact: true,
}),
).toBeEnabled();
if (requester === 'openai') {
await expect(form.locator('input[name="base_url"]')).toHaveValue(
`https://${id}.example.test/v1`,
);
await expect(form.locator('input[name="api_key"]')).toHaveValue(
`fixture-key-${id}`,
);
await expect(
form.getByRole('button', { name: /OpenAI fixture/ }),
).toBeVisible();
} else {
await expect(form.locator('input[name="api_key"]')).toHaveCount(0);
await expect(
form.getByRole('button', { name: /Codex fixture/ }),
).toBeVisible();
}
}
for (const requester of ['openai', 'openai-codex']) {
for (const dependency of ['detail', 'requesters'] as const) {
test(`edit waits for ${dependency} before showing populated ${requester} form`, async ({
page,
}) => {
const state = await fixture(page, requester);
state.hold = dependency;
await editButton(page).click();
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
await expectLoading(page);
// Remain gated for the whole delay, not just the first render.
await page.waitForTimeout(250);
await expectLoading(page);
state.hold = '';
state.held.forEach((request) => request.release());
await expectReady(page, 'alpha', requester);
expect(state.mutations).toEqual([]);
expect(state.errors).toEqual([]);
});
}
}
for (const dependency of ['detail', 'requesters'] as const) {
test(`${dependency} load failure is recoverable with Retry or Cancel`, async ({
page,
}) => {
const state = await fixture(page);
state.fail = dependency;
await editButton(page).click();
const form = dialog(page);
await expect(form.getByRole('alert')).toContainText('Failed to load data');
await expect(form.locator('input')).toHaveCount(0);
await expect(
form.getByRole('button', { name: /^(Save|Done|Delete)$/ }),
).toHaveCount(0);
await expect(
form.getByRole('button', { name: 'Retry', exact: true }),
).toBeEnabled();
await expect(
form.getByRole('button', { name: 'Cancel', exact: true }),
).toBeEnabled();
state.fail = '';
state.hold = dependency;
await form.getByRole('button', { name: 'Retry', exact: true }).click();
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
await expectLoading(page);
state.hold = '';
state.held.forEach((request) => request.release());
await expectReady(page);
await form.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(form).toHaveCount(0);
state.fail = dependency;
await editButton(page).click();
await expect(form.getByRole('alert')).toBeVisible();
await form.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(form).toHaveCount(0);
expect(state.mutations).toEqual([]);
expect(state.errors).toEqual([]);
});
}
for (const next of ['alpha', 'beta']) {
for (const staleFailure of [false, true]) {
test(`closed request ${staleFailure ? 'failure' : 'success'} cannot affect reopened ${next}`, async ({
page,
}) => {
const state = await fixture(page);
state.hold = 'detail';
state.fail = staleFailure ? 'detail' : '';
await editButton(page).click();
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
await expectLoading(page);
const staleRequests = state.held.splice(0);
await dialog(page)
.getByRole('button', { name: 'Cancel', exact: true })
.click();
state.fail = '';
// Reopen during the closing animation, before Radix's retained content unmounts.
await editButton(page, next).dispatchEvent('click');
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
await expectLoading(page);
state.hold = '';
state.held.forEach((request) => request.release());
await expectReady(page, next);
await dialog(page)
.locator('input[name="name"]')
.fill('Unsaved fixture edit');
staleRequests.forEach((request) => request.release());
await Promise.all(staleRequests.map((request) => request.finished));
await page.waitForTimeout(250);
await expect(dialog(page).locator('input[name="name"]')).toHaveValue(
'Unsaved fixture edit',
);
await expect(dialog(page).getByRole('alert')).toHaveCount(0);
expect(state.mutations).toEqual([]);
expect(state.errors).toEqual([]);
});
}
}