新增: playwright e2e 测试基建与 tauri 垫片

invoke 条件垫片使前端可在无 tauri 容器下直连 dev server 做端到端验证
This commit is contained in:
lxy
2026-10-01 02:41:21 +08:00
parent df3ed55d01
commit efdf1e0282
6 changed files with 452 additions and 0 deletions
+350
View File
@@ -0,0 +1,350 @@
//! 浏览器无头测试专用 Tauri IPC 垫片(条件注入)
//!
//! 为什么存在:纯 vite 浏览器打开(Playwright 无头 chromium)时,@tauri-apps/api 在调用时读
//! window.__TAURI_INTERNALS__,浏览器里没有这个对象,同步 TypeError 直接打断启动链致整页白屏。
//! 本模块在该对象缺失时注入一份纯内存实现,invoke 按命令名分发到内置只读假数据。
//!
//! 数据边界:全部返回值是模块内写死的内存常量,不读也不写任何真实数据库
//! (生产 devflow.db 与开发 devflow-dev.db 均不接触),无文件系统与网络访问。
//!
//! 桌面壳影响面:Tauri 壳内 __TAURI_INTERNALS__ 已由宿主注入,整个模块立即返回,
//! 桌面应用零行为变化。
// 对齐 @tauri-apps/api v2.11 实际访问面(mocks.js 官方 mock 同款字段集)
interface TauriInternals {
invoke(cmd: string, args?: Record<string, unknown>, options?: unknown): Promise<unknown>
transformCallback(cb: (data: unknown) => void, once?: boolean): number
unregisterCallback(id: number): void
runCallback(id: number, data: unknown): void
callbacks: Map<number, (data: unknown) => void>
convertFileSrc(filePath: string, protocol?: string): string
metadata: {
currentWindow: { label: string }
currentWebview: { windowLabel: string; label: string }
}
}
interface AiConversationSummaryFixture {
id: string
title: string | null
provider_id: string | null
model: string | null
archived: boolean
pinned: boolean
prompt_tokens: number
completion_tokens: number
created_at: string
updated_at: string
}
// 会话摘要假数据(新到旧排序,一条置顶,标题区分业务场景便于 UI 截图辨认)
const CONVERSATIONS: AiConversationSummaryFixture[] = [
{
id: 'conv-mock-1',
title: '任务看板数据汇总',
provider_id: 'prov-mock-1',
model: 'mock-model',
archived: false,
pinned: true,
prompt_tokens: 1024,
completion_tokens: 512,
created_at: '2026-10-01T09:00:00Z',
updated_at: '2026-10-01T09:12:00Z',
},
{
id: 'conv-mock-2',
title: '知识库条目提炼',
provider_id: 'prov-mock-1',
model: 'mock-model',
archived: false,
pinned: false,
prompt_tokens: 640,
completion_tokens: 320,
created_at: '2026-09-30T15:00:00Z',
updated_at: '2026-09-30T15:20:00Z',
},
{
id: 'conv-mock-3',
title: '项目依赖图排查',
provider_id: 'prov-mock-1',
model: 'mock-model',
archived: false,
pinned: false,
prompt_tokens: 128,
completion_tokens: 64,
created_at: '2026-09-29T10:00:00Z',
updated_at: '2026-09-29T10:05:00Z',
},
]
// conv-mock-1 的历史消息。assistant 一条消息携带三个工具调用 + 对应 tool 消息,
// 供前端工具卡折叠/展开渲染路径使用;字段结构与后端 ChatMessage 序列化格式对齐
// (tool_calls[].function.arguments 是 JSON 字符串,tool 消息靠 tool_call_id 关联)。
const CONV1_MESSAGES = [
{
id: 'msg-mock-u1',
role: 'user',
content: '汇总一下进行中的任务,并看看相关项目状态',
timestamp: 1759274400000,
},
{
id: 'msg-mock-a1',
role: 'assistant',
content: '',
timestamp: 1759274402000,
tool_calls: [
{
id: 'tc-mock-1',
type: 'function',
function: { name: 'list_tasks', arguments: '{"status":"in_progress"}' },
},
{
id: 'tc-mock-2',
type: 'function',
function: { name: 'list_projects', arguments: '{}' },
},
{
id: 'tc-mock-3',
type: 'function',
function: { name: 'count_tasks', arguments: '{"status":"in_progress"}' },
},
],
},
{
id: 'msg-mock-t1',
role: 'tool',
tool_call_id: 'tc-mock-1',
content: '[{"id":"task-mock-1","title":"编写无头测试基建","status":"in_progress"}]',
timestamp: 1759274403000,
},
{
id: 'msg-mock-t2',
role: 'tool',
tool_call_id: 'tc-mock-2',
content: '[{"id":"proj-mock-1","name":"DevFlow","status":"in_progress"}]',
timestamp: 1759274404000,
},
{
id: 'msg-mock-t3',
role: 'tool',
tool_call_id: 'tc-mock-3',
content: '3',
timestamp: 1759274405000,
},
{
id: 'msg-mock-a2',
role: 'assistant',
content: '进行中的任务共 3 个,均归属 DevFlow 项目。其中「编写无头测试基建」已进入自验阶段,其余两个阻塞在依赖安装上。',
model: 'mock-model',
timestamp: 1759274410000,
prompt_tokens: 128,
completion_tokens: 96,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 128,
reasoning_tokens: 0,
},
{
id: 'msg-mock-u2',
role: 'user',
content: '把结论整理成三行以内的摘要',
timestamp: 1759274420000,
},
{
id: 'msg-mock-a3',
role: 'assistant',
content: '1. 进行中任务 3 个,全部属于 DevFlow。\n2. 测试基建项进展最快。\n3. 其余任务等依赖安装完成即可推进。',
model: 'mock-model',
timestamp: 1759274425000,
prompt_tokens: 96,
completion_tokens: 48,
prompt_cache_hit_tokens: 64,
prompt_cache_miss_tokens: 32,
reasoning_tokens: 0,
},
]
const OTHER_CONV_MESSAGES = [
{
id: 'msg-mock-b1',
role: 'user',
content: '这是一条演示用的历史消息',
timestamp: 1759180000000,
},
{
id: 'msg-mock-b2',
role: 'assistant',
content: '收到,这是浏览器测试环境的固定回复。',
model: 'mock-model',
timestamp: 1759180005000,
prompt_tokens: 32,
completion_tokens: 16,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 32,
reasoning_tokens: 0,
},
]
// 提供商假数据:api_key 置空,垫片永远只读,发送消息不会真的发起模型调用
const PROVIDERS = [
{
id: 'prov-mock-1',
name: 'Mock Provider',
provider_type: 'openai_compat',
api_key: '',
base_url: 'http://localhost.invalid/v1',
default_model: 'mock-model',
models: '["mock-model"]',
model_configs: [
{
model_id: 'mock-model',
enabled: true,
modalities: ['text'],
capabilities: ['tool_use'],
cost_tier: 'low',
intelligence: 'standard',
weight: 50,
context_window: 32768,
probe_source: 'default',
},
],
is_default: true,
config: null,
created_at: '2026-09-01T00:00:00Z',
updated_at: '2026-09-01T00:00:00Z',
enabled: true,
weight: 50,
},
]
// 技能假数据(斜杠联想列表用,浏览器测试不触发注入)
const SKILLS = [
{ name: 'mock-skill-a', description: '演示技能 A', argument_hint: '', source: 'skill', path: '' },
{ name: 'mock-skill-b', description: '演示技能 B', argument_hint: '', source: 'skill', path: '' },
]
// 返回空列表的查询类命令(值集来自全仓 invoke 调用点盘点,覆盖启动链与各页面挂载路径)
const LIST_COMMANDS = new Set([
'list_projects',
'list_deleted_projects',
'list_tasks',
'list_ideas',
'list_idea_evaluations',
'list_workflow_executions',
'list_project_modules',
'list_branches',
'list_module_dependencies',
'knowledge_list',
'knowledge_list_candidates',
'knowledge_events',
'ai_get_allowed_dirs',
'ai_pending_tool_calls',
])
// 返回固定标量的命令
const SCALAR_COMMANDS: Record<string, () => unknown> = {
count_tasks: () => 0,
settings_get: () => null,
settings_get_all: () => ({}),
settings_set: () => true,
settings_delete: () => true,
get_plan_execution: () => false,
get_data_dir: () => 'C:\\devflow-browser-mock-data',
ai_is_generating: () => false,
list_tool_executions: () => ({ items: [], total: 0, has_more: false }),
ai_list_providers: () => PROVIDERS,
ai_list_skills: () => SKILLS,
}
function conversationDetail(conversationId: string) {
const messages = conversationId === 'conv-mock-1' ? CONV1_MESSAGES : OTHER_CONV_MESSAGES
return {
id: conversationId,
title: CONVERSATIONS.find(c => c.id === conversationId)?.title ?? null,
messages: JSON.stringify(messages),
has_more: false,
earliest_seq: null,
readonly: false,
}
}
// 命令分发表。返回值直接给 Promise.resolve;写入类命令返回 null 即可,
// 前端只关心成功与否,浏览器测试不验证写路径。
function buildHandlers(): Record<string, (args: Record<string, unknown>) => unknown> {
const handlers: Record<string, (args: Record<string, unknown>) => unknown> = {
ai_conversation_list: () => CONVERSATIONS,
ai_conversation_switch: args => conversationDetail(String(args?.conversationId ?? 'conv-mock-1')),
ai_conversation_load_more: () => ({ messages: '[]', has_more: false, earliest_seq: null }),
knowledge_get_config: () => ({
auto_extract: false,
trigger_mode: 'manual_only',
min_messages: 10,
auto_inject: false,
vector_enabled: false,
embedding_provider_id: null,
embedding_model: null,
}),
knowledge_get_detail: () => null,
}
for (const cmd of LIST_COMMANDS) handlers[cmd] = () => []
for (const [cmd, value] of Object.entries(SCALAR_COMMANDS)) handlers[cmd] = value
return handlers
}
function install(): void {
const internals = window as unknown as Record<string, unknown>
// 壳内已有宿主注入,立即退出保证桌面零影响;防重复注入(热更新场景幂等)
if (internals.__TAURI_INTERNALS__) return
if (internals.__DF_TAURI_SHIM_INSTALLED__) return
internals.__DF_TAURI_SHIM_INSTALLED__ = true
const handlers = buildHandlers()
// 回调注册表对齐官方 mocks.js 结构,listen 返回的 unlisten 依赖这组方法
const callbacks = new Map<number, (data: unknown) => void>()
let callbackSeq = 0
const tauriInternals: TauriInternals = {
invoke(cmd, args) {
// 事件插件统一空实现:listen 返回递增 id 充当 eventId,unlisten/emit 无操作。
// 真实事件源在浏览器里不存在,注册成功的假监听永远不会被触发,符合只读预期。
if (cmd.startsWith('plugin:event|')) return Promise.resolve(++callbackSeq)
if (cmd === 'plugin:window|get_all_windows') return Promise.resolve<string[]>([])
const handler = handlers[cmd]
if (handler) return Promise.resolve(handler(args ?? {}))
// 未知命令返回 null:调用方拿到的语义与后端返 NULL 一致,避免引入新异常面
return Promise.resolve(null)
},
transformCallback(cb) {
const id = ++callbackSeq
if (cb) callbacks.set(id, cb)
return id
},
unregisterCallback(id) {
callbacks.delete(id)
},
runCallback(id, data) {
const cb = callbacks.get(id)
if (cb) cb(data)
},
callbacks,
convertFileSrc(filePath) {
// 浏览器里没有 asset 协议,透传原值,图片场景留空即可
return filePath
},
metadata: {
currentWindow: { label: 'main' },
currentWebview: { windowLabel: 'main', label: 'main' },
},
}
internals.__TAURI_INTERNALS__ = tauriInternals
// 事件插件的浏览器侧登记处,listen 返回的 unlisten 回调会先走这里再 invoke unlisten
internals.__TAURI_EVENT_PLUGIN_INTERNALS__ = {
unregisterListener() {},
}
}
install()
export {}