Skip to content

Commit 5f98531

Browse files
committed
fix(test): address import/export review — BOM, idempotency triad, If-Match *, --out tests, error split
1 parent a843254 commit 5f98531

3 files changed

Lines changed: 230 additions & 16 deletions

File tree

src/commands/test.test.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3407,6 +3407,185 @@ describe('runExport / runImport', () => {
34073407
),
34083408
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
34093409
});
3410+
3411+
it('import accepts a BOM-prefixed definition file (PowerShell utf8 writes one)', async () => {
3412+
const { credentialsPath } = makeCreds();
3413+
const dir = mkdtempSync(join(tmpdir(), 'cli-import-bom-'));
3414+
const file = join(dir, 'def.testsprite.json');
3415+
writeFileSync(
3416+
file,
3417+
`${JSON.stringify({
3418+
schemaVersion: 1,
3419+
projectId: 'project_alice',
3420+
type: 'backend',
3421+
name: 'BOM test',
3422+
})}`,
3423+
'utf8',
3424+
);
3425+
const fetchImpl = (async () =>
3426+
new Response(JSON.stringify({ testId: 'test_bom' }), {
3427+
status: 200,
3428+
headers: { 'content-type': 'application/json' },
3429+
})) as typeof fetch;
3430+
const result = await runImport(
3431+
{ profile: 'default', output: 'json', debug: false, file },
3432+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: () => undefined },
3433+
);
3434+
expect(result).toEqual({ testId: 'test_bom', action: 'created' });
3435+
});
3436+
3437+
it('import splits missing-file from malformed-JSON errors', async () => {
3438+
const dir = mkdtempSync(join(tmpdir(), 'cli-import-err-'));
3439+
await expect(
3440+
runImport(
3441+
{ profile: 'default', output: 'json', debug: false, file: join(dir, 'nope.json') },
3442+
{ stdout: () => undefined },
3443+
),
3444+
).rejects.toMatchObject({
3445+
code: 'VALIDATION_ERROR',
3446+
nextAction: expect.stringContaining('file not found'),
3447+
});
3448+
const bad = join(dir, 'bad.json');
3449+
writeFileSync(bad, '{not json', 'utf8');
3450+
await expect(
3451+
runImport(
3452+
{ profile: 'default', output: 'json', debug: false, file: bad },
3453+
{ stdout: () => undefined },
3454+
),
3455+
).rejects.toMatchObject({
3456+
code: 'VALIDATION_ERROR',
3457+
nextAction: expect.stringContaining('is not valid JSON'),
3458+
});
3459+
});
3460+
3461+
it('import with codeVersion null sends If-Match: * and warns about the unconditional overwrite', async () => {
3462+
const { credentialsPath } = makeCreds();
3463+
const dir = mkdtempSync(join(tmpdir(), 'cli-import-null-'));
3464+
const file = join(dir, 'def.testsprite.json');
3465+
writeFileSync(
3466+
file,
3467+
JSON.stringify({
3468+
schemaVersion: 1,
3469+
testId: 'test_be',
3470+
projectId: 'project_alice',
3471+
type: 'backend',
3472+
name: 'Legacy row',
3473+
code: { language: 'python', framework: 'pytest', body: 'print(3)\n', codeVersion: null },
3474+
}),
3475+
'utf8',
3476+
);
3477+
const seen: Array<{ url: string; ifMatch: string | null }> = [];
3478+
const fetchImpl = (async (input: Parameters<typeof fetch>[0], init: RequestInit = {}) => {
3479+
const headers = new Headers(init.headers);
3480+
seen.push({ url: String(input), ifMatch: headers.get('if-match') });
3481+
return new Response(JSON.stringify({ testId: 'test_be' }), {
3482+
status: 200,
3483+
headers: { 'content-type': 'application/json' },
3484+
});
3485+
}) as typeof fetch;
3486+
const errs: string[] = [];
3487+
await runImport(
3488+
{ profile: 'default', output: 'json', debug: false, file },
3489+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: line => errs.push(line) },
3490+
);
3491+
expect(seen[1]!.url).toContain('/code');
3492+
expect(seen[1]!.ifMatch).toBe('*');
3493+
expect(errs.join('\n')).toContain('If-Match: *');
3494+
});
3495+
3496+
it('import mints one echoed idempotency key and derives :meta/:code from a supplied one', async () => {
3497+
const { credentialsPath } = makeCreds();
3498+
const dir = mkdtempSync(join(tmpdir(), 'cli-import-idem-'));
3499+
const file = join(dir, 'def.testsprite.json');
3500+
writeFileSync(
3501+
file,
3502+
JSON.stringify({
3503+
schemaVersion: 1,
3504+
testId: 'test_be',
3505+
projectId: 'project_alice',
3506+
type: 'backend',
3507+
name: 'Idem test',
3508+
code: { language: 'python', framework: 'pytest', body: 'print(4)\n', codeVersion: 'v3' },
3509+
}),
3510+
'utf8',
3511+
);
3512+
const keys: Array<string | null> = [];
3513+
const fetchImpl = (async (input: Parameters<typeof fetch>[0], init: RequestInit = {}) => {
3514+
keys.push(new Headers(init.headers).get('idempotency-key'));
3515+
return new Response(JSON.stringify({ testId: 'test_be' }), {
3516+
status: 200,
3517+
headers: { 'content-type': 'application/json' },
3518+
});
3519+
}) as typeof fetch;
3520+
const errs: string[] = [];
3521+
await runImport(
3522+
{ profile: 'default', output: 'json', debug: false, file, idempotencyKey: 'ci-key-1' },
3523+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: line => errs.push(line) },
3524+
);
3525+
expect(keys).toEqual(['ci-key-1:meta', 'ci-key-1:code']);
3526+
expect(errs.join('\n')).not.toContain('idempotency-key:');
3527+
3528+
keys.length = 0;
3529+
await runImport(
3530+
{ profile: 'default', output: 'json', debug: false, file },
3531+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: line => errs.push(line) },
3532+
);
3533+
expect(keys[0]).toMatch(/^cli-import-.+:meta$/);
3534+
expect(keys[1]).toMatch(/^cli-import-.+:code$/);
3535+
expect(errs.join('\n')).toContain('idempotency-key: cli-import-');
3536+
});
3537+
3538+
it('export --out writes the file, refuses to overwrite without --force, overwrites with it', async () => {
3539+
const { credentialsPath } = makeCreds();
3540+
const fetchImpl = makeFetch(url => ({ body: url.includes('/code') ? CODE_ROW : TEST_ROW }));
3541+
const dir = mkdtempSync(join(tmpdir(), 'cli-export-out-'));
3542+
const outFile = join(dir, 'def.testsprite.json');
3543+
const errs: string[] = [];
3544+
await runExport(
3545+
{
3546+
profile: 'default',
3547+
output: 'json',
3548+
debug: false,
3549+
testId: 'test_be',
3550+
out: outFile,
3551+
force: false,
3552+
},
3553+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: line => errs.push(line) },
3554+
);
3555+
const written = JSON.parse(readFileSync(outFile, 'utf8')) as { testId?: string };
3556+
expect(written).toMatchObject({ schemaVersion: 1, testId: 'test_be' });
3557+
expect(errs.join('\n')).toContain('Definition written to');
3558+
3559+
await expect(
3560+
runExport(
3561+
{
3562+
profile: 'default',
3563+
output: 'json',
3564+
debug: false,
3565+
testId: 'test_be',
3566+
out: outFile,
3567+
force: false,
3568+
},
3569+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: () => undefined },
3570+
),
3571+
).rejects.toMatchObject({
3572+
code: 'VALIDATION_ERROR',
3573+
nextAction: expect.stringContaining('already exists'),
3574+
});
3575+
3576+
await runExport(
3577+
{
3578+
profile: 'default',
3579+
output: 'json',
3580+
debug: false,
3581+
testId: 'test_be',
3582+
out: outFile,
3583+
force: true,
3584+
},
3585+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: () => undefined },
3586+
);
3587+
expect(JSON.parse(readFileSync(outFile, 'utf8'))).toMatchObject({ testId: 'test_be' });
3588+
});
34103589
});
34113590

34123591
describe('runLint', () => {

src/commands/test.ts

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4179,6 +4179,8 @@ export async function runExport(
41794179

41804180
export interface ImportOptions extends CommonOptions {
41814181
file: string;
4182+
/** --idempotency-key: caller-supplied; auto-minted UUID when absent. */
4183+
idempotencyKey?: string;
41824184
}
41834185

41844186
/**
@@ -4194,18 +4196,24 @@ export async function runImport(
41944196
): Promise<{ testId: string; action: 'created' | 'updated' }> {
41954197
const out = makeOutput(opts.output, deps);
41964198
const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
4199+
assertIdempotencyKey(opts.idempotencyKey);
41974200

41984201
const absolute = resolveAbsolute(opts.file);
4202+
// Read and parse are split so a typo'd path and malformed JSON stop being
4203+
// the same message. stripBom mirrors every other JSON file-read in this
4204+
// file: PowerShell 5.1's `Set-Content -Encoding utf8` writes a BOM, and
4205+
// bare JSON.parse rejects it.
4206+
let raw: string;
4207+
try {
4208+
raw = stripBom(readFileSync(absolute, 'utf8'));
4209+
} catch {
4210+
throw localValidationError('file', `file not found: ${absolute}`, undefined, 'field');
4211+
}
41994212
let parsed: unknown;
42004213
try {
4201-
parsed = JSON.parse(readFileSync(absolute, 'utf8'));
4214+
parsed = JSON.parse(raw);
42024215
} catch {
4203-
throw localValidationError(
4204-
'file',
4205-
`is not readable valid JSON: ${absolute}`,
4206-
undefined,
4207-
'field',
4208-
);
4216+
throw localValidationError('file', `is not valid JSON: ${absolute}`, undefined, 'field');
42094217
}
42104218
const def = parsed as Partial<CliTestDefinition>;
42114219
if (def.schemaVersion !== 1) {
@@ -4239,6 +4247,17 @@ export async function runImport(
42394247
return sample;
42404248
}
42414249

4250+
// One base key covers the whole import; per-mutation keys derive from it
4251+
// deterministically (`:meta`, `:code`, `:create`) so a caller-supplied key
4252+
// replayed after an ambiguous transport failure reuses the SAME wire keys
4253+
// instead of minting fresh ones and duplicating the mutation (same contract
4254+
// as `test create` / `test update` / `code put`). Echoed on stderr below
4255+
// for the auto-minted case, mirroring the `test create` site.
4256+
const idempotencyKey = opts.idempotencyKey ?? `cli-import-${randomUUID()}`;
4257+
if (opts.idempotencyKey === undefined && (opts.output === 'json' || opts.verbose || opts.debug)) {
4258+
stderrFn(`idempotency-key: ${idempotencyKey}`);
4259+
}
4260+
42424261
const client = makeClient(opts, deps);
42434262
if (typeof def.testId === 'string' && def.testId.length > 0) {
42444263
const testId = def.testId;
@@ -4247,19 +4266,28 @@ export async function runImport(
42474266
name: def.name.trim(),
42484267
...(def.description !== undefined ? { description: def.description } : {}),
42494268
},
4250-
headers: { 'idempotency-key': `cli-import-meta-${randomUUID()}` },
4269+
headers: { 'idempotency-key': `${idempotencyKey}:meta` },
42514270
});
42524271
if (def.code !== undefined && typeof def.code.body === 'string') {
4272+
const recordedVersion = def.code.codeVersion;
4273+
if (recordedVersion === null || recordedVersion === undefined) {
4274+
// Legacy export with no stamped codeVersion: `If-Match: *` applies the
4275+
// backend's force path (audit-logged) instead of silently omitting the
4276+
// precondition — same handling as `code put`'s auto-fetch on a legacy
4277+
// row, and the notice makes the unconditional overwrite visible.
4278+
stderrFn(
4279+
`note: definition has codeVersion: null (legacy export) — sending If-Match: * (unconditional overwrite of the server copy)`,
4280+
);
4281+
}
42534282
await client.put(`/tests/${encodeURIComponent(testId)}/code`, {
42544283
body: { code: def.code.body },
42554284
headers: {
4256-
'idempotency-key': `cli-import-code-${randomUUID()}`,
4285+
'idempotency-key': `${idempotencyKey}:code`,
42574286
// Replay the recorded provenance so a server copy that moved on
42584287
// fails with the existing 412 PRECONDITION_FAILED contract instead
42594288
// of being silently clobbered.
4260-
...(def.code.codeVersion !== null && def.code.codeVersion !== undefined
4261-
? { 'if-match': def.code.codeVersion }
4262-
: {}),
4289+
'if-match':
4290+
recordedVersion !== null && recordedVersion !== undefined ? recordedVersion : '*',
42634291
},
42644292
});
42654293
}
@@ -4276,7 +4304,7 @@ export async function runImport(
42764304
...(def.description !== undefined ? { description: def.description } : {}),
42774305
...(def.code !== undefined ? { code: def.code.body } : {}),
42784306
},
4279-
headers: { 'idempotency-key': `cli-import-create-${randomUUID()}` },
4307+
headers: { 'idempotency-key': `${idempotencyKey}:create` },
42804308
});
42814309
const result = { testId: created.testId, action: 'created' as const };
42824310
out.print(result, () => `created: ${created.testId}`);
@@ -9111,9 +9139,16 @@ export function createTestCommand(deps: TestDeps = {}): Command {
91119139
.description(
91129140
'Create or update a test from a definition file produced by `test export`: a testId in the file updates (code PUT replays the recorded codeVersion as If-Match), no testId creates.',
91139141
)
9142+
.option(
9143+
'--idempotency-key <key>',
9144+
'opaque key for safe retries (1–256 chars). Printed to stderr at --debug if auto-generated.',
9145+
)
91149146
.addHelpText('after', GLOBAL_OPTS_HINT)
9115-
.action(async (file: string, _cmdOpts: unknown, command: Command) => {
9116-
await runImport({ ...resolveCommonOptions(command), file }, deps);
9147+
.action(async (file: string, cmdOpts: { idempotencyKey?: string }, command: Command) => {
9148+
await runImport(
9149+
{ ...resolveCommonOptions(command), file, idempotencyKey: cmdOpts.idempotencyKey },
9150+
deps,
9151+
);
91179152
});
91189153

91199154
test

test/__snapshots__/help.snapshot.test.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ Commands:
246246
versionable JSON file. Frontend plans
247247
are write-only on the API, so FE
248248
exports carry planUnavailable: true.
249-
import <file> Create or update a test from a
249+
import [options] <file> Create or update a test from a
250250
definition file produced by \`test
251251
export\`: a testId in the file updates
252252
(code PUT replays the recorded

0 commit comments

Comments
 (0)