Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

133 changes: 133 additions & 0 deletions packages/bun-plugin/__regression__/worktree-isolation.bun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'

import { afterAll, expect, it } from 'bun:test'

// NOTE: named `*.bun.ts`, NOT `*.test.ts`, so the root suite (root = "packages",
// source preload + 100% coverage gate) does not auto-discover it. Run it via
// `bun run --filter @devup-ui/bun-plugin test:regression`; it needs the BUILT
// plugin (dist/index.mjs) plus the WASM artifacts.
//
// Regression: developing one repository in several checkouts at once (git
// worktrees, a CI matrix, sibling clones) used to make every checkout but the
// first fail with
//
// error: Cannot find module '<other checkout>/df/devup-ui/devup-ui.css'
// from '<this checkout>/src/Component.tsx'
//
// Bun stores transpiled modules in a machine-wide on-disk cache
// (`<bun cache>/@t@`) keyed by module contents, with plugin-resolved import
// specifiers already baked in; the key covers neither the cwd nor the importing
// file. The checkouts hold byte-identical sources, so they share one cache
// entry — and the plugin used to answer `onResolve` with
// `<cwd>/df/devup-ui/devup-ui.css`, an absolute path that is only correct for
// whichever checkout populated the entry first.
//
// This test drives the real failure: two checkouts whose fixture is byte for
// byte the same (and padded past the size at which Bun persists transpiled
// output), loaded by two separate `bun` processes that share that cache.

const pluginEntry = resolve(import.meta.dir, '..', 'dist', 'index.mjs')

// Byte-identical in both checkouts: that is what collapses them onto one cache
// entry. `css()` is compile-only, so the plugin erases the @devup-ui/react
// import entirely and the fixture needs no node_modules of its own — while the
// extractor still injects the `df/devup-ui/devup-ui.css` import under test. The
// dead exports pad the module past the size at which Bun persists transpiled
// output (comments are stripped before hashing, so padding must be code).
const fixture = [
`import { css } from '@devup-ui/react'`,
...Array.from(
{ length: 4000 },
(_, i) =>
`export const pad${i} = 'devup-ui worktree isolation padding ${i}'`,
),
`export const cls = css({ background: 'red', padding: '4px' })`,
'',
].join('\n')

// A static import, loaded by `bun test` behind a preloaded plugin: the exact
// shape in which consumers hit this — and the shape Bun caches.
const fixtureTest = [
`import { expect, it } from 'bun:test'`,
``,
`import { cls } from './fixture'`,
``,
`it('extracted its own stylesheet', () => {`,
` console.log(JSON.stringify({ cwd: process.cwd(), cls }))`,
` expect(cls).toBeTruthy()`,
`})`,
'',
].join('\n')

const bunfig = `[test]\npreload = [${JSON.stringify(pluginEntry.replaceAll('\\', '/'))}]\n`

const root = mkdtempSync(join(tmpdir(), 'devup-worktrees-'))

afterAll(() => {
rmSync(root, { recursive: true, force: true })
})

function makeCheckout(name: string) {
const dir = join(root, name)
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'fixture.ts'), fixture, 'utf-8')
writeFileSync(join(dir, 'fixture.test.ts'), fixtureTest, 'utf-8')
writeFileSync(join(dir, 'bunfig.toml'), bunfig, 'utf-8')
return dir
}

function loadIn(dir: string) {
const proc = Bun.spawnSync([process.execPath, 'test'], {
cwd: dir,
stdout: 'pipe',
stderr: 'pipe',
})
const output = proc.stdout.toString() + proc.stderr.toString()
const reported = /^\{"cwd".*\}$/m.exec(output)?.[0]
return {
exitCode: proc.exitCode,
output,
reported: reported
? (JSON.parse(reported) as { cwd: string; cls: string })
: undefined,
}
}

it('keeps two checkouts of one repository on their own stylesheet', () => {
const checkoutA = makeCheckout('checkout-a')
const checkoutB = makeCheckout('checkout-b')

// Sequential, sharing this machine's Bun transpiler cache: A populates the
// entry, B reuses it.
const first = loadIn(checkoutA)
const second = loadIn(checkoutB)

for (const [dir, run] of [
[checkoutA, first],
[checkoutB, second],
] as const) {
expect(run.exitCode, `${dir} failed to load:\n${run.output}`).toBe(0)
// Extraction really happened, so the injected stylesheet import — the thing
// being resolved — was actually present in the module under test.
expect(
run.reported?.cls,
`no extraction in ${dir}:\n${run.output}`,
).toBeTruthy()
expect(run.reported?.cwd).toBe(dir)
// Each checkout materialised its own dist dir.
expect(existsSync(join(dir, 'df', 'devup-ui'))).toBe(true)
}

// Neither checkout may reach into the other. Before the fix this is precisely
// where checkout B reported checkout A's absolute `df/devup-ui/devup-ui.css`.
expect(first.output).not.toContain(checkoutB)
expect(second.output).not.toContain(checkoutA)
})
2 changes: 1 addition & 1 deletion packages/bun-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"scripts": {
"lint": "eslint",
"build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.cjs.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external && bun build --target node src/register.ts --production --env=disable --outfile dist/register.cjs --format cjs --packages external && bun build --target node src/register.ts --production --env=disable --outfile dist/register.mjs --format esm --packages external",
"test:regression": "cd __regression__ && bun test ./preload-race.bun.ts"
"test:regression": "cd __regression__ && bun test ./preload-race.bun.ts ./worktree-isolation.bun.ts"
},
"publishConfig": {
"access": "public"
Expand Down
138 changes: 138 additions & 0 deletions packages/bun-plugin/src/__tests__/css-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { join } from 'node:path'

import { describe, expect, it } from 'bun:test'

import { cssDirName, cssNamespace, resolveCssId } from '../css-id'

const distDir = 'df'

// Two checkouts of one repository, as produced by `git worktree add`. They hold
// byte-identical sources at identical repository-relative paths and differ only
// in their root, which is exactly the situation Bun's content-keyed transpiler
// cache collapses into a single entry.
const checkoutA = join('/repos', 'app', 'worktree-a')
const checkoutB = join('/repos', 'app', 'worktree-b')
const importerIn = (checkout: string) =>
join(checkout, 'src', 'components', 'Card.tsx')

// The specifier the extractor injects: relative to the importing file.
const injectedSpecifier = '../../df/devup-ui/devup-ui.css'

describe('resolveCssId', () => {
it('resolves the injected stylesheet onto the virtual namespace', () => {
expect(
resolveCssId(injectedSpecifier, importerIn(checkoutA), distDir),
).toEqual({
path: 'devup-ui.css',
namespace: cssNamespace,
})
})

it('resolves numbered per-file stylesheets', () => {
expect(
resolveCssId(
'../../df/devup-ui/devup-ui-12.css',
importerIn(checkoutA),
distDir,
),
).toEqual({
path: 'devup-ui-12.css',
namespace: cssNamespace,
})
})

it('strips a query suffix from the stylesheet name', () => {
expect(
resolveCssId(
'../../df/devup-ui/devup-ui.css?inline',
importerIn(checkoutA),
distDir,
),
).toEqual({
path: 'devup-ui.css',
namespace: cssNamespace,
})
})

it('resolves against the cwd when there is no importer', () => {
expect(
resolveCssId(
join(distDir, cssDirName, 'devup-ui.css'),
undefined,
distDir,
),
).toEqual({
path: 'devup-ui.css',
namespace: cssNamespace,
})
})

// --- The regression this module exists for -------------------------------
//
// Bun stores transpiled modules in a machine-wide cache keyed by module
// contents, with plugin-resolved specifiers baked in and neither the cwd nor
// the importer in the key. Any id that varies per checkout therefore leaks
// into the other checkout as
// "Cannot find module '<other checkout>/df/devup-ui/devup-ui.css'".

it('yields the same, path-free id for two checkouts of one repository', () => {
const fromA = resolveCssId(
injectedSpecifier,
importerIn(checkoutA),
distDir,
)
const fromB = resolveCssId(
injectedSpecifier,
importerIn(checkoutB),
distDir,
)

expect(fromA).toEqual(fromB)
// Nothing checkout-specific may survive into the resolved id.
expect(fromA?.path).not.toContain(checkoutA)
expect(fromB?.path).not.toContain(checkoutB)
})

it('repairs a foreign absolute path baked in by an older plugin version', () => {
// What a poisoned cache entry hands back: checkout A's absolute stylesheet
// path, replayed while checkout B is the one being loaded.
const poisoned = join(checkoutA, distDir, cssDirName, 'devup-ui.css')

expect(resolveCssId(poisoned, importerIn(checkoutB), distDir)).toEqual({
path: 'devup-ui.css',
namespace: cssNamespace,
})
})

// --- Stylesheets that are not ours ---------------------------------------

it('ignores a devup-ui.css that does not live in the dist css dir', () => {
expect(
resolveCssId('../../vendor/devup-ui.css', importerIn(checkoutA), distDir),
).toBeUndefined()
})

it('ignores a css dir nested under a different dist dir', () => {
expect(
resolveCssId(
'../../other/devup-ui/devup-ui.css',
importerIn(checkoutA),
distDir,
),
).toBeUndefined()
})

it('ignores a differently named stylesheet in the dist css dir', () => {
expect(
resolveCssId(
'../../df/devup-ui/theme.css',
importerIn(checkoutA),
distDir,
),
).toBeUndefined()
})

it('ignores an empty specifier', () => {
expect(resolveCssId('', importerIn(checkoutA), distDir)).toBeUndefined()
})
})
Loading
Loading