@@ -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+
32613412describe ( 'runLint' , ( ) => {
32623413 const VALID_PLAN = JSON . stringify ( {
32633414 projectId : 'project_alice' ,
0 commit comments