mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
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>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
// Isolated real React/Radix fixture. No backend or OAuth requests are made.
|
||||
async function mount(page: Page, mode: string) {
|
||||
page.on('pageerror', (error) => console.error(error.message));
|
||||
await page.route('**/copy-harness', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: `
|
||||
<div id="root"></div><script type="module">
|
||||
import RefreshRuntime from '/@react-refresh';
|
||||
RefreshRuntime.injectIntoGlobalHook(window);
|
||||
window.$RefreshReg$ = () => {};
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
</script><script type="module">
|
||||
import React from '/node_modules/.vite/deps/react.js';
|
||||
import ReactDOM from '/node_modules/.vite/deps/react-dom_client.js';
|
||||
const {createRoot} = ReactDOM;
|
||||
import i18n from '/node_modules/.vite/deps/i18next.js';
|
||||
import {initReactI18next} from '/node_modules/.vite/deps/react-i18next.js';
|
||||
import {Toaster} from '/src/components/ui/sonner.tsx';
|
||||
import '/src/app/global.css';
|
||||
import {Dialog, DialogContent, DialogTitle} from '/src/components/ui/dialog.tsx';
|
||||
import Section from '/src/app/home/components/models-dialog/component/provider-form/CodexAccountSection.tsx';
|
||||
await i18n.use(initReactI18next).init({lng:'en', resources:{en:{translation:{}}}, interpolation:{escapeValue:false}});
|
||||
const root=createRoot(document.getElementById('root'));
|
||||
window.renderCode=(code='FIXTURE-1234',attempt='attempt-1')=>root.render(React.createElement(Dialog,{open:true},
|
||||
React.createElement(DialogContent,{},React.createElement(DialogTitle,{},'Copy fixture'),React.createElement(Section,{providerId:'fixture',login:{phase:'pending',device:{user_code:code,authorization_id:attempt,verification_uri:'https://example.invalid',expires_at:9999999999}}})),React.createElement(Toaster)));
|
||||
window.renderCode();
|
||||
</script>`,
|
||||
}),
|
||||
);
|
||||
await page.addInitScript((mode) => {
|
||||
const w = window as any;
|
||||
w.copyEvents = [];
|
||||
document.addEventListener('copy', () => {
|
||||
const el = document.activeElement as HTMLTextAreaElement;
|
||||
w.copyEvents.push({
|
||||
tag: el.tagName,
|
||||
selected: el.value?.slice(el.selectionStart, el.selectionEnd),
|
||||
});
|
||||
});
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value:
|
||||
mode === 'unavailable'
|
||||
? undefined
|
||||
: {
|
||||
writeText: (text: string) => {
|
||||
if (mode === 'success') {
|
||||
w.written = text;
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (mode === 'delayed')
|
||||
return new Promise((resolve) => {
|
||||
w.resolveCopy = resolve;
|
||||
});
|
||||
return Promise.reject(new Error('denied'));
|
||||
},
|
||||
},
|
||||
});
|
||||
if (mode === 'false') document.execCommand = () => false;
|
||||
if (mode === 'throw')
|
||||
document.execCommand = () => {
|
||||
throw new Error('denied');
|
||||
};
|
||||
}, mode);
|
||||
await page.goto('/copy-harness');
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'models.codex.copyCode', exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
const copy = (page: Page) =>
|
||||
page.getByRole('button', { name: 'models.codex.copyCode', exact: true });
|
||||
const copied = (page: Page) =>
|
||||
page.getByRole('button', { name: 'models.codex.copied', exact: true });
|
||||
|
||||
test('Clipboard API success shows icon, toast and transient feedback', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mount(page, 'success');
|
||||
await expect(copy(page).locator('svg.lucide-copy')).toBeVisible();
|
||||
await copy(page).click();
|
||||
await expect(copied(page).locator('svg.lucide-check')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('common.copySuccess', { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(await page.evaluate(() => (window as any).written)).toBe(
|
||||
'FIXTURE-1234',
|
||||
);
|
||||
await expect(copy(page)).toBeVisible({ timeout: 4000 });
|
||||
});
|
||||
for (const mode of ['unavailable', 'rejected'])
|
||||
test(`${mode} API performs a real selected-text copy inside modal`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await mount(page, mode);
|
||||
await copy(page).click();
|
||||
await expect(copied(page)).toBeVisible();
|
||||
expect(await page.evaluate(() => (window as any).copyEvents)).toEqual([
|
||||
{ tag: 'TEXTAREA', selected: 'FIXTURE-1234' },
|
||||
]);
|
||||
await expect(copied(page)).toBeFocused();
|
||||
await expect(page.locator('textarea')).toHaveCount(0);
|
||||
});
|
||||
for (const mode of ['false', 'throw'])
|
||||
test(`${mode} fallback reports failure and manual guidance`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await mount(page, mode);
|
||||
await copy(page).click();
|
||||
await expect(
|
||||
page.getByText('common.copyFailed', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('models.codex.copyManually', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(copy(page)).toBeVisible();
|
||||
await expect(page.locator('textarea')).toHaveCount(0);
|
||||
await expect(copy(page)).toBeFocused();
|
||||
});
|
||||
test('new code or attempt clears copied feedback', async ({ page }) => {
|
||||
await mount(page, 'success');
|
||||
await copy(page).click();
|
||||
await expect(copied(page)).toBeVisible();
|
||||
await page.evaluate(() =>
|
||||
(window as any).renderCode('FIXTURE-5678', 'attempt-2'),
|
||||
);
|
||||
await expect(copy(page)).toBeVisible();
|
||||
await copy(page).click();
|
||||
await expect(copied(page)).toBeVisible();
|
||||
await page.evaluate(() =>
|
||||
(window as any).renderCode('FIXTURE-5678', 'attempt-3'),
|
||||
);
|
||||
await expect(copy(page)).toBeVisible();
|
||||
});
|
||||
test('completion from an old attempt cannot mark the new code copied', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mount(page, 'delayed');
|
||||
await copy(page).click();
|
||||
await page.evaluate(() =>
|
||||
(window as any).renderCode('FIXTURE-5678', 'attempt-2'),
|
||||
);
|
||||
await expect(page.getByText('FIXTURE-5678')).toBeVisible();
|
||||
await page.evaluate(() => (window as any).resolveCopy());
|
||||
await expect(copy(page)).toBeVisible();
|
||||
await expect(copied(page)).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
// All OAuth, provider and model responses here are explicit UI fixtures.
|
||||
// These tests never authenticate with OpenAI or use a real subscription.
|
||||
async function fixture(page: Page) {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const state = {
|
||||
providers: [] as Record<string, unknown>[],
|
||||
creates: 0,
|
||||
starts: 0,
|
||||
polls: 0,
|
||||
cancels: 0,
|
||||
disconnects: 0,
|
||||
connected: false,
|
||||
failStart: false,
|
||||
pollStatus: 'pending',
|
||||
interval: 1,
|
||||
expiresIn: 600,
|
||||
};
|
||||
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 (path.endsWith('/icon'))
|
||||
return route.fulfill({
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><circle cx="12" cy="12" r="10" fill="#555"/></svg>',
|
||||
});
|
||||
if (path.endsWith('/requesters'))
|
||||
return ok(route, {
|
||||
requesters: ['openai-codex', 'openai'].map((name) => ({
|
||||
name,
|
||||
label: {
|
||||
en_US: name === 'openai-codex' ? 'OpenAI Codex' : 'OpenAI API',
|
||||
},
|
||||
description: { en_US: '' },
|
||||
spec: {
|
||||
provider_category: 'manufacturer',
|
||||
support_type: ['llm'],
|
||||
config: [
|
||||
{ name: 'base_url', default: 'https://api.openai.com/v1' },
|
||||
],
|
||||
},
|
||||
})),
|
||||
});
|
||||
if (path.endsWith('/providers')) {
|
||||
if (method === 'POST') {
|
||||
state.creates++;
|
||||
const provider = {
|
||||
...route.request().postDataJSON(),
|
||||
uuid: `provider-${state.creates}`,
|
||||
};
|
||||
state.providers.push(provider);
|
||||
return ok(route, { uuid: provider.uuid });
|
||||
}
|
||||
return ok(route, { providers: state.providers });
|
||||
}
|
||||
if (path.endsWith('/codex/status'))
|
||||
return ok(route, {
|
||||
status: state.connected ? 'connected' : 'disconnected',
|
||||
connected: state.connected,
|
||||
expires_at: null,
|
||||
});
|
||||
if (path.endsWith('/codex/device') && method === 'POST') {
|
||||
state.starts++;
|
||||
if (state.failStart)
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: { code: 400, msg: 'Fixture start failure' },
|
||||
});
|
||||
return ok(route, {
|
||||
authorization_id: `attempt-${state.starts}`,
|
||||
user_code: 'TEST-1234',
|
||||
verification_uri: 'https://auth.openai.com/codex/device',
|
||||
interval: state.interval,
|
||||
expires_at: Date.now() / 1000 + state.expiresIn,
|
||||
});
|
||||
}
|
||||
if (path.endsWith('/codex/device/poll')) {
|
||||
state.polls++;
|
||||
expect(route.request().postDataJSON()).toEqual({
|
||||
authorization_id: `attempt-${state.starts}`,
|
||||
});
|
||||
if (state.pollStatus === 'connected') state.connected = true;
|
||||
return ok(route, { status: state.pollStatus, interval: state.interval });
|
||||
}
|
||||
if (path.includes('/codex/device/') && method === 'DELETE') {
|
||||
state.cancels++;
|
||||
return ok(route, {});
|
||||
}
|
||||
if (path.endsWith('/codex/auth') && method === 'DELETE') {
|
||||
state.disconnects++;
|
||||
state.connected = false;
|
||||
return ok(route, {});
|
||||
}
|
||||
if (/\/providers\/provider-\d+$/.test(path)) {
|
||||
const provider = state.providers.find((p) =>
|
||||
path.endsWith(String(p.uuid)),
|
||||
);
|
||||
if (method === 'PUT')
|
||||
Object.assign(provider!, route.request().postDataJSON());
|
||||
return ok(route, { provider });
|
||||
}
|
||||
if (path.includes('/models/')) return ok(route, { models: [] });
|
||||
return ok(route, {});
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
async function openModels(page: Page) {
|
||||
await page.goto('/home/bots');
|
||||
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
|
||||
}
|
||||
async function choose(page: Page, name: string) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Select Provider Type', exact: true })
|
||||
.click();
|
||||
await page.getByRole('button', { name: new RegExp(name) }).click();
|
||||
}
|
||||
|
||||
for (const width of [1280, 390, 320]) {
|
||||
test(`subscription sign-in in the existing provider dialog (${width}px, UI fixture)`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await openModels(page);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.locator('input[name="name"]').fill('My Codex');
|
||||
await choose(page, 'OpenAI Codex');
|
||||
await expect(page.locator('input[name="api_key"]')).toHaveCount(0);
|
||||
await expect(page.locator('input[name="base_url"]')).toHaveCount(0);
|
||||
await page
|
||||
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||
.click();
|
||||
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Copy code', exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Copied', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Copy Successfully', { exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
expect(state.creates).toBe(1);
|
||||
expect(state.providers[0]).toMatchObject({
|
||||
requester: 'openai-codex',
|
||||
api_keys: [],
|
||||
base_url: 'https://chatgpt.com/backend-api/codex',
|
||||
});
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Continue at OpenAI' }),
|
||||
).toHaveAttribute('href', 'https://auth.openai.com/codex/device');
|
||||
const geometry = await page.getByTestId('codex-account').evaluate((el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return {
|
||||
left: box.left,
|
||||
right: box.right,
|
||||
width: innerWidth,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
expect(geometry.left).toBeGreaterThanOrEqual(0);
|
||||
expect(geometry.right).toBeLessThanOrEqual(width);
|
||||
expect(geometry.documentWidth).toBeLessThanOrEqual(width);
|
||||
if (process.env.CODEX_EVIDENCE_DIR) {
|
||||
await page.locator('[data-sonner-toast]').evaluate(async (el) => {
|
||||
await Promise.all(
|
||||
el
|
||||
.getAnimations({ subtree: true })
|
||||
.map((animation) => animation.finished.catch(() => undefined)),
|
||||
);
|
||||
});
|
||||
const screenshot = `${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.png`;
|
||||
await page.screenshot({ path: screenshot, fullPage: true });
|
||||
writeFileSync(
|
||||
`${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
evidence: 'UI fixture only; not live OpenAI sign-in',
|
||||
viewport: { width, height: 900 },
|
||||
geometry,
|
||||
screenshot,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
state.pollStatus = 'connected';
|
||||
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
await expect(page.getByText('My Codex', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Add Model', exact: true }),
|
||||
).toBeVisible();
|
||||
expect(state.creates).toBe(1);
|
||||
expect(
|
||||
await page.evaluate(() => JSON.stringify({ ...localStorage })),
|
||||
).not.toContain('attempt-');
|
||||
});
|
||||
}
|
||||
|
||||
test('failed start retries reuse saved provider; cancellation refreshes list', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
state.failStart = true;
|
||||
await openModels(page);
|
||||
await page.locator('input[name="name"]').fill('Retry Codex');
|
||||
await choose(page, 'OpenAI Codex');
|
||||
await page
|
||||
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole('alert')).toContainText('Unable to sign in');
|
||||
state.failStart = false;
|
||||
await page.getByRole('button', { name: 'Try again', exact: true }).click();
|
||||
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Cancel sign-in', exact: true })
|
||||
.click();
|
||||
await expect.poll(() => state.cancels).toBe(1);
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(page.getByText('Retry Codex', { exact: true })).toBeVisible();
|
||||
expect(state.creates).toBe(1);
|
||||
});
|
||||
|
||||
test('reconnect cancellation preserves connection and disconnect requires confirmation', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
state.pollStatus = 'connected';
|
||||
await openModels(page);
|
||||
await page.locator('input[name="name"]').fill('Managed Codex');
|
||||
await choose(page, 'OpenAI Codex');
|
||||
await page
|
||||
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||
.click();
|
||||
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
|
||||
state.pollStatus = 'pending';
|
||||
await page.getByRole('button', { name: 'Reconnect', exact: true }).click();
|
||||
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Cancel sign-in', exact: true })
|
||||
.click();
|
||||
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
|
||||
expect(state.disconnects).toBe(0);
|
||||
await page.getByRole('button', { name: 'Disconnect', exact: true }).click();
|
||||
expect(state.disconnects).toBe(0);
|
||||
await page
|
||||
.getByRole('button', { name: 'Confirm disconnect', exact: true })
|
||||
.click();
|
||||
await expect(page.getByText('Not connected', { exact: true })).toBeVisible();
|
||||
expect(state.disconnects).toBe(1);
|
||||
expect(state.creates).toBe(1);
|
||||
});
|
||||
|
||||
test('expiration permits retry without duplicate provider and closing cancels pending login', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
state.expiresIn = 1;
|
||||
await openModels(page);
|
||||
await page.locator('input[name="name"]').fill('Expired Codex');
|
||||
await choose(page, 'OpenAI Codex');
|
||||
await page
|
||||
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText('Sign-in expired. Start again to get a new code.'),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => state.cancels).toBe(1);
|
||||
state.expiresIn = 600;
|
||||
await page.getByRole('button', { name: 'Try again', exact: true }).click();
|
||||
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect.poll(() => state.cancels).toBe(2);
|
||||
await expect(page.getByText('Expired Codex', { exact: true })).toBeVisible();
|
||||
expect(state.creates).toBe(1);
|
||||
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
|
||||
await page.locator('input[name="name"]').fill('Second Codex');
|
||||
await choose(page, 'OpenAI Codex');
|
||||
await page
|
||||
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||
.click();
|
||||
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||
expect(state.creates).toBe(2);
|
||||
expect(state.providers.map((provider) => provider.name)).toEqual([
|
||||
'Expired Codex',
|
||||
'Second Codex',
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect.poll(() => state.cancels).toBe(3);
|
||||
});
|
||||
|
||||
test('model test retains the connected provider identity', async ({ page }) => {
|
||||
const state = await fixture(page);
|
||||
state.connected = true;
|
||||
state.providers.push({
|
||||
uuid: 'provider-1',
|
||||
name: 'Connected Codex',
|
||||
requester: 'openai-codex',
|
||||
base_url: 'https://chatgpt.com/backend-api/codex',
|
||||
api_keys: [],
|
||||
});
|
||||
await page.goto('/home/bots');
|
||||
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Add Model', exact: true }).click();
|
||||
await page
|
||||
.getByPlaceholder('Model Name', { exact: true })
|
||||
.fill('fixture-codex-model');
|
||||
const requestPromise = page.waitForRequest('**/models/llm/_/test');
|
||||
await page.getByRole('button', { name: 'Test', exact: true }).click();
|
||||
const payload = (await requestPromise).postDataJSON();
|
||||
expect(payload.provider_uuid).toBe('provider-1');
|
||||
expect(payload.provider.uuid).toBe('provider-1');
|
||||
expect(payload.provider.api_keys).toEqual([]);
|
||||
});
|
||||
|
||||
test('ordinary API-key provider still saves and closes', async ({ page }) => {
|
||||
const state = await fixture(page);
|
||||
await openModels(page);
|
||||
await page.locator('input[name="name"]').fill('My API');
|
||||
await choose(page, 'OpenAI API');
|
||||
await page.locator('input[name="api_key"]').fill('fixture-api-key-not-real');
|
||||
await page
|
||||
.locator('input[name="base_url"]')
|
||||
.fill('https://api.example.test/v1');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(page.getByText('My API', { exact: true })).toBeVisible();
|
||||
expect(state.providers[0]).toMatchObject({
|
||||
requester: 'openai',
|
||||
api_keys: ['fixture-api-key-not-real'],
|
||||
base_url: 'https://api.example.test/v1',
|
||||
});
|
||||
expect(state.starts).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
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',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
// UI fixtures only: never authenticate or write a real provider.
|
||||
test.use({ hasTouch: true });
|
||||
for (const width of [1280, 390, 320]) {
|
||||
test(`provider dropdown bounded without dialog growth (${width}px)`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.route('**/api/v1/provider/**', async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path.endsWith('/icon'))
|
||||
return route.fulfill({
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
});
|
||||
const data = path.endsWith('/requesters')
|
||||
? {
|
||||
requesters: Array.from({ length: 30 }, (_, i) => ({
|
||||
name: i === 0 ? 'openai-codex' : `provider-${i}`,
|
||||
label: { en_US: i === 0 ? 'OpenAI Codex' : `Provider ${i}` },
|
||||
description: { en_US: '' },
|
||||
spec: {
|
||||
provider_category: 'manufacturer',
|
||||
config: [],
|
||||
support_type: ['llm'],
|
||||
},
|
||||
})),
|
||||
}
|
||||
: { providers: [], models: [] };
|
||||
await route.fulfill({ json: { code: 0, data } });
|
||||
});
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto('/home/bots');
|
||||
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Add Provider', exact: true })
|
||||
.click();
|
||||
await page.setViewportSize({ width, height: 720 });
|
||||
const trigger = page.getByRole('button', {
|
||||
name: 'Select Provider Type',
|
||||
exact: true,
|
||||
});
|
||||
const dialog = page
|
||||
.locator('[role="dialog"]')
|
||||
.filter({ has: page.locator('input[name="name"]') });
|
||||
await trigger.scrollIntoViewIfNeeded();
|
||||
const before = await dialog.evaluate((el) => ({
|
||||
height: el.clientHeight,
|
||||
scroll: el.scrollHeight,
|
||||
}));
|
||||
await trigger.click();
|
||||
const search = page.getByPlaceholder('Search providers...');
|
||||
await expect(search).toBeFocused();
|
||||
const menu = search.locator('../..');
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Provider 29', exact: false }),
|
||||
).toBeAttached();
|
||||
await menu.evaluate(async (el) => {
|
||||
await Promise.all(el.getAnimations().map((a) => a.finished));
|
||||
});
|
||||
const options = menu.locator(':scope > div').last();
|
||||
await options.hover();
|
||||
await page.mouse.wheel(0, 1200);
|
||||
await expect
|
||||
.poll(() => options.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
if (width < 1280) {
|
||||
await page.mouse.wheel(0, -1200);
|
||||
await expect.poll(() => options.evaluate((el) => el.scrollTop)).toBe(0);
|
||||
const box = (await options.boundingBox())!;
|
||||
const session = await page.context().newCDPSession(page);
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height - 30;
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x, y }],
|
||||
});
|
||||
for (let step = 1; step <= 10; step++) {
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{ x, y: y - step * 18 }],
|
||||
});
|
||||
}
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchEnd',
|
||||
touchPoints: [],
|
||||
});
|
||||
await session.detach();
|
||||
await expect
|
||||
.poll(() => options.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
const geometry = await menu.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const list = el.lastElementChild as HTMLElement;
|
||||
const clipped: string[] = [];
|
||||
for (
|
||||
let parent = el.parentElement;
|
||||
parent;
|
||||
parent = parent.parentElement
|
||||
) {
|
||||
const bounds = parent.getBoundingClientRect();
|
||||
if (
|
||||
/(auto|scroll|hidden|clip)/.test(
|
||||
getComputedStyle(parent).overflowY,
|
||||
) &&
|
||||
(rect.bottom > bounds.bottom + 1 || rect.top < bounds.top - 1)
|
||||
)
|
||||
clipped.push(parent.tagName);
|
||||
}
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
clipped,
|
||||
listHeight: list.clientHeight,
|
||||
listScroll: list.scrollHeight,
|
||||
scrollTop: list.scrollTop,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
const after = await dialog.evaluate((el) => ({
|
||||
height: el.clientHeight,
|
||||
scroll: el.scrollHeight,
|
||||
}));
|
||||
const dir = process.env.DROPDOWN_EVIDENCE_DIR || testInfo.outputDir;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: `${dir}/dropdown-${width}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
writeFileSync(
|
||||
`${dir}/dropdown-${width}.json`,
|
||||
JSON.stringify(
|
||||
{ evidence: 'UI fixture only', width, before, after, geometry },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
expect.soft(after).toEqual(before);
|
||||
expect.soft(geometry.clipped).toEqual([]);
|
||||
expect.soft(geometry.left).toBeGreaterThanOrEqual(0);
|
||||
expect.soft(geometry.right).toBeLessThanOrEqual(width);
|
||||
expect.soft(geometry.top).toBeGreaterThanOrEqual(0);
|
||||
expect.soft(geometry.bottom).toBeLessThanOrEqual(720);
|
||||
expect.soft(geometry.documentWidth).toBeLessThanOrEqual(width);
|
||||
expect(geometry.listScroll).toBeGreaterThan(geometry.listHeight);
|
||||
expect(geometry.scrollTop).toBeGreaterThan(0);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(search).toBeHidden();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(trigger).toBeFocused();
|
||||
await trigger.click();
|
||||
await search.fill('Provider 29');
|
||||
await page.locator('input[name="name"]').click();
|
||||
await expect(search).toBeHidden();
|
||||
await expect(page.locator('input[name="name"]')).toBeFocused();
|
||||
await trigger.click();
|
||||
await expect(search).toHaveValue('');
|
||||
await search.fill('Codex');
|
||||
await page
|
||||
.getByRole('button', { name: 'OpenAI Codex', exact: false })
|
||||
.click();
|
||||
await expect(search).toBeHidden();
|
||||
await expect(page.locator('input[name="api_key"]')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save and sign in', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'OpenAI Codex', exact: false }),
|
||||
).toBeFocused();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
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([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user