@@ -13,10 +13,59 @@ import { readJson, writeJson, withStoreLock } from './result-store.ts'
1313import { assembleSite } from './assemble-site.ts'
1414import { loadHistory } from '../src/data.ts'
1515import { promoteRun , storeRun } from './publish-results.ts'
16+ import { mergeBundles } from './merge-results.ts'
1617
1718const repository = 'example/benchmarks'
1819const root = resolve ( import . meta. dirname , '..' )
1920
21+ test ( 'partial retries retain successful measurements from the same source revision' , ( ) => {
22+ const bundle = fixture ( )
23+ const shards = bundle . measurements . map ( measurement => {
24+ const runner = structuredClone ( bundle . runners . find ( entry => entry . id === measurement . runnerId ) ! )
25+ runner . id = measurement . id
26+ return { ...structuredClone ( bundle ) , runners : [ runner ] , measurements : [ { ...structuredClone ( measurement ) , runnerId : runner . id } ] }
27+ } )
28+ const retry = structuredClone ( shards [ 0 ] ! )
29+ retry . run . attempt = 2
30+ retry . run . workflow . url = `https://github.com/${ repository } /actions/runs/${ retry . run . id } /attempts/2`
31+ retry . runners [ 0 ] ! . name = 'retry-runner'
32+ const measurement = shards [ 0 ] ! . measurements [ 0 ] !
33+ shards [ 0 ] ! . measurements = [ { id : measurement . id , runnerId : measurement . runnerId , runtimeId : measurement . runtimeId , strategy : measurement . strategy , status : 'failed' , reason : 'Transient failure' , rawOutputs : [ ] } ]
34+ const retained = shards . slice ( 1 )
35+ const merged = mergeBundles ( [ retry , ...retained ] , 2 , bundle . run . id )
36+ assert . equal ( merged . run . attempt , 2 )
37+ assert . equal ( merged . run . workflow . url , retry . run . workflow . url )
38+ assert . equal ( merged . measurements . length , 108 )
39+ assert . deepEqual ( merged . measurements [ 0 ] , retry . measurements [ 0 ] )
40+ assert . deepEqual ( merged . measurements [ 1 ] , shards [ 1 ] ! . measurements [ 0 ] )
41+ assert . equal ( merged . runners . find ( runner => runner . id === retry . runners [ 0 ] ! . id ) ! . name , 'retry-runner' )
42+ assert . throws ( ( ) => mergeBundles ( [ retry , ...retained , structuredClone ( retry ) ] , 2 ) , / D u p l i c a t e / )
43+ assert . equal ( mergeBundles ( [ retry , ...retained ] , 3 ) . run . attempt , 3 )
44+
45+ const failedRetry = structuredClone ( retry )
46+ failedRetry . measurements = structuredClone ( shards [ 0 ] ! . measurements )
47+ assert . throws ( ( ) => mergeBundles ( [ failedRetry , ...retained ] , 2 ) , / M i s s i n g s u c c e s s f u l c o n f i g u r a t i o n s | O n l y s u c c e s s f u l / )
48+ assert . throws ( ( ) => mergeBundles ( shards , 2 ) , / M i s s i n g s u c c e s s f u l c o n f i g u r a t i o n s | O n l y s u c c e s s f u l / )
49+ assert . throws ( ( ) => mergeBundles ( [ ...shards . slice ( 1 , - 1 ) , retry ] , 2 ) , / M i s s i n g s u c c e s s f u l c o n f i g u r a t i o n s / )
50+ assert . throws ( ( ) => mergeBundles ( [ ...shards , retry ] , 2 ) , / D u p l i c a t e / )
51+ assert . throws ( ( ) => mergeBundles ( [ retry , ...retained ] , 1 ) , / e x c e e d s c o l l e c t i o n a t t e m p t / )
52+ assert . throws ( ( ) => mergeBundles ( [ retry , ...retained ] , 2 , 'another-run' ) , / R u n i d e n t i t y d i f f e r s / )
53+ for ( const mutate of [
54+ ( input : typeof retry ) => { input . run . id = 'another-run' } ,
55+ ( input : typeof retry ) => { input . run . commit . sha = 'f' . repeat ( 40 ) } ,
56+ ( input : typeof retry ) => { input . run . commit . tree = 'f' . repeat ( 40 ) } ,
57+ ( input : typeof retry ) => { input . run . pullRequest ! . base = 'f' . repeat ( 40 ) } ,
58+ ( input : typeof retry ) => { input . benchmark . settings . concurrency = 1 } ,
59+ ] ) {
60+ const invalid = structuredClone ( retry )
61+ mutate ( invalid )
62+ assert . throws ( ( ) => mergeBundles ( [ ...retained , invalid ] , 2 ) , / S h a r d d e f i n i t i o n s d i f f e r / )
63+ }
64+ const invalidUrl = structuredClone ( retry )
65+ invalidUrl . run . workflow . url = bundle . run . workflow . url
66+ assert . throws ( ( ) => mergeBundles ( [ ...retained , invalidUrl ] , 2 ) , / S h a r d w o r k f l o w U R L d i f f e r s / )
67+ } )
68+
2069test ( 'local history loads measured results but cannot be published' , async context => {
2170 const bundle = fixture ( )
2271 bundle . source = 'local'
@@ -327,13 +376,14 @@ test('CI archival and promotion with simulated GitHub and Git', async context =>
327376 ...Array . from ( { length : 2 } , ( _ , index ) => `prepare (${ index } )` ) ,
328377 ...Array . from ( { length : 108 } , ( _ , index ) => `measure (${ index } )` ) ,
329378 ] . map ( name => ( { name, conclusion : 'success' } ) )
379+ let retryJobs : typeof jobs = [ ]
330380 context . mock . method ( globalThis , 'fetch' , async ( url : string ) => {
331381 const parsed = new URL ( url )
332382 const path = parsed . pathname . replace ( `/repos/${ repository } /` , '' )
333383 if ( path . endsWith ( '/jobs' ) ) {
334384 const page = Number ( parsed . searchParams . get ( 'page' ) )
335- const response = structuredClone ( jobs )
336- if ( failedJob ) response [ 0 ] ! . conclusion = 'failure'
385+ const response = structuredClone ( path . includes ( '/attempts/2/' ) ? retryJobs : jobs )
386+ if ( failedJob && ! path . includes ( '/attempts/2/' ) ) response [ 0 ] ! . conclusion = 'failure'
337387 return Response . json ( { jobs : response . slice ( ( page - 1 ) * 100 , page * 100 ) , total_count : response . length } )
338388 }
339389 const responses : Record < string , unknown > = {
@@ -344,6 +394,7 @@ test('CI archival and promotion with simulated GitHub and Git', async context =>
344394 'issues/7/events' : [ { event : 'labeled' , label : { name : 'benchmarks: skip' } , actor : { login : 'maintainer' } } ] ,
345395 'collaborators/maintainer/permission' : { permission : 'write' } ,
346396 'actions/runs/12345/attempts/1' : { id : 12345 , event : 'pull_request' , conclusion : 'success' , path : '.github/workflows/benchmark.yml' , head_sha : workflowHead , created_at : bundle . run . createdAt } ,
397+ 'actions/runs/12345/attempts/2' : { id : 12345 , event : 'pull_request' , conclusion : 'success' , path : '.github/workflows/benchmark.yml' , head_sha : workflowHead , created_at : bundle . run . createdAt } ,
347398 'contents/.github/workflows/benchmark.yml' : { encoding : 'base64' , content : Buffer . from ( workflow ) . toString ( 'base64' ) } ,
348399 [ `git/commits/${ bundle . run . commit . sha } ` ] : { tree : { sha : bundle . run . commit . tree } , parents : [ { sha : bundle . run . pullRequest ! . base } , { sha : bundle . run . pullRequest ! . head } ] } ,
349400 [ `git/commits/${ pr . merge_commit_sha } ` ] : { tree : { sha : mergedTree } } ,
@@ -424,6 +475,36 @@ test('CI archival and promotion with simulated GitHub and Git', async context =>
424475 assert . equal ( downloads , 0 )
425476 assert . equal ( pushes , 0 )
426477 } )
478+ await context . test ( 'partial retry archives and promotes retained successful jobs' , async ( ) => {
479+ clearStore ( )
480+ const originalUrl = bundle . run . workflow . url
481+ bundle . run . attempt = 2
482+ bundle . run . workflow . url = `https://github.com/${ repository } /actions/runs/12345/attempts/2`
483+ retryJobs = jobs . filter ( job => [ 'eligibility' , 'measure (0)' , 'collect' , 'Benchmark Status' ] . includes ( job . name ) )
484+ failedJob = true
485+ writeJson ( eventPath , { workflow_run : { ...notification . workflow_run , run_attempt : 2 } } , false )
486+ try {
487+ await main ( )
488+ assert . equal ( downloads , 1 )
489+ assert . equal ( pushes , 2 )
490+ assert . deepEqual ( readJson ( resolve ( temporary , 'data-store/index.json' ) ) , { schemaVersion : 1 , runs : [ { id : '12345' , attempt : 2 } ] } )
491+ const stored = validateCompleteRun ( readJson ( resolve ( temporary , 'data-store/runs/12345/2.json' ) ) )
492+ assert . deepEqual ( stored . measurements , bundle . measurements )
493+ retryJobs = retryJobs . map ( job => ( { ...job , conclusion : job . name === 'measure (0)' ? 'failure' : 'success' } ) )
494+ await assert . rejects ( main ( ) , / s u c c e s s f u l m e a s u r e / )
495+ assert . equal ( downloads , 1 , 'A failed retry must not use an earlier successful job' )
496+ retryJobs = retryJobs . filter ( job => job . name !== 'eligibility' )
497+ await assert . rejects ( main ( ) , / s u c c e s s f u l e l i g i b i l i t y / )
498+ assert . equal ( downloads , 1 )
499+ } finally {
500+ bundle . run . attempt = 1
501+ bundle . run . workflow . url = originalUrl
502+ retryJobs = [ ]
503+ failedJob = false
504+ writeJson ( eventPath , notification , false )
505+ clearStore ( )
506+ }
507+ } )
427508 await context . test ( 'label changes prevent archival and promotion' , async ( ) => {
428509 pr . labels = [ { name : 'benchmarks: skip' } ]
429510 await main ( )
0 commit comments