* Write per-step artifacts under ` /steps/`. Each step gets up to * three files: ` -screenshot.png` (when a screenshot URL exists — * M2 backend never sets one yet), ` -snapshot.html` (when * `htmlSnapshotUrl` exists), and ` -evidence.json` (a small JSON * file containing the non-sn
( step: CliTestStep, allEvidence: ReadonlyArray<CliFailureContext['failure']['evidence'][number]>, stepsTmpDir: string, fetchImpl: FetchImpl, filesWritten: string[], )
| 590 | * there's nothing to put in it; it's NOT a placeholder. |
| 591 | */ |
| 592 | async function writeStepArtifacts( |
| 593 | step: CliTestStep, |
| 594 | allEvidence: ReadonlyArray<CliFailureContext['failure']['evidence'][number]>, |
| 595 | stepsTmpDir: string, |
| 596 | fetchImpl: FetchImpl, |
| 597 | filesWritten: string[], |
| 598 | ): Promise<void> { |
| 599 | const prefix = stepFilenamePrefix(step.stepIndex); |
| 600 | |
| 601 | if (step.screenshotUrl) { |
| 602 | const file = `${prefix}-screenshot.png`; |
| 603 | await streamUrlToFile(step.screenshotUrl, join(stepsTmpDir, file), fetchImpl); |
| 604 | filesWritten.push(`steps/${file}`); |
| 605 | } |
| 606 | |
| 607 | if (step.htmlSnapshotUrl) { |
| 608 | const file = `${prefix}-snapshot.html`; |
| 609 | await streamUrlToFile(step.htmlSnapshotUrl, join(stepsTmpDir, file), fetchImpl); |
| 610 | filesWritten.push(`steps/${file}`); |
| 611 | } |
| 612 | |
| 613 | // Evidence sidecar artifacts. Per codex round-1 P2: the bundle must |
| 614 | // be self-contained — every `evidence[].url` in the failure response |
| 615 | // resolves to a local file by the time `meta.json` is written. The |
| 616 | // earlier filter dropped screenshot/snapshot kinds on the assumption |
| 617 | // they were always duplicates of `step.screenshotUrl` / |
| 618 | // `step.htmlSnapshotUrl`, but if the evidence carries a different URL |
| 619 | // (different snapshot variant, a per-evidence sidecar shot, etc.) the |
| 620 | // bundle silently lost it and `failure.json` still referenced an |
| 621 | // expiring presigned link. Rule now: include every evidence entry; |
| 622 | // remap to the existing step file when URLs match, download a fresh |
| 623 | // sidecar otherwise. |
| 624 | // |
| 625 | // Critical: leaving any URL inside `<NN>-evidence.json` means the |
| 626 | // bundle claims completeness but actually points at presigned URLs |
| 627 | // that expire after 15 min — an agent opening the bundle one hour |
| 628 | // later would see metadata referencing dead links. Streaming the |
| 629 | // bytes here makes the bundle self-contained. |
| 630 | const sidecar = allEvidence.filter(e => e.stepIndex === step.stepIndex); |
| 631 | if (sidecar.length > 0) { |
| 632 | const dereferenced = await Promise.all( |
| 633 | sidecar.map(async (entry, i) => { |
| 634 | // Reuse the already-downloaded step file when the evidence URL |
| 635 | // matches the step's primary screenshot/snapshot URL. Cheap |
| 636 | // dedupe — no extra HTTP round-trip, no duplicate bytes on disk. |
| 637 | if (entry.kind === 'screenshot' && step.screenshotUrl && step.screenshotUrl === entry.url) { |
| 638 | return { |
| 639 | kind: entry.kind, |
| 640 | stepIndex: entry.stepIndex, |
| 641 | summary: entry.summary, |
| 642 | path: `steps/${prefix}-screenshot.png`, |
| 643 | }; |
| 644 | } |
| 645 | if ( |
| 646 | entry.kind === 'snapshot' && |
| 647 | step.htmlSnapshotUrl && |
| 648 | step.htmlSnapshotUrl === entry.url |
| 649 | ) { |
no test coverage detected