From 961e336fb7ecb18f477dcb7c4a9c36e489cf049f Mon Sep 17 00:00:00 2001 From: luckyzd Date: Sun, 18 Oct 2026 20:19:10 +0800 Subject: [PATCH 1/9] fix(run): resolve backend verdicts in mixed tunnel batches --- DOCUMENTATION.md | 2 + src/commands/test.local-batch.spec.ts | 220 +++++++++++++++++++++++++- src/commands/test.ts | 27 +++- 3 files changed, 240 insertions(+), 9 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index aac4beeb..0e881efa 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -809,6 +809,8 @@ testsprite test run --env local-dev --timeout 1800 **Login and execution.** Tunnels serve frontend tests. An explicit loopback target on a backend test fails client validation (exit 5); single backend tests keep their previous cloud path; tunnel batches skip only backend tests whose own selected environment is loopback, and include their IDs in `skipped`. Public-environment backend tests dispatch normally. The project environment's username/password are passed to the cloud agent, which logs in inline through the tunnel. OTP environments are refused **before charge** with exit 6 (`tunnel-otp-auth-unsupported`). V3 `--local` runs always use the agent path, never saved-code replay, and **do not overwrite the test's saved code**. Saved loopback environments also open tunnels for reruns. Rerun and test-list tunnel batches use one `--timeout` deadline, including capacity waits. Explicit public-environment sign-in against a local origin is announced once on stderr. Rerun/test-list responses must echo the tunnel client id; if an old server strips it, the CLI attempts to cancel what started and exits with a clear error. +When explicit test IDs mix a saved-loopback frontend with a public-environment backend, the backend runs without tunnel routing. If its run row remains non-terminal, the CLI also reads the backend test result and accepts a terminal verdict only when its run ID matches (or, for legacy results without a run ID, its finish time is no earlier than the trigger's enqueue time). This keeps JSON summaries, JUnit reports, and exit codes aligned with completed backend results instead of timing them out. Unrelated, incomplete, or unavailable results leave normal run polling in place within the original timeout. + **Timeout, cancellation, and refunds.** When a single owned `--local` run stops waiting before a terminal result, the CLI cancels it by default and closes its tunnel. This includes `--timeout` (exit 7), Ctrl-C (exit 130), and non-terminal polling/tunnel failures. In a local batch, each test has its own timeout measured from its trigger: a timed-out run is cancelled on an owned tunnel, the other tests continue, and the tunnel closes after the batch settles. Cancellation can race completion or fail; read the reported outcome instead of assuming it succeeded. **A run cancelled before it finished is refunded.** Cancelling an already-finished run does not replace its result or refund it. After an owned local timeout, start a **new** run with `testsprite test run --env --timeout 1800`; `test wait` cannot restore the closed tunnel. `--no-cancel-on-interrupt` detaches instead, but does not keep an owned tunnel alive. A borrowed `--tunnel-client` run is not cancelled on Ctrl-C/SIGTERM: the borrower detaches, never closes or deletes the adopted tunnel, and `test wait ` can resume polling while the owner keeps it alive. If liveness reports that the owner is gone while the run is non-terminal, the borrower cancels **its own run** exactly as an owned doomed run does. The message names the run id and the observed result (`cancelled`, `already finished`, or `skipped`), then points to the run read before a retry. A run cancelled before it finished is refunded. `--no-cancel-on-interrupt` skips the owner-gone cancel too, leaving the run executing and billable without a working tunnel. diff --git a/src/commands/test.local-batch.spec.ts b/src/commands/test.local-batch.spec.ts index 0758ce49..89ef887c 100644 --- a/src/commands/test.local-batch.spec.ts +++ b/src/commands/test.local-batch.spec.ts @@ -9,7 +9,7 @@ import { sanitizeTelemetryExtras, takeTelemetryExtras } from '../lib/telemetry.j import type { TunnelClientOptions } from '../vendor/tunnel-client/index.js'; import { ErrCode } from '../vendor/tunnel-client/index.js'; import type { RunResponse } from '../lib/runs.types.js'; -import { createTestCommand } from './test.js'; +import { createTestCommand, type CliLatestResult } from './test.js'; type Call = { method: string; @@ -186,6 +186,98 @@ function runningResponse(testId: string): Response { ); } +/** Model a local frontend and a public-environment backend with an unfinalized run row. */ +function mixedBackendFixture( + result: Partial = {}, + options: { + finalRunStatus?: 'passed' | 'failed'; + terminalImmediately?: boolean; + resultUnavailable?: boolean; + remoteType?: 'frontend' | 'backend'; + } = {}, +) { + let backendPolls = 0; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + return fixture(['fe', 'be'], { + sleep: options.finalRunStatus ? async () => {} : ms => new Promise(r => setTimeout(r, ms)), + onCall: async call => { + if (call.method === 'POST' && call.url.endsWith('/tests/be/runs')) + return json({ + runId: 'run_be', + status: 'queued', + enqueuedAt: '2026-09-22T00:00:00.000Z', + codeVersion: 'v1', + targetUrl: 'https://example.com', + }); + if (call.method !== 'GET') return undefined; + if (call.url.endsWith('/tests/be')) + return json({ + id: 'be', + name: 'Backend checkout', + type: options.remoteType ?? 'backend', + projectId: 'R', + }); + if (call.url.endsWith('/projects/R/env')) + return json({ + environments: [ + { name: 'default', url: 'https://example.com', isDefault: true, isTemporary: false }, + ], + }); + if (call.url.includes('/runs/run_be')) { + const run = (await runningResponse('be').json()) as RunResponse; + backendPolls++; + const status = + options.terminalImmediately || backendPolls > 1 ? options.finalRunStatus : undefined; + return json({ + ...run, + projectId: 'R', + targetUrl: null, + ...(status + ? { + status, + finishedAt: '2026-09-22T00:00:02.000Z', + stepSummary: { + total: 1, + completed: 1, + passedCount: status === 'passed' ? 1 : 0, + failedCount: status === 'failed' ? 1 : 0, + }, + } + : {}), + }); + } + if (call.url.endsWith('/tests/be/result')) { + if (options.resultUnavailable) + return json( + { error: { code: 'NOT_FOUND', message: 'Not ready', nextAction: '', requestId: 'r1' } }, + 404, + ); + return json({ + testId: 'be', + status: 'passed', + startedAt: '2026-09-22T00:00:01.000Z', + finishedAt: '2026-09-22T00:00:02.000Z', + runIdIfAvailable: 'run_be', + snapshotId: 'snapshot_be', + codeVersion: 'v1', + targetUrl: null, + failedStepIndex: result.status === 'failed' ? 0 : null, + failureKind: result.status === 'failed' ? 'assertion' : null, + videoUrl: null, + failureAnalysisUrl: null, + verdict: + result.status === 'running' ? null : result.status === 'failed' ? 'failed' : 'passed', + executionStatus: result.status === 'running' ? 'running' : 'completed', + summary: result.status ?? 'passed', + ...result, + } satisfies CliLatestResult); + } + return undefined; + }, + }); +} + afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); @@ -193,6 +285,132 @@ afterEach(() => { }); describe('test run local batch', () => { + describe('mixed backend verdict fallback', () => { + it.each([ + { status: 'passed', borrowed: false }, + { status: 'failed', borrowed: false }, + { status: 'passed', borrowed: true }, + { status: 'failed', borrowed: true }, + ] as const)( + 'resolves $status with borrowed=$borrowed and writes accurate reports', + async ({ status, borrowed }) => { + const f = mixedBackendFixture({ status }); + const dir = mkdtempSync(join(tmpdir(), 'mixed-backend-report-')); + const summaryFile = join(dir, 'summary.json'); + const reportFile = join(dir, 'report.xml'); + const error = await f + .run( + 'fe', + 'be', + '--wait', + '--skip-preflight', + '--timeout', + '1', + '--summary-file', + summaryFile, + '--report', + 'junit', + '--report-file', + reportFile, + ...(borrowed ? ['--tunnel-client', f.clientId] : []), + ) + .then(() => undefined) + .catch(e => e); + if (status === 'passed') expect(error).toBeUndefined(); + else expect(error).toMatchObject({ exitCode: 1 }); + const payload = JSON.parse(f.stdout.join('')); + expect(payload.results).toEqual([ + expect.objectContaining({ testId: 'fe', runId: 'run_fe', status: 'passed' }), + expect.objectContaining({ + testId: 'be', + runId: 'run_be', + status, + testTitle: 'Backend checkout', + projectId: 'R', + }), + ]); + const summary = JSON.parse(readFileSync(summaryFile, 'utf8')); + expect(summary).toMatchObject({ + total: 2, + passed: status === 'passed' ? 2 : 1, + failed: status === 'failed' ? 1 : 0, + timedOut: 0, + }); + const xml = readFileSync(reportFile, 'utf8'); + expect(xml).toContain('name="Backend checkout" testId="be" runId="run_be" time="1"'); + expect(xml).not.toContain('type="timeout"'); + if (status === 'failed') expect(xml).toContain(''); + expect(f.calls.filter(c => c.url.endsWith('/tests/be/result'))).toHaveLength(1); + expect( + f.calls.find(c => c.method === 'POST' && c.url.endsWith('/tests/be/runs'))?.body, + ).toEqual({ source: 'cli' }); + expect( + f.calls.find(c => c.method === 'POST' && c.url.endsWith('/tests/fe/runs'))?.body, + ).toMatchObject({ tunnelClientId: f.clientId, targetUrl: f.targetUrl }); + expect(f.calls.some(c => c.url.endsWith('/cancel'))).toBe(false); + expect(f.calls.filter(c => c.method === 'DELETE')).toHaveLength(borrowed ? 0 : 1); + expect(f.calls.filter(c => c.method === 'GET' && c.url.includes('/tunnel/'))).toHaveLength( + 0, + ); + }, + ); + + it('resolves a legacy result with no run ID only when it is fresh enough', async () => { + const f = mixedBackendFixture({ runIdIfAvailable: null }); + await f.run( + 'be', + 'fe', + '--skip-preflight', + '--timeout', + '1', + '--max-concurrency', + '1', + '--text-output', + ); + expect(f.stdout.join('')).toContain('passed'); + expect(f.stderr.join('\n')).toContain('2/2 passed'); + expect(f.calls.filter(c => c.url.endsWith('/tests/be/result'))).toHaveLength(1); + }); + + it.each([ + { runIdIfAvailable: 'run_other' }, + { runIdIfAvailable: null, finishedAt: '2026-09-21T23:59:59.000Z' }, + { status: 'running', finishedAt: null }, + ] satisfies Array>)( + 'keeps polling instead of accepting unrelated or incomplete result %j', + async result => { + const f = mixedBackendFixture(result, { finalRunStatus: 'failed' }); + await expect(f.run('fe', 'be', '--skip-preflight', '--timeout', '1')).rejects.toMatchObject( + { exitCode: 1 }, + ); + expect(JSON.parse(f.stdout.join('')).results[1]).toMatchObject({ + testId: 'be', + runId: 'run_be', + status: 'failed', + }); + expect(f.calls.filter(c => c.url.endsWith('/tests/be/result'))).toHaveLength(1); + expect(f.calls.filter(c => c.url.includes('/runs/run_be'))).toHaveLength(2); + }, + ); + + it('keeps the run-row verdict when the result endpoint is unavailable', async () => { + const f = mixedBackendFixture({}, { finalRunStatus: 'passed', resultUnavailable: true }); + await f.run('fe', 'be', '--skip-preflight', '--timeout', '1'); + expect(JSON.parse(f.stdout.join('')).summary.passed).toBe(2); + expect(f.calls.filter(c => c.url.endsWith('/tests/be/result'))).toHaveLength(1); + }); + + it.each([ + { finalRunStatus: 'passed', terminalImmediately: true }, + { finalRunStatus: 'passed', remoteType: 'frontend' }, + ] as const)('does not fetch an alternate result for the control %j', async options => { + const f = mixedBackendFixture({}, options); + await f.run('fe', 'be', '--skip-preflight', '--timeout', '1'); + expect(JSON.parse(f.stdout.join('')).summary.passed).toBe(2); + expect(f.calls.filter(c => c.url.endsWith('/result'))).toHaveLength(0); + }); + }); + it('keeps the paused-workspace envelope when every local trigger is refused', async () => { const f = fixture(['a', 'b'], { onCall: call => diff --git a/src/commands/test.ts b/src/commands/test.ts index 31f3b107..79fe643b 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -11250,6 +11250,7 @@ export async function runTestRunLocalBatch( ) stderrFn(`idempotency-key: ${idempotencyBase}`); + /** Dispatch and poll one member within its own deadline, retaining per-run fallback state. */ const runOne = async (index: number) => { const row = results[index]!; let triggerStartedAtMs: number; @@ -11360,6 +11361,23 @@ export async function runTestRunLocalBatch( const startMs = triggerStartedAtMs; const remainingSeconds = (startMs + opts.timeoutSeconds * 1000 - Date.now()) / 1000; if (remainingSeconds <= 0) throw new TimeoutError(row.runId!, opts.timeoutSeconds); + // Public-environment backend members share this pool with local frontend + // runs, but their run rows may never finalize. Reuse the ordinary wait's + // correlated fallback without adding result probes to tunnel-bound runs. + const resolveBackendAlternate = targets.has(row.testId) + ? undefined + : makeBackendWaitFallback({ + client, + resolveTestId: () => row.testId, + resolveNotBefore: () => response.enqueuedAt, + }); + const resolveAlternate = + targets.has(row.testId) && checkBorrowedLiveness + ? async (_run: RunResponse, _elapsed: number, signal: AbortSignal) => { + await checkBorrowedLiveness!(signal); + return null; + } + : resolveBackendAlternate; const finalRun = await pollRunUntilTerminal(client, row.runId!, { timeoutSeconds: remainingSeconds, sleep: deps.sleep, @@ -11373,14 +11391,7 @@ export async function runTestRunLocalBatch( } ticker.update(formatRunProgressLine(run, elapsedMs, `(${row.testId})`)); }, - ...(targets.has(row.testId) && checkBorrowedLiveness - ? { - resolveAlternate: async (_run, _elapsed, signal) => { - await checkBorrowedLiveness!(signal); - return null; - }, - } - : {}), + resolveAlternate, }); row.status = finalRun.status as LocalBatchResult['status']; unsettled.delete(row); From 57036e1d2e875093fe3d5512b86f614b15e4db51 Mon Sep 17 00:00:00 2001 From: luckyzd Date: Sun, 18 Oct 2026 20:31:44 +0800 Subject: [PATCH 2/9] docs(run): explain mixed-batch polling fallback --- src/commands/test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/commands/test.ts b/src/commands/test.ts index 79fe643b..e9dde35a 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -11371,6 +11371,12 @@ export async function runTestRunLocalBatch( resolveTestId: () => row.testId, resolveNotBefore: () => response.enqueuedAt, }); + /** + * Select this member's polling fallback. Borrowed frontend tunnels only + * check owner liveness and return null so normal run polling continues; + * remote members may return a correlated terminal backend result. Both + * callbacks use the poller's abort signal and remaining deadline. + */ const resolveAlternate = targets.has(row.testId) && checkBorrowedLiveness ? async (_run: RunResponse, _elapsed: number, signal: AbortSignal) => { From 2818c7edaa51f0190263262b9fd425d570b74a36 Mon Sep 17 00:00:00 2001 From: luckyzd Date: Sun, 18 Oct 2026 20:36:05 +0800 Subject: [PATCH 3/9] docs(run): attach liveness docstring to polling callback --- src/commands/test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/commands/test.ts b/src/commands/test.ts index e9dde35a..0003afd4 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -11371,15 +11371,14 @@ export async function runTestRunLocalBatch( resolveTestId: () => row.testId, resolveNotBefore: () => response.enqueuedAt, }); - /** - * Select this member's polling fallback. Borrowed frontend tunnels only - * check owner liveness and return null so normal run polling continues; - * remote members may return a correlated terminal backend result. Both - * callbacks use the poller's abort signal and remaining deadline. - */ const resolveAlternate = targets.has(row.testId) && checkBorrowedLiveness - ? async (_run: RunResponse, _elapsed: number, signal: AbortSignal) => { + ? /** + * Check the borrowed frontend tunnel using the poller's abort signal. + * Return null after a successful check so run polling continues; + * throw when the tunnel owner is unavailable. + */ + async (_run: RunResponse, _elapsed: number, signal: AbortSignal) => { await checkBorrowedLiveness!(signal); return null; } From c2c82a68e5dacbf047b296551a10c0b5744630ac Mon Sep 17 00:00:00 2001 From: luckyzd Date: Sun, 18 Oct 2026 20:41:55 +0800 Subject: [PATCH 4/9] docs(test): describe mixed-batch response fixture --- src/commands/test.local-batch.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/test.local-batch.spec.ts b/src/commands/test.local-batch.spec.ts index 89ef887c..42764e93 100644 --- a/src/commands/test.local-batch.spec.ts +++ b/src/commands/test.local-batch.spec.ts @@ -197,6 +197,7 @@ function mixedBackendFixture( } = {}, ) { let backendPolls = 0; + /** Return API-shaped JSON so the fixture exercises the client's response decoding. */ const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); return fixture(['fe', 'be'], { From 16616d9910e47242fb1fc904ce85cc3d1069dd01 Mon Sep 17 00:00:00 2001 From: luckyzd Date: Sun, 18 Oct 2026 20:53:45 +0800 Subject: [PATCH 5/9] fix(run): reject invalid legacy result timestamps --- DOCUMENTATION.md | 2 +- src/commands/test.local-batch.spec.ts | 38 +++++++++++++++++++++++++-- src/commands/test.test.ts | 23 ++++++++++++++++ src/commands/test.ts | 8 +++--- 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 0e881efa..a588e165 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -809,7 +809,7 @@ testsprite test run --env local-dev --timeout 1800 **Login and execution.** Tunnels serve frontend tests. An explicit loopback target on a backend test fails client validation (exit 5); single backend tests keep their previous cloud path; tunnel batches skip only backend tests whose own selected environment is loopback, and include their IDs in `skipped`. Public-environment backend tests dispatch normally. The project environment's username/password are passed to the cloud agent, which logs in inline through the tunnel. OTP environments are refused **before charge** with exit 6 (`tunnel-otp-auth-unsupported`). V3 `--local` runs always use the agent path, never saved-code replay, and **do not overwrite the test's saved code**. Saved loopback environments also open tunnels for reruns. Rerun and test-list tunnel batches use one `--timeout` deadline, including capacity waits. Explicit public-environment sign-in against a local origin is announced once on stderr. Rerun/test-list responses must echo the tunnel client id; if an old server strips it, the CLI attempts to cancel what started and exits with a clear error. -When explicit test IDs mix a saved-loopback frontend with a public-environment backend, the backend runs without tunnel routing. If its run row remains non-terminal, the CLI also reads the backend test result and accepts a terminal verdict only when its run ID matches (or, for legacy results without a run ID, its finish time is no earlier than the trigger's enqueue time). This keeps JSON summaries, JUnit reports, and exit codes aligned with completed backend results instead of timing them out. Unrelated, incomplete, or unavailable results leave normal run polling in place within the original timeout. +When explicit test IDs mix a saved-loopback frontend with a public-environment backend, the backend runs without tunnel routing. If its run row remains non-terminal, the CLI also reads the backend test result and accepts a terminal verdict only when its run ID matches (or, for legacy results without a run ID, its finish time is no earlier than the trigger's enqueue time). Invalid legacy finish timestamps or invalid provided enqueue timestamps leave normal polling in place. This keeps JSON summaries, JUnit reports, and exit codes aligned with completed backend results instead of timing them out. Unrelated, incomplete, or unavailable results leave normal run polling in place within the original timeout. **Timeout, cancellation, and refunds.** When a single owned `--local` run stops waiting before a terminal result, the CLI cancels it by default and closes its tunnel. This includes `--timeout` (exit 7), Ctrl-C (exit 130), and non-terminal polling/tunnel failures. In a local batch, each test has its own timeout measured from its trigger: a timed-out run is cancelled on an owned tunnel, the other tests continue, and the tunnel closes after the batch settles. Cancellation can race completion or fail; read the reported outcome instead of assuming it succeeded. **A run cancelled before it finished is refunded.** Cancelling an already-finished run does not replace its result or refund it. After an owned local timeout, start a **new** run with `testsprite test run --env --timeout 1800`; `test wait` cannot restore the closed tunnel. `--no-cancel-on-interrupt` detaches instead, but does not keep an owned tunnel alive. diff --git a/src/commands/test.local-batch.spec.ts b/src/commands/test.local-batch.spec.ts index 42764e93..2464ddc9 100644 --- a/src/commands/test.local-batch.spec.ts +++ b/src/commands/test.local-batch.spec.ts @@ -194,6 +194,7 @@ function mixedBackendFixture( terminalImmediately?: boolean; resultUnavailable?: boolean; remoteType?: 'frontend' | 'backend'; + backendEnqueuedAt?: string; } = {}, ) { let backendPolls = 0; @@ -207,10 +208,18 @@ function mixedBackendFixture( return json({ runId: 'run_be', status: 'queued', - enqueuedAt: '2026-09-22T00:00:00.000Z', + enqueuedAt: options.backendEnqueuedAt ?? '2026-09-22T00:00:00.000Z', codeVersion: 'v1', targetUrl: 'https://example.com', }); + if (call.method === 'POST' && call.url.endsWith('/tests/fe/runs')) + return json({ + runId: 'run_fe', + status: 'queued', + enqueuedAt: '2026-09-22T00:00:01.000Z', + codeVersion: 'v1', + targetUrl: 'http://127.0.0.1:5173', + }); if (call.method !== 'GET') return undefined; if (call.url.endsWith('/tests/be')) return json({ @@ -357,7 +366,11 @@ describe('test run local batch', () => { ); it('resolves a legacy result with no run ID only when it is fresh enough', async () => { - const f = mixedBackendFixture({ runIdIfAvailable: null }); + const f = mixedBackendFixture({ + runIdIfAvailable: null, + startedAt: '2026-09-22T00:00:00.100Z', + finishedAt: '2026-09-22T00:00:00.500Z', + }); await f.run( 'be', 'fe', @@ -376,6 +389,7 @@ describe('test run local batch', () => { it.each([ { runIdIfAvailable: 'run_other' }, { runIdIfAvailable: null, finishedAt: '2026-09-21T23:59:59.000Z' }, + { runIdIfAvailable: null, finishedAt: 'not-a-date' }, { status: 'running', finishedAt: null }, ] satisfies Array>)( 'keeps polling instead of accepting unrelated or incomplete result %j', @@ -394,6 +408,26 @@ describe('test run local batch', () => { }, ); + it.each(['not-a-date', ''])( + 'keeps polling when the backend enqueue timestamp is invalid: %j', + async backendEnqueuedAt => { + const f = mixedBackendFixture( + { runIdIfAvailable: null }, + { finalRunStatus: 'failed', backendEnqueuedAt }, + ); + await expect(f.run('fe', 'be', '--skip-preflight', '--timeout', '1')).rejects.toMatchObject( + { exitCode: 1 }, + ); + expect(JSON.parse(f.stdout.join('')).results[1]).toMatchObject({ + testId: 'be', + runId: 'run_be', + status: 'failed', + }); + expect(f.calls.filter(c => c.url.endsWith('/tests/be/result'))).toHaveLength(1); + expect(f.calls.filter(c => c.url.includes('/runs/run_be'))).toHaveLength(2); + }, + ); + it('keeps the run-row verdict when the result endpoint is unavailable', async () => { const f = mixedBackendFixture({}, { finalRunStatus: 'passed', resultUnavailable: true }); await f.run('fe', 'be', '--skip-preflight', '--timeout', '1'); diff --git a/src/commands/test.test.ts b/src/commands/test.test.ts index 97ce59e1..7903da45 100644 --- a/src/commands/test.test.ts +++ b/src/commands/test.test.ts @@ -1796,6 +1796,29 @@ describe('backendResultIsForThisRun — auto-resume stale-verdict floor (finding ), ).toBe(true); }); + + it.each([ + { finishedAt: 'not-a-date', notBefore: inFlightCreatedAt, expected: false }, + { finishedAt: 'not-a-date', notBefore: undefined, expected: false }, + { finishedAt: inFlightCreatedAt, notBefore: 'not-a-date', expected: false }, + { finishedAt: inFlightCreatedAt, notBefore: '', expected: false }, + { finishedAt: inFlightCreatedAt, notBefore: undefined, expected: true }, + { finishedAt: inFlightCreatedAt, notBefore: inFlightCreatedAt, expected: true }, + ])('validates legacy timestamp correlation: %j', ({ finishedAt, notBefore, expected }) => { + expect( + backendResultIsForThisRun({ ...staleResult, finishedAt }, 'run_inflight', notBefore), + ).toBe(expected); + }); + + it('keeps exact run ID correlation independent of malformed legacy timestamps', () => { + expect( + backendResultIsForThisRun( + { ...staleResult, runIdIfAvailable: 'run_inflight', finishedAt: 'not-a-date' }, + 'run_inflight', + 'not-a-date', + ), + ).toBe(true); + }); }); describe('backend wait fallback — testTitle overlay used for CI report titles', () => { diff --git a/src/commands/test.ts b/src/commands/test.ts index 0003afd4..78fd5f70 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -7219,7 +7219,8 @@ export function backendResultToRunResponse( * just-finished prior run could still be showing. Gate: * - result names our runId → accept (strongest signal); * - result names a different runId → reject (a different run); - * - result has no runId (legacy) → accept iff finishedAt >= notBefore. + * - result has no runId (legacy) → require a valid finishedAt, and when + * notBefore is supplied, require a valid floor and finishedAt >= notBefore. */ export function backendResultIsForThisRun( result: CliLatestResult, @@ -7231,10 +7232,11 @@ export function backendResultIsForThisRun( return result.runIdIfAvailable === runId; } if (!result.finishedAt) return false; - if (!notBefore) return true; const finished = Date.parse(result.finishedAt); + if (Number.isNaN(finished)) return false; + if (notBefore === undefined) return true; const floor = Date.parse(notBefore); - if (Number.isNaN(finished) || Number.isNaN(floor)) return true; + if (Number.isNaN(floor)) return false; return finished >= floor; } From fa93631c004d777270e759cb3d2ca75043ddb91f Mon Sep 17 00:00:00 2001 From: luckyzd Date: Sun, 18 Oct 2026 22:47:30 +0800 Subject: [PATCH 6/9] docs(test): document running response fixture --- src/commands/test.local-batch.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/commands/test.local-batch.spec.ts b/src/commands/test.local-batch.spec.ts index 2464ddc9..38f370f7 100644 --- a/src/commands/test.local-batch.spec.ts +++ b/src/commands/test.local-batch.spec.ts @@ -160,6 +160,10 @@ function fixture( return { ids, calls, stdout, stderr, terminal, clientId, targetUrl, run }; } +/** + * Return a running response for testId with no finish time and an incomplete + * step, so polling fixtures can control when a terminal verdict becomes available. + */ function runningResponse(testId: string): Response { return new Response( JSON.stringify({ From 6805691464a28bd8b067bc3affd8c5e2b9466220 Mon Sep 17 00:00:00 2001 From: luckyzd Date: Sun, 18 Oct 2026 22:55:09 +0800 Subject: [PATCH 7/9] docs(test): explain mixed batch response override --- src/commands/test.local-batch.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/commands/test.local-batch.spec.ts b/src/commands/test.local-batch.spec.ts index 38f370f7..dcad4e03 100644 --- a/src/commands/test.local-batch.spec.ts +++ b/src/commands/test.local-batch.spec.ts @@ -207,6 +207,10 @@ function mixedBackendFixture( new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); return fixture(['fe', 'be'], { sleep: options.finalRunStatus ? async () => {} : ms => new Promise(r => setTimeout(r, ms)), + /** + * Override trigger, run, and result responses for the mixed-backend scenario. + * Return undefined for requests that should use the base fixture's responses. + */ onCall: async call => { if (call.method === 'POST' && call.url.endsWith('/tests/be/runs')) return json({ From 02c8121f1e18e632f741601f50ecd042ffda1e63 Mon Sep 17 00:00:00 2001 From: luckyzd Date: Sun, 18 Oct 2026 22:58:30 +0800 Subject: [PATCH 8/9] docs(test): document base batch fixture --- src/commands/test.local-batch.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/commands/test.local-batch.spec.ts b/src/commands/test.local-batch.spec.ts index dcad4e03..993c4749 100644 --- a/src/commands/test.local-batch.spec.ts +++ b/src/commands/test.local-batch.spec.ts @@ -18,6 +18,10 @@ type Call = { idempotencyKey?: string | null; }; +/** + * Build an offline batch-run CLI fixture with injectable HTTP, tunnel, and timer + * behavior, exposing the command runner, captured requests and output, and test IDs. + */ function fixture( ids: string[], options: { From 3d57b67164e8782f083e3d079b28a2e125dfd700 Mon Sep 17 00:00:00 2001 From: luckyzd Date: Wed, 28 Oct 2026 15:31:32 +0800 Subject: [PATCH 9/9] fix(ci): sync patched undici and document safe report paths Match the undici 7.30.1 lock entry already in upstream main 52812029681269d8a1aa94791022a44008649247. The production audit changes from one high vulnerability to zero. No manifest or unrelated dependency changes. Typecheck, lint, formatting, full coverage and local E2E pass on Node 22.23.2. The two report paths are fixed filenames under this test's own mkdtempSync directory. Line-specific security lint comments document that boundary without changing assertions; changed-line security lint passes. --- package-lock.json | 6 +++--- src/commands/test.local-batch.spec.ts | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1be0c9c9..e3f82585 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3450,9 +3450,9 @@ } }, "node_modules/undici": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", - "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/src/commands/test.local-batch.spec.ts b/src/commands/test.local-batch.spec.ts index 993c4749..85d0baec 100644 --- a/src/commands/test.local-batch.spec.ts +++ b/src/commands/test.local-batch.spec.ts @@ -351,6 +351,7 @@ describe('test run local batch', () => { projectId: 'R', }), ]); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- reads summary.json from this test's own mkdtempSync-created directory; neither path component comes from user input. const summary = JSON.parse(readFileSync(summaryFile, 'utf8')); expect(summary).toMatchObject({ total: 2, @@ -358,6 +359,7 @@ describe('test run local batch', () => { failed: status === 'failed' ? 1 : 0, timedOut: 0, }); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- reads report.xml from this test's own mkdtempSync-created directory; neither path component comes from user input. const xml = readFileSync(reportFile, 'utf8'); expect(xml).toContain('name="Backend checkout" testId="be" runId="run_be" time="1"'); expect(xml).not.toContain('type="timeout"');