Skip to content

Commit 6fd29bb

Browse files
authored
Merge pull request #611 from dev-five-git/fix-write-issue
Update testcase
2 parents d218dde + ca8a6be commit 6fd29bb

4 files changed

Lines changed: 365 additions & 45 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"changes":{"packages/next-plugin/package.json":"Patch"},"note":"Fix write issue","date":"2026-05-13T07:13:54.316827100Z"}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ storybook-static
2121
test-results
2222
playwright-report
2323
.omc
24+
.playwright-mcp

packages/next-plugin/src/__tests__/coordinator.test.ts

Lines changed: 240 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515

1616
import {
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

Comments
 (0)