mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-07 01:57:15 +00:00
0f216a0d4d
* 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>
178 lines
6.4 KiB
TypeScript
178 lines
6.4 KiB
TypeScript
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();
|
|
});
|
|
}
|