test(web): replace Workspace source checks with runtime assertions

This commit is contained in:
huanghuoguoguo
2026-10-17 16:38:21 +08:00
parent c05aac538f
commit c14bc0a45a
8 changed files with 412 additions and 171 deletions
+118
View File
@@ -0,0 +1,118 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
for (const cleanup of ['close', 'expiry'] as const) {
test(`QR start, polling and ${cleanup} cleanup retain the session Workspace`, async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const calls: Array<{
method: string;
origin: string;
path: string;
workspace?: string;
authorization?: string;
}> = [];
const endpoint = '/api/v1/platform/adapters/weixin/login';
await page.route(`**${endpoint}**`, async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
calls.push({
method: request.method(),
origin: new URL(request.url()).origin,
path,
workspace: request.headers()['x-workspace-id'],
authorization: request.headers().authorization,
});
const data =
request.method() === 'POST'
? {
session_id: 'qr-session',
qr_url: 'https://scan.example/qr',
expire_at: Date.now() / 1000 + 8,
}
: { status: 'waiting' };
await route.fulfill({ json: { code: 0, data } });
});
await page.goto('/home/bots');
await expect(
page.getByRole('button', { name: 'Models', exact: true }),
).toBeVisible();
const modules = await page.evaluate(() => {
const resources = performance
.getEntriesByType('resource')
.map((entry) => entry.name);
return {
reactPath: resources.find((url) =>
new URL(url).pathname.endsWith('/react.js'),
)!,
domPath: resources.find((url) =>
new URL(url).pathname.endsWith('/react-dom_client.js'),
)!,
};
});
await page.clock.install();
await page.evaluate(async ({ reactPath, domPath }) => {
const reactModule = await import(reactPath);
const React = reactModule.default || reactModule;
const domModule = await import(domPath);
const { createRoot } = domModule.default || domModule;
const componentPath =
'/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx';
const { default: Dialog } = await import(componentPath);
const contextPath = '/src/app/infra/http/workspaceContext.ts';
(await import(contextPath)).setActiveWorkspaceUuid('qr-original');
const host = document.createElement('div');
document.body.appendChild(host);
function Harness() {
const [open, setOpen] = React.useState(true);
return React.createElement(Dialog, {
open,
onOpenChange: setOpen,
platform: 'weixin',
onSuccess: () => {},
});
}
createRoot(host).render(React.createElement(Harness));
}, modules);
await expect(page.getByRole('dialog').locator('img')).toBeVisible();
await page.evaluate(async () => {
const path = '/src/app/infra/http/workspaceContext.ts';
(await import(path)).setActiveWorkspaceUuid('qr-other');
});
await page.clock.runFor(3100);
await expect
.poll(() => calls.some((call) => call.method === 'GET'))
.toBe(true);
if (cleanup === 'close') await page.keyboard.press('Escape');
else await page.clock.runFor(6000);
await expect
.poll(() => calls.filter((call) => call.method === 'DELETE').length)
.toBe(1);
expect(calls).toEqual([
{
method: 'POST',
origin: new URL(page.url()).origin,
path: endpoint,
workspace: 'qr-original',
authorization: 'Bearer playwright-token',
},
...calls
.filter((call) => call.method === 'GET')
.map(() => ({
method: 'GET',
origin: new URL(page.url()).origin,
path: `${endpoint}/status/qr-session`,
workspace: 'qr-original',
authorization: 'Bearer playwright-token',
})),
{
method: 'DELETE',
origin: new URL(page.url()).origin,
path: `${endpoint}/qr-session`,
workspace: 'qr-original',
authorization: 'Bearer playwright-token',
},
]);
});
}
+53
View File
@@ -0,0 +1,53 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
for (const fragment of [true, false]) {
test(`direct launch assertion is fragment-only and cleared before exchange, fragment=${fragment}`, async ({
page,
}) => {
await installLangBotApiMocks(page);
await page.addInitScript(() => {
const replace = history.replaceState.bind(history);
history.replaceState = (...args) => {
sessionStorage.setItem('last-replaced-url', String(args[2]));
replace(...args);
};
});
let body: unknown;
let fragmentAtExchange: string | null = null;
await page.route('**/api/v1/user/space/callback', async (route) => {
body = route.request().postDataJSON();
fragmentAtExchange = await page.evaluate(() => location.hash);
await route.fulfill({
status: 403,
json: { code: 'launch_denied', msg: 'Denied for test' },
});
});
const query = '/auth/space/callback?code=launch-code&state=launch-state';
await page.goto(
fragment
? `${query}#workspace_uuid=target-workspace&launch_assertion=fragment-secret`
: `${query}&workspace_uuid=target-workspace&launch_assertion=query-must-not-be-used`,
);
await expect(
page.getByRole('button', { name: 'Back to Login', exact: true }),
).toBeVisible();
if (fragment) {
expect(body).toEqual({
code: 'launch-code',
state: 'launch-state',
redirect_uri: new URL('/auth/space/callback', page.url()).href,
workspace_uuid: 'target-workspace',
launch_assertion: 'fragment-secret',
});
expect(fragmentAtExchange).toBe('');
} else {
expect(body).toBeUndefined();
expect(fragmentAtExchange).toBeNull();
}
if (fragment)
expect(
await page.evaluate(() => sessionStorage.getItem('last-replaced-url')),
).toBe(query);
});
}
+213
View File
@@ -0,0 +1,213 @@
import { expect, test } from '@playwright/test';
import {
installLangBotApiMocks,
makeWorkspaceEntry,
} from './fixtures/langbot-api';
const cloudSystemInfo = {
edition: 'cloud',
deployment_mode: 'cloud',
cloud_service_url: 'https://space.langbot.app',
wizard_status: 'completed',
};
for (const scenario of [
'singleton-local',
'singleton-cloud',
'multiple',
] as const) {
test(`Workspace switcher visibility and placement for ${scenario}`, async ({
page,
}) => {
const first = makeWorkspaceEntry(
'layout-workspace',
'A Workspace with a deliberately very long name to exercise truncation',
scenario === 'singleton-local' ? 'local' : 'cloud_projection',
);
const workspaces =
scenario === 'multiple'
? [first, makeWorkspaceEntry('other-workspace', 'Other', 'local')]
: [first];
await installLangBotApiMocks(page, {
authenticated: true,
workspaces,
storage: { langbot_active_workspace_uuid: first.workspace.uuid },
});
if (scenario !== 'singleton-local') {
await page.route('**/api/v1/system/info', (route) =>
route.fulfill({ json: { code: 0, data: cloudSystemInfo } }),
);
await page.route(
/\/api\/v1\/workspaces\/layout-workspace\/(members|invitations)$/,
(route) =>
route.fulfill({
json: { code: 0, data: { members: [], invitations: [] } },
}),
);
}
await page.goto('/home/bots');
await expect(
page.getByRole('button', { name: 'Models', exact: true }),
).toBeVisible();
const trigger = page.getByRole('button', {
name: 'Switch Workspace',
exact: true,
});
await expect(trigger).toHaveCount(scenario === 'singleton-local' ? 0 : 1);
if (scenario === 'singleton-local') return;
const sidebar = page.locator('[data-slot="sidebar-inner"]');
const header = sidebar.locator('[data-slot="sidebar-header"]');
const home = sidebar.getByText('Home', { exact: true });
const navigation = sidebar.getByRole('button', {
name: 'Models',
exact: true,
});
const [headerBox, triggerBox, homeBox, navBox] = await Promise.all([
header.boundingBox(),
trigger.boundingBox(),
home.boundingBox(),
navigation.boundingBox(),
]);
expect(triggerBox!.y).toBeGreaterThanOrEqual(
headerBox!.y + headerBox!.height,
);
expect(triggerBox!.y + triggerBox!.height).toBeLessThanOrEqual(homeBox!.y);
expect(Math.abs(triggerBox!.x - navBox!.x)).toBeLessThanOrEqual(1);
expect(
Math.abs(triggerBox!.x + triggerBox!.width - navBox!.x - navBox!.width),
).toBeLessThanOrEqual(1);
await trigger.click();
const row = page
.getByRole('menuitem')
.filter({ hasText: first.workspace.name });
await expect(row).toBeVisible();
await expect(
row.getByRole('button', { name: 'Workspace Settings', exact: true }),
).toBeVisible();
const longName = row.getByText(first.workspace.name, { exact: true });
expect(
await longName.evaluate((element) => ({
overflow: getComputedStyle(element).textOverflow,
whiteSpace: getComputedStyle(element).whiteSpace,
clipped: element.scrollWidth > element.clientWidth,
})),
).toEqual({ overflow: 'ellipsis', whiteSpace: 'nowrap', clipped: true });
await row
.getByRole('button', { name: 'Workspace Settings', exact: true })
.click();
await expect(
page
.getByRole('dialog')
.getByRole('heading', { name: 'Workspace', exact: true }),
).toBeVisible();
await expect(
page
.getByRole('dialog')
.getByRole('heading', { name: 'Members', exact: true }),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Switch Workspace',
exact: true,
includeHidden: true,
}),
).toHaveCount(1);
});
}
test('the Cloud Workspace row shows its plan and opens plan upgrades in settings', async ({
page,
}) => {
const workspace = {
...makeWorkspaceEntry(
'plan-workspace',
'Plan Workspace',
'cloud_projection',
),
plan_name: 'Pro',
};
await installLangBotApiMocks(page, {
authenticated: true,
workspaces: [workspace],
});
await page.route('**/api/v1/system/info', (route) =>
route.fulfill({ json: { code: 0, data: cloudSystemInfo } }),
);
await page.route('**/api/v1/workspaces/plan-workspace/members', (route) =>
route.fulfill({ json: { code: 0, data: { members: [] } } }),
);
await page.route('**/api/v1/workspaces/plan-workspace/invitations', (route) =>
route.fulfill({ json: { code: 0, data: { invitations: [] } } }),
);
let catalogs = 0;
await page.route(
'https://space.langbot.app/api/v1/cloud/v2/plans',
async (route) => {
catalogs++;
await route.fulfill({ json: { code: 0, data: [] } });
},
);
await page.goto('/home/bots');
await page
.getByRole('button', { name: 'Switch Workspace', exact: true })
.click();
const row = page.getByRole('menuitem').filter({ hasText: 'Plan Workspace' });
await expect(row.getByText('Pro', { exact: true })).toBeVisible();
await row
.getByRole('button', { name: 'Workspace Settings', exact: true })
.click();
await expect(page.getByRole('dialog')).toBeVisible();
await page
.getByRole('button', { name: 'Compare plans', exact: true })
.click();
await expect(
page.getByRole('heading', { name: 'Compare plans', exact: true }),
).toBeVisible();
await expect(page.getByRole('dialog', { includeHidden: true })).toHaveCount(
2,
);
await expect.poll(() => catalogs).toBeGreaterThan(0);
});
test('monitoring uses infrastructure Box health independently of Plugin Runtime status', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let runtimeRequests = 0;
let scopedStatusRequests = 0;
await page.route('**/api/v1/box/runtime-status', async (route) => {
runtimeRequests++;
await route.fulfill({
json: {
code: 0,
data: {
available: false,
enabled: true,
connector_error: 'Box infrastructure unavailable',
},
},
});
});
await page.route('**/api/v1/box/status', async (route) => {
scopedStatusRequests++;
await route.fulfill({
json: { code: 0, data: { available: true, enabled: true } },
});
});
await page.goto('/home/monitoring');
const card = page
.locator('[data-slot="card"]')
.filter({ hasText: 'System Status' });
await expect(card.getByText('Box Runtime', { exact: true })).toBeVisible();
await card.getByRole('button').click();
const dialog = page.getByRole('dialog');
await expect(
dialog.getByText('Box infrastructure unavailable', { exact: true }),
).toBeVisible();
await expect(dialog.getByText('Connected', { exact: true })).toHaveCount(1);
await expect(dialog.getByText('Disconnected', { exact: true })).toHaveCount(
1,
);
expect(runtimeRequests).toBeGreaterThan(0);
expect(scopedStatusRequests).toBe(0);
});
@@ -73,6 +73,10 @@ test('workspace member list displays each member email without overlapping contr
await expect(page.getByText('RockChinQ')).toBeVisible();
await expect(page.getByText('[email protected]')).toBeVisible();
await expect(page.getByText('Junyan Qin')).toBeVisible();
const ownerRow = page
.locator('[data-slot="item"]')
.filter({ hasText: 'RockChinQ' });
await expect(ownerRow.getByText('Owner', { exact: true })).toBeVisible();
const longEmail = page.getByText(
'[email protected]',
);
@@ -80,6 +84,7 @@ test('workspace member list displays each member email without overlapping contr
await longEmail.scrollIntoViewIfNeeded();
const roleSelect = page.getByRole('combobox').last();
await expect(roleSelect).toHaveText('Admin');
const [emailBox, selectBox] = await Promise.all([
longEmail.boundingBox(),
roleSelect.boundingBox(),
+23 -35
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import ts from 'typescript';
const root = process.cwd();
const dialogPath = path.join(
@@ -12,43 +13,30 @@ const localeDir = path.join(root, 'src/i18n/locales');
const backendUrlPath = path.join(root, 'src/app/infra/http/backendUrl.ts');
const dialogSource = fs.readFileSync(dialogPath, 'utf8');
const backendUrlSource = fs.readFileSync(backendUrlPath, 'utf8');
const { resolveBackendBaseUrl } = await import(
`data:text/javascript;base64,${Buffer.from(
ts.transpileModule(fs.readFileSync(backendUrlPath, 'utf8'), {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
).toString('base64')}`
);
test('QR login resolves a proxy-root API base against the current origin', () => {
assert.match(dialogSource, /const baseUrl = getBackendBaseUrl\(\)/);
assert.match(backendUrlSource, /!configured \|\| configured === '\/'/);
assert.match(backendUrlSource, /return normalizedOrigin/);
assert.doesNotMatch(
dialogSource,
/import\.meta\.env\.VITE_API_BASE_URL \|\| window\.location\.origin/,
);
});
test('QR credential exchanges preserve the active Workspace scope', () => {
assert.match(dialogSource, /getActiveWorkspaceUuid/);
assert.match(
dialogSource,
/sessionWorkspaceUuidRef\.current = workspaceUuid/,
);
assert.match(
dialogSource,
/const workspaceUuid = sessionWorkspaceUuidRef\.current/,
);
assert.match(dialogSource, /sessionApiBaseRef\.current = cfg\.apiBase/);
assert.match(
dialogSource,
/`\$\{baseUrlRef\.current\}\$\{sessionApiBaseRef\.current\}\/\$\{sessionIdRef\.current\}`/,
);
assert.match(dialogSource, /'X-Workspace-Id': workspaceUuid/);
const workspaceHeaderUses = dialogSource.match(
/'X-Workspace-Id': workspaceUuid/g,
);
assert.equal(
workspaceHeaderUses?.length,
4,
'start, poll, expiry cleanup, and dialog cleanup must all retain Workspace scope',
);
for (const [configured, expected] of [
[undefined, 'https://core.example'],
['', 'https://core.example'],
['/', 'https://core.example'],
['/proxy/', 'https://core.example/proxy'],
['proxy', 'https://core.example/proxy'],
['https://api.example/root/', 'https://api.example/root'],
])
assert.equal(
resolveBackendBaseUrl(configured, 'https://core.example///'),
expected,
);
});
test('WeChat QR login never reuses Feishu progress copy', () => {
@@ -1,24 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const source = fs.readFileSync(
new URL('../../src/app/auth/space/callback/page.tsx', import.meta.url),
'utf8',
);
test('direct launch assertion is fragment-only and removed before exchange', () => {
assert.doesNotMatch(source, /searchParams\.get\(['"]launch_assertion['"]\)/);
const readIndex = source.indexOf("fragmentParams.get('launch_assertion')");
const clearIndex = source.indexOf('window.history.replaceState');
const exchangeIndex = source.indexOf('handleOAuthCallback(', clearIndex);
assert.ok(readIndex >= 0, 'fragment assertion read is missing');
assert.ok(
clearIndex > readIndex,
'URL fragment is not cleared after copying the assertion',
);
assert.ok(
exchangeIndex > clearIndex,
'assertion exchange starts before the fragment is cleared',
);
});
@@ -1,24 +0,0 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const panelSource = await readFile(
new URL(
'../../src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx',
import.meta.url,
),
'utf8',
);
const entitySource = await readFile(
new URL('../../src/app/infra/entities/workspace.ts', import.meta.url),
'utf8',
);
test('workspace member rows show display name, email, and role', () => {
assert.match(entitySource, /display_name:\s*string/);
assert.match(panelSource, /\{member\.display_name\}/);
assert.match(
panelSource,
/<ItemDescription[^>]*>[\s\S]*?\{member\.email\}[\s\S]*?workspace\.roles\.\$\{member\.role\}[\s\S]*?<\/ItemDescription>/,
);
});
@@ -18,103 +18,15 @@ const homeSidebarSource = readSource(
const workspaceSettingsPanelSource = readSource(
'src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx',
);
const workspaceSwitcherSource = readSource(
'src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx',
);
test('renders WorkspaceSwitcher only from HomeSidebar', () => {
assert.doesNotMatch(homeLayoutSource, /<WorkspaceSwitcher\b/);
assert.doesNotMatch(workspaceSettingsPanelSource, /<WorkspaceSwitcher\b/);
assert.equal(homeSidebarSource.match(/<WorkspaceSwitcher\b/g)?.length, 1);
});
test('places WorkspaceSwitcher between the sidebar header and Home navigation', () => {
const headerEnd = homeSidebarSource.indexOf('</SidebarHeader>');
const switcher = homeSidebarSource.indexOf('<WorkspaceSwitcher');
const contentStart = homeSidebarSource.indexOf('<SidebarContent');
const homeGroup = homeSidebarSource.indexOf(
"<SidebarGroupLabel>{t('sidebar.home')}</SidebarGroupLabel>",
);
assert.notEqual(headerEnd, -1);
assert.notEqual(switcher, -1);
assert.notEqual(contentStart, -1);
assert.notEqual(homeGroup, -1);
assert.ok(
headerEnd < switcher,
'WorkspaceSwitcher must follow SidebarHeader',
);
assert.ok(
switcher < contentStart && switcher < homeGroup,
'WorkspaceSwitcher must precede SidebarContent and the Home group',
);
});
test('hides WorkspaceSwitcher for the singleton local OSS workspace and keeps it for Cloud or multiple workspaces', () => {
assert.match(
workspaceSwitcherSource,
/if \(!currentWorkspace\) return null;/,
);
assert.match(
homeSidebarSource,
/const workspaces = useWorkspaceBootstrap\(\);/,
);
assert.match(
homeSidebarSource,
/const showWorkspaceSwitcher\s*=\s*workspaces\.length\s*>\s*1\s*\|\|\s*currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'/,
);
assert.match(
homeSidebarSource,
/\{showWorkspaceSwitcher\s*&&\s*\(\s*<div className="px-2[^>]*>[\s\S]*?<WorkspaceSwitcher/,
);
});
test('keeps Cloud workspace member management in Workspace Settings', () => {
assert.match(workspaceSettingsPanelSource, /workspace\.inviteMember/);
assert.doesNotMatch(workspaceSettingsPanelSource, /#workspace-members/);
assert.doesNotMatch(workspaceSettingsPanelSource, /cloudMembersURL/);
assert.doesNotMatch(workspaceSettingsPanelSource, /canManageCloudMembers/);
});
test('keeps workspace plan and settings controls on the workspace row', () => {
assert.match(workspaceSwitcherSource, /entry\.plan_name/);
assert.match(
workspaceSwitcherSource,
/aria-label=\{t\('workspace\.settings'\)\}/,
);
assert.match(workspaceSwitcherSource, /className="size-8"/);
assert.doesNotMatch(workspaceSwitcherSource, /workspace\.currentPlan/);
assert.doesNotMatch(workspaceSwitcherSource, /workspace\.upgradePlan/);
assert.doesNotMatch(workspaceSwitcherSource, /workspace\.roles/);
assert.doesNotMatch(workspaceSwitcherSource, /entry\.membership\.role/);
});
test('moves Cloud plan upgrades into Workspace Settings', () => {
assert.match(workspaceSettingsPanelSource, /cloudPlans\.title/);
assert.match(workspaceSettingsPanelSource, /<CloudPlansDialog/);
assert.match(workspaceSettingsPanelSource, /systemInfo\.cloud_service_url/);
});
test('aligns the workspace trigger with navigation entries on both sides and truncates long names', () => {
assert.match(
homeSidebarSource,
/<div className="px-2[^>]*>[\s\S]*<WorkspaceSwitcher className="w-full/,
);
assert.doesNotMatch(
homeSidebarSource,
/WorkspaceSwitcher className="[^"]*w-4\/5/,
);
assert.match(workspaceSwitcherSource, /h-9/);
assert.match(workspaceSwitcherSource, /w-64/);
assert.doesNotMatch(workspaceSwitcherSource, /min-w-80/);
assert.match(workspaceSwitcherSource, /max-w-\[7rem\][^>]*truncate/);
});
test('uses an infrastructure-level Box health endpoint in monitoring', () => {
const statusCardSource = readSource(
'src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx',
);
const backendClientSource = readSource('src/app/infra/http/BackendClient.ts');
assert.match(backendClientSource, /getBoxRuntimeStatus/);
assert.match(statusCardSource, /getBoxRuntimeStatus/);
});