新增: playwright e2e 测试基建与 tauri 垫片
invoke 条件垫片使前端可在无 tauri 容器下直连 dev server 做端到端验证
This commit is contained in:
@@ -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 {}
|
||||
Reference in New Issue
Block a user