Skip to content

Commit 1f37de1

Browse files
fix(test): validate failure get --out before API fetch (#26)
runFailureGet now resolves and validates --out via resolveBundleDir and assertOutDirParentExists before calling GET /tests/{id}/failure, matching runArtifactGet and runCodeGet fast-fail behavior. Adds regression tests asserting zero fetch calls on empty --out and missing parent dir paths.
1 parent 3b7ce07 commit 1f37de1

2 files changed

Lines changed: 60 additions & 9 deletions

File tree

src/commands/test.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3267,6 +3267,52 @@ function makeFailureContext(overrides: Partial<CliFailureContext> = {}): CliFail
32673267
}
32683268

32693269
describe('runFailureGet', () => {
3270+
it('--out rejects an empty path with VALIDATION_ERROR (exit 5) before any network I/O', async () => {
3271+
const { credentialsPath } = makeCreds();
3272+
let fetchCalls = 0;
3273+
const fetchImpl = makeFetch(() => {
3274+
fetchCalls += 1;
3275+
return { body: makeFailureContext() };
3276+
});
3277+
await expect(
3278+
runFailureGet(
3279+
{
3280+
profile: 'default',
3281+
output: 'text',
3282+
debug: false,
3283+
testId: 'test_failed',
3284+
failedOnly: false,
3285+
out: '',
3286+
},
3287+
{ credentialsPath, fetchImpl },
3288+
),
3289+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
3290+
expect(fetchCalls).toBe(0);
3291+
});
3292+
3293+
it('--out rejects a path under a missing parent dir with VALIDATION_ERROR (exit 5) before any network I/O', async () => {
3294+
const { credentialsPath } = makeCreds();
3295+
let fetchCalls = 0;
3296+
const fetchImpl = makeFetch(() => {
3297+
fetchCalls += 1;
3298+
return { body: makeFailureContext() };
3299+
});
3300+
await expect(
3301+
runFailureGet(
3302+
{
3303+
profile: 'default',
3304+
output: 'text',
3305+
debug: false,
3306+
testId: 'test_failed',
3307+
failedOnly: false,
3308+
out: `/tmp/_p5_no_such_dir_${process.pid}_${Date.now()}/bundle`,
3309+
},
3310+
{ credentialsPath, fetchImpl },
3311+
),
3312+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
3313+
expect(fetchCalls).toBe(0);
3314+
});
3315+
32703316
it('JSON mode (no --out) prints the wire envelope verbatim to stdout', async () => {
32713317
const { credentialsPath } = makeCreds();
32723318
const ctx = makeFailureContext();

src/commands/test.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4063,11 +4063,16 @@ export async function runFailureGet(
40634063
): Promise<FailureGetResult> {
40644064
const out = makeOutput(opts.output, deps);
40654065
const client = makeClient(opts, deps);
4066-
// We resolve the output dir BEFORE the network call so a missing /
4066+
4067+
// Resolve and validate --out BEFORE the network call so a missing /
40674068
// empty path surfaces as VALIDATION_ERROR (exit 5) without spending
4068-
// an API call. `writeBundle` re-validates internally; this is the
4069-
// fast-fail.
4070-
const requestedDir = opts.out;
4069+
// an API call. Mirrors `runArtifactGet`; `writeBundle` re-validates
4070+
// internally as defense-in-depth.
4071+
let resolvedDir: string | undefined;
4072+
if (opts.out !== undefined) {
4073+
resolvedDir = resolveBundleDir(opts.out);
4074+
await assertOutDirParentExists(resolvedDir);
4075+
}
40714076

40724077
const context = await client.get<CliFailureContext>(
40734078
`/tests/${encodeURIComponent(opts.testId)}/failure`,
@@ -4080,7 +4085,7 @@ export async function runFailureGet(
40804085
// internally; this call is the cheap upfront trap.
40814086
assertContextIntegrity(context, 'local');
40824087

4083-
if (requestedDir !== undefined) {
4088+
if (resolvedDir !== undefined) {
40844089
// Dry-run: do NOT call writeBundle (which would mkdir, fetch
40854090
// presigned URLs, and write files). Print the would-be bundle layout
40864091
// to stderr and emit the wire envelope to stdout so the agent sees
@@ -4089,18 +4094,18 @@ export async function runFailureGet(
40894094
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
40904095
const fileNames = plannedBundleFiles(context, opts.failedOnly);
40914096
stderr(
4092-
`[dry-run] would write bundle to ${requestedDir} (${fileNames.length} files; meta.json renames last)`,
4097+
`[dry-run] would write bundle to ${resolvedDir} (${fileNames.length} files; meta.json renames last)`,
40934098
);
40944099
for (const f of fileNames) stderr(`[dry-run] ${f}`);
40954100
if (opts.output === 'json') {
4096-
out.print({ ok: true, dir: requestedDir, dryRun: true, context });
4101+
out.print({ ok: true, dir: resolvedDir, dryRun: true, context });
40974102
} else {
40984103
// Use a dry-run-specific renderer: the real success renderer
40994104
// says "Bundle written to ..." which would be a lie here. Stdout
41004105
// is the success contract automation may parse, so it must not
41014106
// imply the bundle was created.
41024107
out.print(
4103-
{ dir: requestedDir, files: fileNames.length, snapshotId: context.snapshotId },
4108+
{ dir: resolvedDir, files: fileNames.length, snapshotId: context.snapshotId },
41044109
data =>
41054110
renderBundleDryRunText(data as { dir: string; files: number; snapshotId: string }),
41064111
);
@@ -4109,7 +4114,7 @@ export async function runFailureGet(
41094114
}
41104115

41114116
const bundle = await writeBundle(context, {
4112-
dir: requestedDir,
4117+
dir: resolvedDir,
41134118
failedOnly: opts.failedOnly,
41144119
fetchImpl: deps.fetchImpl,
41154120
});

0 commit comments

Comments
 (0)