test: cover frontend CRUD smoke flows

This commit is contained in:
huanghuoguoguo
2026-06-16 11:36:44 +08:00
parent f390980d0a
commit 90a5dcb7f0
2 changed files with 490 additions and 3 deletions

View File

@@ -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$/);
});
});

View File

@@ -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<string, number>;
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: [],
};