@@ -279,6 +279,130 @@ describe("safe_outputs_handlers", () => {
279279 } ) ;
280280 } ) ;
281281
282+ describe ( "uploadArtifactHandler" , ( ) => {
283+ let testStagingDir ;
284+
285+ beforeEach ( ( ) => {
286+ const testId = Math . random ( ) . toString ( 36 ) . substring ( 7 ) ;
287+ testStagingDir = `/tmp/test-staging-${ testId } ` ;
288+ process . env . RUNNER_TEMP = testStagingDir ;
289+ } ) ;
290+
291+ afterEach ( ( ) => {
292+ delete process . env . RUNNER_TEMP ;
293+ try {
294+ if ( fs . existsSync ( testStagingDir ) ) {
295+ fs . rmSync ( testStagingDir , { recursive : true , force : true } ) ;
296+ }
297+ } catch {
298+ // Ignore cleanup errors
299+ }
300+ } ) ;
301+
302+ it ( "should copy absolute-path file to staging and rewrite path to basename" , ( ) => {
303+ const srcFile = path . join ( testWorkspaceDir , "chart.png" ) ;
304+ fs . writeFileSync ( srcFile , "png data" ) ;
305+
306+ const result = handlers . uploadArtifactHandler ( { path : srcFile } ) ;
307+
308+ // File should be in staging
309+ const stagedPath = path . join ( testStagingDir , "gh-aw" , "safeoutputs" , "upload-artifacts" , "chart.png" ) ;
310+ expect ( fs . existsSync ( stagedPath ) ) . toBe ( true ) ;
311+ expect ( fs . readFileSync ( stagedPath , "utf8" ) ) . toBe ( "png data" ) ;
312+
313+ // JSONL entry should use the basename, not the absolute path
314+ expect ( mockAppendSafeOutput ) . toHaveBeenCalledWith ( expect . objectContaining ( { type : "upload_artifact" , path : "chart.png" } ) ) ;
315+
316+ // Response should be success
317+ const responseData = JSON . parse ( result . content [ 0 ] . text ) ;
318+ expect ( responseData . result ) . toBe ( "success" ) ;
319+ } ) ;
320+
321+ it ( "should include temporary_id in response when provided" , ( ) => {
322+ const srcFile = path . join ( testWorkspaceDir , "plot.png" ) ;
323+ fs . writeFileSync ( srcFile , "png data" ) ;
324+
325+ const result = handlers . uploadArtifactHandler ( { path : srcFile , temporary_id : "aw_test123" } ) ;
326+
327+ const responseData = JSON . parse ( result . content [ 0 ] . text ) ;
328+ expect ( responseData . result ) . toBe ( "success" ) ;
329+ expect ( responseData . temporary_id ) . toBe ( "aw_test123" ) ;
330+ } ) ;
331+
332+ it ( "should throw when absolute-path file does not exist" , ( ) => {
333+ expect ( ( ) => handlers . uploadArtifactHandler ( { path : "/tmp/nonexistent-file.png" } ) ) . toThrow ( expect . objectContaining ( { message : expect . stringContaining ( "file not found" ) } ) ) ;
334+ } ) ;
335+
336+ it ( "should throw when path is a symlink" , ( ) => {
337+ const srcFile = path . join ( testWorkspaceDir , "real.png" ) ;
338+ fs . writeFileSync ( srcFile , "data" ) ;
339+ const linkPath = path . join ( testWorkspaceDir , "link.png" ) ;
340+ fs . symlinkSync ( srcFile , linkPath ) ;
341+
342+ expect ( ( ) => handlers . uploadArtifactHandler ( { path : linkPath } ) ) . toThrow ( expect . objectContaining ( { message : expect . stringContaining ( "symlinks are not allowed" ) } ) ) ;
343+ } ) ;
344+
345+ it ( "should not overwrite existing staged file on duplicate call" , ( ) => {
346+ const srcFile = path . join ( testWorkspaceDir , "chart.png" ) ;
347+ fs . writeFileSync ( srcFile , "original" ) ;
348+
349+ // First call stages the file
350+ handlers . uploadArtifactHandler ( { path : srcFile } ) ;
351+
352+ const stagedPath = path . join ( testStagingDir , "gh-aw" , "safeoutputs" , "upload-artifacts" , "chart.png" ) ;
353+ expect ( fs . readFileSync ( stagedPath , "utf8" ) ) . toBe ( "original" ) ;
354+
355+ // Second call with modified source should not overwrite
356+ fs . writeFileSync ( srcFile , "updated" ) ;
357+ handlers . uploadArtifactHandler ( { path : srcFile } ) ;
358+ expect ( fs . readFileSync ( stagedPath , "utf8" ) ) . toBe ( "original" ) ;
359+ } ) ;
360+
361+ it ( "should pass through relative path without copying to staging" , ( ) => {
362+ // Relative paths reference files already in staging - no copy needed
363+ const result = handlers . uploadArtifactHandler ( { path : "already-staged.png" } ) ;
364+
365+ // Staging dir should NOT have been created/written by the handler
366+ const stagingDir = path . join ( testStagingDir , "gh-aw" , "safeoutputs" , "upload-artifacts" ) ;
367+ const stagedFile = path . join ( stagingDir , "already-staged.png" ) ;
368+ expect ( fs . existsSync ( stagedFile ) ) . toBe ( false ) ;
369+
370+ // JSONL entry should preserve the relative path as-is
371+ expect ( mockAppendSafeOutput ) . toHaveBeenCalledWith ( expect . objectContaining ( { type : "upload_artifact" , path : "already-staged.png" } ) ) ;
372+
373+ const responseData = JSON . parse ( result . content [ 0 ] . text ) ;
374+ expect ( responseData . result ) . toBe ( "success" ) ;
375+ } ) ;
376+
377+ it ( "should pass through filters-based request without file copy" , ( ) => {
378+ const result = handlers . uploadArtifactHandler ( { filters : { include : [ "**/*.png" ] } } ) ;
379+
380+ const stagingDir = path . join ( testStagingDir , "gh-aw" , "safeoutputs" , "upload-artifacts" ) ;
381+ expect ( fs . existsSync ( stagingDir ) ) . toBe ( false ) ;
382+
383+ expect ( mockAppendSafeOutput ) . toHaveBeenCalledWith ( expect . objectContaining ( { type : "upload_artifact" , filters : { include : [ "**/*.png" ] } } ) ) ;
384+
385+ const responseData = JSON . parse ( result . content [ 0 ] . text ) ;
386+ expect ( responseData . result ) . toBe ( "success" ) ;
387+ } ) ;
388+
389+ it ( "should recursively copy directory to staging" , ( ) => {
390+ const srcDir = path . join ( testWorkspaceDir , "charts" ) ;
391+ fs . mkdirSync ( path . join ( srcDir , "sub" ) , { recursive : true } ) ;
392+ fs . writeFileSync ( path . join ( srcDir , "a.png" ) , "a" ) ;
393+ fs . writeFileSync ( path . join ( srcDir , "sub" , "b.png" ) , "b" ) ;
394+
395+ handlers . uploadArtifactHandler ( { path : srcDir } ) ;
396+
397+ const stagingBase = path . join ( testStagingDir , "gh-aw" , "safeoutputs" , "upload-artifacts" , "charts" ) ;
398+ expect ( fs . existsSync ( path . join ( stagingBase , "a.png" ) ) ) . toBe ( true ) ;
399+ expect ( fs . existsSync ( path . join ( stagingBase , "sub" , "b.png" ) ) ) . toBe ( true ) ;
400+
401+ // Entry path should be the directory basename
402+ expect ( mockAppendSafeOutput ) . toHaveBeenCalledWith ( expect . objectContaining ( { type : "upload_artifact" , path : "charts" } ) ) ;
403+ } ) ;
404+ } ) ;
405+
282406 describe ( "createPullRequestHandler" , ( ) => {
283407 it ( "should be defined" , ( ) => {
284408 expect ( handlers . createPullRequestHandler ) . toBeDefined ( ) ;
@@ -446,6 +570,7 @@ describe("safe_outputs_handlers", () => {
446570 it ( "should export all required handlers" , ( ) => {
447571 expect ( handlers . defaultHandler ) . toBeDefined ( ) ;
448572 expect ( handlers . uploadAssetHandler ) . toBeDefined ( ) ;
573+ expect ( handlers . uploadArtifactHandler ) . toBeDefined ( ) ;
449574 expect ( handlers . createPullRequestHandler ) . toBeDefined ( ) ;
450575 expect ( handlers . pushToPullRequestBranchHandler ) . toBeDefined ( ) ;
451576 expect ( handlers . pushRepoMemoryHandler ) . toBeDefined ( ) ;
0 commit comments