Skip to content

Commit a843254

Browse files
committed
feat(test): add "test export" / "test import" to round-trip test definitions
1 parent 2708a40 commit a843254

3 files changed

Lines changed: 432 additions & 0 deletions

File tree

src/commands/test.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import {
3131
runFailureGet,
3232
runFailureSummary,
3333
runGet,
34+
runExport,
35+
runImport,
3436
runLint,
3537
runList,
3638
runOpen,
@@ -128,9 +130,11 @@ describe('createTestCommand — surface', () => {
128130
'delete',
129131
'delete-batch',
130132
'diff',
133+
'export',
131134
'failure',
132135
'flaky',
133136
'get',
137+
'import',
134138
'lint',
135139
'list',
136140
'open',
@@ -3258,6 +3262,153 @@ describe('runDiff', () => {
32583262
});
32593263
});
32603264

3265+
describe('runExport / runImport', () => {
3266+
const TEST_ROW = {
3267+
id: 'test_be',
3268+
projectId: 'project_alice',
3269+
projectName: 'Alice',
3270+
name: 'Health check',
3271+
type: 'backend',
3272+
createdFrom: 'cli',
3273+
status: 'ready',
3274+
createdAt: '2026-06-01T10:00:00.000Z',
3275+
updatedAt: '2026-06-01T10:00:00.000Z',
3276+
};
3277+
const CODE_ROW = {
3278+
testId: 'test_be',
3279+
language: 'python',
3280+
framework: 'pytest',
3281+
code: 'import requests\n',
3282+
codeVersion: 'v3',
3283+
};
3284+
3285+
it('export composes metadata + code with codeVersion provenance (backend: lossless)', async () => {
3286+
const { credentialsPath } = makeCreds();
3287+
const fetchImpl = makeFetch(url => ({ body: url.includes('/code') ? CODE_ROW : TEST_ROW }));
3288+
const out: string[] = [];
3289+
const definition = await runExport(
3290+
{ profile: 'default', output: 'json', debug: false, testId: 'test_be', force: false },
3291+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
3292+
);
3293+
expect(definition).toMatchObject({
3294+
schemaVersion: 1,
3295+
testId: 'test_be',
3296+
projectId: 'project_alice',
3297+
type: 'backend',
3298+
code: { language: 'python', body: 'import requests\n', codeVersion: 'v3' },
3299+
});
3300+
expect(definition.planUnavailable).toBeUndefined();
3301+
expect(JSON.parse(out.join('')) as unknown).toEqual(definition);
3302+
});
3303+
3304+
it('a frontend export declares planUnavailable and warns on stderr', async () => {
3305+
const { credentialsPath } = makeCreds();
3306+
const feRow = { ...TEST_ROW, id: 'test_fe2', type: 'frontend' };
3307+
const fetchImpl = makeFetch(url => ({ body: url.includes('/code') ? CODE_ROW : feRow }));
3308+
const errs: string[] = [];
3309+
const definition = await runExport(
3310+
{ profile: 'default', output: 'json', debug: false, testId: 'test_fe2', force: false },
3311+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: line => errs.push(line) },
3312+
);
3313+
expect(definition.planUnavailable).toBe(true);
3314+
expect(errs.join('\n')).toContain('write-only');
3315+
});
3316+
3317+
it('import without testId creates via POST /tests with the definition body', async () => {
3318+
const { credentialsPath } = makeCreds();
3319+
const dir = mkdtempSync(join(tmpdir(), 'cli-import-'));
3320+
const file = join(dir, 'def.testsprite.json');
3321+
writeFileSync(
3322+
file,
3323+
JSON.stringify({
3324+
schemaVersion: 1,
3325+
projectId: 'project_alice',
3326+
type: 'backend',
3327+
name: 'Imported test',
3328+
code: { language: 'python', framework: 'pytest', body: 'print(1)\n', codeVersion: null },
3329+
}),
3330+
'utf8',
3331+
);
3332+
const seen: Array<{ url: string; method: string; body: unknown }> = [];
3333+
const fetchImpl = (async (input: Parameters<typeof fetch>[0], init: RequestInit = {}) => {
3334+
seen.push({
3335+
url: String(input),
3336+
method: init.method ?? 'GET',
3337+
body: init.body === undefined ? undefined : JSON.parse(init.body as string),
3338+
});
3339+
return new Response(JSON.stringify({ testId: 'test_new' }), {
3340+
status: 200,
3341+
headers: { 'content-type': 'application/json' },
3342+
});
3343+
}) as typeof fetch;
3344+
const result = await runImport(
3345+
{ profile: 'default', output: 'json', debug: false, file },
3346+
{ credentialsPath, fetchImpl, stdout: () => undefined },
3347+
);
3348+
expect(result).toEqual({ testId: 'test_new', action: 'created' });
3349+
expect(seen[0]!.method).toBe('POST');
3350+
expect(seen[0]!.body).toMatchObject({
3351+
projectId: 'project_alice',
3352+
type: 'backend',
3353+
name: 'Imported test',
3354+
code: 'print(1)\n',
3355+
});
3356+
});
3357+
3358+
it('import with testId updates metadata then PUTs the code with If-Match provenance', async () => {
3359+
const { credentialsPath } = makeCreds();
3360+
const dir = mkdtempSync(join(tmpdir(), 'cli-import-upd-'));
3361+
const file = join(dir, 'def.testsprite.json');
3362+
writeFileSync(
3363+
file,
3364+
JSON.stringify({
3365+
schemaVersion: 1,
3366+
testId: 'test_be',
3367+
projectId: 'project_alice',
3368+
type: 'backend',
3369+
name: 'Renamed test',
3370+
code: { language: 'python', framework: 'pytest', body: 'print(2)\n', codeVersion: 'v3' },
3371+
}),
3372+
'utf8',
3373+
);
3374+
const seen: Array<{ url: string; method: string; ifMatch: string | null }> = [];
3375+
const fetchImpl = (async (input: Parameters<typeof fetch>[0], init: RequestInit = {}) => {
3376+
const headers = new Headers(init.headers);
3377+
seen.push({
3378+
url: String(input),
3379+
method: init.method ?? 'GET',
3380+
ifMatch: headers.get('if-match'),
3381+
});
3382+
return new Response(JSON.stringify({ testId: 'test_be' }), {
3383+
status: 200,
3384+
headers: { 'content-type': 'application/json' },
3385+
});
3386+
}) as typeof fetch;
3387+
const result = await runImport(
3388+
{ profile: 'default', output: 'json', debug: false, file },
3389+
{ credentialsPath, fetchImpl, stdout: () => undefined },
3390+
);
3391+
expect(result).toEqual({ testId: 'test_be', action: 'updated' });
3392+
expect(seen).toHaveLength(2);
3393+
expect(seen[0]!.method).toBe('PUT');
3394+
expect(seen[0]!.url).toContain('/tests/test_be');
3395+
expect(seen[1]!.url).toContain('/tests/test_be/code');
3396+
expect(seen[1]!.ifMatch).toBe('v3');
3397+
});
3398+
3399+
it('import rejects a wrong schemaVersion with a field-level VALIDATION_ERROR', async () => {
3400+
const dir = mkdtempSync(join(tmpdir(), 'cli-import-bad-'));
3401+
const file = join(dir, 'def.json');
3402+
writeFileSync(file, JSON.stringify({ schemaVersion: 2 }), 'utf8');
3403+
await expect(
3404+
runImport(
3405+
{ profile: 'default', output: 'json', debug: false, file },
3406+
{ stdout: () => undefined },
3407+
),
3408+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
3409+
});
3410+
});
3411+
32613412
describe('runLint', () => {
32623413
const VALID_PLAN = JSON.stringify({
32633414
projectId: 'project_alice',

0 commit comments

Comments
 (0)