test(web): verify resource writes and recovery through behavior

This commit is contained in:
huanghuoguoguo
2026-10-17 15:42:39 +08:00
parent bbf517edbc
commit f7c4194b3d
8 changed files with 286 additions and 112 deletions
+158 -7
View File
@@ -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 ({
+25 -4
View File
@@ -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...' }),
+34
View File
@@ -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();
});
+4
View File
@@ -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);
});
@@ -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');
+20 -2
View File
@@ -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 ({
@@ -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, /<ProcessorDetailWorkbench/);
assert.match(agentDetail, /debugContent=/);
assert.match(pipelineDetail, /<ProcessorDetailWorkbench/);
assert.match(pipelineDetail, /compact=\{true\}/);
assert.doesNotMatch(
websocketClient,
/this\.ws\.onopen = \(\) => \{\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, /<TabsList[^>]*grid-cols-2/);
assert.match(pipelineForm, /<TabsList[^>]*grid-cols-3/);
assert.doesNotMatch(agentForm, /<ol className=/);
assert.doesNotMatch(pipelineForm, /<ol className=/);
});
@@ -10,10 +10,6 @@ const utilsPath = path.resolve(
currentDirectory,
'../../src/app/home/monitoring/utils.ts',
);
const componentPath = path.resolve(
currentDirectory,
'../../src/app/home/monitoring/components/TokenMonitoring.tsx',
);
function loadMonitoringUtils() {
const source = fs.readFileSync(utilsPath, 'utf8');
@@ -45,9 +41,3 @@ test('token monitoring extracts messages from structured API errors', () => {
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\)\)/);
});