From 0764da581e6d713e2c0583d40243fc191f474651 Mon Sep 17 00:00:00 2001 From: Shubhdeep Chhabra Date: Sat, 17 Oct 2026 16:22:19 +0530 Subject: [PATCH 1/2] fix(test): derive batch trigger keys so a retry with the same idempotency key doesn't start every run again --- DOCUMENTATION.md | 2 + src/commands/test.create-batch-run.spec.ts | 84 ++++++++++++------- src/commands/test.environment-tunnel.spec.ts | 46 ++++++++++ src/commands/test.local-batch.spec.ts | 11 +-- src/commands/test.test.ts | 23 +++++ src/commands/test.ts | 37 +++++--- test/__snapshots__/help.snapshot.test.ts.snap | 3 +- 7 files changed, 160 insertions(+), 46 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index aac4beeb..d9213d24 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -393,6 +393,8 @@ testsprite test create --plan-from ./checkout.plan.json --dry-run --output json Bulk-create frontend tests from a JSONL plan-steps file (or a directory of plan files with `--plan-from-dir`). Optional `--run --max-concurrency ` fans out triggers. Without `--wait`, each run is dispatched (`status: "queued"`) and the command exits 0 when every trigger is accepted — mirroring single `test run` without `--wait`; a trigger error still exits non-zero. With `--wait`, it polls every run to terminal and exits non-zero if any run does not pass. +With `--run`, each trigger's idempotency key is derived from the batch key (`:run:`), so retrying the command with the same `--idempotency-key` — or the auto-minted one printed to stderr under `--output json`, `--verbose` or `--debug` — returns the runs the first attempt already started instead of starting new, billed ones. Several-id `test run` batches through a tunnel do the same with `:`. A key too long to fit a suffix within 256 characters is shortened, with a digest of the full key kept so it stays unique. + ```bash testsprite test create-batch --plans ./plans.jsonl --run --max-concurrency 4 --output json testsprite test create-batch --plan-from-dir ./plans/ --dry-run --output json diff --git a/src/commands/test.create-batch-run.spec.ts b/src/commands/test.create-batch-run.spec.ts index be439ca8..113925c5 100644 --- a/src/commands/test.create-batch-run.spec.ts +++ b/src/commands/test.create-batch-run.spec.ts @@ -845,10 +845,10 @@ describe('runCreateBatch --run --wait: --timeout per-run', () => { }); // --------------------------------------------------------------------------- -// Idempotency: each per-run key is unique (regression guard) +// Idempotency: per-run keys are distinct and stable across retries // --------------------------------------------------------------------------- -describe('runCreateBatch --run: idempotency key uniqueness', () => { +describe('runCreateBatch --run: run idempotency keys', () => { let logSpy: ReturnType; let errorSpy: ReturnType; @@ -862,14 +862,17 @@ describe('runCreateBatch --run: idempotency key uniqueness', () => { errorSpy.mockRestore(); }); - it('each run trigger gets a distinct idempotency key, none matches the create key', async () => { + /** + * Run `create-batch --run --wait` for three FE specs against a fake server + * and record the idempotency key on the create POST and on each trigger. + */ + async function runCapturingKeys(idempotencyKey?: string) { const { credentialsPath } = makeCreds(); const testIds = ['test_f1', 'test_f2', 'test_f3']; const plansFile = writePlansJsonl([FE_SPEC, FE_SPEC, FE_SPEC]); - const createIdemKey = 'cli-create-batch-test-key'; - - const seenRunIdempotencyKeys: string[] = []; - let createIdempotencyKeySeen = ''; + const runKeys: Record = {}; + const stderr: string[] = []; + let createKey = ''; const fetchImpl = (async (input: FetchInput, init: RequestInit = {}) => { const url = @@ -881,7 +884,7 @@ describe('runCreateBatch --run: idempotency key uniqueness', () => { const headers = new Headers(init.headers as Record); if (url.includes('/tests/batch')) { - createIdempotencyKeySeen = headers.get('idempotency-key') ?? ''; + createKey = headers.get('idempotency-key') ?? ''; return new Response(JSON.stringify(makeBatchCreateResponse(testIds)), { status: 200, headers: { 'content-type': 'application/json' }, @@ -891,10 +894,8 @@ describe('runCreateBatch --run: idempotency key uniqueness', () => { const triggerMatch = /\/tests\/(test_[a-z0-9]+)\/runs$/.exec(url); if (triggerMatch?.[1]) { const testId = triggerMatch[1]; - const key = headers.get('idempotency-key') ?? ''; - seenRunIdempotencyKeys.push(key); - const runId = `run_${testId}`; - return new Response(JSON.stringify(makeTriggerResponse(testId, runId)), { + runKeys[testId] = headers.get('idempotency-key') ?? ''; + return new Response(JSON.stringify(makeTriggerResponse(testId, `run_${testId}`)), { status: 200, headers: { 'content-type': 'application/json' }, }); @@ -903,8 +904,7 @@ describe('runCreateBatch --run: idempotency key uniqueness', () => { const pollMatch = /\/runs\/(run_test_[a-z0-9]+)/.exec(url); if (pollMatch?.[1]) { const runId = pollMatch[1]; - const testId = runId.replace('run_', ''); - return new Response(JSON.stringify(makePassedRun(testId, runId)), { + return new Response(JSON.stringify(makePassedRun(runId.replace('run_', ''), runId)), { status: 200, headers: { 'content-type': 'application/json' }, }); @@ -927,31 +927,59 @@ describe('runCreateBatch --run: idempotency key uniqueness', () => { plans: plansFile, run: true, wait: true, - idempotencyKey: createIdemKey, + ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), timeoutSeconds: 60, }, { credentialsPath, fetchImpl, stdout: () => {}, - stderr: () => {}, + stderr: line => stderr.push(line), sleep: instantSleep, }, ); + return { createKey, runKeys, stderr }; + } + + it('derives a distinct run key per test from the create key, never the create key itself', async () => { + const { createKey, runKeys } = await runCapturingKeys('cli-create-batch-test-key'); + + expect(createKey).toBe('cli-create-batch-test-key'); + expect(runKeys).toEqual({ + test_f1: 'cli-create-batch-test-key:run:test_f1', + test_f2: 'cli-create-batch-test-key:run:test_f2', + test_f3: 'cli-create-batch-test-key:run:test_f3', + }); + }); + + it('re-sends the same run keys when retried with the same --idempotency-key', async () => { + const first = await runCapturingKeys('ci-build-42'); + const retry = await runCapturingKeys('ci-build-42'); + + // Identical keys let the server return the runs the first attempt started + // instead of starting (and billing) them a second time. + expect(retry.runKeys).toEqual(first.runKeys); + }); + + it('re-sends the same run keys when retried with the printed auto-minted key', async () => { + const first = await runCapturingKeys(); + const printed = first.stderr + .find(line => line.startsWith('idempotency-key: ')) + ?.slice('idempotency-key: '.length); + expect(printed).toBe(first.createKey); + + const retry = await runCapturingKeys(printed); + expect(retry.runKeys).toEqual(first.runKeys); + }); + + it('keeps a derived run key within 256 characters when the create key is at the limit', async () => { + const { runKeys } = await runCapturingKeys('k'.repeat(256)); - // 3 run triggers → 3 run idempotency keys - expect(seenRunIdempotencyKeys).toHaveLength(3); - // All run keys must be unique - const uniqueKeys = new Set(seenRunIdempotencyKeys); - expect(uniqueKeys.size).toBe(3); - // None of the run keys matches the create key (regression guard) - expect(seenRunIdempotencyKeys).not.toContain(createIdemKey); - // The create key was used on the batch POST - expect(createIdempotencyKeySeen).toBe(createIdemKey); - // All run keys should start with 'cli-batch-run-' - for (const key of seenRunIdempotencyKeys) { - expect(key).toMatch(/^cli-batch-run-[0-9a-f-]{36}$/); + for (const [testId, key] of Object.entries(runKeys)) { + expect(key.length).toBeLessThanOrEqual(256); + expect(key.endsWith(`:run:${testId}`)).toBe(true); } + expect(new Set(Object.values(runKeys)).size).toBe(3); }); }); diff --git a/src/commands/test.environment-tunnel.spec.ts b/src/commands/test.environment-tunnel.spec.ts index 728cab13..3f8c30fe 100644 --- a/src/commands/test.environment-tunnel.spec.ts +++ b/src/commands/test.environment-tunnel.spec.ts @@ -1391,6 +1391,52 @@ describe('environment tunnel resolution', () => { expect(keys.every(key => key.length <= 256)).toBe(true); }); + /** Record the idempotency key each `POST /tests/{id}/runs` carries, by test id. */ + function captureTriggerKeys(f: ReturnType): Record { + const keys: Record = {}; + const original = f.deps.fetchImpl; + f.deps.fetchImpl = vi.fn(async (input: Parameters[0], init: RequestInit = {}) => { + const trigger = /\/tests\/([^/]+)\/runs$/.exec(String(input)); + if (trigger?.[1] && init.method === 'POST') + keys[trigger[1]] = new Headers(init.headers).get('idempotency-key')!; + return original(input, init); + }); + return keys; + } + + const threeTests = [ + { id: 'a', type: 'frontend', projectId: 'P' }, + { id: 'b', type: 'frontend', projectId: 'P' }, + { id: 'c', type: 'frontend', projectId: 'P' }, + ]; + + it('derives every trigger key of a several-id run from --idempotency-key and the test id', async () => { + const f = fixture({ tests: threeTests }); + const keys = captureTriggerKeys(f); + await f.run('run', 'a', 'b', 'c', '--skip-preflight', '--idempotency-key', 'ci-build-42'); + expect(keys).toEqual({ a: 'ci-build-42:a', b: 'ci-build-42:b', c: 'ci-build-42:c' }); + }); + + it('re-sends the same trigger keys when a several-id run is retried with the printed key', async () => { + const first = fixture({ tests: threeTests }); + const firstKeys = captureTriggerKeys(first); + await first.run('run', 'a', 'b', 'c', '--skip-preflight'); + const printed = first.stderr + .find(line => line.startsWith('idempotency-key: ')) + ?.slice('idempotency-key: '.length); + expect(printed).toMatch(/^cli-run-/); + + const retry = fixture({ tests: threeTests }); + const retryKeys = captureTriggerKeys(retry); + await retry.run('run', 'a', 'b', 'c', '--skip-preflight', '--idempotency-key', printed!); + + // Identical keys let the server return the runs the first attempt started + // instead of starting (and billing) every test a second time. + expect(Object.keys(firstKeys)).toHaveLength(3); + expect(retryKeys).toEqual(firstKeys); + expect(new Set(Object.values(firstKeys)).size).toBe(3); + }); + it('keeps run receipts when an owned rerun is interrupted during deferred backoff', async () => { const interrupt = new InterruptError('SIGINT'); const f = fixture({ diff --git a/src/commands/test.local-batch.spec.ts b/src/commands/test.local-batch.spec.ts index 0758ce49..a5604052 100644 --- a/src/commands/test.local-batch.spec.ts +++ b/src/commands/test.local-batch.spec.ts @@ -1248,16 +1248,17 @@ describe('test run local batch', () => { expect(f.stderr.filter(line => line.startsWith('idempotency-key:'))).toEqual([]); }); - it('prints the first automatic trigger key once', async () => { + it('prints the automatic key once and derives every trigger key from it', async () => { const f = fixture(['a', 'b']); await f.run('a', 'b', '--local', '5173', '--skip-preflight'); const keys = f.calls .filter(c => c.method === 'POST' && /\/tests\/[^/]+\/runs/.test(c.url)) .map(c => c.idempotencyKey); - expect(keys[0]).not.toBe(keys[1]); - expect(f.stderr.filter(line => line.startsWith('idempotency-key:'))).toEqual([ - `idempotency-key: ${keys[0]}`, - ]); + const printed = f.stderr.filter(line => line.startsWith('idempotency-key:')); + expect(printed).toHaveLength(1); + const base = printed[0]!.slice('idempotency-key: '.length); + // Passing the printed key back with --idempotency-key reproduces these keys. + expect(keys).toEqual([`${base}:a`, `${base}:b`]); }); it('prints the text table without inventing a project dashboard link', async () => { diff --git a/src/commands/test.test.ts b/src/commands/test.test.ts index 97ce59e1..87577f73 100644 --- a/src/commands/test.test.ts +++ b/src/commands/test.test.ts @@ -26,6 +26,7 @@ import { type CliTestStep, type TestDeps, backendResultIsForThisRun, + deriveIdempotencyKey, backendResultToRunResponse, makeBackendWaitFallback, createTestCommand, @@ -1753,6 +1754,28 @@ const RESULT_PASSED: CliLatestResult = { summary: 'Test passed.', }; +describe('deriveIdempotencyKey', () => { + it('appends the suffix to a key that fits', () => { + expect(deriveIdempotencyKey('ci-build-42', ':run:test_f1')).toBe('ci-build-42:run:test_f1'); + const exact = 'k'.repeat(256 - ':a'.length); + expect(deriveIdempotencyKey(exact, ':a')).toBe(`${exact}:a`); + }); + + it('keeps long keys that differ only in their tail distinct within 256 characters', () => { + const shared = 'k'.repeat(250); + const first = deriveIdempotencyKey(`${shared}-job-1`, ':run:test_f1'); + const second = deriveIdempotencyKey(`${shared}-job-2`, ':run:test_f1'); + + expect(first).not.toBe(second); + for (const key of [first, second]) { + expect(key).toHaveLength(256); + expect(key.endsWith(':run:test_f1')).toBe(true); + } + // Deterministic, so a retry with the same long key still re-sends it. + expect(deriveIdempotencyKey(`${shared}-job-1`, ':run:test_f1')).toBe(first); + }); +}); + describe('backendResultIsForThisRun — auto-resume stale-verdict floor (finding 2)', () => { // The orphaned-row case the whole fallback exists for: a terminal result for // the test that carries NO runIdIfAvailable, so the createdAt floor is the only diff --git a/src/commands/test.ts b/src/commands/test.ts index 31f3b107..878a666d 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -29,7 +29,7 @@ import { } from 'node:fs'; import { rename, stat, unlink } from 'node:fs/promises'; import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'; -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { Command } from 'commander'; import { openInBrowser } from '../lib/browser.js'; import { @@ -1228,6 +1228,21 @@ interface CreateOptions extends CommonOptions { skipPreflight?: boolean; } +/** + * Derive the idempotency key for one request of a batch from the batch key: + * ``, so a retry with the same base key re-sends the same keys + * and the server returns the runs the first attempt started. When that exceeds + * the 256-character limit, the base is shortened and a digest of the full base + * is kept, so long base keys that differ only in their tail stay distinct. + */ +export function deriveIdempotencyKey(base: string, suffix: string): string { + const key = `${base}${suffix}`; + if (key.length <= 256) return key; + const digest = createHash('sha256').update(base).digest('hex').slice(0, 16); + const room = Math.max(0, 256 - suffix.length - digest.length - 1); + return `${base.slice(0, room)}-${digest}${suffix}`.slice(-256); +} + /** * Chained `test create --run` / `test create --plan-from --run` derive the * trigger idempotency key as `:run`. Validate that derived key @@ -4169,7 +4184,7 @@ export async function runCreateBatch( if (opts.run === true) { await withCreatedReceipt(opts, deps, response, runDeps => runBatchRun( - opts, + { ...opts, idempotencyKey }, response, runDeps.environmentTunnel?.client ?? client, makeOutput(opts.output, runDeps), @@ -4207,7 +4222,7 @@ export async function runCreateBatch( * that code only when ALL runs share the same code; otherwise 1. */ async function runBatchRun( - opts: CreateBatchOptions, + opts: CreateBatchOptions & { idempotencyKey: string }, createResponse: CliCreateBatchResponse, client: HttpClient, out: Output, @@ -4383,8 +4398,9 @@ async function runBatchRun( const pendingTriggers = new Set>(); async function triggerOne(testId: string): Promise { - // Mint a fresh idempotency key per run — MUST NOT reuse the create key. - const runIdempotencyKey = `cli-batch-run-${randomUUID()}`; + // Derived from the create key, never the create key itself (that would + // replay the create response). + const runIdempotencyKey = deriveIdempotencyKey(opts.idempotencyKey, `:run:${testId}`); if (opts.debug) { stderrFn(`[batch-run] ${testId} idempotency-key: ${runIdempotencyKey}`); } @@ -11253,12 +11269,9 @@ export async function runTestRunLocalBatch( const runOne = async (index: number) => { const row = results[index]!; let triggerStartedAtMs: number; - const suffix = `:${row.testId}`; - const key = opts.idempotencyKey - ? `${idempotencyBase.slice(0, Math.max(0, 256 - suffix.length))}${suffix}`.slice(-256) - : index === 0 - ? idempotencyBase - : `cli-run-${randomUUID()}`; + // Derived for every test, auto-minted base included, so a retry with + // the printed key re-sends the same keys. + const key = deriveIdempotencyKey(idempotencyBase, `:${row.testId}`); let response: TriggerRunResponse; try { if (shutdown.signal.aborted) throw shutdown.signal.reason; @@ -14078,7 +14091,7 @@ export function createTestCommand(deps: TestDeps = {}): Command { .option('--skip-preflight', SKIP_PREFLIGHT_HELP, false) .option( '--idempotency-key ', - 'opaque idempotency token for the batch create (1-256 ASCII chars). Defaults to a UUIDv4 minted per invocation; pin one yourself for safe retries.', + 'opaque idempotency token for the batch create (1-256 ASCII chars); with --run, each trigger key is derived from it. Defaults to a UUIDv4 minted per invocation; pin one yourself for safe retries.', ) .addHelpText('after', GLOBAL_OPTS_HINT) .action(async (cmdOpts: CreateBatchFlagOpts, command: Command) => { diff --git a/test/__snapshots__/help.snapshot.test.ts.snap b/test/__snapshots__/help.snapshot.test.ts.snap index 966acfaf..ba75e02c 100644 --- a/test/__snapshots__/help.snapshot.test.ts.snap +++ b/test/__snapshots__/help.snapshot.test.ts.snap @@ -880,7 +880,8 @@ Options: flag when you know better. Zero network calls when set. (default: false) --idempotency-key opaque idempotency token for the batch create - (1-256 ASCII chars). Defaults to a UUIDv4 minted + (1-256 ASCII chars); with --run, each trigger key + is derived from it. Defaults to a UUIDv4 minted per invocation; pin one yourself for safe retries. -h, --help display help for command From 78daec5af98b7fff64f1a91e3884bdc62c450191 Mon Sep 17 00:00:00 2001 From: Shubhdeep Chhabra Date: Sat, 17 Oct 2026 17:56:38 +0530 Subject: [PATCH 2/2] fix(test): scope tunnel-routed batch trigger keys to the tunnel client --- DOCUMENTATION.md | 2 +- src/commands/test.environment-tunnel.spec.ts | 70 ++++++++++++++++++-- src/commands/test.local-batch.spec.ts | 2 +- src/commands/test.ts | 24 +++++-- 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index d9213d24..5c715ac9 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -393,7 +393,7 @@ testsprite test create --plan-from ./checkout.plan.json --dry-run --output json Bulk-create frontend tests from a JSONL plan-steps file (or a directory of plan files with `--plan-from-dir`). Optional `--run --max-concurrency ` fans out triggers. Without `--wait`, each run is dispatched (`status: "queued"`) and the command exits 0 when every trigger is accepted — mirroring single `test run` without `--wait`; a trigger error still exits non-zero. With `--wait`, it polls every run to terminal and exits non-zero if any run does not pass. -With `--run`, each trigger's idempotency key is derived from the batch key (`:run:`), so retrying the command with the same `--idempotency-key` — or the auto-minted one printed to stderr under `--output json`, `--verbose` or `--debug` — returns the runs the first attempt already started instead of starting new, billed ones. Several-id `test run` batches through a tunnel do the same with `:`. A key too long to fit a suffix within 256 characters is shortened, with a digest of the full key kept so it stays unique. +With `--run`, each trigger's idempotency key is derived from the batch key (`:run:`), so retrying the command with the same `--idempotency-key` — or the auto-minted one printed to stderr under `--output json`, `--verbose` or `--debug` — returns the runs the first attempt already started instead of starting new, billed ones. Several-id `test run` batches do the same with `:`. A trigger routed through a tunnel also carries the tunnel's client id (`::`): a retry through the same tunnel (adopted with `--tunnel-client`) gets its runs back, while a retry through a new tunnel starts new runs, since the old ones cannot reach the app without their tunnel. A key too long to fit its suffix within 256 characters is shortened, with a digest of the full key kept so it stays unique. ```bash testsprite test create-batch --plans ./plans.jsonl --run --max-concurrency 4 --output json diff --git a/src/commands/test.environment-tunnel.spec.ts b/src/commands/test.environment-tunnel.spec.ts index 3f8c30fe..3676232b 100644 --- a/src/commands/test.environment-tunnel.spec.ts +++ b/src/commands/test.environment-tunnel.spec.ts @@ -37,6 +37,8 @@ function fixture( envStatus?: number; echo?: boolean; mintStatus?: number; + /** Client id the tunnel mint returns (a different one simulates a new tunnel). */ + tunnelClientId?: string; triggerStatus?: number; batchResponse?: (body: Record) => Record; envs?: Array>; @@ -134,7 +136,7 @@ function fixture( ? error(options.mintStatus, 'missing_scope') : json( { - clientId, + clientId: options.tunnelClientId ?? clientId, secret: 'fixture-secret', controlUrl: 'ws://tunnel.example/ws', tunnelAddr: 'tunnel.example:7400', @@ -142,7 +144,7 @@ function fixture( }, 201, ); - if (method === 'DELETE' && path === `/tunnel/${clientId}`) + if (method === 'DELETE' && path === `/tunnel/${options.tunnelClientId ?? clientId}`) return new Response(null, { status: 204 }); if (method === 'POST' && path.endsWith('/cancel')) return json({ @@ -1410,11 +1412,15 @@ describe('environment tunnel resolution', () => { { id: 'c', type: 'frontend', projectId: 'P' }, ]; - it('derives every trigger key of a several-id run from --idempotency-key and the test id', async () => { + it('derives every trigger key of a several-id run from --idempotency-key, the tunnel and the test id', async () => { const f = fixture({ tests: threeTests }); const keys = captureTriggerKeys(f); await f.run('run', 'a', 'b', 'c', '--skip-preflight', '--idempotency-key', 'ci-build-42'); - expect(keys).toEqual({ a: 'ci-build-42:a', b: 'ci-build-42:b', c: 'ci-build-42:c' }); + expect(keys).toEqual({ + a: `ci-build-42:${clientId}:a`, + b: `ci-build-42:${clientId}:b`, + c: `ci-build-42:${clientId}:c`, + }); }); it('re-sends the same trigger keys when a several-id run is retried with the printed key', async () => { @@ -1437,6 +1443,62 @@ describe('environment tunnel resolution', () => { expect(new Set(Object.values(firstKeys)).size).toBe(3); }); + it('uses new trigger keys when a several-id run is retried through a new tunnel', async () => { + const first = fixture({ tests: threeTests }); + const firstKeys = captureTriggerKeys(first); + await first.run('run', 'a', 'b', 'c', '--skip-preflight', '--idempotency-key', 'ci-build-42'); + + const retry = fixture({ tests: threeTests, tunnelClientId: 'new-tunnel-client' }); + const retryKeys = captureTriggerKeys(retry); + await retry.run('run', 'a', 'b', 'c', '--skip-preflight', '--idempotency-key', 'ci-build-42'); + + // The old runs are bound to a tunnel that is gone, and reusing their keys + // with a new tunnelClientId would be refused as IDEMPOTENCY_BODY_MISMATCH. + expect(retryKeys).toEqual({ + a: 'ci-build-42:new-tunnel-client:a', + b: 'ci-build-42:new-tunnel-client:b', + c: 'ci-build-42:new-tunnel-client:c', + }); + expect(Object.values(retryKeys).some(key => Object.values(firstKeys).includes(key))).toBe( + false, + ); + }); + + it('scopes create-batch --run trigger keys to the tunnel', async () => { + const f = fixture(); + const keys = captureTriggerKeys(f); + const plans = join(mkdtempSync(join(tmpdir(), 'env-plans-')), 'plans.jsonl'); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- fixture-owned temporary directory. + writeFileSync( + plans, + ['a', 'b'] + .map(name => + JSON.stringify({ + projectId: 'P', + type: 'frontend', + name, + planSteps: [{ type: 'action', description: 'Open the app' }], + }), + ) + .join('\n'), + ); + await runCreateBatch( + { + ...common, + plans, + run: true, + wait: false, + idempotencyKey: 'ci-build-42', + skipPreflight: true, + }, + f.deps, + ); + expect(keys).toEqual({ + a: `ci-build-42:run:${clientId}:a`, + b: `ci-build-42:run:${clientId}:b`, + }); + }); + it('keeps run receipts when an owned rerun is interrupted during deferred backoff', async () => { const interrupt = new InterruptError('SIGINT'); const f = fixture({ diff --git a/src/commands/test.local-batch.spec.ts b/src/commands/test.local-batch.spec.ts index a5604052..7c671aa7 100644 --- a/src/commands/test.local-batch.spec.ts +++ b/src/commands/test.local-batch.spec.ts @@ -1258,7 +1258,7 @@ describe('test run local batch', () => { expect(printed).toHaveLength(1); const base = printed[0]!.slice('idempotency-key: '.length); // Passing the printed key back with --idempotency-key reproduces these keys. - expect(keys).toEqual([`${base}:a`, `${base}:b`]); + expect(keys).toEqual([`${base}:${f.clientId}:a`, `${base}:${f.clientId}:b`]); }); it('prints the text table without inventing a project dashboard link', async () => { diff --git a/src/commands/test.ts b/src/commands/test.ts index 878a666d..c777d294 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -1234,6 +1234,13 @@ interface CreateOptions extends CommonOptions { * and the server returns the runs the first attempt started. When that exceeds * the 256-character limit, the base is shortened and a digest of the full base * is kept, so long base keys that differ only in their tail stay distinct. + * + * A trigger routed through a tunnel names the tunnel's client id in its body, + * and the server refuses a reused key whose body changed + * (IDEMPOTENCY_BODY_MISMATCH). Callers therefore put that client id in the + * suffix: a retry through the same tunnel (one adopted with --tunnel-client) + * gets its runs back, and a retry through a new tunnel starts new runs, since + * the old ones cannot reach the app once their tunnel is gone. */ export function deriveIdempotencyKey(base: string, suffix: string): string { const key = `${base}${suffix}`; @@ -4399,8 +4406,15 @@ async function runBatchRun( async function triggerOne(testId: string): Promise { // Derived from the create key, never the create key itself (that would - // replay the create response). - const runIdempotencyKey = deriveIdempotencyKey(opts.idempotencyKey, `:run:${testId}`); + // replay the create response), and scoped to the tunnel when routed + // through one (see deriveIdempotencyKey). + const tunnelScope = deps.environmentTunnel?.targets?.has(testId) + ? `:${deps.environmentTunnel.clientId}` + : ''; + const runIdempotencyKey = deriveIdempotencyKey( + opts.idempotencyKey, + `:run${tunnelScope}:${testId}`, + ); if (opts.debug) { stderrFn(`[batch-run] ${testId} idempotency-key: ${runIdempotencyKey}`); } @@ -11270,8 +11284,10 @@ export async function runTestRunLocalBatch( const row = results[index]!; let triggerStartedAtMs: number; // Derived for every test, auto-minted base included, so a retry with - // the printed key re-sends the same keys. - const key = deriveIdempotencyKey(idempotencyBase, `:${row.testId}`); + // the printed key re-sends the same keys; scoped to the tunnel when + // routed through one (see deriveIdempotencyKey). + const tunnelScope = targets.has(row.testId) ? `:${liveSession.clientId}` : ''; + const key = deriveIdempotencyKey(idempotencyBase, `${tunnelScope}:${row.testId}`); let response: TriggerRunResponse; try { if (shutdown.signal.aborted) throw shutdown.signal.reason;