From cd27163660a36e5564643f4ef1d7c990b7180ef2 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 26 Sep 2026 01:53:04 +0800 Subject: [PATCH] test: stabilize asynchronous UI checks in release CI Control the native inventory retry response and wait for the complete browser-close result instead of asserting on intermediate renders. Run native note-layout input in a visible isolated Electron window, scan the fixture dependencies up front, and report timeout explicitly. Keep all behavior and geometry assertions and Electron sandboxing. The three focused regressions, full typecheck, and lint pass locally. Linux CI must confirm the release failures are resolved. No product, Agent, version, or release-tag changes are included. --- docs/quality/recurring-defects.md | 14 +++++++++++++ src/renderer/src/App.test.tsx | 21 +++++++++++++------ .../src/RightAssistantSidebar.resize.test.tsx | 7 +++++-- tests/magic-notes-layout.electron.test.ts | 17 ++++++++++----- 4 files changed, 46 insertions(+), 13 deletions(-) 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'))