From efdf1e0282eaece154d5504e93db69243799c0e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Thu, 1 Oct 2026 02:41:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20playwright=20e2e=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=9F=BA=E5=BB=BA=E4=B8=8E=20tauri=20?= =?UTF-8?q?=E5=9E=AB=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit invoke 条件垫片使前端可在无 tauri 容器下直连 dev server 做端到端验证 --- index.html | 1 + package-lock.json | 30 ++++ package.json | 2 + playwright.config.ts | 21 +++ src/mocks/tauri-shim.ts | 350 ++++++++++++++++++++++++++++++++++++++++ tests/e2e/smoke.spec.ts | 48 ++++++ 6 files changed, 452 insertions(+) create mode 100644 playwright.config.ts create mode 100644 src/mocks/tauri-shim.ts create mode 100644 tests/e2e/smoke.spec.ts diff --git a/index.html b/index.html index bde5baa..84ea6c1 100644 --- a/index.html +++ b/index.html @@ -14,6 +14,7 @@ else if (t === 'system' && !window.matchMedia('(prefers-color-scheme: dark)').matches) document.documentElement.setAttribute('data-theme', 'light'); +
diff --git a/package-lock.json b/package-lock.json index 7fc0711..c4acf9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@types/dompurify": "^3.2.0", "@types/sortablejs": "^1.15.9", "@vitejs/plugin-vue": "^5.2.1", + "playwright": "^1.63.0", "typescript": "~5.6.3", "vite": "^6.0.7", "vue-tsc": "^2.2.0" @@ -2990,6 +2991,35 @@ "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": { "version": "0.2.0", "resolved": "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz", diff --git a/package.json b/package.json index 39bbc6a..a897a6d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev:restart": "npm run dev:stop && npm run dev", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", + "test:e2e": "playwright test", "tauri": "tauri", "build:dev": "tauri build --debug --no-bundle" }, @@ -37,6 +38,7 @@ "@types/dompurify": "^3.2.0", "@types/sortablejs": "^1.15.9", "@vitejs/plugin-vue": "^5.2.1", + "playwright": "^1.63.0", "typescript": "~5.6.3", "vite": "^6.0.7", "vue-tsc": "^2.2.0" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..9773868 --- /dev/null +++ b/playwright.config.ts @@ -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, + }, +}) diff --git a/src/mocks/tauri-shim.ts b/src/mocks/tauri-shim.ts new file mode 100644 index 0000000..820d37a --- /dev/null +++ b/src/mocks/tauri-shim.ts @@ -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, options?: unknown): Promise + transformCallback(cb: (data: unknown) => void, once?: boolean): number + unregisterCallback(id: number): void + runCallback(id: number, data: unknown): void + callbacks: Map 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 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) => unknown> { + const handlers: Record) => 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 + + // 壳内已有宿主注入,立即退出保证桌面零影响;防重复注入(热更新场景幂等) + 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 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([]) + 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 {} diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000..51b236a --- /dev/null +++ b/tests/e2e/smoke.spec.ts @@ -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).__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 } + }).__TAURI_INTERNALS__ + const list = (await internals.invoke('ai_conversation_list')) as unknown[] + return list.length + }) + expect(convCount).toBeGreaterThan(0) +})