Files
LangBot/web/tests/e2e/fixtures/langbot-api.ts
2026-06-16 11:36:44 +08:00

737 lines
18 KiB
TypeScript

import { Page, Route } from '@playwright/test';
type JsonRecord = Record<string, unknown>;
interface SkillMock {
name: string;
display_name: string;
description: string;
instructions: string;
package_root: string;
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[];
}
function ok(data: unknown) {
return {
code: 0,
message: 'ok',
data,
timestamp: Date.now(),
};
}
async function fulfillJson(route: Route, data: unknown) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(ok(data)),
});
}
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: {
total_messages: 0,
llm_calls: 0,
embedding_calls: 0,
model_calls: 0,
success_rate: 0,
active_sessions: 0,
},
messages: [],
llmCalls: [],
embeddingCalls: [],
sessions: [],
errors: [],
totalCount: {
messages: 0,
llmCalls: 0,
embeddingCalls: 0,
sessions: 0,
errors: 0,
},
};
}
function emptyTokenStatistics() {
return {
summary: {
total_calls: 0,
success_calls: 0,
error_calls: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_tokens: 0,
total_cost: 0,
avg_tokens_per_call: 0,
avg_duration_ms: 0,
avg_tokens_per_second: 0,
zero_token_success_calls: 0,
},
by_model: [],
timeseries: [],
bucket: 'day',
};
}
function makeSkill(data: JsonRecord): SkillMock {
return {
name: String(data.name || ''),
display_name: String(data.display_name || ''),
description: String(data.description || ''),
instructions: String(data.instructions || ''),
package_root: String(data.package_root || ''),
updated_at: new Date().toISOString(),
};
}
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());
const path = url.pathname;
const method = request.method();
if (path === '/api/v1/system/info') {
return fulfillJson(route, {
debug: false,
version: 'frontend-smoke',
edition: 'community',
cloud_service_url: 'https://space.langbot.app',
enable_marketplace: true,
allow_modify_login_info: true,
disable_models_service: false,
limitation: {
max_bots: -1,
max_pipelines: -1,
max_extensions: -1,
},
outbound_ips: [],
wizard_status: 'completed',
wizard_progress: null,
});
}
if (path === '/api/v1/user/account-info') {
return fulfillJson(route, {
initialized: true,
account_type: 'local',
has_password: true,
});
}
if (path === '/api/v1/user/check-token') {
return fulfillJson(route, { token: '' });
}
if (path === '/api/v1/user/auth') {
return fulfillJson(route, { token: 'playwright-token' });
}
if (path === '/api/v1/user/info') {
return fulfillJson(route, {
user: 'admin@example.com',
account_type: 'local',
has_password: true,
});
}
if (path === '/api/v1/user/space-credits') {
return fulfillJson(route, { credits: null });
}
if (path === '/api/v1/platform/bots') {
return fulfillJson(route, { bots: [] });
}
if (path === '/api/v1/pipelines/_/metadata') {
return fulfillJson(route, { configs: [] });
}
if (path === '/api/v1/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') {
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') {
return fulfillJson(route, {
needed: false,
internal_kb_count: 0,
external_kb_count: 0,
});
}
if (path === '/api/v1/plugins') {
return fulfillJson(route, { plugins: [] });
}
if (path === '/api/v1/extensions') {
return fulfillJson(route, { extensions: [] });
}
if (path === '/api/v1/mcp/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') {
if (method === 'POST') {
const skill = makeSkill(
JSON.parse(request.postData() || '{}') as JsonRecord,
);
state.skills = [
...state.skills.filter((item) => item.name !== skill.name),
skill,
];
return fulfillJson(route, { skill });
}
return fulfillJson(route, { skills: state.skills });
}
const skillFileMatch = path.match(
/^\/api\/v1\/skills\/([^/]+)\/files\/(.+)$/,
);
if (skillFileMatch) {
const skillName = decodeURIComponent(skillFileMatch[1]);
const filePath = decodeURIComponent(skillFileMatch[2]);
const skill = state.skills.find((item) => item.name === skillName);
return fulfillJson(route, {
skill: { name: skillName },
path: filePath,
content: skill?.instructions || '',
});
}
const skillFilesMatch = path.match(/^\/api\/v1\/skills\/([^/]+)\/files$/);
if (skillFilesMatch) {
const skillName = decodeURIComponent(skillFilesMatch[1]);
return fulfillJson(route, {
skill: { name: skillName },
base_path: '.',
entries: [
{
path: 'SKILL.md',
name: 'SKILL.md',
is_dir: false,
size: null,
},
],
truncated: false,
});
}
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: '',
description: '',
instructions: '',
package_root: '',
updated_at: new Date().toISOString(),
};
return fulfillJson(route, { skill });
}
if (path === '/api/v1/system/status/plugin-system') {
return fulfillJson(route, {
is_enable: true,
is_connected: true,
plugin_connector_error: '',
});
}
if (path === '/api/v1/plugins/debug-info') {
return fulfillJson(route, {
debug_url: 'ws://127.0.0.1:5300/plugin/debug',
plugin_debug_key: 'test-debug-key',
});
}
if (path === '/api/v1/box/status') {
return fulfillJson(route, {
available: true,
enabled: true,
profile: 'playwright',
recent_error_count: 0,
active_sessions: 0,
managed_processes: 0,
session_ttl_sec: 3600,
backend: {
name: 'playwright',
available: true,
},
});
}
if (path === '/api/v1/box/sessions') {
return fulfillJson(route, []);
}
if (path === '/api/v1/monitoring/data') {
return fulfillJson(route, emptyMonitoringData());
}
if (path === '/api/v1/monitoring/overview') {
return fulfillJson(route, emptyMonitoringData().overview);
}
if (path === '/api/v1/monitoring/token-statistics') {
return fulfillJson(route, emptyTokenStatistics());
}
if (path === '/api/v1/monitoring/feedback/stats') {
return fulfillJson(route, {
total_feedback: 0,
total_likes: 0,
total_dislikes: 0,
satisfaction_rate: 0,
});
}
if (path === '/api/v1/monitoring/feedback') {
return fulfillJson(route, { feedback: [], total: 0 });
}
if (path === '/api/v1/survey/pending') {
return fulfillJson(route, { survey: null });
}
if (path === '/api/v1/system/tasks') {
return fulfillJson(route, { tasks: [] });
}
if (
path === '/api/v1/marketplace/plugins' ||
path === '/api/v1/marketplace/plugins/search' ||
path === '/api/v1/marketplace/extensions/search' ||
path === '/api/v1/marketplace/mcps/search' ||
path === '/api/v1/marketplace/skills/search'
) {
return fulfillJson(route, { plugins: [], total: 0 });
}
if (path === '/api/v1/marketplace/tags') {
return fulfillJson(route, { tags: [] });
}
if (path === '/api/v1/marketplace/recommendation-lists') {
return fulfillJson(route, { lists: [] });
}
if (path === '/api/v1/dist/info/releases') {
return fulfillJson(route, []);
}
if (path === '/api/v1/dist/info/repo') {
return fulfillJson(route, {
repo: {
stargazers_count: 0,
forks_count: 0,
open_issues_count: 0,
},
contributors: [],
});
}
await fulfillJson(route, {});
}
async function handleCloudApi(route: Route) {
const path = routePath(route);
if (
path === '/api/v1/marketplace/plugins' ||
path === '/api/v1/marketplace/plugins/search' ||
path === '/api/v1/marketplace/extensions/search' ||
path === '/api/v1/marketplace/mcps/search' ||
path === '/api/v1/marketplace/skills/search'
) {
return fulfillJson(route, { plugins: [], total: 0 });
}
if (path === '/api/v1/marketplace/tags') {
return fulfillJson(route, { tags: [] });
}
if (path === '/api/v1/marketplace/recommendation-lists') {
return fulfillJson(route, { lists: [] });
}
if (path === '/api/v1/dist/info/releases') {
return fulfillJson(route, []);
}
if (path === '/api/v1/dist/info/repo') {
return fulfillJson(route, {
repo: {
stargazers_count: 0,
forks_count: 0,
open_issues_count: 0,
},
contributors: [],
});
}
await fulfillJson(route, {});
}
export async function installLangBotApiMocks(
page: Page,
options: { authenticated?: boolean; storage?: JsonRecord } = {},
) {
const { authenticated = false, storage = {} } = options;
const state: LangBotApiMockState = {
counters: {},
knowledgeBases: [],
mcpServers: [],
pipelines: [],
skills: [],
};
await page.addInitScript(
({ authenticated, storage }) => {
localStorage.setItem('langbot_language', 'en-US');
localStorage.setItem('extensions_group_by_type', 'false');
if (authenticated) {
localStorage.setItem('token', 'playwright-token');
localStorage.setItem('userEmail', 'admin@example.com');
} else {
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
}
for (const [key, value] of Object.entries(storage)) {
localStorage.setItem(key, String(value));
}
},
{ authenticated, storage },
);
await page.route('**/api/v1/**', (route) => handleBackendApi(route, state));
await page.route('https://space.langbot.app/**', handleCloudApi);
}