@@ -12,10 +12,22 @@ import {
1212import { NextRequest } from 'next/server'
1313import { beforeEach , describe , expect , it , vi } from 'vitest'
1414
15- const { mockExecuteInE2B, mockExecuteInIsolatedVM, mockUploadFile } = vi . hoisted ( ( ) => ( {
15+ const {
16+ mockExecuteInE2B,
17+ mockExecuteInIsolatedVM,
18+ mockGetWorkspaceFile,
19+ mockUpdateWorkspaceFileContent,
20+ mockUploadFile,
21+ mockValidateWorkspaceFileWriteTarget,
22+ mockWriteWorkspaceFileByPath,
23+ } = vi . hoisted ( ( ) => ( {
1624 mockExecuteInE2B : vi . fn ( ) ,
1725 mockExecuteInIsolatedVM : vi . fn ( ) ,
26+ mockGetWorkspaceFile : vi . fn ( ) ,
27+ mockUpdateWorkspaceFileContent : vi . fn ( ) ,
1828 mockUploadFile : vi . fn ( ) ,
29+ mockValidateWorkspaceFileWriteTarget : vi . fn ( ) ,
30+ mockWriteWorkspaceFileByPath : vi . fn ( ) ,
1931} ) )
2032
2133vi . mock ( '@/lib/execution/isolated-vm' , ( ) => ( {
@@ -37,9 +49,40 @@ vi.mock('@/lib/copilot/request/tools/files', () => ({
3749 } ,
3850 normalizeOutputWorkspaceFileName : vi . fn ( ( p : string ) => p . replace ( / ^ f i l e s \/ / , '' ) ) ,
3951 resolveOutputFormat : vi . fn ( ( ) => 'json' ) ,
52+ getOutputFileDeclarations : vi . fn ( ( params : Record < string , any > ) => {
53+ if ( Array . isArray ( params . outputs ?. files ) ) {
54+ return params . outputs . files . map ( ( file : Record < string , any > ) => ( {
55+ path : file . path ,
56+ mode : file . mode === 'overwrite' ? 'overwrite' : 'create' ,
57+ sandboxPath : file . sandboxPath ,
58+ mimeType : file . mimeType ,
59+ format : file . format ,
60+ } ) )
61+ }
62+ return params . outputPath
63+ ? [
64+ {
65+ path : params . overwriteFileId || params . outputPath ,
66+ mode : params . overwriteFileId ? 'overwrite' : 'create' ,
67+ sandboxPath : params . outputSandboxPath ,
68+ mimeType : params . outputMimeType ,
69+ format : params . outputFormat ,
70+ formatPath : params . outputPath ,
71+ overwriteFileId : params . overwriteFileId ,
72+ } ,
73+ ]
74+ : [ ]
75+ } ) ,
76+ } ) )
77+
78+ vi . mock ( '@/lib/copilot/vfs/resource-writer' , ( ) => ( {
79+ validateWorkspaceFileWriteTarget : mockValidateWorkspaceFileWriteTarget ,
80+ writeWorkspaceFileByPath : mockWriteWorkspaceFileByPath ,
4081} ) )
4182
4283vi . mock ( '@/lib/uploads/contexts/workspace/workspace-file-manager' , ( ) => ( {
84+ getWorkspaceFile : mockGetWorkspaceFile ,
85+ updateWorkspaceFileContent : mockUpdateWorkspaceFileContent ,
4386 uploadWorkspaceFile : vi . fn ( ) ,
4487} ) )
4588
@@ -79,6 +122,35 @@ describe('Function Execute API Route', () => {
79122 stdout : 'e2b output' ,
80123 sandboxId : 'test-sandbox-id' ,
81124 } )
125+ mockGetWorkspaceFile . mockResolvedValue ( {
126+ id : 'wf_existing' ,
127+ name : 'existing.png' ,
128+ size : 10 ,
129+ type : 'image/png' ,
130+ url : '/api/files/view/existing' ,
131+ key : 'workspace/existing.png' ,
132+ } )
133+ mockUpdateWorkspaceFileContent . mockResolvedValue ( {
134+ id : 'wf_existing' ,
135+ name : 'existing.png' ,
136+ size : 20 ,
137+ type : 'image/png' ,
138+ url : '/api/files/view/existing' ,
139+ key : 'workspace/existing.png' ,
140+ } )
141+ mockValidateWorkspaceFileWriteTarget . mockImplementation ( async ( { target } ) => ( {
142+ mode : target . mode ,
143+ vfsPath : target . path ,
144+ } ) )
145+ mockWriteWorkspaceFileByPath . mockImplementation ( async ( { target, buffer } ) => ( {
146+ id : `wf_${ String ( target . path ) . split ( '/' ) . pop ( ) ?. replace ( / \W + / g, '_' ) || 'file' } ` ,
147+ name : String ( target . path ) . split ( '/' ) . pop ( ) || 'file' ,
148+ vfsPath : target . path ,
149+ downloadUrl : `/api/files/view/${ encodeURIComponent ( target . path ) } ` ,
150+ mode : target . mode ,
151+ size : buffer . length ,
152+ contentType : target . mimeType || 'application/octet-stream' ,
153+ } ) )
82154 } )
83155
84156 describe ( 'Security Tests' , ( ) => {
@@ -268,6 +340,196 @@ describe('Function Execute API Route', () => {
268340 expect ( isLargeValueRef ( data . output . result . text ) ) . toBe ( true )
269341 } )
270342
343+ it ( 'exports multiple declared sandbox output files' , async ( ) => {
344+ featureFlagsMock . isE2bEnabled = true
345+ mockExecuteInE2B . mockResolvedValueOnce ( {
346+ result : 'done' ,
347+ stdout : 'ok' ,
348+ sandboxId : 'sandbox-123' ,
349+ exportedFiles : {
350+ '/home/user/chart.png' : 'iVBORw0KGgo=' ,
351+ '/home/user/summary.json' : '{"ok":true}' ,
352+ } ,
353+ } )
354+
355+ const req = createMockRequest ( 'POST' , {
356+ code : 'print("done")' ,
357+ language : 'python' ,
358+ workspaceId : 'workspace-1' ,
359+ outputs : {
360+ files : [
361+ {
362+ path : 'files/reports/chart.png' ,
363+ mode : 'create' ,
364+ sandboxPath : '/home/user/chart.png' ,
365+ mimeType : 'image/png' ,
366+ } ,
367+ {
368+ path : 'files/reports/summary.json' ,
369+ mode : 'overwrite' ,
370+ sandboxPath : '/home/user/summary.json' ,
371+ mimeType : 'application/json' ,
372+ } ,
373+ ] ,
374+ } ,
375+ } )
376+
377+ const response = await POST ( req )
378+ const data = await response . json ( )
379+
380+ expect ( response . status ) . toBe ( 200 )
381+ expect ( data . success ) . toBe ( true )
382+ expect ( mockExecuteInE2B ) . toHaveBeenCalledWith (
383+ expect . objectContaining ( {
384+ outputSandboxPaths : [ '/home/user/chart.png' , '/home/user/summary.json' ] ,
385+ } )
386+ )
387+ expect ( mockValidateWorkspaceFileWriteTarget ) . toHaveBeenCalledTimes ( 2 )
388+ expect ( mockWriteWorkspaceFileByPath ) . toHaveBeenCalledTimes ( 2 )
389+ expect ( mockWriteWorkspaceFileByPath ) . toHaveBeenNthCalledWith (
390+ 1 ,
391+ expect . objectContaining ( {
392+ target : expect . objectContaining ( { path : 'files/reports/chart.png' , mode : 'create' } ) ,
393+ } )
394+ )
395+ expect ( mockWriteWorkspaceFileByPath ) . toHaveBeenNthCalledWith (
396+ 2 ,
397+ expect . objectContaining ( {
398+ target : expect . objectContaining ( {
399+ path : 'files/reports/summary.json' ,
400+ mode : 'overwrite' ,
401+ } ) ,
402+ } )
403+ )
404+ expect ( data . output . result . files ) . toHaveLength ( 2 )
405+ expect ( data . resources ) . toEqual ( [
406+ expect . objectContaining ( { path : 'files/reports/chart.png' } ) ,
407+ expect . objectContaining ( { path : 'files/reports/summary.json' } ) ,
408+ ] )
409+ } )
410+
411+ it ( 'prevalidates all sandbox output destinations before writing any files' , async ( ) => {
412+ featureFlagsMock . isE2bEnabled = true
413+ mockExecuteInE2B . mockResolvedValueOnce ( {
414+ result : 'done' ,
415+ stdout : 'ok' ,
416+ sandboxId : 'sandbox-123' ,
417+ exportedFiles : {
418+ '/home/user/first.json' : '{"first":true}' ,
419+ '/home/user/second.json' : '{"second":true}' ,
420+ } ,
421+ } )
422+ mockValidateWorkspaceFileWriteTarget
423+ . mockResolvedValueOnce ( { mode : 'create' , vfsPath : 'files/first.json' } )
424+ . mockRejectedValueOnce ( new Error ( 'Directory not yet created: files/missing' ) )
425+
426+ const req = createMockRequest ( 'POST' , {
427+ code : 'print("done")' ,
428+ language : 'python' ,
429+ workspaceId : 'workspace-1' ,
430+ outputs : {
431+ files : [
432+ {
433+ path : 'files/first.json' ,
434+ mode : 'create' ,
435+ sandboxPath : '/home/user/first.json' ,
436+ } ,
437+ {
438+ path : 'files/missing/second.json' ,
439+ mode : 'create' ,
440+ sandboxPath : '/home/user/second.json' ,
441+ } ,
442+ ] ,
443+ } ,
444+ } )
445+
446+ const response = await POST ( req )
447+ const data = await response . json ( )
448+
449+ expect ( response . status ) . toBe ( 400 )
450+ expect ( data . success ) . toBe ( false )
451+ expect ( data . error ) . toContain ( 'Directory not yet created' )
452+ expect ( mockWriteWorkspaceFileByPath ) . not . toHaveBeenCalled ( )
453+ } )
454+
455+ it ( 'rejects duplicate sandbox output destinations before writing files' , async ( ) => {
456+ featureFlagsMock . isE2bEnabled = true
457+ mockExecuteInE2B . mockResolvedValueOnce ( {
458+ result : 'done' ,
459+ stdout : 'ok' ,
460+ sandboxId : 'sandbox-123' ,
461+ exportedFiles : {
462+ '/home/user/first.json' : '{"first":true}' ,
463+ '/home/user/second.json' : '{"second":true}' ,
464+ } ,
465+ } )
466+ mockValidateWorkspaceFileWriteTarget . mockResolvedValue ( {
467+ mode : 'create' ,
468+ vfsPath : 'files/dupe.json' ,
469+ } )
470+
471+ const req = createMockRequest ( 'POST' , {
472+ code : 'print("done")' ,
473+ language : 'python' ,
474+ workspaceId : 'workspace-1' ,
475+ outputs : {
476+ files : [
477+ {
478+ path : 'files/dupe.json' ,
479+ mode : 'create' ,
480+ sandboxPath : '/home/user/first.json' ,
481+ } ,
482+ {
483+ path : 'files/dupe.json' ,
484+ mode : 'create' ,
485+ sandboxPath : '/home/user/second.json' ,
486+ } ,
487+ ] ,
488+ } ,
489+ } )
490+
491+ const response = await POST ( req )
492+ const data = await response . json ( )
493+
494+ expect ( response . status ) . toBe ( 400 )
495+ expect ( data . success ) . toBe ( false )
496+ expect ( data . error ) . toContain ( 'Duplicate sandbox output destination' )
497+ expect ( mockWriteWorkspaceFileByPath ) . not . toHaveBeenCalled ( )
498+ } )
499+
500+ it ( 'returns a targeted error when a declared sandbox output is missing' , async ( ) => {
501+ featureFlagsMock . isE2bEnabled = true
502+ mockExecuteInE2B . mockResolvedValueOnce ( {
503+ result : 'done' ,
504+ stdout : 'ok' ,
505+ sandboxId : 'sandbox-123' ,
506+ exportedFiles : { } ,
507+ } )
508+
509+ const req = createMockRequest ( 'POST' , {
510+ code : 'print("done")' ,
511+ language : 'python' ,
512+ workspaceId : 'workspace-1' ,
513+ outputs : {
514+ files : [
515+ {
516+ path : 'files/missing.json' ,
517+ mode : 'create' ,
518+ sandboxPath : '/home/user/missing.json' ,
519+ } ,
520+ ] ,
521+ } ,
522+ } )
523+
524+ const response = await POST ( req )
525+ const data = await response . json ( )
526+
527+ expect ( response . status ) . toBe ( 500 )
528+ expect ( data . success ) . toBe ( false )
529+ expect ( data . error ) . toContain ( 'Sandbox file "/home/user/missing.json" was not found' )
530+ expect ( mockWriteWorkspaceFileByPath ) . not . toHaveBeenCalled ( )
531+ } )
532+
271533 it ( 'should return computed result for multi-line code' , async ( ) => {
272534 mockExecuteInIsolatedVM . mockResolvedValueOnce ( { result : 10 , stdout : '' } )
273535
0 commit comments