diff --git a/stasis-plugins/src/esbuild.js b/stasis-plugins/src/esbuild.js index 695929cc..4bc10f76 100644 --- a/stasis-plugins/src/esbuild.js +++ b/stasis-plugins/src/esbuild.js @@ -21,6 +21,11 @@ export class StasisEsbuild { #resources #loaders + // Build starts observed by this (capture-mode) instance, across rebuilds AND across separate + // esbuild.build()/context() calls sharing one instance. The second start is refused -- see + // the onStart hook in setup(). + #captureBuildCount = 0 + constructor(options = {}, { loaders } = {}) { // A caller that owns a State (e.g. `stasis build`) can pass it directly to drive the plugin, bypassing resolvePluginState; mode follows that State's config, and a foreign-copy State fails closed via the instanceof miss. const state = options instanceof State @@ -49,9 +54,33 @@ export class StasisEsbuild { return 'stasis' } - setup = ({ onResolve, onLoad, onEnd, resolve }) => { + setup = ({ onResolve, onLoad, onStart, onEnd, resolve }) => { if (!this.#state) return // noop plugin + // Watch/rebuild capture is EXPLICITLY UNSUPPORTED -- fail the second build loudly before it + // captures anything. Capture's dedupe is keyed by PATH, not content (#seen below, plus + // State.write's compare-and-skip caches), so on a rebuild where a file's bytes changed the + // plugin would skip re-capture: the emitted output would use the new bytes while the + // bundle/lockfile silently kept attesting the OLD ones. Rather than track content changes + // across rebuilds, refuse them outright. esbuild rebuilds (watch mode, context.rebuild()) + // re-fire onStart/onEnd against the SAME setup registrations, so an instance-level counter + // sees every start; it also covers reusing one plugin instance across two esbuild.build() + // calls, which has the same staleness hazard. Returning errors from onStart is the + // esbuild-idiomatic hard failure (the build reports them and onEnd's clean-build gate then + // skips the write). Load mode registers nothing here: it serves immutable attested bytes, + // so rebuilds have no drift hazard. + if (!this.#state.config.loadBundle) { + onStart(() => { + this.#captureBuildCount += 1 + if (this.#captureBuildCount === 1) return undefined + return { + errors: [{ + text: 'StasisEsbuild: watch/rebuild is not supported for capture -- run a one-shot build (rebuilds would silently attest stale content)', + }], + } + }) + } + // The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js). // Standalone and sidecar States have no such hook, so the plugin writes them when the // build finishes -- but only on a clean build, otherwise a partial bundle/lockfile diff --git a/stasis-plugins/src/metro.js b/stasis-plugins/src/metro.js index df85d445..3d8cae47 100644 --- a/stasis-plugins/src/metro.js +++ b/stasis-plugins/src/metro.js @@ -108,13 +108,19 @@ function metroDefaultSerializer() { // hook) is a lower-coverage fallback -- see its note. // // KNOWN LIMITATIONS (Metro-specific coverage gaps -- documented, not silently ignored): -// - Child-process capture is best-effort and one-shot-build only. A worker KILLED by signal +// - Capture is ONE-SHOT ONLY, enforced: a dev server (`metro start`) re-invokes the serializer +// per rebuild, and the SECOND invocation throws (see #run) -- capture's path-keyed dedupe +// would otherwise keep attesting a file's first-build bytes after an edit changed them. +// Metro gives the serializer no watch signal on the first build, so the first rebuild is the +// earliest refusal point. Load mode (the companion worker transformer) is unaffected and +// runs fine under a dev server -- it only reads immutable attested bytes. +// - Child-process capture is best-effort within that one shot. A worker KILLED by signal // before its exit hook (jest-worker forceExit / pool overflow) loses its shard SILENTLY at // capture time -- a later frozen run still catches the gap (fail-closed), but nothing warns -// during capture. And it relies on the loader's exit-time write: a dev server (`metro start`, -// pool kept warm, killed by signal) never gets there, and standalone (no stasis loader) Metro -// mints no shard dir -- neither produces a toolchain-complete lockfile. Use a one-shot -// `metro build` under `stasis run --child-process` to capture, then verify with `--lock=frozen`. +// during capture. And it relies on the loader's exit-time write, which standalone (no stasis +// loader) Metro never reaches -- it mints no shard dir and produces no toolchain-complete +// lockfile. Use a one-shot `metro build` under `stasis run --child-process` to capture, then +// verify with `--lock=frozen`. // - Scaled asset variants: `import x from './logo.png'` is ONE module in the graph, keyed // by the base path; Metro discovers `logo@2x.png` / `@3x` via its AssetData (parallel // scales/files arrays), NOT as separate graph modules, so only the base file is attested @@ -130,6 +136,11 @@ export class StasisMetro { #state #resources + // Whether this instance already served one capture (#run). The serializer fires once per + // one-shot `metro build`; a dev server re-invokes it per rebuild, which capture refuses -- + // see the guard in #run. + #ran = false + constructor(options = {}) { const { state } = resolvePluginState('StasisMetro', options, process.cwd()) this.#state = state // null when the plugin should be inert (Rule 7) @@ -192,15 +203,31 @@ export class StasisMetro { 'serializer config in load mode, or use bundle=add/replace/frozen here to capture.' ) } + // Watch/dev-server capture is EXPLICITLY UNSUPPORTED -- refuse the second serialization + // loudly before it captures anything. Capture's dedupe is keyed by PATH, not content + // (#seen below, plus State.write's compare-and-skip caches), so on a rebuild where a + // file's bytes changed the plugin would skip re-capture: the emitted bundle would use the + // new bytes while the stasis bundle/lockfile silently kept attesting the OLD ones. The + // serializer fires exactly once per one-shot `metro build`; only a dev server + // (`metro start`) re-invokes it, and Metro hands the serializer no watch-mode signal on + // the FIRST build, so the first rebuild is the earliest point capture can refuse. (Load + // mode has no such hazard and legitimately runs under a dev server -- it lives in the + // companion worker transformer, rejected above, which only READS immutable attested bytes.) + if (this.#ran) { + throw new Error( + 'StasisMetro: watch/dev-server rebuilds are not supported for capture -- use a one-shot `metro build`' + ) + } + this.#ran = true this.#capture(graph, preModules) // The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js). // Standalone and sidecar States have no such hook, so the plugin writes them here. The // serializer only runs once Metro has successfully built + transformed the whole graph // (a failed build throws before serialization), so this is the clean-build equivalent of - // webpack's `stats.hasErrors()` / esbuild's `result.errors.length` gate. In watch / dev - // mode it fires on every rebuild; State.write's per-artifact compare-and-skip makes the - // unchanged-graph case a cheap no-op. + // webpack's `stats.hasErrors()` / esbuild's `result.errors.length` gate -- and it runs at + // most once per instance (the rebuild guard above), so this write is the one-shot build's + // single flush. if (this.#state !== State.preload) this.#state.write() } diff --git a/stasis-plugins/src/webpack.js b/stasis-plugins/src/webpack.js index c7035aaa..351c8142 100644 --- a/stasis-plugins/src/webpack.js +++ b/stasis-plugins/src/webpack.js @@ -484,35 +484,45 @@ export class StasisWebpack { }) }) + // Watch-mode capture is EXPLICITLY UNSUPPORTED -- refuse it loudly before anything is + // captured. Capture's dedupe is keyed by PATH, not content (#seen above, plus State.write's + // compare-and-skip caches), so on a watch rebuild where a file's bytes changed the plugin + // would skip re-capture: the emitted output would use the new bytes while the bundle / + // lockfile silently kept attesting the OLD ones. Rather than track content changes across + // rebuilds, refuse watch outright. compiler.hooks.watchRun fires ONLY under + // compiler.watch() (both webpack 4 and 5) and BEFORE the first watch build's compilation, + // so this rejects the whole watch session up front, not just the second build. watchRun is + // an AsyncSeriesHook: a sync `.tap` callback that throws propagates through the hook's call + // chain and fails the watcher -- exactly the loud refusal we want, so keep it a plain tap. + // Load mode is deliberately untouched (see #applyLoadMode): it serves immutable attested + // bytes back into the build, so a dev-server rebuild has no drift hazard there. + compiler.hooks.watchRun.tap('Stasis', () => { + throw new Error( + 'StasisWebpack: watch mode is not supported for capture -- ' + + 'run a one-shot build (watch rebuilds would silently attest stale content)' + ) + }) + // The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js). // Standalone and sidecar States have no such hook, so the plugin writes them when the // compiler finishes -- but only on a clean build. A failed compilation has incomplete // state, and lock=replace would otherwise overwrite a good lockfile with a partial one. if (this.#state !== State.preload) { - // Watch-mode detection. compiler.hooks.watchRun fires ONLY under compiler.watch() (both - // webpack 4 and 5) and always before that build's `done`, so the flag is set in time. - // The flag is per-compiler (this closure), which is what we want: each compiler reports - // its own run kind even when one shared plugin instance drives several. - let watching = false - compiler.hooks.watchRun.tap('Stasis', () => { watching = true }) compiler.hooks.done.tap('Stasis', (stats) => { if (stats.hasErrors()) return - // Write immediately when the bundle cannot grow across `done`s: - // - watch mode: a watcher keeps the event loop alive, so a deferred flush would never - // fire; per-rebuild freshness is the point, and State.write's compare-and-skip keeps - // an unchanged rebuild a ~no-op; and - // - a single compiler: only one `done`, so there is no N-write amplification and no - // reason to give up the old write-by-`done` durability (the bundle is on disk by the - // time compiler.run()'s callback resolves). - // Defer ONLY the multi-compiler one-shot case. compiler.hooks.done fires once PER CHILD - // COMPILER, so a MultiCompiler with N targets sharing this one State's growing bundle - // would otherwise pay N brotli + writeFileSync passes over an ever-larger payload (the + // Write immediately for a single compiler: only one `done`, so there is no N-write + // amplification and no reason to give up the old write-by-`done` durability (the + // bundle is on disk by the time compiler.run()'s callback resolves). Defer ONLY the + // multi-compiler one-shot case. compiler.hooks.done fires once PER CHILD COMPILER, so + // a MultiCompiler with N targets sharing this one State's growing bundle would + // otherwise pay N brotli + writeFileSync passes over an ever-larger payload (the // dominant, super-linear cost). Deferral collapses those into ONE write of the final - // bundle at process exit -- mirroring how the preload writes (hooks.js). esbuild / metro + // bundle at process exit -- mirroring how the preload writes (hooks.js). Watch mode + // never reaches this hook: capture refuses it at watchRun above. esbuild / metro // are deliberately NOT changed: their write hook fires once per build (no per-child // amplification), and stasis build reads esbuild output in-process, so their synchronous // write is load-bearing in a way webpack's per-child `done` write is not. - if (watching || this.#captureApplyCount === 1) this.#state.write() + if (this.#captureApplyCount === 1) this.#state.write() else this.#deferWrite() }) } @@ -527,8 +537,9 @@ export class StasisWebpack { // a process that never exits normally -- one that stays alive after building and is then // killed by a signal (SIGINT/SIGTERM/SIGKILL), which bypass both beforeExit and exit -- would // not flush. That is the same capture-then-exit assumption the preload already relies on - // (hooks.js) and metro documents; the common build-and-exit and watch (`--watch`, dev-server) - // flows are unaffected, and a single-compiler build never reaches here (it writes on `done`). + // (hooks.js) and metro documents; the common build-and-exit flow is unaffected, a + // single-compiler build never reaches here (it writes on `done`), and watch (`--watch`, + // dev-server) capture is refused outright at watchRun (see #applyCaptureMode). #deferWrite() { this.#deferredWriteDirty = true if (this.#deferredWriteArmed) return diff --git a/tests/plugins-rebuild-refusal.test.js b/tests/plugins-rebuild-refusal.test.js new file mode 100644 index 00000000..031a79f1 --- /dev/null +++ b/tests/plugins-rebuild-refusal.test.js @@ -0,0 +1,131 @@ +// Watch/rebuild CAPTURE is explicitly unsupported across the bundler plugins: capture dedupes +// files by PATH, not content (each plugin's instance-level `#seen` Set, plus State.write's +// compare-and-skip caches), so a watch/dev-server rebuild where a file's bytes changed would +// skip re-capture -- the emitted output would use the new bytes while the stasis +// bundle/lockfile silently kept attesting the OLD ones. Rather than track content changes +// across rebuilds, each plugin refuses the rebuild loudly at its first signal: +// - webpack throws from its watchRun tap, before the FIRST watch build (covered alongside +// the write-timing cases in webpack-defer-write.test.js); +// - esbuild fails the SECOND build start from onStart -- rebuilds re-fire onStart against +// the same setup registrations, and an instance-level counter also catches one plugin +// instance reused across two esbuild.build() calls (same staleness hazard); +// - metro throws on the serializer's SECOND invocation -- a one-shot `metro build` +// serializes once, only a dev server (`metro start`) re-invokes it per rebuild. +// Load mode is deliberately untouched: it serves immutable attested bytes back into the +// build, so rebuilds have no drift hazard there. +// +// No real bundlers here (they aren't dependencies of this repo): the esbuild plugin's setup() +// is driven with stub build hooks and the metro plugin's customSerializer is fed a minimal +// Metro-shaped graph -- the same stub approach as webpack-defer-write.test.js / +// metro-fs.test.js. + +import { test } from 'node:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { State } from '@exodus/stasis-core/state' +import { StasisEsbuild } from '@exodus/stasis-plugins/esbuild' +import { StasisMetro } from '@exodus/stasis-plugins/metro' + +// Run `mk` chdir'd into a throwaway project dir (resolvePluginState anchors standalone plugin +// States at process.cwd(), like webpack-defer-write.test.js's withPlugin), restore cwd, and +// clean the dir up after the test. lock='none' + bundle='add' is the valid preload-less +// capture config the plugins are constructed with below. +const inProjectDir = (t, mk) => { + const dir = mkdtempSync(join(tmpdir(), 'stasis-rebuild-')) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fx', version: '0.0.0' })) + const cwd = process.cwd() + process.chdir(dir) + let made + try { + made = mk(dir) + } finally { + process.chdir(cwd) + } + t.after(() => rmSync(dir, { recursive: true, force: true })) + return made +} + +// Drive StasisEsbuild.setup() with stub build hooks, collecting the registered callbacks by +// kind -- what esbuild's plugin runner does once per build/context creation. A rebuild does NOT +// re-run setup(); it re-fires the collected onStart/onEnd callbacks, which the tests below do +// by hand. +const setupStub = (plugin) => { + const cbs = { onStart: [], onEnd: [], onResolve: [], onLoad: [] } + plugin.setup({ + onStart: (fn) => cbs.onStart.push(fn), + onEnd: (fn) => cbs.onEnd.push(fn), + onResolve: (_filter, fn) => cbs.onResolve.push(fn), + onLoad: (_filter, fn) => cbs.onLoad.push(fn), + resolve: async () => ({ errors: [], warnings: [] }), + }) + return cbs +} + +const fireStarts = (cbs) => cbs.onStart.map((fn) => fn()).find((r) => r !== undefined) + +test('esbuild: the second build start is refused in capture mode (watch/rebuild)', (t) => { + const plugin = inProjectDir(t, (dir) => + new StasisEsbuild({ lock: 'none', bundle: 'add', bundleFile: join(dir, 'sources.br') })) + const cbs = setupStub(plugin) + t.assert.equal(cbs.onStart.length, 1, 'capture registers the rebuild guard') + + t.assert.equal(fireStarts(cbs), undefined, 'the first build start proceeds') + // esbuild fails a build when a plugin's onStart returns errors -- the refusal fires before + // the rebuild can (not) re-capture anything, and onEnd's clean-build gate then skips write(). + const refused = fireStarts(cbs) + t.assert.equal(refused.errors.length, 1) + t.assert.match(refused.errors[0].text, + /StasisEsbuild: watch\/rebuild is not supported for capture -- run a one-shot build/) +}) + +test('esbuild: a fresh setup() on the same instance still refuses (instance reuse across builds)', (t) => { + // Two esbuild.build() calls sharing ONE plugin instance each run setup() afresh, so a + // per-setup counter would reset and miss the hazard; the counter must live on the instance. + const plugin = inProjectDir(t, (dir) => + new StasisEsbuild({ lock: 'none', bundle: 'add', bundleFile: join(dir, 'sources.br') })) + t.assert.equal(fireStarts(setupStub(plugin)), undefined, 'first build() proceeds') + const refused = fireStarts(setupStub(plugin)) + t.assert.match(refused.errors[0].text, /StasisEsbuild: watch\/rebuild is not supported for capture/) +}) + +test('esbuild: load mode registers no rebuild guard', (t) => { + // Load serves immutable attested bytes (getFile re-verifies hashes on every read), so + // rebuilds have no drift hazard and must stay permitted -- no onStart is registered at all. + const plugin = inProjectDir(t, (dir) => { + const bundleFile = join(dir, 'sources.br') + // Mint an (empty) bundle to load: a capture State's write() emits it. + new State(dir, { lock: 'none', bundle: 'add', bundleFile }).write() + // The plugin accepts a caller-owned State directly (the `stasis build` path). + return new StasisEsbuild(new State(dir, { lock: 'none', bundle: 'load', bundleFile })) + }) + const cbs = setupStub(plugin) + t.assert.equal(cbs.onStart.length, 0, 'load mode must not refuse rebuilds') + t.assert.ok(cbs.onResolve.length > 0, 'sanity: the plugin did register its load-mode hooks') +}) + +test('metro: the second serialization is refused in capture mode (dev-server rebuild)', (t) => { + let baseCalls = 0 + const serialize = inProjectDir(t, (dir) => { + // childProcess mirrors `stasis run --child-process`, which StasisMetro requires for any + // capture-that-writes (Metro transforms in worker processes; see the constructor assert). + const plugin = new StasisMetro({ + lock: 'none', bundle: 'add', bundleFile: join(dir, 'sources.br'), childProcess: true, + }) + return plugin.customSerializer(() => { baseCalls += 1; return '// bundle output' }) + }) + // The minimal Metro ReadOnlyGraph shape the plugin reads: `dependencies` (Map) and `entryPoints` (Set) -- both empty here, capture itself is not the + // point. preModules: none (like the experimental-hook path). + const graph = { dependencies: new Map(), entryPoints: new Set() } + + t.assert.equal(serialize('/app/entry.js', [], graph, {}), '// bundle output', + 'the one-shot build serializes through the base serializer') + t.assert.throws( + () => serialize('/app/entry.js', [], graph, {}), + /StasisMetro: watch\/dev-server rebuilds are not supported for capture -- use a one-shot `metro build`/, + 'a dev-server re-invocation must throw' + ) + t.assert.equal(baseCalls, 1, 'the refusal fires before the base serializer produces output') +}) diff --git a/tests/state-split-bundle.test.js b/tests/state-split-bundle.test.js index 53db32ef..48996b3e 100644 --- a/tests/state-split-bundle.test.js +++ b/tests/state-split-bundle.test.js @@ -219,7 +219,8 @@ test('bundle=replace re-writes a half on a later write() after an empty write() const codePath = join(dir, 'code.br') const resPath = join(dir, 'res.br') - // One long-lived State across two write()s (a watch-mode plugin reuses its State): the + // One long-lived State across two write()s (a plugin State writes more than once in-process, + // e.g. webpack's per-child-compiler `done`s or the deferred flush's exit backstop): the // deletion must reset the per-artifact cache so a later non-empty write() re-emits the file. const st = new State(dir, { lock: 'replace', bundle: 'replace', bundleFile: codePath, resourcesBundleFile: resPath }) st.addFile(pathToFileURL(join(dir, 'entry.js')).toString(), { format: 'module', isEntry: true }) diff --git a/tests/state-write-skip.test.js b/tests/state-write-skip.test.js index 7333b523..f7be5966 100644 --- a/tests/state-write-skip.test.js +++ b/tests/state-write-skip.test.js @@ -7,9 +7,10 @@ import { pathToFileURL } from 'node:url' import { State } from '@exodus/stasis-core/state' // write() is called once per process for `stasis run` (beforeExit/exit), but the -// webpack/esbuild plugins call it on every `compiler.hooks.done` event -- i.e. on -// every rebuild in watch mode. Without compare-and-skip, every rebuild re-brotlis -// (quality 11, multi-MB inputs, super-linear in size) and rewrites every output. +// plugins can call it more than once -- webpack's `compiler.hooks.done` fires per +// child compiler under a MultiCompiler, and the deferred flush has an exit backstop. +// Without compare-and-skip, every repeat write() re-brotlis (quality 11, multi-MB +// inputs, super-linear in size) and rewrites every output. // These tests pin that a second write() with no state change is a true no-op: // no brotli, no file touch. diff --git a/tests/webpack-defer-write.test.js b/tests/webpack-defer-write.test.js index 421b8b82..bdafabfe 100644 --- a/tests/webpack-defer-write.test.js +++ b/tests/webpack-defer-write.test.js @@ -12,14 +12,16 @@ import { State } from '@exodus/stasis-core/state' // hook once PER CHILD COMPILER, so a MultiCompiler with N targets sharing one plugin instance // would re-serialize + brotli-compress (quality 11, super-linear in size) the whole, ever-growing // bundle N times. The fix defers ONLY that multi-compiler one-shot case to a single -// beforeExit/exit flush (one write of the final bundle); a single compiler and watch mode keep the -// immediate write-on-`done`. No real webpack here -- a fake compiler exposing the hooks the plugin -// taps (normalModuleFactory, watchRun, done) drives the completion logic; afterResolve lets a test -// feed the plugin a real source file so the deferred write's CONTENT can be checked. +// beforeExit/exit flush (one write of the final bundle); a single compiler keeps the immediate +// write-on-`done`, and watch mode is refused outright at watchRun (capture's path-keyed dedupe +// would silently attest stale content on a rebuild). No real webpack here -- a fake compiler +// exposing the hooks the plugin taps (normalModuleFactory, watchRun, done) drives the completion +// logic; afterResolve lets a test feed the plugin a real source file so the deferred write's +// CONTENT can be checked. // // Test 1 (single) and Test 2 (multi) are the regression guards: revert the fix to a direct // write()-in-`done` and Test 2's "deferred until flush" assertions fail. The remaining cases pin -// the fix's other properties (exit-path durability, retry-after-throw, watch immediacy, failed +// the fix's other properties (exit-path durability, retry-after-throw, watch refusal, failed // builds, byte content). // Minimal tapable-style hook: collects tapped fns, `call` invokes them in order. @@ -168,23 +170,25 @@ test('deferred write that throws on beforeExit is retried by the exit backstop ( t.assert.ok(existsSync(bundleFile), 'bundle written on the retry') }) -test('watch mode: writes immediately on every `done`, never deferred', (t) => { - const { plugin, bundleFile, writes, flushBeforeExit } = withPlugin(t) - // Two compilers so captureApplyCount > 1 -- proving it is watch, not the single-compiler path, - // that forces the immediate write. - const compilers = [fakeCompiler(), fakeCompiler()] - for (const c of compilers) c.applyTo(plugin) - for (const c of compilers) c.hooks.watchRun.call({}) // entering watch marks each compiler - - compilers[0].hooks.done.call(CLEAN) - t.assert.equal(writes(), 1, 'watch rebuild writes right away (no beforeExit)') - t.assert.ok(existsSync(bundleFile)) +test('watch mode: capture refuses at watchRun, before anything is written', (t) => { + // Capture's dedupe is keyed by path, not content, so a watch rebuild whose bytes changed would + // silently keep attesting the OLD bytes. The plugin therefore throws from its watchRun tap -- + // which webpack fires before the FIRST watch build, failing the watcher up front. + const { plugin, bundleFile, writes, flushBeforeExit, flushExit } = withPlugin(t) + const c = fakeCompiler() + c.applyTo(plugin) - compilers[1].hooks.done.call(CLEAN) // another rebuild writes again (compare-and-skip keeps it cheap) - t.assert.equal(writes(), 2) + t.assert.throws( + () => c.hooks.watchRun.call({}), + /StasisWebpack: watch mode is not supported for capture/, + 'entering watch must fail the watcher loudly' + ) - flushBeforeExit() // watch never armed a deferred flush - t.assert.equal(writes(), 2, 'no deferred flush in watch mode') + // Nothing was captured or scheduled: no write on any path, immediate or deferred. + flushBeforeExit() + flushExit() + t.assert.equal(writes(), 0, 'refused watch never writes') + t.assert.ok(!existsSync(bundleFile), 'no bundle on disk after the refusal') }) test('failed build schedules no deferred write', (t) => { diff --git a/tests/webpack-fs.test.js b/tests/webpack-fs.test.js index b5a23823..393547bd 100644 --- a/tests/webpack-fs.test.js +++ b/tests/webpack-fs.test.js @@ -26,7 +26,7 @@ import { installFsHooks } from '../stasis-core/src/fs.js' // collected fns, exactly as webpack would when the hook runs. const mkHook = () => { const fns = []; return { tap: (_n, fn) => fns.push(fn), call: (arg) => { for (const fn of fns) fn(arg) } } } // The compiler hooks the capture-mode plugin taps: normalModuleFactory (resolve capture), -// watchRun (one-shot vs watch write timing), and done (the write itself). Real webpack exposes +// watchRun (refusing watch-mode capture), and done (the write itself). Real webpack exposes // all three on both v4 and v5; the stub must too, or apply() throws on the missing hook. const compilerHooks = () => ({ normalModuleFactory: mkHook(), watchRun: mkHook(), done: mkHook() })