diff --git a/packages/bun-plugin/__regression__/css-emit.bun.ts b/packages/bun-plugin/__regression__/css-emit.bun.ts new file mode 100644 index 00000000..836570bd --- /dev/null +++ b/packages/bun-plugin/__regression__/css-emit.bun.ts @@ -0,0 +1,70 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +const pluginEntry = resolve(import.meta.dir, '..', 'dist', 'index.mjs') + +// Separate processes keep the real WASM sheet and plugin hooks isolated from +// the root suite's mocks. Every process starts without a df directory. +it.each(['empty theme', 'configured theme', 'extracted styles'])( + 'emits an importable stylesheet from a clean checkout: %s', + (scenario) => { + const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-')) + try { + writeFileSync(join(cwd, 'bunfig.toml'), '') + if (scenario === 'configured theme') { + writeFileSync( + join(cwd, 'devup.json'), + JSON.stringify({ + theme: { colors: { default: { primary: '#123456' } } }, + }), + ) + } + const widths = Array.from({ length: 8 }, (_, i) => 101 + i) + for (const width of widths) { + writeFileSync( + join(cwd, `fixture-${width}.ts`), + `import { css } from '@devup-ui/react' +export const cls = css({ width: '${width}px' }) +`, + ) + } + writeFileSync( + join(cwd, 'check.ts'), + `import { existsSync, readFileSync } from 'node:fs' +import { expect } from 'bun:test' + +expect(existsSync('df')).toBe(false) +await import(${JSON.stringify(pluginEntry.replaceAll('\\', '/'))}) +const cssPath = './df/devup-ui/devup-ui.css' +${ + scenario === 'extracted styles' + ? `const widths = ${JSON.stringify(widths)} +const modules = await Promise.all(widths.map(width => import('./fixture-' + width + '.ts'))) +for (const mod of modules) expect(mod.cls).toBeTruthy() +const css = readFileSync(cssPath, 'utf-8') +for (const width of widths) expect(css).toContain('width:' + width + 'px')` + : `expect(existsSync(cssPath)).toBe(true) +${scenario === 'configured theme' ? "expect(readFileSync(cssPath, 'utf-8')).toContain('--primary:#123456')" : ''}` +} +await import(cssPath) +`, + ) + expect(existsSync(join(cwd, 'df'))).toBe(false) + const result = Bun.spawnSync([process.execPath, 'run', 'check.ts'], { + cwd, + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, + }) + expect( + result.exitCode, + result.stdout.toString() + result.stderr.toString(), + ).toBe(0) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } + }, +) diff --git a/packages/bun-plugin/__regression__/css-emit.md b/packages/bun-plugin/__regression__/css-emit.md new file mode 100644 index 00000000..18630b10 --- /dev/null +++ b/packages/bun-plugin/__regression__/css-emit.md @@ -0,0 +1,62 @@ +# Bun CSS emission regression + +The source at `6f9079ec` already resolves injected CSS imports to a path-free +virtual module (`src/css-id.ts`) and loads it as an empty JavaScript module. +That fixes Bun runtime loading and cross-worktree transpiler-cache isolation, +but `writeDataFiles()` only creates the CSS directory and `loadSourceFile()` +discards extracted CSS. No stylesheet is written. + +The other plugins have different bundler lifecycles: + +- Next initializes `devup-ui.css` with `getCss(null, false)` and refreshes CSS + through its loaders/coordinator. +- Webpack writes initial CSS in watch mode, writes extracted CSS in its source + loader, serves CSS through its CSS loader, and writes the final base sheet + after a successful build. +- Vite creates the directory before its initial base stylesheet, materializes + CSS imports during transformation, and serves/finalizes CSS from `getCss`. +- Rsbuild materializes stylesheet imports during transformation and serves + their contents through its CSS transform. + +Bun uses `singleCss = true`, so its artifact is `df/devup-ui/devup-ui.css`. +Initialize it after directory creation with actual theme CSS, then refresh it +from `getCss(null, false)` after extraction. A placeholder alone would discard +the styles, since Bun's virtual runtime module intentionally contains no CSS. +Keep virtual resolution for cache isolation. No shared utility is needed for +this small change: the other plugins' emission timing and loaders differ. + +Run `bun run build`, then +`bun run --filter @devup-ui/bun-plugin test:regression`. +The CSS regression runs isolated Bun processes in temporary directories without +`df`, using the built plugin and real WASM. It checks initial emission with and +without a theme, parallel extraction of eight distinct styles, and CSS imports. +It disables the transpiler cache for these cold-start cases; the existing +worktree-isolation regression continues to exercise the shared cache. + +## Verification (Windows, Bun 1.4.1) + +Before implementation: + +- Initial `bun test`: 0 pass, 66 fail/errors because workspace build artifacts + were missing. Initial `bun run lint`: Rust checks passed; ESLint could not + load the unbuilt local ESLint plugin. +- Initial `bun run test`: Rust tests passed, coverage 98.21% (8572/8728 lines); + the Bun phase failed because build artifacts were still missing. +- `bun run build`: passed. +- Prepared `bun test`: 5180 pass, 0 fail; 87 snapshots; 100% function/line + coverage under the existing repository configuration. +- Prepared `bun run lint`: passed (format, Clippy, ESLint). +- Regression RED, before production edits: 2 existing tests passed; all 3 new + cases failed because the stylesheet did not exist (`false` / `ENOENT`). + +After implementation: + +- `bun run --filter @devup-ui/bun-plugin build`: passed. +- `bun run --filter @devup-ui/bun-plugin test:regression`: 5 pass, 0 fail, + including the unchanged preload and worktree-cache regressions. +- `bun run test`: passed. Rust coverage remains 98.21% (8572/8728 lines, + +0.00%); Bun remains 5180 pass, 0 fail, 87 snapshots and 100% function/line + coverage. No coverage configuration or existing assertions were changed. +- Running Tarpaulin and Clippy concurrently caused transient missing `target` + fingerprint files on Windows. Validation commands were rerun sequentially. +- Sequential `bun run lint`: passed, matching the prepared baseline. diff --git a/packages/bun-plugin/package.json b/packages/bun-plugin/package.json index 0862abfc..48c14ec7 100644 --- a/packages/bun-plugin/package.json +++ b/packages/bun-plugin/package.json @@ -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 ./worktree-isolation.bun.ts" + "test:regression": "cd __regression__ && bun test ./preload-race.bun.ts ./worktree-isolation.bun.ts ./css-emit.bun.ts" }, "publishConfig": { "access": "public" diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 3738d560..9b2230fc 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { existsSync, writeFileSync } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join, relative, resolve } from 'node:path' @@ -10,6 +10,7 @@ import { } from '@devup-ui/plugin-utils' import { codeExtract, + getCss, getThemeInterface, hasDevupUI, registerShorthands, @@ -51,6 +52,7 @@ async function writeDataFiles() { if (!existsSync(cssDir)) { await mkdir(cssDir, { recursive: true }) } + await writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') } async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { @@ -89,6 +91,10 @@ async function loadSourceFile(filePath: string) { false, importAliases, ) + // singleCss stores every extracted style in the base sheet. Finish the + // write before returning the injected import; synchronous writes also keep + // concurrent source loads from overwriting a newer sheet with an older one. + writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') return { contents: code.code, loader } } return { contents, loader }