diff --git a/web/tests/e2e/qr-code-workspace.spec.ts b/web/tests/e2e/qr-code-workspace.spec.ts new file mode 100644 index 000000000..063c5f466 --- /dev/null +++ b/web/tests/e2e/qr-code-workspace.spec.ts @@ -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', + }, + ]); + }); +} diff --git a/web/tests/e2e/space-launch.spec.ts b/web/tests/e2e/space-launch.spec.ts new file mode 100644 index 000000000..eae42799f --- /dev/null +++ b/web/tests/e2e/space-launch.spec.ts @@ -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); + }); +} diff --git a/web/tests/e2e/workspace-layout.spec.ts b/web/tests/e2e/workspace-layout.spec.ts new file mode 100644 index 000000000..dae717afd --- /dev/null +++ b/web/tests/e2e/workspace-layout.spec.ts @@ -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); +}); diff --git a/web/tests/e2e/workspace-member-email.spec.ts b/web/tests/e2e/workspace-member-email.spec.ts index e6f952ca5..91b8fc680 100644 --- a/web/tests/e2e/workspace-member-email.spec.ts +++ b/web/tests/e2e/workspace-member-email.spec.ts @@ -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('rock@example.com')).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( 'a.very.long.workspace.member.email.address@example-company.test', ); @@ -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(), diff --git a/web/tests/unit/qr-code-login-workspace.test.mjs b/web/tests/unit/qr-code-login-workspace.test.mjs index 32e890e49..4b1a6f2d5 100644 --- a/web/tests/unit/qr-code-login-workspace.test.mjs +++ b/web/tests/unit/qr-code-login-workspace.test.mjs @@ -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', () => { diff --git a/web/tests/unit/space-launch-callback.test.mjs b/web/tests/unit/space-launch-callback.test.mjs deleted file mode 100644 index 7debabf6c..000000000 --- a/web/tests/unit/space-launch-callback.test.mjs +++ /dev/null @@ -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', - ); -}); diff --git a/web/tests/unit/workspace-member-email.test.mjs b/web/tests/unit/workspace-member-email.test.mjs deleted file mode 100644 index 977adb0de..000000000 --- a/web/tests/unit/workspace-member-email.test.mjs +++ /dev/null @@ -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, - /]*>[\s\S]*?\{member\.email\}[\s\S]*?workspace\.roles\.\$\{member\.role\}[\s\S]*?<\/ItemDescription>/, - ); -}); diff --git a/web/tests/unit/workspace-switcher-layout.test.mjs b/web/tests/unit/workspace-switcher-layout.test.mjs index 09e63aefa..8be7004d5 100644 --- a/web/tests/unit/workspace-switcher-layout.test.mjs +++ b/web/tests/unit/workspace-switcher-layout.test.mjs @@ -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, / { - const headerEnd = homeSidebarSource.indexOf(''); - const switcher = homeSidebarSource.indexOf('{t('sidebar.home')}", - ); - - 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*
{ - assert.match(workspaceSettingsPanelSource, /cloudPlans\.title/); - assert.match(workspaceSettingsPanelSource, / { - assert.match( - homeSidebarSource, - /
]*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/); -});