@@ -15,6 +15,7 @@ import {
1515
1616import {
1717 type CoordinatorOptions ,
18+ flushCoordinatorWrites ,
1819 resetCoordinator ,
1920 startCoordinator ,
2021} from '../coordinator'
@@ -481,14 +482,14 @@ describe('coordinator', () => {
481482 const portStr = ( writeFileSyncSpy . mock . calls [ 0 ] as [ string , string ] ) [ 1 ]
482483 const port = parseInt ( portStr )
483484
484- // Mock Date.now to simulate time passing beyond MAX_WAIT_MS (30s)
485+ // Mock Date.now to simulate time passing beyond MAX_WAIT_MS (60s).
485486 let callCount = 0
486487 const dateNowSpy = spyOn ( Date , 'now' ) . mockImplementation ( ( ) => {
487488 callCount ++
488- // First call is `const start = Date.now()` → return 0
489+ // First call is `const start = Date.now()` — return 0
489490 // Subsequent calls return past MAX_WAIT_MS threshold
490491 if ( callCount <= 1 ) return 0
491- return 31_000
492+ return 61_000
492493 } )
493494
494495 // Request CSS with waitForIdle=true but no extractions ever happen
@@ -712,4 +713,240 @@ describe('coordinator', () => {
712713 // Calling again should be safe (server is already null)
713714 resetCoordinator ( )
714715 } )
716+
717+ it ( 'should coalesce duplicate writes to the same path within one /extract handler' , async ( ) => {
718+ // When a single /extract invocation triggers multiple writes to the same
719+ // path (singleCss + updatedBaseStyle=true → both the base-CSS write and
720+ // the cssFile write target `devup-ui.css`), the second write must be
721+ // collapsed by the latest-wins serializer: the first chained run sees the
722+ // *latest* content and writes it once, the second chained run finds
723+ // `latestContent` already consumed and resolves as a no-op.
724+ codeExtractSpy . mockReturnValue ( {
725+ code : 'single css code' ,
726+ map : undefined ,
727+ cssFile : 'devup-ui.css' ,
728+ updatedBaseStyle : true ,
729+ free : mock ( ) ,
730+ [ Symbol . dispose ] : mock ( ) ,
731+ } )
732+ getCssSpy . mockReturnValue ( 'all-styles' )
733+ exportSheetSpy . mockReturnValue ( 'sheet-json' )
734+ exportClassMapSpy . mockReturnValue ( 'classmap-json' )
735+ exportFileMapSpy . mockReturnValue ( 'filemap-json' )
736+
737+ const options = makeOptions ( { singleCss : true } )
738+ const coordinator = startCoordinator ( options )
739+
740+ await new Promise ( ( r ) => setTimeout ( r , 100 ) )
741+
742+ const portStr = ( writeFileSyncSpy . mock . calls [ 0 ] as [ string , string ] ) [ 1 ]
743+ const port = parseInt ( portStr )
744+
745+ const res = await httpRequest (
746+ port ,
747+ 'POST' ,
748+ '/extract' ,
749+ JSON . stringify ( {
750+ filename : 'src/App.tsx' ,
751+ code : 'const x = <Box bg="red" />' ,
752+ resourcePath : join ( process . cwd ( ) , 'src' , 'App.tsx' ) ,
753+ } ) ,
754+ )
755+
756+ expect ( res . status ) . toBe ( 200 )
757+
758+ // Both safeWrite calls target `devup-ui.css`. Coalescing means exactly
759+ // one physical writeFile call is made for that path; the sheet/classMap/
760+ // fileMap writes (3 more) all go to distinct paths.
761+ const devupUiCssWrites = writeFileSpy . mock . calls . filter ( ( call ) =>
762+ String ( call [ 0 ] ) . endsWith ( 'devup-ui.css' ) ,
763+ )
764+ expect ( devupUiCssWrites . length ) . toBe ( 1 )
765+
766+ coordinator . close ( )
767+ } )
768+
769+ it ( 'should expose flushCoordinatorWrites to drain queued writes' , async ( ) => {
770+ // The exported helper must return a settled promise even when no writes
771+ // are pending (idle coordinator), so build orchestration can safely await
772+ // it without risk of hanging.
773+ await expect ( flushCoordinatorWrites ( ) ) . resolves . toBeUndefined ( )
774+
775+ // After triggering a real /extract, awaiting the helper must wait for all
776+ // queued writes (chained per path) to settle. We assert the spy has been
777+ // invoked by the time the helper resolves.
778+ codeExtractSpy . mockReturnValue ( {
779+ code : 'code' ,
780+ map : undefined ,
781+ cssFile : 'devup-ui-7.css' ,
782+ updatedBaseStyle : false ,
783+ free : mock ( ) ,
784+ [ Symbol . dispose ] : mock ( ) ,
785+ } )
786+ getCssSpy . mockReturnValue ( 'per-file-css' )
787+ exportSheetSpy . mockReturnValue ( 'sheet-json' )
788+ exportClassMapSpy . mockReturnValue ( 'classmap-json' )
789+ exportFileMapSpy . mockReturnValue ( 'filemap-json' )
790+
791+ const options = makeOptions ( )
792+ const coordinator = startCoordinator ( options )
793+
794+ await new Promise ( ( r ) => setTimeout ( r , 100 ) )
795+
796+ const portStr = ( writeFileSyncSpy . mock . calls [ 0 ] as [ string , string ] ) [ 1 ]
797+ const port = parseInt ( portStr )
798+
799+ await httpRequest (
800+ port ,
801+ 'POST' ,
802+ '/extract' ,
803+ JSON . stringify ( {
804+ filename : 'src/App.tsx' ,
805+ code : 'const x = <Box bg="red" />' ,
806+ resourcePath : join ( process . cwd ( ) , 'src' , 'App.tsx' ) ,
807+ } ) ,
808+ )
809+
810+ await expect ( flushCoordinatorWrites ( ) ) . resolves . toBeUndefined ( )
811+ expect ( writeFileSpy . mock . calls . length ) . toBeGreaterThan ( 0 )
812+
813+ coordinator . close ( )
814+ } )
815+
816+ it ( 'should continue chained writes after a previous write fails (chain error recovery)' , async ( ) => {
817+ // The serializer must not let one failed write poison every subsequent
818+ // write for that path. We force the first writeFile to fail, then verify
819+ // the second extraction's writes still happen for that same path.
820+ codeExtractSpy . mockReturnValue ( {
821+ code : 'code' ,
822+ map : undefined ,
823+ cssFile : 'devup-ui.css' ,
824+ updatedBaseStyle : false ,
825+ free : mock ( ) ,
826+ [ Symbol . dispose ] : mock ( ) ,
827+ } )
828+ getCssSpy . mockReturnValue ( 'css-content' )
829+ exportSheetSpy . mockReturnValue ( 'sheet-json' )
830+ exportClassMapSpy . mockReturnValue ( 'classmap-json' )
831+ exportFileMapSpy . mockReturnValue ( 'filemap-json' )
832+
833+ // Re-install writeFile spy with controlled failure: any write to the
834+ // devup-ui.css path errors out on the *first* invocation only.
835+ writeFileSpy . mockRestore ( )
836+ let devupCssCallCount = 0
837+ writeFileSpy = spyOn ( fs , 'writeFile' ) . mockImplementation (
838+ ( _path : any , _data : any , _encOrCb : any , maybeCb ?: any ) => {
839+ const cb = typeof _encOrCb === 'function' ? _encOrCb : maybeCb
840+ if ( cb ) {
841+ if ( String ( _path ) . endsWith ( 'devup-ui.css' ) ) {
842+ devupCssCallCount ++
843+ if ( devupCssCallCount === 1 ) {
844+ cb ( new Error ( 'simulated disk error' ) )
845+ return
846+ }
847+ }
848+ cb ( null )
849+ }
850+ } ,
851+ )
852+
853+ const options = makeOptions ( { singleCss : true } )
854+ const coordinator = startCoordinator ( options )
855+
856+ await new Promise ( ( r ) => setTimeout ( r , 100 ) )
857+
858+ const portStr = ( writeFileSyncSpy . mock . calls [ 0 ] as [ string , string ] ) [ 1 ]
859+ const port = parseInt ( portStr )
860+
861+ // First /extract: triggers a write to devup-ui.css that we make fail.
862+ // The coordinator will respond with 500 (await Promise.all([..., failingWrite])
863+ // rejects), but the chain itself must NOT be poisoned.
864+ const firstRes = await httpRequest (
865+ port ,
866+ 'POST' ,
867+ '/extract' ,
868+ JSON . stringify ( {
869+ filename : 'src/A.tsx' ,
870+ code : 'const x = <Box bg="red" />' ,
871+ resourcePath : join ( process . cwd ( ) , 'src' , 'A.tsx' ) ,
872+ } ) ,
873+ )
874+ expect ( firstRes . status ) . toBe ( 500 )
875+
876+ // Second /extract for the same path must SUCCEED — the `.catch(() => {})`
877+ // chain-survival branch is what makes this work.
878+ const secondRes = await httpRequest (
879+ port ,
880+ 'POST' ,
881+ '/extract' ,
882+ JSON . stringify ( {
883+ filename : 'src/B.tsx' ,
884+ code : 'const y = <Box bg="blue" />' ,
885+ resourcePath : join ( process . cwd ( ) , 'src' , 'B.tsx' ) ,
886+ } ) ,
887+ )
888+ expect ( secondRes . status ) . toBe ( 200 )
889+
890+ // We must have observed at least 2 attempts on the devup-ui.css path:
891+ // the first (failed) and the second (succeeded).
892+ expect ( devupCssCallCount ) . toBeGreaterThanOrEqual ( 2 )
893+
894+ coordinator . close ( )
895+ } )
896+
897+ it ( 'should release the pending-extract slot when readBody throws before promotion' , async ( ) => {
898+ // If JSON.parse on the request body throws, the handler must still tear
899+ // down its `pendingExtractStarts` reservation (rather than the active
900+ // counter) so waitForIdle is not left waiting forever for a phantom
901+ // extraction. We verify by sending a malformed body, then proving the
902+ // coordinator still processes a follow-up extraction normally.
903+ codeExtractSpy . mockReturnValue ( {
904+ code : 'code' ,
905+ map : undefined ,
906+ cssFile : 'devup-ui-1.css' ,
907+ updatedBaseStyle : false ,
908+ free : mock ( ) ,
909+ [ Symbol . dispose ] : mock ( ) ,
910+ } )
911+ getCssSpy . mockReturnValue ( 'per-file-css' )
912+ exportSheetSpy . mockReturnValue ( 'sheet-json' )
913+ exportClassMapSpy . mockReturnValue ( 'classmap-json' )
914+ exportFileMapSpy . mockReturnValue ( 'filemap-json' )
915+
916+ const options = makeOptions ( )
917+ const coordinator = startCoordinator ( options )
918+
919+ await new Promise ( ( r ) => setTimeout ( r , 100 ) )
920+
921+ const portStr = ( writeFileSyncSpy . mock . calls [ 0 ] as [ string , string ] ) [ 1 ]
922+ const port = parseInt ( portStr )
923+
924+ // Send an invalid body so JSON.parse throws BEFORE activeExtractions is
925+ // incremented. The handler must still respond 500 cleanly.
926+ const badRes = await httpRequest ( port , 'POST' , '/extract' , 'not-json' )
927+ expect ( badRes . status ) . toBe ( 500 )
928+ const errorPayload = JSON . parse ( badRes . body ) as { error : string }
929+ expect ( typeof errorPayload . error ) . toBe ( 'string' )
930+
931+ // A subsequent well-formed extraction must succeed. If the pending-slot
932+ // bookkeeping was wrong (decrementing activeExtractions instead of
933+ // pendingExtractStarts in finally), internal counters would drift negative
934+ // — that would not crash this request but is asserted by the next test
935+ // case via waitForIdle behaviour.
936+ const goodRes = await httpRequest (
937+ port ,
938+ 'POST' ,
939+ '/extract' ,
940+ JSON . stringify ( {
941+ filename : 'src/App.tsx' ,
942+ code : 'const x = <Box bg="red" />' ,
943+ resourcePath : join ( process . cwd ( ) , 'src' , 'App.tsx' ) ,
944+ } ) ,
945+ )
946+ expect ( goodRes . status ) . toBe ( 200 )
947+ const okPayload = JSON . parse ( goodRes . body ) as { code : string }
948+ expect ( okPayload . code ) . toBe ( 'code' )
949+
950+ coordinator . close ( )
951+ } )
715952} )
0 commit comments