From 90a5dcb7f0e936016766fd06cfec2f91a69b3705 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:36:44 +0800 Subject: [PATCH] test: cover frontend CRUD smoke flows --- web/tests/e2e/crud-smoke.spec.ts | 168 +++++++++++++ web/tests/e2e/fixtures/langbot-api.ts | 325 +++++++++++++++++++++++++- 2 files changed, 490 insertions(+), 3 deletions(-) create mode 100644 web/tests/e2e/crud-smoke.spec.ts diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts new file mode 100644 index 00000000..3dd7c403 --- /dev/null +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -0,0 +1,168 @@ +import { expect, Page, test } from '@playwright/test'; + +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +async function save(page: Page) { + const button = page.getByRole('button', { name: /^Save$/ }); + await expect(button).toBeEnabled(); + await button.click(); +} + +async function submit(page: Page) { + await page.getByRole('button', { name: /^Submit$/ }).click(); +} + +async function confirmDelete(page: Page) { + await page + .getByRole('dialog') + .getByRole('button', { name: /^Confirm Delete$/ }) + .click(); +} + +test.describe('frontend CRUD smoke flows', () => { + test('creates, edits, and deletes a pipeline', async ({ page }) => { + await installLangBotApiMocks(page, { authenticated: true }); + + await page.goto('/home/pipelines?id=new'); + + await expect(page.locator('input[name="basic.name"]')).toBeVisible(); + await page.locator('input[name="basic.name"]').fill('Escalation Pipeline'); + await page + .locator('input[name="basic.description"]') + .fill('Routes urgent customer issues.'); + await submit(page); + + await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/); + await page.reload(); + await expect(page.locator('input[name="basic.name"]')).toHaveValue( + 'Escalation Pipeline', + ); + + await page + .locator('input[name="basic.description"]') + .fill('Routes urgent customer issues to operators.'); + await save(page); + await expect(page.locator('input[name="basic.description"]')).toHaveValue( + 'Routes urgent customer issues to operators.', + ); + + await page.getByRole('button', { name: /^Delete$/ }).click(); + await confirmDelete(page); + + await expect(page).toHaveURL(/\/home\/pipelines$/); + await expect( + page.getByText('Select a pipeline from the sidebar'), + ).toBeVisible(); + }); + + test('creates, edits, and deletes a knowledge base', async ({ page }) => { + await installLangBotApiMocks(page, { authenticated: true }); + + await page.goto('/home/knowledge?id=new'); + + await expect(page.locator('input[name="name"]')).toBeVisible(); + await page.locator('input[name="name"]').fill('Support Knowledge'); + await page + .locator('input[name="description"]') + .fill('Source material for support answers.'); + await submit(page); + + await expect(page).toHaveURL(/\/home\/knowledge\?id=knowledge-1$/); + await page.reload(); + await expect(page.locator('input[name="name"]')).toHaveValue( + 'Support Knowledge', + ); + await page.waitForTimeout(600); + + await page + .locator('input[name="description"]') + .fill('Updated source material for support answers.'); + await save(page); + await expect(page.locator('input[name="description"]')).toHaveValue( + 'Updated source material for support answers.', + ); + + await page.getByRole('button', { name: /^Delete$/ }).click(); + await confirmDelete(page); + + await expect(page).toHaveURL(/\/home\/knowledge$/); + await expect( + page.getByText('Select a knowledge base from the sidebar'), + ).toBeVisible(); + }); + + test('creates, edits, and deletes an MCP server', async ({ page }) => { + await installLangBotApiMocks(page, { authenticated: true }); + + await page.goto('/home/mcp?id=new'); + + await expect(page.locator('input[name="name"]')).toBeVisible(); + await page.locator('input[name="name"]').fill('playwright-mcp'); + await page + .locator('input[name="url"]') + .fill('https://mcp.example.test/sse'); + await submit(page); + + await expect(page).toHaveURL(/\/home\/mcp\?id=playwright-mcp$/); + await page.reload(); + await expect(page.locator('input[name="name"]')).toHaveValue( + 'playwright-mcp', + ); + + await page + .locator('input[name="url"]') + .fill('https://mcp.example.test/updated-sse'); + await save(page); + 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 expect(page).toHaveURL(/\/home\/mcp$/); + await expect( + page.getByText('Select an MCP server from the sidebar'), + ).toBeVisible(); + }); + + test('updates and deletes a manually-created skill', async ({ page }) => { + await installLangBotApiMocks(page, { authenticated: true }); + + await page.goto('/home/skills?action=create'); + + await page.locator('#display_name').fill('Release Notes'); + await page.locator('#name').fill('release_notes'); + await page.locator('#description').fill('Drafts release notes.'); + await page + .locator('#instructions') + .fill('Summarize merged changes for the next release.'); + await save(page); + + await expect(page).toHaveURL(/\/home\/skills\?id=release_notes$/); + await page.reload(); + await expect(page.locator('#description')).toHaveValue( + 'Drafts release notes.', + ); + + await page + .locator('#description') + .fill('Drafts concise release notes for maintainers.'); + await expect(page.locator('#description')).toHaveValue( + 'Drafts concise release notes for maintainers.', + ); + await save(page); + await page.reload(); + await expect(page.locator('#description')).toHaveValue( + 'Drafts concise release notes for maintainers.', + ); + await expect(page.locator('#instructions')).toHaveValue( + 'Summarize merged changes for the next release.', + ); + + await page.getByRole('button', { name: /^Delete$/ }).click(); + await confirmDelete(page); + + await expect(page).toHaveURL(/\/home\/add-extension$/); + }); +}); diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index 08f23a5b..ab8e2224 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -11,7 +11,54 @@ interface SkillMock { updated_at: string; } +interface PipelineMock { + uuid: string; + name: string; + description: string; + config: JsonRecord; + emoji: string; + is_default: boolean; + updated_at: string; +} + +interface KnowledgeBaseMock { + uuid: string; + name: string; + description: string; + emoji: string; + knowledge_engine_plugin_id: string; + creation_settings: JsonRecord; + retrieval_settings: JsonRecord; + knowledge_engine: { + plugin_id: string; + name: { + en_US: string; + zh_Hans: string; + }; + capabilities: string[]; + }; + updated_at: string; +} + +interface MCPServerMock { + name: string; + mode: 'sse' | 'stdio' | 'http'; + enable: boolean; + extra_args: JsonRecord; + runtime_info: { + status: 'connected'; + tool_count: number; + tools: unknown[]; + }; + readme: string; + updated_at: string; +} + interface LangBotApiMockState { + counters: Record; + knowledgeBases: KnowledgeBaseMock[]; + mcpServers: MCPServerMock[]; + pipelines: PipelineMock[]; skills: SkillMock[]; } @@ -36,6 +83,19 @@ function routePath(route: Route) { return new URL(route.request().url()).pathname; } +function parseJsonBody(route: Route): JsonRecord { + return JSON.parse(route.request().postData() || '{}') as JsonRecord; +} + +function now() { + return new Date().toISOString(); +} + +function nextId(state: LangBotApiMockState, prefix: string) { + state.counters[prefix] = (state.counters[prefix] || 0) + 1; + return `${prefix}-${state.counters[prefix]}`; +} + function emptyMonitoringData() { return { overview: { @@ -93,6 +153,86 @@ function makeSkill(data: JsonRecord): SkillMock { }; } +function makePipeline( + state: LangBotApiMockState, + data: JsonRecord, + uuid = nextId(state, 'pipeline'), +): PipelineMock { + return { + uuid, + name: String(data.name || ''), + description: String(data.description || ''), + config: (data.config as JsonRecord | undefined) || { + ai: {}, + trigger: {}, + safety: {}, + output: {}, + }, + emoji: String(data.emoji || '⚙️'), + is_default: false, + updated_at: now(), + }; +} + +function knowledgeEngine() { + return { + plugin_id: 'builtin/minimal-knowledge', + name: { + en_US: 'Minimal Knowledge Engine', + zh_Hans: '最小知识库引擎', + }, + description: { + en_US: 'Minimal mocked engine for frontend smoke tests.', + zh_Hans: '用于前端冒烟测试的最小模拟引擎。', + }, + capabilities: ['text_retrieval'], + creation_schema: [], + retrieval_schema: [], + }; +} + +function makeKnowledgeBase( + state: LangBotApiMockState, + data: JsonRecord, + uuid = nextId(state, 'knowledge'), +): KnowledgeBaseMock { + const engine = knowledgeEngine(); + return { + uuid, + name: String(data.name || ''), + description: String(data.description || ''), + emoji: String(data.emoji || '📚'), + knowledge_engine_plugin_id: String( + data.knowledge_engine_plugin_id || engine.plugin_id, + ), + creation_settings: (data.creation_settings as JsonRecord | undefined) || {}, + retrieval_settings: + (data.retrieval_settings as JsonRecord | undefined) || {}, + knowledge_engine: { + plugin_id: engine.plugin_id, + name: engine.name, + capabilities: engine.capabilities, + }, + updated_at: now(), + }; +} + +function makeMCPServer(data: JsonRecord): MCPServerMock { + return { + name: String(data.name || ''), + mode: (data.mode as MCPServerMock['mode']) || 'sse', + enable: data.enable !== false, + extra_args: (data.extra_args as JsonRecord | undefined) || {}, + runtime_info: { + status: 'connected', + tool_count: 0, + tools: [], + }, + readme: '', + updated_at: now(), + }; +} + async function handleBackendApi(route: Route, state: LangBotApiMockState) { const request = route.request(); const url = new URL(request.url()); @@ -151,12 +291,117 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) { return fulfillJson(route, { bots: [] }); } + if (path === '/api/v1/pipelines/_/metadata') { + return fulfillJson(route, { configs: [] }); + } + if (path === '/api/v1/pipelines') { - return fulfillJson(route, { pipelines: [] }); + if (method === 'POST') { + const pipeline = makePipeline(state, parseJsonBody(route)); + state.pipelines = [ + ...state.pipelines.filter((item) => item.uuid !== pipeline.uuid), + pipeline, + ]; + return fulfillJson(route, { uuid: pipeline.uuid }); + } + + return fulfillJson(route, { pipelines: state.pipelines }); + } + + const pipelineMatch = path.match(/^\/api\/v1\/pipelines\/([^/]+)$/); + if (pipelineMatch) { + const pipelineId = decodeURIComponent(pipelineMatch[1]); + + if (method === 'PUT') { + const pipeline = makePipeline(state, parseJsonBody(route), pipelineId); + state.pipelines = [ + ...state.pipelines.filter((item) => item.uuid !== pipelineId), + pipeline, + ]; + return fulfillJson(route, {}); + } + + if (method === 'DELETE') { + state.pipelines = state.pipelines.filter( + (item) => item.uuid !== pipelineId, + ); + return fulfillJson(route, {}); + } + + const pipeline = state.pipelines.find((item) => item.uuid === pipelineId); + return fulfillJson(route, { + pipeline: + pipeline || makePipeline(state, { name: pipelineId }, pipelineId), + }); + } + + const pipelineExtensionsMatch = path.match( + /^\/api\/v1\/pipelines\/([^/]+)\/extensions$/, + ); + if (pipelineExtensionsMatch) { + return fulfillJson(route, { + enable_all_plugins: true, + enable_all_mcp_servers: true, + enable_all_skills: true, + bound_plugins: [], + available_plugins: [], + bound_mcp_servers: [], + available_mcp_servers: state.mcpServers, + bound_skills: [], + available_skills: state.skills, + }); } if (path === '/api/v1/knowledge/bases') { - return fulfillJson(route, { bases: [] }); + if (method === 'POST') { + const base = makeKnowledgeBase(state, parseJsonBody(route)); + state.knowledgeBases = [ + ...state.knowledgeBases.filter((item) => item.uuid !== base.uuid), + base, + ]; + return fulfillJson(route, { uuid: base.uuid }); + } + + return fulfillJson(route, { bases: state.knowledgeBases }); + } + + const knowledgeBaseFilesMatch = path.match( + /^\/api\/v1\/knowledge\/bases\/([^/]+)\/files$/, + ); + if (knowledgeBaseFilesMatch) { + return fulfillJson(route, { files: [] }); + } + + const knowledgeBaseMatch = path.match( + /^\/api\/v1\/knowledge\/bases\/([^/]+)$/, + ); + if (knowledgeBaseMatch) { + const baseId = decodeURIComponent(knowledgeBaseMatch[1]); + + if (method === 'PUT') { + const base = makeKnowledgeBase(state, parseJsonBody(route), baseId); + state.knowledgeBases = [ + ...state.knowledgeBases.filter((item) => item.uuid !== baseId), + base, + ]; + return fulfillJson(route, { uuid: base.uuid }); + } + + if (method === 'DELETE') { + state.knowledgeBases = state.knowledgeBases.filter( + (item) => item.uuid !== baseId, + ); + return fulfillJson(route, {}); + } + + const base = state.knowledgeBases.find((item) => item.uuid === baseId); + return fulfillJson(route, { + base: base || makeKnowledgeBase(state, { name: baseId }, baseId), + }); + } + + if (path === '/api/v1/knowledge/engines') { + return fulfillJson(route, { engines: [knowledgeEngine()] }); } if (path === '/api/v1/knowledge/migration/status') { @@ -176,7 +421,60 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) { } if (path === '/api/v1/mcp/servers') { - return fulfillJson(route, { servers: [] }); + if (method === 'POST') { + const server = makeMCPServer(parseJsonBody(route)); + state.mcpServers = [ + ...state.mcpServers.filter((item) => item.name !== server.name), + server, + ]; + return fulfillJson(route, { task_id: nextId(state, 'task') }); + } + + return fulfillJson(route, { servers: state.mcpServers }); + } + + const mcpTestMatch = path.match(/^\/api\/v1\/mcp\/servers\/([^/]+)\/test$/); + if (mcpTestMatch) { + return fulfillJson(route, { + runtime_info: { + status: 'connected', + tool_count: 0, + tools: [], + }, + }); + } + + const mcpServerMatch = path.match(/^\/api\/v1\/mcp\/servers\/([^/]+)$/); + if (mcpServerMatch) { + const serverName = decodeURIComponent(mcpServerMatch[1]); + + if (method === 'PUT') { + const existing = state.mcpServers.find( + (item) => item.name === serverName, + ); + const server = makeMCPServer({ + ...(existing || {}), + ...parseJsonBody(route), + name: serverName, + }); + state.mcpServers = [ + ...state.mcpServers.filter((item) => item.name !== serverName), + server, + ]; + return fulfillJson(route, { task_id: nextId(state, 'task') }); + } + + if (method === 'DELETE') { + state.mcpServers = state.mcpServers.filter( + (item) => item.name !== serverName, + ); + return fulfillJson(route, { task_id: nextId(state, 'task') }); + } + + const server = state.mcpServers.find((item) => item.name === serverName); + return fulfillJson(route, { + server: server || makeMCPServer({ name: serverName }), + }); } if (path === '/api/v1/skills') { @@ -229,6 +527,23 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) { const skillMatch = path.match(/^\/api\/v1\/skills\/([^/]+)$/); if (skillMatch) { const skillName = decodeURIComponent(skillMatch[1]); + if (method === 'PUT') { + const skill = makeSkill({ + ...parseJsonBody(route), + name: skillName, + }); + state.skills = [ + ...state.skills.filter((item) => item.name !== skillName), + skill, + ]; + return fulfillJson(route, { skill }); + } + + if (method === 'DELETE') { + state.skills = state.skills.filter((item) => item.name !== skillName); + return fulfillJson(route, {}); + } + const skill = state.skills.find((item) => item.name === skillName) || { name: skillName, display_name: '', @@ -389,6 +704,10 @@ export async function installLangBotApiMocks( ) { const { authenticated = false, storage = {} } = options; const state: LangBotApiMockState = { + counters: {}, + knowledgeBases: [], + mcpServers: [], + pipelines: [], skills: [], };