diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index ba0d3b176..e1865bda1 100644 --- a/web/tests/e2e/crud-smoke.spec.ts +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -3,6 +3,7 @@ import { expect, Page, test } from '@playwright/test'; import { installLangBotApiMocks, makeWorkspaceEntry, + pipelineMetadata, } from './fixtures/langbot-api'; async function save(page: Page) { @@ -31,11 +32,20 @@ async function createPlaywrightBot(page: Page, name: string, description = '') { await submit(page); } -async function confirmDelete(page: Page) { +async function confirmDelete(page: Page, apiPath: string) { + const responses: boolean[] = []; + page.on('response', (response) => { + if ( + response.request().method() === 'DELETE' && + new URL(response.url()).pathname === apiPath + ) + responses.push(response.ok()); + }); await page .getByRole('dialog') .getByRole('button', { name: /^Confirm Delete$/ }) .click(); + await expect.poll(() => responses).toEqual([true]); } async function installDelayedFirstSave(page: Page, apiPath: string) { @@ -198,10 +208,14 @@ test.describe('frontend CRUD smoke flows', () => { ).toBeVisible(); await page.getByRole('button', { name: /^Delete$/ }).click(); - await confirmDelete(page); + await confirmDelete(page, '/api/v1/platform/bots/bot-1'); await expect(page).toHaveURL(/\/home\/bots$/); await expect(page.getByText('Select a bot from the sidebar')).toBeVisible(); + await page.reload(); + await expect( + page.getByText('Support Bot Updated', { exact: true }), + ).toHaveCount(0); }); test('creates, edits, and deletes a pipeline', async ({ page }) => { @@ -264,15 +278,26 @@ test.describe('frontend CRUD smoke flows', () => { .getByLabel('Description') .fill('Routes urgent customer issues to operators.'); await pipelineInfoDialog.getByRole('button', { name: 'Save' }).click(); + await expect(pipelineInfoDialog).toHaveCount(0); + await page.reload(); + await page.getByRole('button', { name: 'Edit basic information' }).click(); + await expect( + page.getByRole('dialog').getByLabel('Description'), + ).toHaveValue('Routes urgent customer issues to operators.'); + await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Management' }).click(); await page.getByRole('button', { name: /^Delete$/ }).click(); - await confirmDelete(page); + await confirmDelete(page, '/api/v1/pipelines/pipeline-1'); await expect(page).toHaveURL(/\/home\/agents$/); await expect( page.getByText('Select a processor from the sidebar'), ).toBeVisible(); + await page.reload(); + await expect( + page.getByText('Escalation Pipeline', { exact: true }), + ).toHaveCount(0); }); test('opens pipeline AI capabilities with malformed model options', async ({ @@ -337,12 +362,16 @@ test.describe('frontend CRUD smoke flows', () => { ).toBeVisible(); await page.getByRole('button', { name: /^Delete$/ }).click(); - await confirmDelete(page); + await confirmDelete(page, '/api/v1/knowledge/bases/knowledge-1'); await expect(page).toHaveURL(/\/home\/knowledge$/); await expect( page.getByText('Select a knowledge base from the sidebar'), ).toBeVisible(); + await page.reload(); + await expect( + page.getByText('Support Knowledge Updated', { exact: true }), + ).toHaveCount(0); }); test('creates, edits, and deletes an MCP server', async ({ page }) => { @@ -366,18 +395,33 @@ test.describe('frontend CRUD smoke flows', () => { await page .locator('input[name="url"]') .fill('https://mcp.example.test/updated-sse'); + const update = page.waitForResponse( + (response) => + response.request().method() === 'PUT' && + response.url().endsWith('/api/v1/mcp/servers/playwright-mcp'), + ); await save(page); + expect((await update).request().postDataJSON()).toMatchObject({ + name: 'playwright-mcp', + mode: 'remote', + extra_args: { url: 'https://mcp.example.test/updated-sse' }, + }); + await page.reload(); await expect(page.locator('input[name="url"]')).toHaveValue( 'https://mcp.example.test/updated-sse', ); await page.getByRole('button', { name: /^Delete$/ }).click(); - await confirmDelete(page); + await confirmDelete(page, '/api/v1/mcp/servers/playwright-mcp'); await expect(page).toHaveURL(/\/home\/mcp$/); await expect( page.getByText('Select an MCP server from the sidebar'), ).toBeVisible(); + await page.reload(); + await expect(page.getByText('playwright-mcp', { exact: true })).toHaveCount( + 0, + ); }); test('updates and deletes a manually-created skill', async ({ page }) => { @@ -415,9 +459,13 @@ test.describe('frontend CRUD smoke flows', () => { ); await page.getByRole('button', { name: /^Delete$/ }).click(); - await confirmDelete(page); + await confirmDelete(page, '/api/v1/skills/release_notes'); await expect(page).toHaveURL(/\/home\/add-extension$/); + await page.goto('/home/skills'); + await expect(page.getByText('Release Notes', { exact: true })).toHaveCount( + 0, + ); }); }); @@ -679,6 +727,54 @@ test.describe('bot advanced flows', () => { test('switches between bot detail tabs', async ({ page }) => { await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/platform/bots/bot-1/logs', (route) => + route.fulfill({ + json: { + code: 0, + data: { + logs: [ + { + seq_id: 1, + level: 'info', + text: 'Bot tab log response', + images: [], + message_session_id: '', + timestamp: 1792200000, + }, + ], + total_count: 1, + }, + }, + }), + ); + await page.route('**/api/v1/monitoring/sessions?**', (route) => { + expect(new URL(route.request().url()).searchParams.get('botId')).toBe( + 'bot-1', + ); + return route.fulfill({ + json: { + code: 0, + data: { + sessions: [ + { + session_id: 'person_tab-user', + bot_id: 'bot-1', + bot_name: 'Tab Test Bot', + pipeline_id: 'pipeline-tab', + pipeline_name: 'Tab pipeline', + message_count: 1, + start_time: '2026-10-17T00:00:00Z', + last_activity: '2026-10-17T00:01:00Z', + is_active: true, + user_id: 'tab-user', + user_name: 'Tab Session User', + }, + ], + total: 1, + }, + }, + }); + }); // Create a bot await page.goto('/home/bots?id=new'); @@ -699,6 +795,10 @@ test.describe('bot advanced flows', () => { 'active', ); + await expect( + page.getByText('Bot tab log response', { exact: true }), + ).toBeVisible(); + // Switch to Sessions tab await page.getByRole('tab', { name: /Sessions/ }).click(); await expect(page.getByRole('tab', { name: /Sessions/ })).toHaveAttribute( @@ -706,6 +806,10 @@ test.describe('bot advanced flows', () => { 'active', ); + await expect( + page.getByText('Tab Session User', { exact: true }), + ).toBeVisible(); + // Switch back to Configuration await page.getByRole('tab', { name: /Configuration/ }).click(); await expect( @@ -716,6 +820,14 @@ test.describe('bot advanced flows', () => { test('save button is disabled when form is clean', async ({ page }) => { await installLangBotApiMocks(page, { authenticated: true }); + await page.goto('/home/agents?id=new'); + await page.getByRole('radio', { name: /^Pipeline/ }).click(); + await page.locator('#agent-create-form').getByRole('combobox').click(); + await page.getByRole('option', { name: /Local Agent/ }).click(); + await page.locator('input[name="name"]').fill('Dirty state target'); + await submit(page); + await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/); + // Create a bot await page.goto('/home/bots?id=new'); await createPlaywrightBot(page, 'Clean Form Bot'); @@ -728,7 +840,9 @@ test.describe('bot advanced flows', () => { ).toBeVisible(); // After loading, save button should be disabled (form is clean) - const saveButton = page.getByRole('button', { name: /^Save$/ }); + const saveButton = page.getByRole('button', { + name: /^Save(?: and enable)?$/, + }); await expect(saveButton).toBeDisabled(); await page.getByRole('button', { name: 'Edit basic information' }).click(); @@ -736,6 +850,20 @@ test.describe('bot advanced flows', () => { await infoDialog.getByLabel('Description').fill('New description'); await infoDialog.getByRole('button', { name: 'Save' }).click(); await expect(saveButton).toBeDisabled(); + await page.getByRole('button', { name: 'Add behavior' }).click(); + await page.getByRole('menuitem', { name: /^Reply to messages/ }).click(); + await page + .getByRole('combobox') + .filter({ hasText: 'Select processor' }) + .click(); + await page.getByRole('option', { name: /Dirty state target/ }).click(); + await expect(saveButton).toBeEnabled(); + await save(page); + await expect(saveButton).toBeDisabled(); + await page.reload(); + await expect( + page.getByRole('combobox').filter({ hasText: 'Dirty state target' }), + ).toBeVisible(); }); test('shows validation error when bot name is empty', async ({ page }) => { @@ -823,6 +951,17 @@ test.describe('pipeline advanced flows', () => { test('save button reflects form dirty state', async ({ page }) => { await installLangBotApiMocks(page, { authenticated: true }); + const metadata = pipelineMetadata(); + metadata.configs[0].stages[1].config.push({ + name: 'instruction', + label: { en_US: 'Instruction', zh_Hans: 'Instruction' }, + type: 'text', + required: true, + default: 'Original instruction', + } as any); + await page.route('**/api/v1/pipelines/_/metadata', (route) => + route.fulfill({ json: { code: 0, data: metadata } }), + ); // Create a pipeline await page.goto('/home/agents?id=new'); @@ -842,6 +981,18 @@ test.describe('pipeline advanced flows', () => { page.getByRole('heading', { name: /Dirty Form Pipeline Updated/ }), ).toBeVisible(); await expect(saveButton).toBeDisabled(); + await page.getByRole('tab', { name: /^AI$/ }).click(); + const instruction = page.locator('textarea[name="instruction"]'); + await expect(instruction).toHaveValue('Original instruction'); + await instruction.fill('Updated instruction'); + await expect(saveButton).toBeEnabled(); + await save(page); + await expect(saveButton).toBeDisabled(); + await page.reload(); + await page.getByRole('tab', { name: /^AI$/ }).click(); + await expect(page.locator('textarea[name="instruction"]')).toHaveValue( + 'Updated instruction', + ); }); test('shows validation error when pipeline name is empty', async ({ diff --git a/web/tests/e2e/entity-loading.spec.ts b/web/tests/e2e/entity-loading.spec.ts index 27a7f190e..0f051d3a0 100644 --- a/web/tests/e2e/entity-loading.spec.ts +++ b/web/tests/e2e/entity-loading.spec.ts @@ -22,7 +22,7 @@ async function setup(page: Page) { await page.route('**/api/v1/plugins/qa/loading**', async (route) => { const path = new URL(route.request().url()).pathname; const data = path.endsWith('/config') - ? { config: {} } + ? { config: { support_text: 'Restored plugin configuration' } } : path.endsWith('/readme') ? { readme: '# Loaded documentation' } : { @@ -35,7 +35,16 @@ async function setup(page: Page) { label: { en_US: 'Loading test plugin' }, description: { en_US: 'Test' }, }, - spec: { config: [] }, + spec: { + config: [ + { + name: 'support_text', + label: { en_US: 'Plugin greeting' }, + type: 'text', + default: '', + }, + ], + }, }, }, components: [], @@ -179,8 +188,8 @@ const cases = [ name: 'plugin configuration', url: '/home/extensions?id=qa/loading', endpoint: '/plugins/qa/loading/config', - form: '[data-slot="card-title"]', - readyText: 'Plugin Configuration', + form: 'textarea[name="support_text"]', + readyValue: 'Restored plugin configuration', }, ]; @@ -225,7 +234,9 @@ for (const scenario of cases) { test(`${scenario.name}: failed request can be retried`, async ({ page }) => { await setup(page); let fail = true; + let requests = 0; await page.route(`**/api/v1${scenario.endpoint}`, async (route) => { + requests++; if (fail) await route.fulfill({ status: 503, @@ -242,9 +253,19 @@ for (const scenario of cases) { .getByRole('alert') .filter({ hasText: 'Unable to load this content' }); await expect(error).toBeVisible(); + const requestsBeforeRetry = requests; fail = false; await error.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect.poll(() => requests).toBeGreaterThan(requestsBeforeRetry); await expect(error).toHaveCount(0); + if (scenario.readyText) + await expect( + page.getByText(scenario.readyText, { exact: true }).first(), + ).toBeVisible(); + if (scenario.readyValue) + await expect(page.locator(scenario.form)).toHaveValue( + scenario.readyValue, + ); await expect(page.locator(scenario.form).first()).toBeAttached(); await expect( page.getByRole('status').filter({ hasText: 'Loading...' }), diff --git a/web/tests/e2e/home-smoke.spec.ts b/web/tests/e2e/home-smoke.spec.ts index 3f7b808ed..dbc19d85d 100644 --- a/web/tests/e2e/home-smoke.spec.ts +++ b/web/tests/e2e/home-smoke.spec.ts @@ -138,3 +138,37 @@ test.describe('authenticated app shell', () => { ); }); }); + +test('token monitoring displays structured API failures and retries the request', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + let failed = true; + let requests = 0; + await page.route( + '**/api/v1/monitoring/token-statistics?**', + async (route) => { + requests++; + if (failed) + return route.fulfill({ + status: 503, + json: { code: 1, msg: 'Token aggregation unavailable' }, + }); + await route.fallback(); + }, + ); + await page.goto('/home/monitoring'); + await page.getByRole('tab', { name: 'Token Monitoring' }).click(); + const error = page + .getByRole('alert') + .filter({ hasText: 'Token aggregation unavailable' }); + await expect(error).toBeVisible(); + const beforeRetry = requests; + failed = false; + await error.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect.poll(() => requests).toBeGreaterThan(beforeRetry); + await expect(error).toHaveCount(0); + await expect( + page.getByText('No token usage in the selected time range'), + ).toBeVisible(); +}); diff --git a/web/tests/e2e/pipeline-migration.spec.ts b/web/tests/e2e/pipeline-migration.spec.ts index d7b88e022..ab068e2bd 100644 --- a/web/tests/e2e/pipeline-migration.spec.ts +++ b/web/tests/e2e/pipeline-migration.spec.ts @@ -419,7 +419,9 @@ for (const failure of [ test('workspace changes discard previous task results and stop polling', async ({ page, }) => { + await page.clock.install(); const state = await setup(page); + state.done = false; let release!: () => void; state.holdPoll = new Promise((resolve) => { release = resolve; @@ -437,6 +439,8 @@ test('workspace changes discard previous task results and stop polling', async ( await expect( page.getByRole('dialog', { name: 'Pipeline migration' }), ).toHaveCount(0); + await page.clock.runFor(3000); + expect(state.polls).toBe(1); expect(state.posts).toHaveLength(1); }); diff --git a/web/tests/e2e/processor-detail-workbench.spec.ts b/web/tests/e2e/processor-detail-workbench.spec.ts index 6bdb5b45e..bb053e0f9 100644 --- a/web/tests/e2e/processor-detail-workbench.spec.ts +++ b/web/tests/e2e/processor-detail-workbench.spec.ts @@ -77,6 +77,16 @@ test.describe('processor detail workbench', () => { expect(debugBox!.y).toBeGreaterThanOrEqual(0); const flow = configPanel.getByRole('tablist'); + await expect(flow.getByRole('tab')).toHaveCount(2); + const tabs = await flow.getByRole('tab').all(); + const positions = await Promise.all(tabs.map((tab) => tab.boundingBox())); + expect(positions.every((position) => position !== null)).toBe(true); + for (let index = 1; index < positions.length; index++) { + expect( + Math.abs(positions[index]!.y - positions[0]!.y), + ).toBeLessThanOrEqual(1); + expect(positions[index]!.x).toBeGreaterThan(positions[index - 1]!.x); + } await expect(flow.getByRole('tab').nth(0)).toContainText('Runner'); await expect(flow.getByRole('tab').nth(1)).toContainText('Events & tools'); await expect(flow.getByRole('tab')).toHaveCount(2); @@ -490,6 +500,16 @@ test.describe('processor detail workbench', () => { expect(debugBox!.y).toBeGreaterThanOrEqual(0); const flow = configPanel.getByRole('tablist'); + await expect(flow.getByRole('tab')).toHaveCount(3); + const tabs = await flow.getByRole('tab').all(); + const positions = await Promise.all(tabs.map((tab) => tab.boundingBox())); + expect(positions.every((position) => position !== null)).toBe(true); + for (let index = 1; index < positions.length; index++) { + expect( + Math.abs(positions[index]!.y - positions[0]!.y), + ).toBeLessThanOrEqual(1); + expect(positions[index]!.x).toBeGreaterThan(positions[index - 1]!.x); + } await expect(flow.getByRole('tab').nth(0)).toContainText('Trigger'); await expect(flow.getByRole('tab').nth(1)).toContainText('AI'); await expect(flow.getByRole('tab').nth(2)).toContainText('Output'); diff --git a/web/tests/e2e/workspace-assistant.spec.ts b/web/tests/e2e/workspace-assistant.spec.ts index 1f0a4edcc..102d48739 100644 --- a/web/tests/e2e/workspace-assistant.spec.ts +++ b/web/tests/e2e/workspace-assistant.spec.ts @@ -570,16 +570,34 @@ test('setup login starts the existing LangBot Account authorization flow', async await page.route('**/api/v1/provider/models/llm', (route) => route.fulfill({ json: { code: 0, data: { models: [] } } }), ); + const authorizationUrl = + 'https://space.langbot.app/authorize?state=assistant-bind-state'; + await page.route('**/api/v1/user/space/bind-authorize-url?**', (route) => + route.fulfill({ + json: { code: 0, data: { authorize_url: authorizationUrl } }, + }), + ); await page.goto('/home/bots'); + const origin = new URL(page.url()).origin; const request = page.waitForRequest( (request) => - request.url().includes('/space/') && request.url().includes('authorize'), + new URL(request.url()).pathname === + '/api/v1/user/space/bind-authorize-url', ); await page .getByRole('dialog', { name: 'Workspace assistant' }) .getByRole('button', { name: 'Sign in to LangBot Account' }) .click(); - expect((await request).url()).toContain('authorize'); + const authorization = await request; + expect(authorization.method()).toBe('GET'); + expect(new URL(authorization.url()).searchParams.get('redirect_uri')).toBe( + `${origin}/auth/space/callback?mode=bind`, + ); + expect(authorization.headers()['authorization']).toBe( + 'Bearer playwright-token', + ); + expect(authorization.headers()['x-workspace-id']).toBeUndefined(); + await expect(page).toHaveURL(authorizationUrl); }); test('switches conversations, restores background progress after reload and stops only the selected task', async ({ diff --git a/web/tests/unit/processor-detail-workbench.test.mjs b/web/tests/unit/processor-detail-workbench.test.mjs index 5ff6068a9..aede7e39d 100644 --- a/web/tests/unit/processor-detail-workbench.test.mjs +++ b/web/tests/unit/processor-detail-workbench.test.mjs @@ -1,97 +1,33 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import path from 'node:path'; import test from 'node:test'; -import { fileURLToPath } from 'node:url'; -const testDir = path.dirname(fileURLToPath(import.meta.url)); -const webRoot = path.resolve(testDir, '../..'); - -function readSource(relativePath) { - return fs.readFileSync(path.join(webRoot, relativePath), 'utf8'); -} - -test('agent and pipeline details share the split processor workbench', () => { - const workbench = readSource( - 'src/app/home/components/processor-detail/ProcessorDetailWorkbench.tsx', - ); - const agentDetail = readSource('src/app/home/agents/AgentDetailContent.tsx'); - const pipelineDetail = readSource( - 'src/app/home/pipelines/PipelineDetailContent.tsx', - ); - const websocketClient = readSource( - 'src/app/infra/websocket/WebSocketClient.ts', - ); - const pipelineDebug = readSource( - 'src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx', - ); +const source = fs.readFileSync( + new URL( + '../../src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx', + import.meta.url, + ), + 'utf8', +); +// Shared WebSocket tests do not mount this component's socket replacement effect. +test('pipeline debug retains ownership guards before callback and connection-error updates', () => { + for (const callback of [ + 'onConnected', + 'onMessage', + 'onError', + 'onClose', + 'onBroadcast', + ]) { + assert.match( + source, + new RegExp( + `\\.${callback}\\(\\([^)]*\\) => \\{\\s*if \\(wsClientRef\\.current !== wsClient\\) return;`, + ), + ); + } assert.match( - workbench, - /lg:grid-cols-\[minmax\(20rem,0\.72fr\)_minmax\(0,1\.28fr\)\]/, + source, + /await wsClient\.connect\(\);\s*\} catch \(error\) \{\s*if \(!wsClient \|\| wsClientRef\.current !== wsClient\) return;/, ); - assert.ok( - workbench.indexOf('{debugContent}') < - workbench.lastIndexOf('{configPanel}'), - ); - assert.match(agentDetail, / \{\s*this\.reconnectAttempts = 0/, - ); - assert.match( - websocketClient, - /data\.type === 'connected'[\s\S]*this\.reconnectAttempts = 0/, - ); - assert.match(websocketClient, /private reconnectTimeout:/); - assert.match(websocketClient, /private disconnectedByUser = false/); - assert.match( - websocketClient, - /if \(!this\.connectionId\)[\s\S]*this\.shouldReconnect = false/, - ); - assert.match( - pipelineDebug, - /wsClientRef\.current = wsClient;[\s\S]*await wsClient\.connect\(\)/, - ); - assert.match( - pipelineDebug, - /if \(wsClientRef\.current !== wsClient\) return;/, - ); - assert.match(pipelineDebug, /data-slot="scroll-area-viewport"/); - assert.doesNotMatch(pipelineDebug, /scrollIntoView/); -}); - -test('processor forms expose their primary orchestration flow horizontally', () => { - const agentForm = readSource( - 'src/app/home/agents/components/AgentFormComponent.tsx', - ); - const pipelineForm = readSource( - 'src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx', - ); - - const agentSections = agentForm.slice( - agentForm.indexOf('const primarySections:'), - agentForm.indexOf('const runnerStatus ='), - ); - assert.deepEqual( - [...agentSections.matchAll(/name: '([^']+)'/g)].map((match) => match[1]), - ['runner', 'events_and_tools'], - ); - const runnerPanel = agentForm.slice( - agentForm.indexOf("{activeSection === 'runner' &&"), - agentForm.indexOf("{activeSection === 'events_and_tools' &&"), - ); - assert.match(runnerPanel, /renderDynamicStage\(runnerSelectorStage\)/); - assert.match(runnerPanel, /renderDynamicStage\(activeRunnerStage\)/); - assert.match( - pipelineForm, - /const primarySectionNames = \['trigger', 'ai', 'output'\]/, - ); - assert.match(agentForm, /]*grid-cols-2/); - assert.match(pipelineForm, /]*grid-cols-3/); - assert.doesNotMatch(agentForm, /
    { assert.equal(getErrorMessage(new Error('Network failed')), 'Network failed'); assert.equal(getErrorMessage('Request failed'), 'Request failed'); }); - -test('token monitoring uses the structured API error helper', () => { - const source = fs.readFileSync(componentPath, 'utf8'); - assert.match(source, /import \{ getErrorMessage \} from '\.\.\/utils';/); - assert.match(source, /setError\(getErrorMessage\(e\)\)/); -});