| 5506 | * `--wait`, fan-out poll under `--max-concurrency`. `deferred[]` → exit 7. |
| 5507 | */ |
| 5508 | export async function runTestRerun( |
| 5509 | opts: RunTestRerunOptions, |
| 5510 | deps: TestDeps = {}, |
| 5511 | ): Promise<RerunResponse | BatchRerunResponse | undefined> { |
| 5512 | assertIdempotencyKey(opts.idempotencyKey); |
| 5513 | const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); |
| 5514 | const out = makeOutput(opts.output, deps); |
| 5515 | |
| 5516 | // ------------------------------------------------------------------------- |
| 5517 | // Input validation |
| 5518 | // ------------------------------------------------------------------------- |
| 5519 | if (opts.testIds.length === 0 && !opts.all) { |
| 5520 | throw localValidationError( |
| 5521 | 'test-ids', |
| 5522 | 'provide at least one <test-id>, or use --all to rerun all tests in the project', |
| 5523 | ); |
| 5524 | } |
| 5525 | if (opts.all && !opts.projectId) { |
| 5526 | throw localValidationError( |
| 5527 | 'project', |
| 5528 | '--all requires a project context — pass --project <id> or configure a default', |
| 5529 | ); |
| 5530 | } |
| 5531 | // --filter is an --all-only narrowing filter (applied to the fetched project |
| 5532 | // test set). Without --all it would be SILENTLY ignored while explicit ids |
| 5533 | // still get reran — defeating the user's narrowing intent and burning |
| 5534 | // rerun/auto-heal credits (codex). Reject early. (Mirrors delete-batch's |
| 5535 | // --status guard.) |
| 5536 | if (opts.nameFilter !== undefined && opts.nameFilter !== '' && !opts.all) { |
| 5537 | throw localValidationError( |
| 5538 | 'filter', |
| 5539 | '--filter only applies with --all (it narrows which project tests get reran). ' + |
| 5540 | 'Remove --filter, or add --all --project <id>.', |
| 5541 | ); |
| 5542 | } |
| 5543 | if ( |
| 5544 | !Number.isInteger(opts.maxConcurrency) || |
| 5545 | opts.maxConcurrency < 1 || |
| 5546 | opts.maxConcurrency > MAX_BATCH_CONCURRENCY |
| 5547 | ) { |
| 5548 | throw localValidationError('max-concurrency', 'must be an integer between 1 and 100'); |
| 5549 | } |
| 5550 | |
| 5551 | const isSingle = !opts.all && opts.testIds.length === 1; |
| 5552 | |
| 5553 | // ------------------------------------------------------------------------- |
| 5554 | // Pre-flight: auto-heal + Free-tier hint (best-effort, non-blocking) |
| 5555 | // ------------------------------------------------------------------------- |
| 5556 | let effectiveAutoHeal = opts.autoHeal; |
| 5557 | |
| 5558 | if (opts.dryRun) { |
| 5559 | const client = makeClient(opts, deps); |
| 5560 | const idempotencyKey = opts.idempotencyKey ?? `dry-run-${randomUUID()}`; |
| 5561 | if (isSingle) { |
| 5562 | const testId = opts.testIds[0]!; |
| 5563 | const envelope = { |
| 5564 | dryRun: true, |
| 5565 | method: 'POST', |