@@ -44,9 +44,17 @@ function loadLefthook(): LefthookDefinition {
4444 return yaml . parse ( readFileSync ( lefthookPath , 'utf8' ) ) as LefthookDefinition ;
4545}
4646
47+ // CI-5: the old single test job is split into shard jobs plus the aggregating
48+ // `test` job that keeps the required check name.
49+ const testShardJobIds = [ 'test-repo' , 'test-app' , 'test-ui' , 'test' ] ;
50+
4751function getTestJobStep ( name : string ) : WorkflowStep | undefined {
4852 const workflow = loadWorkflow ( ) ;
49- return workflow . jobs ?. test ?. steps ?. find ( ( step ) => step . name === name ) ;
53+ for ( const jobId of testShardJobIds ) {
54+ const step = workflow . jobs ?. [ jobId ] ?. steps ?. find ( ( candidate ) => candidate . name === name ) ;
55+ if ( step ) return step ;
56+ }
57+ return undefined ;
5058}
5159
5260test ( 'UI coverage captures bounded resource evidence without changing the gate' , ( ) => {
@@ -389,9 +397,20 @@ test('ci-verify can dispatch the complete release-candidate matrix manually', ()
389397 expect ( workflow . jobs ?. [ jobId ] ?. if ) . toContain ( "github.event_name == 'workflow_dispatch'" ) ;
390398 }
391399
392- for ( const jobId of [ 'security-actions' , 'changes' , 'lint' , 'test' , 'build' ] ) {
400+ for ( const jobId of [
401+ 'security-actions' ,
402+ 'changes' ,
403+ 'lint' ,
404+ 'test-repo' ,
405+ 'test-app' ,
406+ 'test-ui' ,
407+ 'build' ,
408+ ] ) {
393409 expect ( workflow . jobs ?. [ jobId ] ?. if ) . toBeUndefined ( ) ;
394410 }
411+ // The aggregating required check must run after a failed shard so it can
412+ // report failure itself; a skipped required check counts as passing.
413+ expect ( workflow . jobs ?. test ?. if ) . toBe ( 'always()' ) ;
395414
396415 expect ( workflow . jobs ?. codeql ?. needs ) . toStrictEqual ( [ 'security-actions' ] ) ;
397416 expect ( workflow . jobs ?. fuzz ?. needs ) . toStrictEqual ( [ 'security-actions' ] ) ;
@@ -682,3 +701,95 @@ test('the advisory ci-timing job cannot fail the run or gate a release', () => {
682701 ) ;
683702 expect ( releaseCut ) . not . toMatch ( / c i - t i m i n g | C I t i m i n g / ) ;
684703} ) ;
704+
705+ test ( 'test suites are sharded and a final job keeps the required check name' , ( ) => {
706+ const workflow = loadWorkflow ( ) ;
707+
708+ expect ( workflow . jobs ?. test ?. name ) . toBe ( 'Quality: Test & Coverage' ) ;
709+ expect ( workflow . jobs ?. test ?. needs ) . toStrictEqual ( [ 'test-repo' , 'test-app' , 'test-ui' ] ) ;
710+ for ( const jobId of [ 'test-repo' , 'test-app' , 'test-ui' ] ) {
711+ expect ( workflow . jobs ?. [ jobId ] ?. needs ) . toBeUndefined ( ) ;
712+ expect ( workflow . jobs ?. [ jobId ] ?. name ) . not . toBe ( 'Quality: Test & Coverage' ) ;
713+ }
714+
715+ // Shards run the same commands the pre-push coverage gate runs, so the
716+ // per-workspace 100% thresholds stay enforced where the tests execute.
717+ expect ( getWorkflowStep ( 'test-app' , 'Run app tests' ) ) . toMatchObject ( {
718+ run : 'npm test' ,
719+ 'working-directory' : 'app' ,
720+ } ) ;
721+ expect ( getWorkflowStep ( 'test-ui' , 'Run ui tests' ) ?. run ) . toContain (
722+ 'npm run test:unit -- --maxWorkers=1' ,
723+ ) ;
724+ expect ( getWorkflowStep ( 'test-ui' , 'Typecheck ui' ) ) . toMatchObject ( {
725+ run : 'npm run typecheck' ,
726+ 'working-directory' : 'ui' ,
727+ } ) ;
728+
729+ // build gates on the aggregate, not on individual shards.
730+ expect ( workflow . jobs ?. build ?. needs ) . toStrictEqual ( [ 'lint' , 'test' ] ) ;
731+ } ) ;
732+
733+ test ( 'coverage shards hand lcov and gap reports to the aggregating job' , ( ) => {
734+ for ( const [ jobId , workspace ] of [
735+ [ 'test-app' , 'app' ] ,
736+ [ 'test-ui' , 'ui' ] ,
737+ ] ) {
738+ const lcov = getWorkflowStep ( jobId , 'Upload coverage report' ) ;
739+ expect ( lcov ?. if ) . toBe ( 'always()' ) ;
740+ expect ( lcov ?. with ) . toMatchObject ( {
741+ name : `coverage-shard-${ workspace } ` ,
742+ path : `${ workspace } /coverage/lcov.info` ,
743+ } ) ;
744+
745+ const gaps = getWorkflowStep ( jobId , 'Write coverage gap report' ) ;
746+ expect ( gaps ?. if ) . toBe ( 'failure()' ) ;
747+ expect ( gaps ?. run ) . toContain ( `--workspace ${ workspace } ` ) ;
748+ expect ( gaps ?. run ) . toContain ( '|| true' ) ;
749+ expect ( getWorkflowStep ( jobId , 'Upload coverage gap report' ) ?. with ) . toMatchObject ( {
750+ name : `coverage-gaps-${ workspace } ` ,
751+ path : `.coverage-gaps.${ workspace } .json` ,
752+ } ) ;
753+ // Gap reports are dotfiles; upload-artifact skips hidden files by default.
754+ expect ( getWorkflowStep ( jobId , 'Upload coverage gap report' ) ?. with ) . toMatchObject ( {
755+ 'include-hidden-files' : true ,
756+ } ) ;
757+ }
758+ } ) ;
759+
760+ test ( 'the aggregating job uploads the merged gap report including hidden files' , ( ) => {
761+ expect ( getWorkflowStep ( 'test' , 'Upload coverage gap report' ) ?. with ) . toMatchObject ( {
762+ name : 'coverage-gaps' ,
763+ path : '.coverage-gaps.json' ,
764+ 'include-hidden-files' : true ,
765+ } ) ;
766+ } ) ;
767+
768+ test ( 'the aggregating job merges shard coverage and fails closed on any shard result' , ( ) => {
769+ const merge = getWorkflowStep ( 'test' , 'Merge coverage gap reports' ) ;
770+ expect ( merge ?. if ) . toBe ( 'always()' ) ;
771+ expect ( merge ?. run ) . toContain ( 'scripts/merge-coverage-gaps.mjs' ) ;
772+ expect ( merge ?. run ) . toContain ( '.coverage-gaps.app.json' ) ;
773+ expect ( merge ?. run ) . toContain ( '.coverage-gaps.ui.json' ) ;
774+
775+ const normalize = getWorkflowStep ( 'test' , 'Normalize coverage paths' ) ;
776+ expect ( normalize ?. run ) . toBe ( 'node scripts/prepare-codecov-reports.mjs' ) ;
777+ expect ( normalize ?. if ) . toContain ( "needs.test-app.result == 'success'" ) ;
778+ expect ( normalize ?. if ) . toContain ( "needs.test-ui.result == 'success'" ) ;
779+
780+ const gate = getWorkflowStep ( 'test' , 'Enforce shard results' ) ;
781+ expect ( gate ?. if ) . toBe ( 'always()' ) ;
782+ for ( const shard of [ 'test-repo' , 'test-app' , 'test-ui' ] ) {
783+ expect ( gate ?. env ) . toHaveProperty (
784+ `RESULT_${ shard . replace ( '-' , '_' ) . toUpperCase ( ) } ` ,
785+ `\${{ needs.${ shard } .result }}` ,
786+ ) ;
787+ }
788+ expect ( gate ?. run ) . toContain ( 'success' ) ;
789+ expect ( gate ?. run ) . toContain ( 'exit 1' ) ;
790+
791+ // Codecov upload stays advisory.
792+ for ( const name of [ 'Upload app coverage to Codecov' , 'Upload ui coverage to Codecov' ] ) {
793+ expect ( getWorkflowStep ( 'test' , name ) ?. [ 'continue-on-error' ] ) . toBe ( true ) ;
794+ }
795+ } ) ;
0 commit comments