mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-16 10:46:03 +00:00
Compare commits
1 Commits
master
...
test/front
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90a5dcb7f0 |
168
web/tests/e2e/crud-smoke.spec.ts
Normal file
168
web/tests/e2e/crud-smoke.spec.ts
Normal 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$/);
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user