新增: 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
+1
View File
@@ -14,6 +14,7 @@
else if (t === 'system' && !window.matchMedia('(prefers-color-scheme: dark)').matches) else if (t === 'system' && !window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.setAttribute('data-theme', 'light'); document.documentElement.setAttribute('data-theme', 'light');
</script> </script>
<script type="module" src="/src/mocks/tauri-shim.ts"></script>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
+30
View File
@@ -32,6 +32,7 @@
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
"@types/sortablejs": "^1.15.9", "@types/sortablejs": "^1.15.9",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"playwright": "^1.63.0",
"typescript": "~5.6.3", "typescript": "~5.6.3",
"vite": "^6.0.7", "vite": "^6.0.7",
"vue-tsc": "^2.2.0" "vue-tsc": "^2.2.0"
@@ -2990,6 +2991,35 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/points-on-curve": { "node_modules/points-on-curve": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz", "resolved": "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz",
+2
View File
@@ -9,6 +9,7 @@
"dev:restart": "npm run dev:stop && npm run dev", "dev:restart": "npm run dev:stop && npm run dev",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"test:e2e": "playwright test",
"tauri": "tauri", "tauri": "tauri",
"build:dev": "tauri build --debug --no-bundle" "build:dev": "tauri build --debug --no-bundle"
}, },
@@ -37,6 +38,7 @@
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
"@types/sortablejs": "^1.15.9", "@types/sortablejs": "^1.15.9",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"playwright": "^1.63.0",
"typescript": "~5.6.3", "typescript": "~5.6.3",
"vite": "^6.0.7", "vite": "^6.0.7",
"vue-tsc": "^2.2.0" "vue-tsc": "^2.2.0"
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'playwright/test'
export default defineConfig({
testDir: 'tests/e2e',
timeout: 60_000,
headless: true,
// 单文件单用例,冒烟规模无需并行;减少并发也降低 vite 冷启动抖动导致的偶发失败
workers: 1,
retries: 0,
reporter: [['list']],
use: {
baseURL: 'http://localhost:1420',
headless: true,
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:1420',
reuseExistingServer: true,
timeout: 120_000,
},
})
+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 {}
+48
View File
@@ -0,0 +1,48 @@
import { test, expect } from 'playwright/test'
// 冒烟用例:纯浏览器(无 Tauri 壳)打开 vite dev server,
// 验证 Tauri IPC 垫片(src/mocks/tauri-shim.ts)能让应用完整挂载并路由不白屏。
// 全程只读内存假数据,不接触任何真实数据库。
// 主路由取样:AI 首页(默认路由)、任务、项目、知识库,覆盖四条典型挂载链
const ROUTES = ['/ai-home', '/tasks', '/projects', '/knowledge']
for (const route of ROUTES) {
test(`路由 ${route} 挂载且无未捕获异常`, async ({ page }) => {
const pageErrors: string[] = []
page.on('pageerror', (err) => {
pageErrors.push(`${err.name}: ${err.message}`)
})
await page.goto(`/#${route}`)
// app 完成挂载:main.ts 等路由就绪后才 mount,#app 有元素子节点即视为挂载成功
await expect(page.locator('#app > *').first()).toBeVisible({ timeout: 20_000 })
// 白屏判定:挂载壳内应有实质内容(空壳 fallback 文案也会超过 0,取较高阈值)
const html = await page.innerHTML('#app')
expect(html.length).toBeGreaterThan(200)
// ResizeObserver 循环超限是浏览器布局抖动的良性告警,与业务逻辑无关,过滤
const realErrors = pageErrors.filter(msg => !/ResizeObserver loop/i.test(msg))
expect(realErrors, `路由 ${route} 出现未捕获异常: ${realErrors.join(' | ')}`).toEqual([])
})
}
test('垫片注入生效且假数据链路可用', async ({ page }) => {
await page.goto('/#/ai-home')
await expect(page.locator('#app > *').first()).toBeVisible({ timeout: 20_000 })
// 浏览器里垫片必须已注入(否则所有 invoke 同步 TypeError)
const injected = await page.evaluate(() => Boolean((window as unknown as Record<string, unknown>).__TAURI_INTERNALS__))
expect(injected).toBe(true)
// 假数据链路:经 window 上的垫片 invoke 会话列表命令,验证分发到内存 fixture。
// 不 import @tauri-apps/api,page.evaluate 内联脚本解析不了裸模块名
const convCount = await page.evaluate(async () => {
const internals = (window as unknown as {
__TAURI_INTERNALS__: { invoke: (cmd: string) => Promise<unknown> }
}).__TAURI_INTERNALS__
const list = (await internals.invoke('ai_conversation_list')) as unknown[]
return list.length
})
expect(convCount).toBeGreaterThan(0)
})