From 47c4bd64cf5a218c6e1c8ea96d6eab52ae5d1545 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Sat, 24 Oct 2026 14:54:44 +0800 Subject: [PATCH 1/2] fix: keep a rejected HTTP start's failure through the status poll The first-run HTTP card polls the proxy's status every 3 s and took each answer whole. A poll that found the proxy merely stopped knows nothing about why the start failed, so it replaced the failure with a plain start button: the operator saw the error for at most three seconds. On a slow runner the poll landed before the test's assertion, which is why 'a rejected start shows the failure' failed intermittently on Windows, on main and on PRs. A stopped reading no longer overwrites a failure with its error; any other state still replaces it. Co-Authored-By: Claude Opus 5.5 --- .../src/components/HttpCaptureStep.tsx | 10 ++++++++-- test/first-run-record-terminal.test.tsx | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/HttpCaptureStep.tsx b/src/renderer/src/components/HttpCaptureStep.tsx index 657e73c3..854d4150 100644 --- a/src/renderer/src/components/HttpCaptureStep.tsx +++ b/src/renderer/src/components/HttpCaptureStep.tsx @@ -160,6 +160,12 @@ export function HttpCaptureStep({ onVerified, onPrepare, paused = false }: { useEffect(() => { onVerified?.(verified) }, [verified, onVerified]) + // A rejected start is this card's to report until the proxy says something + // newer. A status read that finds it merely stopped knows nothing about why, + // and taking it whole erased the failure within one 3 s poll. + const takeStatus = (st: ManagedProxyStatus): void => + setStatus((prev) => (prev.state === 'failed' && prev.error && st.state === 'stopped' ? prev : st)) + const check = async (): Promise => { const [pf, st, cfg] = await Promise.all([ window.redlog.runtime.preflight().catch(() => null), @@ -172,7 +178,7 @@ export function HttpCaptureStep({ onVerified, onPrepare, paused = false }: { const mitm = pf.checks.find((c) => c.id === 'mitmdump') setMitmMissing(mitm ? !mitm.found : false) } - if (st) setStatus(st) + if (st) takeStatus(st) if (cfg) setConfig(cfg as Record) } @@ -192,7 +198,7 @@ export function HttpCaptureStep({ onVerified, onPrepare, paused = false }: { // request. Same cadence as the app-wide toggle. useEffect(() => { const timer = setInterval(() => { - window.redlog.httpCapture.status().then(setStatus).catch(() => { /* keep the last known state */ }) + window.redlog.httpCapture.status().then(takeStatus).catch(() => { /* keep the last known state */ }) }, 3_000) return () => clearInterval(timer) }, []) diff --git a/test/first-run-record-terminal.test.tsx b/test/first-run-record-terminal.test.tsx index 3ef0648c..e010535b 100644 --- a/test/first-run-record-terminal.test.tsx +++ b/test/first-run-record-terminal.test.tsx @@ -488,6 +488,24 @@ describe('first run: HTTP(S) card reports its own failures (UI/UX audit F6/F7)', expect(screen.getByTestId('first-run-http-restart')).not.toBeNull() }) + // The card also polls the proxy's status every 3 s. The poll's answer knows + // nothing about this start, so taking it whole erased the failure: on a + // slow runner the poll landed before the assertion above, and the operator + // saw the message for at most three seconds. + it('keeps a rejected start\'s failure through the next status poll', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + install() + bridge.proxyStart.mockRejectedValueOnce(new Error('port 8080 in use')) + draw() + fireEvent.click(await screen.findByRole('button', { name: '重新啟動 HTTP 擷取' }, { timeout: 5000 })) + const card = await screen.findByTestId('first-run-http') + await waitFor(() => expect(card.textContent).toContain('port 8080 in use')) + const polls = bridge.proxyStatus.mock.calls.length + await act(async () => { await vi.advanceTimersByTimeAsync(3_100) }) + expect(bridge.proxyStatus.mock.calls.length).toBeGreaterThan(polls) + expect(card.textContent).toContain('port 8080 in use') + }) + // The terminal-proxy switch moved to Settings ▸ Browser, which owns every // other proxy setting; a global behaviour switch does not belong on the // screen where capture is being proved for the first time. From ae32d72fe1f507f345f2646c7ce5bd02123a9a89 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Sat, 24 Oct 2026 14:54:44 +0800 Subject: [PATCH 2/2] test: give the pane rotation timing guard room on a loaded runner The guard exists to catch a rotation that waits for running commands, which here would mean waiting for sleep 99. One second failed on a Windows runner at 1205 ms while the rotation was correct; five seconds still tells a wait apart. Co-Authored-By: Claude Opus 5.5 --- test/terminal-session-rotation.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/terminal-session-rotation.test.ts b/test/terminal-session-rotation.test.ts index aa54fbea..53f2c049 100644 --- a/test/terminal-session-rotation.test.ts +++ b/test/terminal-session-rotation.test.ts @@ -142,7 +142,10 @@ describe('project recording rotation without killing the pane', () => { expect(detachTerminalRecordings()).toBe(10) closeDB(); resetCausesResolver(); current = 'B'; open('B') expect(resumeTerminalRecordings()).toBe(10) - expect(performance.now() - started).toBeLessThan(1000) + // A rotation that waited would wait for `sleep 99`. Five seconds still + // tells that apart, where one second failed on a loaded Windows runner + // (1205 ms) while the rotation itself did nothing wrong. + expect(performance.now() - started).toBeLessThan(5000) expect(listTerminals()).toHaveLength(10) expect(fake.panes.every(pane => pane.kill.mock.calls.length === 0)).toBe(true) expect(rows().filter(row => row.data.subtype === 'session_start').every(row => row.data.rotationPaneCount === 10)).toBe(true)