mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 23:07:14 +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,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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user