|
| 1 | +import { |
| 2 | + existsSync, |
| 3 | + mkdirSync, |
| 4 | + mkdtempSync, |
| 5 | + rmSync, |
| 6 | + writeFileSync, |
| 7 | +} from 'node:fs' |
| 8 | +import { tmpdir } from 'node:os' |
| 9 | +import { join, resolve } from 'node:path' |
| 10 | + |
| 11 | +import { afterAll, expect, it } from 'bun:test' |
| 12 | + |
| 13 | +// NOTE: named `*.bun.ts`, NOT `*.test.ts`, so the root suite (root = "packages", |
| 14 | +// source preload + 100% coverage gate) does not auto-discover it. Run it via |
| 15 | +// `bun run --filter @devup-ui/bun-plugin test:regression`; it needs the BUILT |
| 16 | +// plugin (dist/index.mjs) plus the WASM artifacts. |
| 17 | +// |
| 18 | +// Regression: developing one repository in several checkouts at once (git |
| 19 | +// worktrees, a CI matrix, sibling clones) used to make every checkout but the |
| 20 | +// first fail with |
| 21 | +// |
| 22 | +// error: Cannot find module '<other checkout>/df/devup-ui/devup-ui.css' |
| 23 | +// from '<this checkout>/src/Component.tsx' |
| 24 | +// |
| 25 | +// Bun stores transpiled modules in a machine-wide on-disk cache |
| 26 | +// (`<bun cache>/@t@`) keyed by module contents, with plugin-resolved import |
| 27 | +// specifiers already baked in; the key covers neither the cwd nor the importing |
| 28 | +// file. The checkouts hold byte-identical sources, so they share one cache |
| 29 | +// entry — and the plugin used to answer `onResolve` with |
| 30 | +// `<cwd>/df/devup-ui/devup-ui.css`, an absolute path that is only correct for |
| 31 | +// whichever checkout populated the entry first. |
| 32 | +// |
| 33 | +// This test drives the real failure: two checkouts whose fixture is byte for |
| 34 | +// byte the same (and padded past the size at which Bun persists transpiled |
| 35 | +// output), loaded by two separate `bun` processes that share that cache. |
| 36 | + |
| 37 | +const pluginEntry = resolve(import.meta.dir, '..', 'dist', 'index.mjs') |
| 38 | + |
| 39 | +// Byte-identical in both checkouts: that is what collapses them onto one cache |
| 40 | +// entry. `css()` is compile-only, so the plugin erases the @devup-ui/react |
| 41 | +// import entirely and the fixture needs no node_modules of its own — while the |
| 42 | +// extractor still injects the `df/devup-ui/devup-ui.css` import under test. The |
| 43 | +// dead exports pad the module past the size at which Bun persists transpiled |
| 44 | +// output (comments are stripped before hashing, so padding must be code). |
| 45 | +const fixture = [ |
| 46 | + `import { css } from '@devup-ui/react'`, |
| 47 | + ...Array.from( |
| 48 | + { length: 4000 }, |
| 49 | + (_, i) => |
| 50 | + `export const pad${i} = 'devup-ui worktree isolation padding ${i}'`, |
| 51 | + ), |
| 52 | + `export const cls = css({ background: 'red', padding: '4px' })`, |
| 53 | + '', |
| 54 | +].join('\n') |
| 55 | + |
| 56 | +// A static import, loaded by `bun test` behind a preloaded plugin: the exact |
| 57 | +// shape in which consumers hit this — and the shape Bun caches. |
| 58 | +const fixtureTest = [ |
| 59 | + `import { expect, it } from 'bun:test'`, |
| 60 | + ``, |
| 61 | + `import { cls } from './fixture'`, |
| 62 | + ``, |
| 63 | + `it('extracted its own stylesheet', () => {`, |
| 64 | + ` console.log(JSON.stringify({ cwd: process.cwd(), cls }))`, |
| 65 | + ` expect(cls).toBeTruthy()`, |
| 66 | + `})`, |
| 67 | + '', |
| 68 | +].join('\n') |
| 69 | + |
| 70 | +const bunfig = `[test]\npreload = [${JSON.stringify(pluginEntry.replaceAll('\\', '/'))}]\n` |
| 71 | + |
| 72 | +const root = mkdtempSync(join(tmpdir(), 'devup-worktrees-')) |
| 73 | + |
| 74 | +afterAll(() => { |
| 75 | + rmSync(root, { recursive: true, force: true }) |
| 76 | +}) |
| 77 | + |
| 78 | +function makeCheckout(name: string) { |
| 79 | + const dir = join(root, name) |
| 80 | + mkdirSync(dir, { recursive: true }) |
| 81 | + writeFileSync(join(dir, 'fixture.ts'), fixture, 'utf-8') |
| 82 | + writeFileSync(join(dir, 'fixture.test.ts'), fixtureTest, 'utf-8') |
| 83 | + writeFileSync(join(dir, 'bunfig.toml'), bunfig, 'utf-8') |
| 84 | + return dir |
| 85 | +} |
| 86 | + |
| 87 | +function loadIn(dir: string) { |
| 88 | + const proc = Bun.spawnSync([process.execPath, 'test'], { |
| 89 | + cwd: dir, |
| 90 | + stdout: 'pipe', |
| 91 | + stderr: 'pipe', |
| 92 | + }) |
| 93 | + const output = proc.stdout.toString() + proc.stderr.toString() |
| 94 | + const reported = /^\{"cwd".*\}$/m.exec(output)?.[0] |
| 95 | + return { |
| 96 | + exitCode: proc.exitCode, |
| 97 | + output, |
| 98 | + reported: reported |
| 99 | + ? (JSON.parse(reported) as { cwd: string; cls: string }) |
| 100 | + : undefined, |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +it('keeps two checkouts of one repository on their own stylesheet', () => { |
| 105 | + const checkoutA = makeCheckout('checkout-a') |
| 106 | + const checkoutB = makeCheckout('checkout-b') |
| 107 | + |
| 108 | + // Sequential, sharing this machine's Bun transpiler cache: A populates the |
| 109 | + // entry, B reuses it. |
| 110 | + const first = loadIn(checkoutA) |
| 111 | + const second = loadIn(checkoutB) |
| 112 | + |
| 113 | + for (const [dir, run] of [ |
| 114 | + [checkoutA, first], |
| 115 | + [checkoutB, second], |
| 116 | + ] as const) { |
| 117 | + expect(run.exitCode, `${dir} failed to load:\n${run.output}`).toBe(0) |
| 118 | + // Extraction really happened, so the injected stylesheet import — the thing |
| 119 | + // being resolved — was actually present in the module under test. |
| 120 | + expect( |
| 121 | + run.reported?.cls, |
| 122 | + `no extraction in ${dir}:\n${run.output}`, |
| 123 | + ).toBeTruthy() |
| 124 | + expect(run.reported?.cwd).toBe(dir) |
| 125 | + // Each checkout materialised its own dist dir. |
| 126 | + expect(existsSync(join(dir, 'df', 'devup-ui'))).toBe(true) |
| 127 | + } |
| 128 | + |
| 129 | + // Neither checkout may reach into the other. Before the fix this is precisely |
| 130 | + // where checkout B reported checkout A's absolute `df/devup-ui/devup-ui.css`. |
| 131 | + expect(first.output).not.toContain(checkoutB) |
| 132 | + expect(second.output).not.toContain(checkoutA) |
| 133 | +}) |
0 commit comments