diff --git a/docs/quality/recurring-defects.md b/docs/quality/recurring-defects.md index 9daed069..bfb515e4 100644 --- a/docs/quality/recurring-defects.md +++ b/docs/quality/recurring-defects.md @@ -7,6 +7,20 @@ 修正复发缺陷时,优先建立一条能覆盖整类问题的机制(共享选择器、共享函数、能发现全部实例 的测试),而不是逐个点名修补。逐个修补正是这些条目反复出现的原因。 +## 异步界面测试只等待部分状态 + +**症状**:本地通过的界面测试在完整 CI 中找不到预期控件,或在前一步更新后立即读取到旧状态。 + +**规则**:复用 Testing Library 的 `act`、`waitFor` 和现有 deferred Promise, +按请求完成和最终可见状态同步,不以另一个元素出现或消失代替完整操作结束。 +测试请求重试时显式控制后一次响应,分别断言等待中和响应完成后的状态; +同一异步操作的多个最终断言放入同一个 `waitFor`。不要只延长全局超时或放宽断言。 + +**历史**:2026-09-26 检查 Desktop 0.13.14 标签 CI `36165805814` 与 +main CI `36164698647`:原生能力重试用例在启动状态未稳定时打开选项, +浏览器关闭用例只等待页签消失便同步断言错误已清除。两处测试改用上述已有同步机制; +是否消除 CI 失败以修复提交的 Linux CI 结果为准。 + ## 整窗遮罩与原生拖动区域 **症状**:弹窗覆盖标题栏区域时,其关闭按钮无法悬停和点击。 diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index c66d8b19..0b630823 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -9368,6 +9368,7 @@ describe("App", () => { supportsToolExecution: true, detail: "Ready", }); const empty = await api.runtimeCustomization.getNativeSnapshot({ provider: "opencode" }); + const retry = deferred(); vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockClear(); vi.mocked(api.runtimeCustomization.getNativeSnapshot) .mockResolvedValueOnce({ @@ -9375,18 +9376,26 @@ describe("App", () => { available: false, inventoryStatus: "unavailable", }) - .mockResolvedValueOnce({ + .mockReturnValueOnce(retry.promise); + + render(); + + await waitFor(() => + expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(2), + ); + openComposerOptions(); + expect(screen.queryByRole("button", { name: /OpenCode Runtime Agent/u })).not.toBeInTheDocument(); + await act(async () => { + retry.resolve({ ...empty, available: true, inventoryStatus: "partial", agents: [{ id: "planner", name: "Planner", mode: "primary", native: true, hidden: false }], commands: [{ id: "review", name: "review", source: "command" }], }); - - render(); - - openComposerOptions(); - fireEvent.click(await screen.findByRole("button", { name: /OpenCode Runtime Agent/u })); + await retry.promise; + }); + fireEvent.click(screen.getByRole("button", { name: /OpenCode Runtime Agent/u })); expect(screen.getByRole("menuitemradio", { name: /Planner/u })).toBeVisible(); fireEvent.keyDown(screen.getByRole("menu", { name: "OpenCode Runtime Agent" }), { key: "Escape" }); fireEvent.click(screen.getByRole("button", { name: /Runtime 快捷操作/u })); diff --git a/src/renderer/src/RightAssistantSidebar.resize.test.tsx b/src/renderer/src/RightAssistantSidebar.resize.test.tsx index a8ed2d3b..71bbed7a 100644 --- a/src/renderer/src/RightAssistantSidebar.resize.test.tsx +++ b/src/renderer/src/RightAssistantSidebar.resize.test.tsx @@ -1795,8 +1795,11 @@ describe('RightAssistantSidebar resizing', () => { ).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: '关闭浏览器 · conversation-a' })) - await waitFor(() => expect(screen.queryByRole('tab', { name: '浏览器 · conversation-a' })).not.toBeInTheDocument()) - expect(screen.queryByText('Close denied')).not.toBeInTheDocument() + await waitFor(() => { + expect(browserApi.closeTab).toHaveBeenCalledTimes(2) + expect(screen.queryByRole('tab', { name: '浏览器 · conversation-a' })).not.toBeInTheDocument() + expect(screen.queryByText('Close denied')).not.toBeInTheDocument() + }) }) it.each(['create', 'close'] as const)( diff --git a/tests/magic-notes-layout.electron.test.ts b/tests/magic-notes-layout.electron.test.ts index abf3891e..a02024e2 100644 --- a/tests/magic-notes-layout.electron.test.ts +++ b/tests/magic-notes-layout.electron.test.ts @@ -19,7 +19,8 @@ it('resizes record columns with native Electron input and preserves desktop widt '')) }) } }], - server: { host: '127.0.0.1', port: 0, watch: null } + server: { host: '127.0.0.1', port: 0, watch: null }, + optimizeDeps: { entries: ['tests/support/magic-notes-layout-regression.tsx'] } }) try { await server.listen() @@ -31,7 +32,7 @@ it('resizes record columns with native Electron input and preserves desktop widt app.commandLine.appendSwitch('force-device-scale-factor', '1'); app.setPath('userData', ${JSON.stringify(join(directory, 'profile'))}); app.whenReady().then(async () => { - const win = new BrowserWindow({ show: false, width: 1280, height: 800, useContentSize: true, + const win = new BrowserWindow({ show: true, width: 1280, height: 800, useContentSize: true, webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } }); const js = code => win.webContents.executeJavaScript(code).catch(error => { throw new Error(code + ': ' + error.message); }); const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))'); @@ -165,10 +166,16 @@ it('resizes record columns with native Electron input and preserves desktop widt let output = '' child.stdout.on('data', data => { output += data }) child.stderr.on('data', data => { output += data }) - // Hidden Electron windows can run rAF at 1 Hz; the measured scenario takes 63s. - const timeout = setTimeout(() => child.kill(), 90000) + // Native input and animation frames need a visible window, including under Xvfb. + let timedOut = false + const timeout = setTimeout(() => { timedOut = true; child.kill() }, 90000) try { - const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) }) + const { code, signal } = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once('close', (code, signal) => resolve({ code, signal })) + child.once('error', reject) + }) + expect(timedOut, `Electron layout exceeded 90s (exit=${code}, signal=${signal}).\n${output}`).toBe(false) + expect(signal, output).toBeNull() expect(code, output).toBe(0) } finally { clearTimeout(timeout) } const result = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))