|
|
@@ -2,7 +2,7 @@
|
|
|
* Node half of the HMR plugin: bundle watches follow the graph, stat changes
|
|
|
* report through clientModuleHost.rebuilt, and everything dies with the fiber.
|
|
|
*/
|
|
|
-import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
|
+import { mkdtempSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'
|
|
|
import { tmpdir } from 'node:os'
|
|
|
import { join } from 'node:path'
|
|
|
import { Context } from 'cordis'
|
|
|
@@ -24,18 +24,29 @@ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
|
|
* the service class carries private scan state a literal need not reproduce.
|
|
|
*/
|
|
|
type FakeHost = ClientModuleHostService & { rebuiltCalls: string[]; fireGraphChanged(): void }
|
|
|
-function fakeClientModuleHost(rows: Map<string, string>): FakeHost {
|
|
|
+interface FakeHostOptions {
|
|
|
+ beforeGraphRead?: () => void
|
|
|
+ rebuilt?: (id: string) => string | undefined
|
|
|
+}
|
|
|
+
|
|
|
+function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost {
|
|
|
const graphListeners = new Set<() => void>()
|
|
|
const rebuiltCalls: string[] = []
|
|
|
const fake: Pick<FakeHost, 'graph' | 'clientPath' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
|
|
|
rebuiltCalls,
|
|
|
fireGraphChanged: () => { for (const l of graphListeners) l() },
|
|
|
- graph: (): WebBootGraph => ({
|
|
|
- rev: 'r',
|
|
|
- entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
|
|
|
- }),
|
|
|
+ graph: (): WebBootGraph => {
|
|
|
+ options.beforeGraphRead?.()
|
|
|
+ return {
|
|
|
+ rev: 'r',
|
|
|
+ entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
|
|
|
+ }
|
|
|
+ },
|
|
|
clientPath: id => rows.get(id),
|
|
|
- rebuilt: (id) => { rebuiltCalls.push(id); return 'r2' },
|
|
|
+ rebuilt: (id) => {
|
|
|
+ rebuiltCalls.push(id)
|
|
|
+ return options.rebuilt?.(id) ?? 'r2'
|
|
|
+ },
|
|
|
onRebuilt: () => () => {},
|
|
|
onGraphChanged: (listener) => {
|
|
|
graphListeners.add(listener)
|
|
|
@@ -81,6 +92,8 @@ describe('hmr node half', () => {
|
|
|
|
|
|
expect(routes).toHaveLength(1)
|
|
|
expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT })
|
|
|
+ expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a'])
|
|
|
+ clientModuleHost.rebuiltCalls.length = 0
|
|
|
|
|
|
// Nudge mtime past stat granularity so the poller sees a content signal.
|
|
|
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
|
|
|
@@ -103,14 +116,89 @@ describe('hmr node half', () => {
|
|
|
const rows = new Map([['pkg-early', early]])
|
|
|
const clientModuleHost = fakeClientModuleHost(rows)
|
|
|
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
|
|
|
+ clientModuleHost.rebuiltCalls.length = 0
|
|
|
|
|
|
writeFileSync(late, 'v1')
|
|
|
rows.set('pkg-late', late)
|
|
|
clientModuleHost.fireGraphChanged()
|
|
|
+ expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-late'])
|
|
|
+ clientModuleHost.rebuiltCalls.length = 0
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
|
|
|
writeFileSync(late, 'v2-longer')
|
|
|
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 })
|
|
|
+
|
|
|
+ rows.delete('pkg-late')
|
|
|
+ clientModuleHost.fireGraphChanged()
|
|
|
+ clientModuleHost.rebuiltCalls.length = 0
|
|
|
+ writeFileSync(late, 'v3-even-longer')
|
|
|
+ await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
|
|
|
+ expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
|
|
|
+ await fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rehashes after baseline capture so a construction-window write cannot become the baseline', async () => {
|
|
|
+ const bundle = join(dir, 'construction.js')
|
|
|
+ writeFileSync(bundle, 'v1')
|
|
|
+ let rewrite = true
|
|
|
+ const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
|
|
|
+ beforeGraphRead: () => {
|
|
|
+ if (!rewrite) return
|
|
|
+ rewrite = false
|
|
|
+ // The graph carries the hash from before this write. The old
|
|
|
+ // fs.watchFile registration asynchronously captured the new file as
|
|
|
+ // its first baseline and never requested a re-hash.
|
|
|
+ writeFileSync(bundle, 'v2-written-during-watch-construction')
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ const fiber = await mount(clientModuleHost, fakeHttpServer([]))
|
|
|
+
|
|
|
+ expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a'])
|
|
|
+ clientModuleHost.rebuiltCalls.length = 0
|
|
|
+ await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
|
|
|
+ expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
|
|
|
+ await fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('marks a vanished bundle dirty so identical metadata still re-hashes after it reappears', async () => {
|
|
|
+ const bundle = join(dir, 'replace.js')
|
|
|
+ writeFileSync(bundle, 'seed')
|
|
|
+ const fixedTime = new Date(1_600_000_000_000)
|
|
|
+ utimesSync(bundle, fixedTime, fixedTime)
|
|
|
+ const baseline = statSync(bundle)
|
|
|
+ const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
|
|
|
+ const fiber = await mount(clientModuleHost, fakeHttpServer([]))
|
|
|
+ clientModuleHost.rebuiltCalls.length = 0
|
|
|
+
|
|
|
+ unlinkSync(bundle)
|
|
|
+ await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
|
|
|
+ writeFileSync(bundle, 'x'.repeat(baseline.size))
|
|
|
+ utimesSync(bundle, fixedTime, fixedTime)
|
|
|
+ const restored = statSync(bundle)
|
|
|
+ expect({ mtimeMs: restored.mtimeMs, size: restored.size }).toEqual({
|
|
|
+ mtimeMs: baseline.mtimeMs,
|
|
|
+ size: baseline.size,
|
|
|
+ })
|
|
|
+ await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a']) }, { timeout: 3_000 })
|
|
|
+ await fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('retains a dirty baseline when the immediate re-hash races a rename', async () => {
|
|
|
+ const bundle = join(dir, 'rename.js')
|
|
|
+ writeFileSync(bundle, 'v1')
|
|
|
+ let first = true
|
|
|
+ const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
|
|
|
+ rebuilt: () => {
|
|
|
+ if (!first) return 'r2'
|
|
|
+ first = false
|
|
|
+ throw Object.assign(new Error('bundle renamed'), { code: 'ENOENT' })
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ const fiber = await mount(clientModuleHost, fakeHttpServer([]))
|
|
|
+
|
|
|
+ await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a', 'pkg-a']) }, { timeout: 3_000 })
|
|
|
await fiber.dispose()
|
|
|
})
|
|
|
})
|