diff --git a/.changepacks/changepack_log_atom_hoist_names.json b/.changepacks/changepack_log_atom_hoist_names.json index 135520653..670ce2e9b 100644 --- a/.changepacks/changepack_log_atom_hoist_names.json +++ b/.changepacks/changepack_log_atom_hoist_names.json @@ -2,10 +2,11 @@ "changes": { "bindings/devup-ui-wasm/package.json": "Patch", "packages/next-plugin/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", "packages/rsbuild-plugin/package.json": "Patch", "packages/vite-plugin/package.json": "Patch", "packages/webpack-plugin/package.json": "Patch" }, - "note": "Enable atomHoist by default at two routes in Webpack, Rsbuild and Next Webpack, with numeric zero opting out and no effect in singleCss mode. Keep Vite and Next Turbopack in per-file mode until their shared-slot reservation lands, warning once for explicit positive atomHoist requests. Allocate shared and file-local short counter names through deterministic reserved slots instead of content names; preserve frozen route plans, dynamic !important, local keyframes, first-value responsive tokens, shared CSS updates and persisted placement. Bump the naming cache generation. No new build errors.", + "note": "Enable atomHoist by default at two boundaries in Vite, Webpack, Rsbuild and Next Webpack, with numeric zero opting out and no effect in singleCss mode. Keep Next Turbopack in per-file mode until its shared-slot reservation lands, warning once for explicit positive atomHoist requests. Allocate shared and file-local short counter names through deterministic reserved slots instead of content names; preserve frozen route plans, dynamic !important, local keyframes, first-value responsive tokens, shared CSS updates and persisted placement. Bump the naming cache generation. Stabilize Vite dev-sheet module identity with independent content-write revisions, preserving cache busting for changed CSS text and unchanged build paths. Track inherited configuration inputs, serialize refreshes and drain owned engine work before releasing shared build ownership. Reuse exact-input semantic extraction only in eligible exclusive serve sessions, registering dependencies on every request without retaining CSS snapshots or replaying writes; builds continue extracting normally. No new build errors.", "date": "2026-10-01T00:00:00.000Z" } diff --git a/.changepacks/changepack_log_bun_latest_coverage.json b/.changepacks/changepack_log_bun_latest_coverage.json new file mode 100644 index 000000000..aca3b06ca --- /dev/null +++ b/.changepacks/changepack_log_bun_latest_coverage.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "Fix sparse trailing-null autofixes to preserve authored values, complete parenthesized syntax and earlier holes using token-based edit boundaries while removing the maximal trailing run of nulls and holes only when the last element is canonical literal null; preserve regular-expression and BigInt literals without validating regex syntax, preserve existing comment and trailing-comma handling, remove an unreachable AST guard and restore complete behavior coverage on the latest Bun without exclusions or new build errors.", + "date": "2026-10-22T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_vite_shared_slots.json b/.changepacks/changepack_log_vite_shared_slots.json new file mode 100644 index 000000000..a01128e3e --- /dev/null +++ b/.changepacks/changepack_log_vite_shared_slots.json @@ -0,0 +1,8 @@ +{ + "changes": { + "packages/plugin-utils/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch" + }, + "note": "Reserve short shared class and CSS-variable slots before Vite extraction across build, serve, and shared environments. Enable atom hoisting by default at two distinct physical module boundaries plus reachable dynamic targets, with zero opting out and singleCss unchanged. Install the closed-inventory canonical and reach plan before scratch and freeze it for the build lifetime, including watch updates. Discover static physical HTML inputs with a Vite-only build-time parse5 parser, keeping it outside non-Vite plugin and loader graphs, and refresh only dirty revisions without styling runtime or new intended source build-error categories.", + "date": "2026-10-18T00:00:00.000Z" +} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5a478fa08..ee6780bfc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -723,7 +723,7 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true - files: ./coverage/lcov.info + files: ./coverage/root/lcov.info,./coverage/vite/lcov.info # Pages deployment: the only job with `pages` and `id-token` write, and it # only runs for a push to main whose validation passed. diff --git a/bun.lock b/bun.lock index 6f29191a0..c41b1973a 100644 --- a/bun.lock +++ b/bun.lock @@ -652,9 +652,11 @@ "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", + "parse5": "^8.0.1", }, "devDependencies": { "typescript": "^7.0.2", + "vite": "^8.3.4", }, "peerDependencies": { "@devup-ui/wasm": "*", @@ -2188,7 +2190,7 @@ "enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="], - "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], @@ -2742,6 +2744,8 @@ "parse-json": ["parse-json@7.1.1", "", { "dependencies": { "@babel/code-frame": "^7.21.4", "error-ex": "^1.3.2", "json-parse-even-better-errors": "^3.0.0", "lines-and-columns": "^2.0.3", "type-fest": "^3.8.0" } }, "sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -3702,6 +3706,8 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "happy-dom/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], diff --git a/bun.matrix.ts b/bun.matrix.ts new file mode 100644 index 000000000..93420a305 --- /dev/null +++ b/bun.matrix.ts @@ -0,0 +1,36 @@ +export {} + +function cartesianProduct(arrays: T) { + return arrays.reduce( + (acc, curr) => acc.flatMap((x) => curr.map((y) => [...x, y])), + [[]], + ) +} + +function createTestMatrix>( + optionsMap: T, +): { + [K in keyof T]: T[K] extends (infer U)[] ? U : never +}[] { + const keys = Object.keys(optionsMap) + const values = Object.values(optionsMap) + + return cartesianProduct(values).map<{ + [K in keyof T]: T[K] extends (infer U)[] ? U : never + }>( + (combination) => + Object.fromEntries(keys.map((key, i) => [key, combination[i]])) as { + [K in keyof T]: T[K] extends (infer U)[] ? U : never + }, + ) +} + +globalThis.createTestMatrix = createTestMatrix + +declare global { + function createTestMatrix>( + optionsMap: T, + ): { + [K in keyof T]: T[K] extends (infer U)[] ? U : never + }[] +} diff --git a/bun.setup.ts b/bun.setup.ts index 4399dfa38..f0939bb02 100644 --- a/bun.setup.ts +++ b/bun.setup.ts @@ -1,3 +1,5 @@ +import './bun.matrix' + import { afterEach, beforeEach, spyOn } from 'bun:test' import { resetWasmForTesting } from './packages/next-plugin/src/wasm' @@ -27,38 +29,3 @@ function resetTestState() { beforeEach(resetTestState) afterEach(resetTestState) - -function cartesianProduct(arrays: T) { - return arrays.reduce( - (acc, curr) => acc.flatMap((x) => curr.map((y) => [...x, y])), - [[]], - ) -} - -function createTestMatrix>( - optionsMap: T, -): { - [K in keyof T]: T[K] extends (infer U)[] ? U : never -}[] { - const keys = Object.keys(optionsMap) - const values = Object.values(optionsMap) - - return cartesianProduct(values).map<{ - [K in keyof T]: T[K] extends (infer U)[] ? U : never - }>( - (combination) => - Object.fromEntries(keys.map((key, i) => [key, combination[i]])) as { - [K in keyof T]: T[K] extends (infer U)[] ? U : never - }, - ) -} - -globalThis.createTestMatrix = createTestMatrix - -declare global { - function createTestMatrix>( - optionsMap: T, - ): { - [K in keyof T]: T[K] extends (infer U)[] ? U : never - }[] -} diff --git a/bunfig.toml b/bunfig.toml index cdc48a859..b86ffe143 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,10 +1,12 @@ [test] root = "packages" +pathIgnorePatterns = ["**/vite-plugin/**"] preload = ["./bun.setup.ts", "./packages/bun-plugin/src/index.ts", "bun-test-env-dom"] # `bun run test` passes `--coverage`; enabling it here would hold focused `bun test ` runs to the full-suite threshold. coverageReporter = ["text", "lcov"] # Bun ignores unknown keys without a warning; packages/__tests__/coverage-threshold.test.ts checks this gate. coverageThreshold = { lines = 1.0, functions = 1.0, statements = 1.0 } +coverageDir = "coverage/root" coverageSkipTestFiles = true # Generated artifacts are tested separately by the packed-consumer release gate. coveragePathIgnorePatterns = ["**/pkg/**", "**/dist/**"] diff --git a/bunfig.vite.toml b/bunfig.vite.toml new file mode 100644 index 000000000..d1c3d8c70 --- /dev/null +++ b/bunfig.vite.toml @@ -0,0 +1,8 @@ +[test] +root = "packages/vite-plugin" +preload = ["./bun.matrix.ts"] +coverageReporter = ["text", "lcov"] +coverageThreshold = { lines = 1.0, functions = 1.0, statements = 1.0 } +coverageSkipTestFiles = true +coveragePathIgnorePatterns = ["**/pkg/**", "**/dist/**"] +coverageDir = "coverage/vite" diff --git a/e2e/landing-zero-runtime.spec.ts b/e2e/landing-zero-runtime.spec.ts index e0a69157b..657e0a1e5 100644 --- a/e2e/landing-zero-runtime.spec.ts +++ b/e2e/landing-zero-runtime.spec.ts @@ -38,7 +38,7 @@ test.describe('Landing Page - Zero Runtime Validation', () => { test('no runtime CSS-in-JS globals are present', async ({ page }) => { const runtimeGlobals = await page.evaluate(() => { - const win = window as Record + const win = window return { // Common CSS-in-JS runtime indicators __devup__: '__devup__' in win, @@ -147,15 +147,47 @@ test.describe('Landing Page - Zero Runtime Validation', () => { .evaluate((element) => Array.from(element.classList)) expect(headingClasses.length).toBeGreaterThan(1) - const atomicClasses = headingClasses.filter((className) => - /^(?:[a-z]-)?[a-z0-9_]{1,3}$/i.test(className), - ) - expect(atomicClasses.length).toBeGreaterThan(1) + const isCounter = (name: string): boolean => { + // num_to_nm_base: 27 initial symbols, 37 continuation symbols, ad escape. + const raw = name.replace(/a-d$/, 'ad') + if (!/^[a-z_]/.test(raw) || /[^a-z0-9_]/.test(raw)) return false + if (raw.replace(/ad$/, 'a-d') !== name) return false + let value = 'abcdefghijklmnopqrstuvwxyz_'.indexOf(raw.charAt(0)) + for (const digit of raw.slice(1)) { + value = + 37 * value + + 27 + + 'abcdefghijklmnopqrstuvwxyz0123456789_'.indexOf(digit) + // The compiler's counter is a wasm32 usize, not a fixed string length. + if (value > 0xffffffff) return false + } + return true + } + const isAtomicClass = (className: string): boolean => { + if ( + /^typo-[a-z0-9]+$/i.test(className) || + /^(?:css|emotion|sc|styled|stitches)-[a-z0-9]+/i.test(className) + ) + return false + return ( + isCounter(className) || + Array.from(className).some( + (character, index) => + character === '-' && + isCounter(className.slice(0, index)) && + isCounter(className.slice(index + 1)), + ) + ) + } + const atomicClasses = headingClasses.filter(isAtomicClass) + expect( + atomicClasses.length, + `Expected multiple compiled atoms, received: ${headingClasses.join(' ')}`, + ).toBeGreaterThan(1) expect( headingClasses.every( (className) => - /^(?:[a-z]-)?[a-z0-9_]{1,3}$/i.test(className) || - /^typo-[a-z0-9]+$/i.test(className), + isAtomicClass(className) || /^typo-[a-z0-9]+$/i.test(className), ), `Expected compact base-37 classes, received: ${headingClasses.join(' ')}`, ).toBe(true) diff --git a/package.json b/package.json index a757be641..0dbdea6da 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "scripts": { "lint": "cargo fmt --all -- --check && cargo clippy --all-targets --all-features -- -D warnings && node --require ./eslint-typescript6.cjs ./node_modules/eslint/bin/eslint.js", "lint:fix": "node --require ./eslint-typescript6.cjs ./node_modules/eslint/bin/eslint.js --fix && cargo fmt", - "test": "cargo tarpaulin --out xml --out stdout --all-targets --engine llvm $(node -p \"process.env.CI ? '--fail-under 100' : ''\") && bun test --coverage", + "test": "cargo tarpaulin --out xml --out stdout --all-targets --engine llvm $(node -p \"process.env.CI ? '--fail-under 100' : ''\") && bun run test:bun", + "test:bun": "node -e \"require('node:fs').rmSync('coverage/root', { recursive: true, force: true })\" && bun test --coverage && bun run test:vite", + "test:vite": "node -e \"require('node:fs').rmSync('coverage/vite', { recursive: true, force: true })\" && bun --config=./bunfig.vite.toml test --coverage", "test:e2e": "bunx playwright test", "test:e2e:next": "LANDING_BUILD_MODE=next bunx playwright test", "test:e2e:rsc-css": "bunx playwright test -c playwright.rsc-css.config.ts", diff --git a/packages/__tests__/coverage-threshold.test.ts b/packages/__tests__/coverage-threshold.test.ts index 842d314bd..0317585b0 100644 --- a/packages/__tests__/coverage-threshold.test.ts +++ b/packages/__tests__/coverage-threshold.test.ts @@ -11,8 +11,9 @@ const manifest: unknown = await Bun.file(resolve(root, 'package.json')).json() assert( typeof manifest === 'object' && manifest !== null && 'scripts' in manifest, ) -const { scripts } = manifest -assert(typeof scripts === 'object' && scripts !== null) +const { scripts: scriptValues } = manifest +assert(typeof scriptValues === 'object' && scriptValues !== null) +const scripts = scriptValues function script(name: string): string { const command: unknown = Reflect.get(scripts, name) @@ -36,6 +37,17 @@ function bunTestCommands( }) } +function configPath(words: readonly string[]): string | undefined { + for (const [index, word] of words.entries()) { + if (word.startsWith('--config=')) return word.slice('--config='.length) + if (word === '--config') { + const path = words[index + 1] + if (typeof path === 'string') return path + } + } + return undefined +} + // `root` and `preload` name repository files; every other option applies unchanged. const fixtureConfig = bunfig.replace(/^(?:root|preload) = .*$/gm, '') const fixtureTest = `import { expect, test } from 'bun:test' @@ -78,6 +90,27 @@ it('collects coverage in every Bun test run of the test script', () => { expect(commands.filter((words) => !words.includes('--coverage'))).toEqual([]) }) +it.each([ + ['--config=PATH', ['bun', '--config=./bunfig.vite.toml', 'test']], + ['--config PATH', ['bun', '--config', './bunfig.vite.toml', 'test']], +])('finds explicit config paths written as %s', (_, words) => { + expect(configPath(words)).toBe('./bunfig.vite.toml') +}) + +it('requires every explicit Bun test config to be fully covered', async () => { + const paths = bunTestCommands('test') + .map(configPath) + .filter((path): path is string => path !== undefined) + expect(paths).not.toEqual([]) + + for (const path of paths) { + const config = Bun.TOML.parse(await Bun.file(resolve(root, path)).text()) + expect(config).toMatchObject({ + test: { coverageThreshold: { lines: 1, functions: 1, statements: 1 } }, + }) + } +}) + it.each([ [ 'an uncovered line', diff --git a/packages/eslint-plugin/src/rules/css-utils-literal-only/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/css-utils-literal-only/__tests__/index.test.ts index fc7fe8169..d4686b857 100644 --- a/packages/eslint-plugin/src/rules/css-utils-literal-only/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/css-utils-literal-only/__tests__/index.test.ts @@ -18,6 +18,25 @@ describe.each(['css' /* 'globalCss', 'keyframes'*/])( }) ruleTester.run('css-utils-literal-only rule', cssUtilsLiteralOnly, { valid: [ + ...[ + "const f = ({'p': n}) => n; css({w: f({p: 1})});", + "const k = 'p'; const f = ({[k]: n}) => n; css({w: f({p: 1})});", + 'const L = [1]; unknown(L[0]); css({w: L[0]});', + 'const L = [1]; unknown(L[9]); css({w: L[0]});', + 'const L = {p: 1}; export const holder = [L]; css({w: L.p});', + 'const L = {p: 1}; module.exports.token = L; css({w: L.p});', + "const L = {p: 1}; module['exports'].token = L; css({w: L.p});", + 'const N = 4; function f() { return N } css({w: f()});', + 'const helper = () => 4; function f() { return helper() } css({w: f()});', + 'const helper = function() { return 4 }; function f() { return helper() } css({w: f()});', + ].map((use) => ({ + code: `import { css } from "@devup-ui/react";\n${use}`, + filename: 'src/app/page.tsx', + })), + { + code: `import { 'create' as create } from '@stylexjs/stylex'; create({a: {width: 1}});`, + filename: 'src/app/page.tsx', + }, { code: `import { ${code} } from "@devup-ui/react";\n${code}({w: 1})`, filename: 'src/app/page.tsx', @@ -145,6 +164,26 @@ describe.each(['css' /* 'globalCss', 'keyframes'*/])( }, ], invalid: [ + ...[ + "const f = ({'normalize': n}) => n; css({w: f({normalize: 1})});", + 'const L = [{p: 1}]; unknown(L[0]); css({w: L[0].p});', + 'const A = [{p: 1}]; const L = [1, ...A]; unknown(L[0]); css({w: A[0].p});', + 'const L = {p: 1}; unknown([L]); css({w: L.p});', + 'const L = {p: 1}; other.exports.token = L; css({w: L.p});', + 'const helper = () => Date.now(); function f() { return helper() } css({w: f()});', + 'class Token {} function f() { return Token } css({w: f()});', + ].map((use) => ({ + code: `import { css } from "@devup-ui/react";\n${use}`, + filename: 'src/app/page.tsx', + output: null, + errors: [{ messageId: 'cssUtilsLiteralOnly' as const }], + })), + { + code: `import { 'create' as create } from '@stylexjs/stylex'; let v = 1; create({a: {width: v}});`, + filename: 'src/app/page.tsx', + output: null, + errors: [{ messageId: 'cssUtilsLiteralOnly' }], + }, ...[ [ `export function C() { let fade = keyframes({ from: { opacity: 0 } }); return css({ animationName: fade }); }`, diff --git a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts index 8af51b383..cf90a4853 100644 --- a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts @@ -1,9 +1,110 @@ import { RuleTester } from '@typescript-eslint/rule-tester' -import { describe } from 'bun:test' +import { TSESLint } from '@typescript-eslint/utils' +import { describe, expect, it } from 'bun:test' +import { parser } from 'typescript-eslint' import { noUselessTailingNulls } from '../index' describe('no-useless-tailing-nulls rule', () => { + const boundaryCases = [ + ['[0,(1),,null]', '[0,(1)]'], + ['[0,((1)),,null]', '[0,((1))]'], + ['[0,(1),null]', '[0,(1)]'], + ['[0,(1),,(null)]', '[0,(1)]'], + ['[(null)]', '[]'], + ['[1,null]', '[1]'], + ['[0,((1)),,((null))]', '[0,((1))]'], + ['[((null)),(null)]', '[]'], + [ + '[0,( /* value */ 1 /* inner */ ), /* remove */ , (null)]', + '[0,( /* value */ 1 /* inner */ )]', + ], + ['[1 /* remove */, /* remove */ null /* keep */]', '[1 /* keep */]'], + ['[/* keep */ (null),null]', '[/* keep */ ]'], + ['[, /* remove */ (null)]', '[]'], + ['[0,(1),null,]', '[0,(1),]'], + ['[0,((1)),,((null)),]', '[0,((1)),]'], + ['[(null),]', '[,]'], + ['[null,]', '[,]'], + ['[1,null, /* keep */]', '[1, /* keep */]'], + ['[1,null /* keep */,]', '[1 /* keep */,]'], + [ + '[0,(1), /* remove */ null /* keep */, /* tail */]', + '[0,(1) /* keep */, /* tail */]', + ], + ['[/* keep */ ((null)) /* tail */]', '[/* keep */ /* tail */]'], + ] as const + const literalKindCases = [ + ['[/(/,null]', '[/(/]'], + ['[/a/gg,null]', '[/a/gg]'], + ['[/a/z,null]', '[/a/z]'], + ['[/a/gg,,null]', '[/a/gg]'], + ['[/a/g,null]', '[/a/g]'], + ['[/a/g,,null]', '[/a/g]'], + ['[(/(/),,(null)]', '[(/(/)]'], + ['[1n,null]', '[1n]'], + ['[0xFFn,,null]', '[0xFFn]'], + ['[1n,null,,(null),]', '[1n,]'], + ] as const + const literalKindControls = [ + '[/(/]', + '[/a/gg]', + '[/a/z]', + '[1,null,/(/]', + '[/a/g]', + '[1n]', + '[0xFFn]', + '[null,1n]', + ] as const + const literalKindValidTests = literalKindControls.flatMap((value) => + [false, true].map((jsx) => ({ + code: `import {css,Box} from '@devup-ui/react';\n${jsx ? `` : `css({w:${value}})`}`, + filename: 'src/app/page.tsx', + })), + ) + const boundaryTests = [...boundaryCases, ...literalKindCases].flatMap( + ([value, fixed]) => + [false, true].map((jsx) => ({ + code: `import {css,Box} from '@devup-ui/react';\n${jsx ? `` : `css({w:${value}})`}`, + output: `import {css,Box} from '@devup-ui/react';\n${jsx ? `` : `css({w:${fixed}})`}`, + filename: 'src/app/page.tsx', + errors: [{ messageId: 'uselessTailingNulls' as const }], + })), + ) + it.each([ + ...boundaryTests, + ...literalKindValidTests.map((test) => ({ ...test, output: test.code })), + ])( + 'preserves authored syntax and parses when fixing $code', + ({ code, output }) => { + const linter = new TSESLint.Linter() + const config = { + files: ['**/*.tsx'], + languageOptions: { + parser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + plugins: { devup: { rules: { trailing: noUselessTailingNulls } } }, + rules: { 'devup/trailing': 'error' }, + } satisfies TSESLint.FlatConfig.Config + + const result = linter.verifyAndFix(code, config, { + filename: 'src/app/page.tsx', + }) + + expect(result.output).toBe(output) + expect( + linter.verify( + result.output, + { ...config, rules: {} }, + { + filename: 'src/app/page.tsx', + }, + ), + ).toEqual([]) + expect(result.messages).toEqual([]) + }, + ) const ruleTester = new RuleTester({ languageOptions: { ecmaVersion: 'latest', @@ -16,6 +117,21 @@ describe('no-useless-tailing-nulls rule', () => { }) ruleTester.run('no-useless-tailing-nulls rule', noUselessTailingNulls, { valid: [ + ...literalKindValidTests, + // Real parser holes are absent elements, not Literal null nodes. + ...[ + '[]', + '[,]', + '[1,null,,]', + '[null,,]', + '[,,]', + '[1,,]', + '[1,,2]', + '[1, null, 2]', + ].map((value) => ({ + code: `import { css } from "@devup-ui/react";\ncss({w: ${value}})`, + filename: 'src/app/page.tsx', + })), { code: 'import { Box } from "@devup-ui/react";\n', filename: 'src/app/page.tsx', @@ -44,6 +160,28 @@ describe('no-useless-tailing-nulls rule', () => { })), ], invalid: [ + ...boundaryTests, + ...[ + ['[null]', '[]'], + ['[null,null]', '[]'], + ['[1,null]', '[1]'], + ['[1,,null]', '[1]'], + ['[,1,null]', '[,1]'], + ['[1,,2,null]', '[1,,2]'], + ['[1,,null,null]', '[1]'], + ['[,null]', '[]'], + ['[,,null,null]', '[]'], + ['[/* keep */ null,null]', '[/* keep */ ]'], + ['[1,null,,null]', '[1]'], + ['[null,1,,null]', '[null,1]'], + ['[value,,null]', '[value]'], + ['[...values,,null]', '[...values]'], + ].map(([value, fixed]) => ({ + code: `import { css } from "@devup-ui/react";\ncss({w: ${value}})`, + output: `import { css } from "@devup-ui/react";\ncss({w: ${fixed}})`, + filename: 'src/app/page.tsx', + errors: [{ messageId: 'uselessTailingNulls' as const }], + })), { code: 'import { Box } from "@devup-ui/react";\n} m={[2, null]} />', output: diff --git a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts index 94ecb4584..d16f185fb 100644 --- a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts +++ b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts @@ -1,5 +1,6 @@ import { AST_NODE_TYPES, + ASTUtils, ESLintUtils, type TSESTree, } from '@typescript-eslint/utils' @@ -13,36 +14,52 @@ const createRule = ESLintUtils.RuleCreator( `https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/${name}`, ) +function isNullLiteral( + node: TSESTree.Node | null | undefined, +): node is TSESTree.NullLiteral { + return node?.type === AST_NODE_TYPES.Literal && node.raw === 'null' +} + function checkUselessTailingNulls>( node: TSESTree.ArrayExpression, context: T, ) { - let nullCount = 0 - for (let i = node.elements.length - 1; i >= 0; i--) { + const lastElement = node.elements[node.elements.length - 1] + if (!isNullLiteral(lastElement)) return + let trailingCount = 1 + for (let i = node.elements.length - 2; i >= 0; i--) { const element = node.elements[i] - if (element?.type === AST_NODE_TYPES.Literal && element.value === null) { - nullCount++ + if (!element || isNullLiteral(element)) { + trailingCount++ } else { break } } - if (nullCount === 0) return - const previousElement = node.elements[node.elements.length - nullCount - 1] - const firstElement = node.elements[0] - const lastElement = node.elements[node.elements.length - 1] - if (!lastElement || (node.elements.length === nullCount && !firstElement)) - return - const removeStart = - node.elements.length > nullCount && previousElement - ? previousElement.range[1] - : firstElement?.range[0] - if (removeStart === undefined) return - + const previousElement = + node.elements[node.elements.length - trailingCount - 1] context.report({ node, messageId: 'uselessTailingNulls', fix(fixer) { - return fixer.removeRange([removeStart, lastElement.range[1]]) + const sourceCode = context.sourceCode + let removeStart = node.range[0] + 1 + if (previousElement) { + const [separator] = sourceCode.getFirstTokensBetween( + previousElement, + lastElement, + { count: 1, filter: ASTUtils.isCommaToken }, + ) + const [previousToken] = sourceCode.getTokensBefore(separator, 1) + removeStart = previousToken.range[1] + } else if (node.elements[0]) { + const [, firstToken] = sourceCode.getFirstTokens(node, 2) + removeStart = firstToken.range[0] + } + const [beforeLastToken, lastToken] = sourceCode.getLastTokens(node, 3) + const removeEnd = ASTUtils.isCommaToken(lastToken) + ? beforeLastToken.range[1] + : lastToken.range[1] + return fixer.removeRange([removeStart, removeEnd]) }, }) } diff --git a/packages/eslint-plugin/src/utils/__tests__/import-sources.test.ts b/packages/eslint-plugin/src/utils/__tests__/import-sources.test.ts index ad5371e35..c39c935ac 100644 --- a/packages/eslint-plugin/src/utils/__tests__/import-sources.test.ts +++ b/packages/eslint-plugin/src/utils/__tests__/import-sources.test.ts @@ -41,6 +41,30 @@ function inspect( } describe('import sources', () => { + for (const use of ['css({w: 1})', 'styled(Base, {w: 1})']) + inspect( + `import { css, styled } from "@devup-ui/react";\n${use}`, + 'page.tsx', + (storage, context) => { + const statement = context.sourceCode.ast.body[1] + if ( + statement.type !== 'ExpressionStatement' || + statement.expression.type !== 'CallExpression' + ) + throw new Error('Expected a parsed style call') + const call = statement.expression + expect(styleValueSite(call.callee, storage)).toBeNull() + const rules = call.arguments.at(-1) + if (!rules) throw new Error('Expected a rules argument') + expect(styleValueSite(rules, storage)).toEqual({ + root: call, + start: call, + }) + if (call.arguments.length === 2) + expect(styleValueSite(call.arguments[0], storage)).toBeNull() + }, + ) + inspect( [ 'import { css as a, Box as B, unknown as u } from "@devup-ui/react";', diff --git a/packages/next-plugin/src/__tests__/availability-child.mjs b/packages/next-plugin/src/__tests__/availability-child.mjs index a4131dde0..1ef3040e7 100644 --- a/packages/next-plugin/src/__tests__/availability-child.mjs +++ b/packages/next-plugin/src/__tests__/availability-child.mjs @@ -94,17 +94,31 @@ try { match[1], match[2].replace(/;$/, ''), ]) - assert.deepEqual( - rules(artifacts.css['devup-ui.css']), - [], - 'fallback base must have no atoms', - ) const expected = [ 'color:red', 'background:blue', 'opacity:.25', 'opacity:.75', ] + // Vite hoists by default (atomHoist 2): both suppliers are reached by two + // pages, so their atoms live in the shared base under global short counters + // while page-only atoms stay private. Next Turbopack suppresses hoisting. + const hoisted = adapter === 'vite' + const baseRules = rules(artifacts.css['devup-ui.css']) + if (hoisted) { + assert.deepEqual( + baseRules.map(([, declaration]) => declaration).sort(), + expected.slice(0, 2).sort(), + 'base must hold exactly the hoisted supplier atoms', + ) + assert.equal( + new Set(baseRules.map(([key]) => key)).size, + baseRules.length, + 'hoisted supplier atoms must keep distinct global counters', + ) + } else { + assert.deepEqual(baseRules, [], 'fallback base must have no atoms') + } for (const [index, file] of files.entries()) { const generated = [ ...artifacts.js[file].code.matchAll( @@ -113,11 +127,21 @@ try { ].flatMap((match) => match[1].trim().split(/\s+/)) assert.equal(generated.length, 1, 'real JSX extraction required') const [name] = generated + const global = hoisted && index < 2 assert.match( name, - /^[a-z0-9]+-[a-z0-9]+$/, - 'short per-file counters required', + global ? /^[a-z0-9]+$/ : /^[a-z0-9]+-[a-z0-9]+$/, + global + ? 'short global counters required for hoisted atoms' + : 'short per-file counters required', ) + if (global) { + assert.deepEqual( + baseRules.filter(([key]) => key === name), + [[name, expected[index]]], + 'hoisted atom must live in the shared base', + ) + } assert.ok( imports[file].some((sheet) => sheet !== 'devup-ui.css'), 'own local CSS import required', diff --git a/packages/next-plugin/src/__tests__/coordinator-port.test.ts b/packages/next-plugin/src/__tests__/coordinator-port.test.ts index 421ba1d17..08f51c22a 100644 --- a/packages/next-plugin/src/__tests__/coordinator-port.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator-port.test.ts @@ -71,6 +71,12 @@ describe('coordinator port file', () => { expect(Bun.file(file).size).toBe(0) }) + it('allows first-run cleanup when no coordinator port file exists', () => { + const file = join(tmp(), 'coordinator.port') + + expect(() => removeStalePortFile(file)).not.toThrow() + }) + it('names port, owner pid, state and recovery when the coordinator is unreachable', () => { const dir = tmp() const file = join(dir, 'coordinator.port') diff --git a/packages/next-plugin/src/__tests__/wasm.test.ts b/packages/next-plugin/src/__tests__/wasm.test.ts index d3cc7068c..1b0e3bae5 100644 --- a/packages/next-plugin/src/__tests__/wasm.test.ts +++ b/packages/next-plugin/src/__tests__/wasm.test.ts @@ -132,6 +132,20 @@ describe('WASM loading', () => { ).toEqual({ isolated: true }) }) + it('resolves from the project manifest without an installed or workspace plugin', () => { + const root = mkdtempSync(join(tmpdir(), 'devup-ui-next-root-')) + tempRoots.push(root) + writeFileSync(join(root, 'package.json'), '{"name":"root-fallback"}') + writeFileSync( + join(root, 'dependency.cjs'), + 'module.exports = { root: true }', + ) + + expect( + requireFromPlugin<{ root: boolean }>('./dependency.cjs', root), + ).toEqual({ root: true }) + }) + it('loads or injects the Webpack plugin without a static dependency', () => { expect(typeof loadWebpackPlugin().DevupUIWebpackPlugin).toBe('function') setWebpackPluginForTesting(webpackPlugin) diff --git a/packages/plugin-utils/src/__tests__/build-session-ownership.cjs b/packages/plugin-utils/src/__tests__/build-session-ownership.cjs new file mode 100644 index 000000000..3892d64d5 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/build-session-ownership.cjs @@ -0,0 +1,32 @@ +const assert = require('node:assert/strict') + +async function main() { + // Given + const { beginBuild, getBuildOwnership } = await import('../build-session.ts') + assert.equal(getBuildOwnership().running, 0) + let resets = 0 + const engine = { + resetBuildState() { + resets++ + }, + } + // When + const first = beginBuild(engine) + // Then + assert.equal(resets, 1) + const before = resets + const second = beginBuild(engine) + assert.equal(resets, before) + second() + second() + first() + beginBuild({})() + const third = beginBuild(engine) + assert.equal(resets, before + 1) + third() + assert.equal(getBuildOwnership().running, 0) +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/plugin-utils/src/__tests__/build-session-ownership.test.ts b/packages/plugin-utils/src/__tests__/build-session-ownership.test.ts new file mode 100644 index 000000000..5c8782f35 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/build-session-ownership.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'bun:test' + +import { beginBuild, getBuildOwnership } from '../build-session' + +describe('shared build ownership', () => { + it('shares cold ownership across independently loaded module copies', async () => { + // Given: shadow only the globalThis alias, never the live ownership slot. + const actualGlobal = globalThis + const descriptor = Object.getOwnPropertyDescriptor( + actualGlobal, + 'globalThis', + ) + if (!descriptor) throw new TypeError('globalThis descriptor is required') + const liveOwnership = actualGlobal.__devupBuildOwnership + const liveSnapshot = getBuildOwnership() + const shadow: Pick = { + __devupBuildOwnership: undefined, + } + Object.setPrototypeOf(shadow, actualGlobal) + Object.defineProperty(shadow, 'globalThis', { value: shadow }) + let sheet = 'stale' + const engine = { + resetBuildState() { + sheet = '' + }, + } + let endA: (() => void) | undefined + let endB: (() => void) | undefined + try { + let a: typeof import('../build-session') + let b: typeof import('../build-session') + Object.defineProperty(actualGlobal, 'globalThis', { + ...descriptor, + value: shadow, + }) + try { + a = await import(`${import.meta.dir}/../build-session.ts?cold-owner-a`) + expect(a.getBuildOwnership()).toEqual({ running: 0, generation: 0 }) + endA = a.beginBuild(engine) + expect(sheet).toBe('') + sheet = 'active' + const ownership = shadow.__devupBuildOwnership + b = await import(`${import.meta.dir}/../build-session.ts?cold-owner-b`) + expect(b).not.toBe(a) + expect(shadow.__devupBuildOwnership).toBe(ownership) + } finally { + Object.defineProperty(actualGlobal, 'globalThis', descriptor) + } + // When: a second evaluated copy joins the first copy's active build. + endB = b.beginBuild(engine) + // Then: overlapping owners share state without wiping the active engine. + expect(sheet).toBe('active') + expect(a.getBuildOwnership()).toEqual({ running: 2, generation: 2 }) + expect(b.getBuildOwnership()).toEqual({ running: 2, generation: 2 }) + endA() + endA() + expect(b.getBuildOwnership()).toEqual({ running: 1, generation: 3 }) + endB() + endB() + expect(a.getBuildOwnership()).toEqual({ running: 0, generation: 4 }) + } finally { + Object.defineProperty(actualGlobal, 'globalThis', descriptor) + endB?.() + endA?.() + expect(actualGlobal.__devupBuildOwnership).toBe(liveOwnership) + expect(getBuildOwnership()).toEqual(liveSnapshot) + expect( + Object.getOwnPropertyDescriptor(actualGlobal, 'globalThis'), + ).toEqual(descriptor) + } + }) + + it('preserves active state when another owner starts and advances transitions', () => { + // Given + let sheet = 'stale' + const engine = { + resetBuildState() { + sheet = '' + }, + } + const initial = getBuildOwnership() + const a = beginBuild(engine) + expect(sheet).toBe(initial.running === 0 ? '' : 'stale') + sheet = 'active' + const before = getBuildOwnership() + // When + const b = beginBuild(engine) + // Then + try { + expect(sheet).toBe('active') + expect(getBuildOwnership()).toEqual({ + running: before.running + 1, + generation: before.generation + 1, + }) + } finally { + b() + a() + } + expect(getBuildOwnership().running).toBe(initial.running) + expect(getBuildOwnership().generation).toBe(initial.generation + 4) + a() + expect(getBuildOwnership().generation).toBe(initial.generation + 4) + }) + + it('remains compatible when an older facade has no reset export', () => { + // Given + const initial = getBuildOwnership() + // When + const end = beginBuild({}) + end() + // Then + expect(getBuildOwnership()).toEqual({ + running: initial.running, + generation: initial.generation + 2, + }) + }) +}) diff --git a/packages/plugin-utils/src/__tests__/config-input-provenance.test.ts b/packages/plugin-utils/src/__tests__/config-input-provenance.test.ts new file mode 100644 index 000000000..3f42ee659 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/config-input-provenance.test.ts @@ -0,0 +1,41 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { loadDevupConfig, loadDevupConfigSync } from '../load-config' + +for (const load of [loadDevupConfigSync, loadDevupConfig]) { + it(`reports actual nested and missing inputs when using ${load.name}`, async () => { + // Given + const root = mkdtempSync(join(tmpdir(), 'serve-coherence-config-')) + const file = (name: string) => join(root, name) + writeFileSync( + file('root.json'), + '{"extends":["./base.json","./missing.json"]}', + ) + writeFileSync( + file('base.json'), + '{"extends":["./nested.json"],"theme":{"colors":{"default":{"primary":"red"}}}}', + ) + writeFileSync( + file('nested.json'), + '{"theme":{"colors":{"default":{"background":"white"}}}}', + ) + const inputs = new Set() + try { + // When + const config = await load(file('root.json'), inputs) + // Then + expect([...inputs]).toEqual( + ['root.json', 'base.json', 'nested.json', 'missing.json'].map(file), + ) + expect(config.theme?.colors).toEqual({ + default: { primary: 'red', background: 'white' }, + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +} diff --git a/packages/plugin-utils/src/__tests__/non-vite-graph.test.ts b/packages/plugin-utils/src/__tests__/non-vite-graph.test.ts new file mode 100644 index 000000000..bd4ea1cfb --- /dev/null +++ b/packages/plugin-utils/src/__tests__/non-vite-graph.test.ts @@ -0,0 +1,47 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { parseImports } from '../import-graph' + +it.each(['next-plugin', 'webpack-plugin', 'rsbuild-plugin', 'bun-plugin'])( + 'keeps HTML parser modules outside the %s plugin and loader graphs', + (name) => { + // Given: include every product entry, including loaders not exported by index. + const packages = resolve(import.meta.dir, '../../..') + const src = join(packages, name, 'src') + const queue = readdirSync(src, { recursive: true }) + .map(String) + .filter( + (file) => /\.[cm]?tsx?$/.test(file) && !file.includes('__tests__'), + ) + .map((file) => join(src, file)) + const reached = new Set() + const parsers: string[] = [] + // When: follow value imports, re-exports and dynamic imports before tree shaking. + for (const file of queue) { + if (reached.has(file)) continue + reached.add(file) + for (const { specifier } of parseImports( + file, + readFileSync(file, 'utf8'), + )) { + if (specifier === 'parse5' || specifier.startsWith('parse5/')) + parsers.push(file) + const target = + specifier === '@devup-ui/plugin-utils' + ? join(packages, 'plugin-utils/src/index.ts') + : specifier.startsWith('.') + ? [ + `${resolve(dirname(file), specifier)}.ts`, + resolve(dirname(file), specifier, 'index.ts'), + ].find(existsSync) + : undefined + if (target) queue.push(target) + } + } + // Then: even unused shared exports cannot introduce parser resolution/cache cost. + expect(parsers).toEqual([]) + }, +) diff --git a/packages/plugin-utils/src/__tests__/reservation-discovery.test.ts b/packages/plugin-utils/src/__tests__/reservation-discovery.test.ts index 757f2b0b9..118651b87 100644 --- a/packages/plugin-utils/src/__tests__/reservation-discovery.test.ts +++ b/packages/plugin-utils/src/__tests__/reservation-discovery.test.ts @@ -169,6 +169,43 @@ it('overlays owned compiled MDX records without reading raw MDX or deduplicating ).toBe(false) }) +it('uses an explicit relative tsconfig instead of the default alias target', () => { + using fs = fixture() + fs.write( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@token': ['wrong.ts'] } }, + }), + ) + fs.write( + 'tsconfig.build.json', + JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@token': ['right.ts'] } }, + }), + ) + fs.write( + 'src/A.tsx', + "import {Box} from '@devup-ui/react'; import {n} from '@token'; export const A=()=> ", + ) + fs.write('wrong.ts', 'export const n=1') + fs.write('right.ts', 'export const n=4') + + const inventory = collectReservationInputs({ + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + tsconfigPath: 'tsconfig.build.json', + toId: (path) => relative(fs.root, path).replaceAll('\\', '/'), + }) + + expect(inventory.inputs.map(({ filename }) => filename)).toEqual([ + 'right.ts', + 'src/A.tsx', + ]) + expect(inventory.inputs[0].code).toBe('export const n=4') +}) + it('rejects conflicting prepared bytes for one physical path before producing an inventory', () => { // Given using fs = fixture() diff --git a/packages/plugin-utils/src/__tests__/reservation-errors.test.ts b/packages/plugin-utils/src/__tests__/reservation-errors.test.ts index 55e65dd31..f575a1e1a 100644 --- a/packages/plugin-utils/src/__tests__/reservation-errors.test.ts +++ b/packages/plugin-utils/src/__tests__/reservation-errors.test.ts @@ -3,7 +3,7 @@ import { join, relative } from 'node:path' import { expect, it } from 'bun:test' -import { createModuleResolver } from '../import-graph' +import { createModuleResolver, listSourceFiles } from '../import-graph' import { collectReservationInputs } from '../reservation-discovery' import { isReservationReadFailure, @@ -112,6 +112,33 @@ it('continues valid directories when an earlier source root changes from directo ]) }) +it('propagates a source root directory-read failure without an error handler', () => { + using fs = fixture() + fs.write('file-root', 'not a directory') + + expect(() => listSourceFiles(join(fs.root, 'file-root'))).toThrow( + expect.objectContaining({ code: 'ENOTDIR' }), + ) +}) + +it('rethrows the original directory-read error when its handler declines it', () => { + using fs = fixture() + fs.write('file-root', 'not a directory') + let received: unknown + let thrown: unknown + + try { + listSourceFiles(join(fs.root, 'file-root'), (error) => { + received = error + return false + }) + } catch (error) { + thrown = error + } + expect(thrown).toMatchObject({ code: 'ENOTDIR' }) + expect(thrown).toBe(received) +}) + it('leaves the normal resolver as publisher of a reachable broken manifest error', () => { // Given using fs = fixture() diff --git a/packages/plugin-utils/src/__tests__/reservation-graph.test.ts b/packages/plugin-utils/src/__tests__/reservation-graph.test.ts new file mode 100644 index 000000000..9a94e9454 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/reservation-graph.test.ts @@ -0,0 +1,129 @@ +import * as fs from 'node:fs' +import { resolve } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { + buildCanonicalMap, + computeFileReach, + computeReachableFiles, +} from '../import-graph' +import { collectReservationInputs } from '../reservation-discovery' +import { reservationGraph } from '../reservation-graph' +import { fixture, STYLE } from './reservation-fixture' + +it('projects the closed real inventory including ordinary importers, include, cycles and typed edges without I/O', () => { + // Given + using project = fixture() + project.write( + 'root.js', + `export * from './physical/A.js'; require('./physical/B.js'); import('./physical/B.js'); import type {T} from './missing.js'; const fake="import './fake.js'"`, + ) + project.write( + 'physical/A.js', + `${STYLE}; import '../shared.js'; import 'accepted'; import './cycle.js'`, + ) + project.write( + 'physical/B.js', + `${STYLE}; require('../shared.js'); import('../lazy.js')`, + ) + project.write('physical/cycle.js', "import './A.js'") + project.write('shared.js', STYLE) + project.write('lazy.js', "import './shared.js'") + project.write('src/unused.js', "import('../unused-lazy.js')") + project.write('unused-lazy.js', STYLE) + project.write('df/stale.js', STYLE) + project.write( + 'node_modules/accepted/package.json', + '{"name":"accepted","main":"dist/index.js"}', + ) + project.write('node_modules/accepted/dist/index.js', STYLE) + const inventory = collectReservationInputs({ + roots: ['src'], + cwd: project.root, + entries: [{ request: './root.js', context: project.root }], + include: ['accepted'], + package: '@devup-ui/react', + importAliases: {}, + }) + const native = (path: string) => resolve(project.root, path) + const key = (path: string) => native(path).replaceAll('\\', '/') + expect(inventory.inputs.some((row) => row.filename === key('root.js'))).toBe( + false, + ) + using read = spyOn(fs, 'readFileSync') + using walk = spyOn(fs, 'readdirSync') + // When + const graph = reservationGraph(inventory) + // Then + expect(read).not.toHaveBeenCalled() + expect(walk).not.toHaveBeenCalled() + expect(graph.staticImports.get(native('root.js'))).toEqual( + new Set([native('physical/A.js'), native('physical/B.js')]), + ) + expect(graph.dynamicImports.get(native('root.js'))).toEqual( + new Set([native('physical/B.js')]), + ) + expect(graph.staticImporters.get(native('shared.js'))).toEqual( + new Set([ + native('lazy.js'), + native('physical/A.js'), + native('physical/B.js'), + ]), + ) + expect(graph.fileSet.has(native('df/stale.js'))).toBe(false) + expect(graph.fileSet.has(native('node_modules/accepted/dist/index.js'))).toBe( + true, + ) + expect(graph.fileSet.has(native('missing.js'))).toBe(false) + const options = { + graph, + srcDir: project.root, + cwd: project.root, + keyBy: 'absolute' as const, + } + const reached = computeReachableFiles({ + ...options, + entries: [native('root.js')], + }) + expect(reached).toContain(native('lazy.js')) + expect(reached).not.toContain(native('unused-lazy.js')) + const reach = computeFileReach({ + ...options, + entries: [ + native('root.js'), + ...reached.filter((file) => graph.dynamicTargets.has(file)), + ], + }) + expect(reach[key('shared.js')]).toEqual([0, 1, 2]) + expect(reach[key('unused-lazy.js')]).toBeUndefined() + expect( + buildCanonicalMap(options)[key('node_modules/accepted/dist/index.js')], + ).toBe(key('physical/A.js')) +}) + +it('keeps literal escaped CRLF require edges while excluding dotted and quoted calls', () => { + // Given + using project = fixture() + project.write( + 'entry.js', + `const x=require(\r\n './suppl\\u0069er.js'\r\n); const quoted="require('./fake.js')"; object.require('./fake.js'); require(name)`, + ) + project.write('supplier.js', STYLE) + const inventory = collectReservationInputs({ + roots: [], + cwd: project.root, + entries: [{ request: './entry.js', context: project.root }], + package: '@devup-ui/react', + importAliases: {}, + }) + // When + const graph = reservationGraph(inventory) + // Then + expect(graph.files).toEqual( + ['entry.js', 'supplier.js'].map((path) => resolve(project.root, path)), + ) + expect(graph.staticImports.get(resolve(project.root, 'entry.js'))).toEqual( + new Set([resolve(project.root, 'supplier.js')]), + ) +}) diff --git a/packages/plugin-utils/src/__tests__/reservation-metadata.test.ts b/packages/plugin-utils/src/__tests__/reservation-metadata.test.ts new file mode 100644 index 000000000..1b6bcfeeb --- /dev/null +++ b/packages/plugin-utils/src/__tests__/reservation-metadata.test.ts @@ -0,0 +1,91 @@ +import { join } from 'node:path' + +import { expect, it, mock } from 'bun:test' + +import type { ReservationInventory } from '../reservation-discovery' +import { createSharedSlotReservation } from '../shared-slots' +import { fixture, flags, recorder, STYLE } from './reservation-fixture' + +it('exposes committed suppliers without starting another discovery pass', () => { + // Given + using fs = fixture() + fs.write('src/A.js', `${STYLE}; import '../outside/supplier.js'`) + fs.write('outside/supplier.js', 'export const n=4') + const manager = createSharedSlotReservation(recorder().engine) + expect(manager.dependencies).toEqual([]) + // When + manager.reserve( + { + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + }, + flags, + ) + // Then + expect(manager.dependencies).toEqual([ + join(fs.root, 'outside/supplier.js').replaceAll('\\', '/'), + join(fs.root, 'src/A.js').replaceAll('\\', '/'), + ]) +}) + +it('installs current uncommitted metadata before engine entry and skips unchanged batches', () => { + // Given + using fs = fixture() + fs.write('src/A.js', `${STYLE}; import '../supplier.js'`) + fs.write('supplier.js', 'export const n=4') + const events: string[] = [] + const manager = createSharedSlotReservation({ + reserveClassSlots() { + events.push('engine') + }, + }) + const discovery = { + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + } + const beforeReserve = mock((inventory: ReservationInventory) => { + expect(manager.dependencies).toEqual([]) + expect( + inventory.sources.get(join(fs.root, 'supplier.js').replaceAll('\\', '/')), + ).toBe('export const n=4') + expect(inventory.resolutions.imports.size).toBe(2) + events.push('metadata') + }) + // When + manager.reserve(discovery, { ...flags, beforeReserve }) + manager.reserve(discovery, { ...flags, beforeReserve }) + // Then + expect(events).toEqual(['metadata', 'engine']) + expect(beforeReserve).toHaveBeenCalledTimes(1) +}) + +it('keeps failed callback batches dirty and retries without publishing dependencies', () => { + // Given + using fs = fixture() + fs.write('src/A.js', STYLE) + const recording = recorder() + const manager = createSharedSlotReservation(recording.engine) + const discovery = { + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + } + const beforeReserve = mock((): void => { + throw new TypeError('metadata failure') + }) + // When + expect(() => manager.reserve(discovery, { ...flags, beforeReserve })).toThrow( + TypeError, + ) + // Then + expect(recording.packets).toEqual([]) + expect(manager.dependencies).toEqual([]) + beforeReserve.mockImplementation(() => {}) + expect(manager.reserve(discovery, { ...flags, beforeReserve })).toBe(true) + expect(recording.packets).toHaveLength(1) +}) diff --git a/packages/plugin-utils/src/__tests__/reservation-package-metadata.test.ts b/packages/plugin-utils/src/__tests__/reservation-package-metadata.test.ts new file mode 100644 index 000000000..435c9273c --- /dev/null +++ b/packages/plugin-utils/src/__tests__/reservation-package-metadata.test.ts @@ -0,0 +1,118 @@ +import { join } from 'node:path' + +import { expect, it, mock } from 'bun:test' + +import { collectReservationInputs } from '../reservation-discovery' +import { createSharedSlotReservation } from '../shared-slots' +import { fixture, flags, recorder, STYLE } from './reservation-fixture' + +it.each(['import', 'entry', 'broken'] as const)( + 'records actual %s package metadata independently of source inventory and nearer dist scopes', + (kind) => { + // Given + using project = fixture() + project.write( + 'src/A.js', + kind === 'entry' ? STYLE : `${STYLE}; import 'accepted'`, + ) + project.write( + 'node_modules/accepted/package.json', + kind === 'broken' ? '{' : '{"exports":"./dist/red.js"}', + ) + project.write( + 'node_modules/accepted/dist/package.json', + '{"name":"nearer"}', + ) + project.write( + 'node_modules/accepted/dist/red.js', + 'export const tone="red"', + ) + const options = { + roots: ['src'], + cwd: project.root, + package: '@devup-ui/react', + importAliases: {}, + include: ['accepted'], + ...(kind === 'entry' + ? { entries: [{ request: 'accepted', context: project.root }] } + : {}), + } + // When + const inventory = collectReservationInputs(options) + // Then + const metadata = join( + project.root, + 'node_modules/accepted/package.json', + ).replaceAll('\\', '/') + expect(inventory.packageManifests).toEqual([metadata]) + expect(inventory.dependencies).not.toContain(metadata) + expect(inventory.sources.has(metadata)).toBe(false) + }, +) + +it('refreshes metadata provenance when equivalent entry resolutions change the selected manifest', () => { + // Given + using project = fixture() + project.write('src/A.js', STYLE) + project.write( + 'node_modules/accepted/package.json', + '{"main":"../../src/A.js"}', + ) + const manager = createSharedSlotReservation(recorder().engine) + const options = { + roots: ['src'], + cwd: project.root, + package: '@devup-ui/react', + importAliases: {}, + } + manager.reserve( + { ...options, entries: [{ request: './src/A.js', context: project.root }] }, + flags, + ) + // When + manager.reserve( + { ...options, entries: [{ request: 'accepted', context: project.root }] }, + flags, + ) + // Then + expect(manager.packageManifests).toEqual([ + join(project.root, 'node_modules/accepted/package.json').replaceAll( + '\\', + '/', + ), + ]) +}) + +it.each(['callback', 'engine'] as const)( + 'does not publish package metadata when %s fails', + (failure) => { + // Given + using project = fixture() + project.write('src/A.js', `${STYLE}; import 'accepted'`) + project.write('node_modules/accepted/package.json', '{"main":"index.js"}') + project.write('node_modules/accepted/index.js', 'export const n=1') + const fail = mock(() => { + throw new TypeError('preparation failed') + }) + const manager = createSharedSlotReservation( + failure === 'engine' ? { reserveClassSlots: fail } : recorder().engine, + ) + const options = { + roots: ['src'], + cwd: project.root, + package: '@devup-ui/react', + importAliases: {}, + include: ['accepted'], + } + // When + expect(() => + manager.reserve(options, { + ...flags, + ...(failure === 'callback' ? { beforeReserve: fail } : {}), + }), + ).toThrow(TypeError) + // Then + expect(manager.packageManifests).toEqual([]) + expect(manager.dependencies).toEqual([]) + }, +) diff --git a/packages/plugin-utils/src/__tests__/state-writer.test.ts b/packages/plugin-utils/src/__tests__/state-writer.test.ts index 661189cf2..a91fe0b58 100644 --- a/packages/plugin-utils/src/__tests__/state-writer.test.ts +++ b/packages/plugin-utils/src/__tests__/state-writer.test.ts @@ -1,10 +1,10 @@ +import { spawnSync } from 'node:child_process' import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'bun:test' -import { beginBuild } from '../build-session' import { createStateWriter, writeFileAtomically } from '../state-writer' describe('createStateWriter', () => { @@ -84,23 +84,13 @@ describe('writeFileAtomically', () => { describe('beginBuild', () => { it('resets the engine only when no other build is running', () => { - let resets = 0 - const engine = { - resetBuildState: () => { - resets += 1 - }, - } - const first = beginBuild(engine) - expect(resets).toBeLessThanOrEqual(1) - const before = resets - const second = beginBuild(engine) - expect(resets).toBe(before) - second() - second() - first() - beginBuild({})() - const third = beginBuild(engine) - expect(resets).toBe(before + 1) - third() + // Given / When: no Bun preload owns the engine in this fresh process. + const result = spawnSync( + 'node', + [join(import.meta.dir, 'build-session-ownership.cjs')], + { encoding: 'utf8', timeout: 30000 }, + ) + // Then + expect(result.status, result.stdout + result.stderr).toBe(0) }) }) diff --git a/packages/plugin-utils/src/build-session.ts b/packages/plugin-utils/src/build-session.ts index bd16e68f3..151fd424b 100644 --- a/packages/plugin-utils/src/build-session.ts +++ b/packages/plugin-utils/src/build-session.ts @@ -2,7 +2,18 @@ export interface ResettableEngine { resetBuildState(): void } -let running = 0 +declare global { + var __devupBuildOwnership: { running: number; generation: number } | undefined +} + +const ownership = (globalThis.__devupBuildOwnership ??= { + running: 0, + generation: 0, +}) + +export function getBuildOwnership(): Readonly { + return { ...ownership } +} /** * Start a build in this process. The engine is shared by every plugin instance @@ -12,12 +23,14 @@ let running = 0 * wipe. Returns the function that ends the build. */ export function beginBuild(engine: Partial): () => void { - if (running === 0) engine.resetBuildState?.() - running += 1 + if (ownership.running === 0) engine.resetBuildState?.() + ownership.running += 1 + ownership.generation += 1 let ended = false return () => { if (ended) return ended = true - running -= 1 + ownership.running -= 1 + ownership.generation += 1 } } diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index 823665036..13197350d 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -1061,6 +1061,7 @@ export interface CreateModuleResolverOptions { */ toId?: (path: string) => string readonly allowPackage?: (specifier: string) => boolean + readonly onPackageManifest?: (path: string) => void } /** @@ -1073,6 +1074,7 @@ export function createModuleResolver({ tsconfigPath = join(cwd, 'tsconfig.json'), toId = (path) => path, allowPackage, + onPackageManifest, }: CreateModuleResolverOptions = {}): ( specifier: string, importer: string, @@ -1091,7 +1093,7 @@ export function createModuleResolver({ .map(resolveSourceFile) .find(Boolean) ?? ((allowPackage?.(specifier) ?? true) - ? resolvePackage(specifier, from) + ? resolvePackage(specifier, from, onPackageManifest) : undefined)) return path ? { path: toId(path), code: readFileSync(path, 'utf-8') } @@ -1119,6 +1121,7 @@ const packageConditions = ['import', 'module', 'default', 'require', 'node'] function resolvePackage( specifier: string, importer: string, + onPackageManifest?: (path: string) => void, ): string | undefined { const parts = specifier.split('/') const nameLength = specifier.startsWith('@') ? 2 : 1 @@ -1126,6 +1129,7 @@ function resolvePackage( const packageDir = findPackage(dirname(importer), name) if (!packageDir) return undefined const manifestPath = join(packageDir, 'package.json') + onPackageManifest?.(realpathSync(manifestPath)) const manifest: unknown = JSON.parse(readFileSync(manifestPath, 'utf-8')) if (!isRecord(manifest) || Array.isArray(manifest)) { throw new PackageManifestShapeError(manifestPath) diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 7c488a90d..df3a2237e 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -1,4 +1,8 @@ -export { beginBuild, type ResettableEngine } from './build-session' +export { + beginBuild, + getBuildOwnership, + type ResettableEngine, +} from './build-session' export { type AtomHoistPlan, buildCanonicalMap, @@ -38,6 +42,7 @@ export { type ReservationInput, type ReservationInventory, } from './reservation-discovery' +export { reservationGraph } from './reservation-graph' export { createNodeModulesExcludeRegex, createThemeInterfaceArgs, diff --git a/packages/plugin-utils/src/load-config.ts b/packages/plugin-utils/src/load-config.ts index a30c977ab..03b151be1 100644 --- a/packages/plugin-utils/src/load-config.ts +++ b/packages/plugin-utils/src/load-config.ts @@ -83,7 +83,11 @@ function mergeExtendedConfigs( * @param configPath - Path to the devup.json file * @returns Resolved configuration with all extends merged */ -export function loadDevupConfigSync(configPath: string): DevupConfig { +export function loadDevupConfigSync( + configPath: string, + inputs?: Set, +): DevupConfig { + inputs?.add(resolve(configPath)) if (!existsSync(configPath)) { return {} } @@ -101,7 +105,7 @@ export function loadDevupConfigSync(configPath: string): DevupConfig { return mergeExtendedConfigs( config, config.extends.map((extendPath) => - loadDevupConfigSync(resolve(configDir, extendPath)), + loadDevupConfigSync(resolve(configDir, extendPath), inputs), ), ) } @@ -115,7 +119,9 @@ export function loadDevupConfigSync(configPath: string): DevupConfig { */ export async function loadDevupConfig( configPath: string, + inputs?: Set, ): Promise { + inputs?.add(resolve(configPath)) if (!existsSync(configPath)) { return {} } @@ -133,7 +139,9 @@ export async function loadDevupConfig( // Load extends sequentially to preserve merge order const extendedConfigs: DevupConfig[] = [] for (const extendPath of config.extends) { - extendedConfigs.push(await loadDevupConfig(resolve(configDir, extendPath))) + extendedConfigs.push( + await loadDevupConfig(resolve(configDir, extendPath), inputs), + ) } return mergeExtendedConfigs(config, extendedConfigs) diff --git a/packages/plugin-utils/src/reservation-discovery.ts b/packages/plugin-utils/src/reservation-discovery.ts index a45205aab..abb5a4e81 100644 --- a/packages/plugin-utils/src/reservation-discovery.ts +++ b/packages/plugin-utils/src/reservation-discovery.ts @@ -41,6 +41,7 @@ export type ReservationDiscoveryOptions = { export type ReservationInventory = { readonly inputs: readonly ReservationInput[] readonly dependencies: readonly string[] + readonly packageManifests: readonly string[] readonly sources: ReadonlyMap readonly resolutions: ReservationResolutions } @@ -101,9 +102,12 @@ export function collectReservationInputs( const initial = roots.flatMap((root) => listSourceFiles(root, isReservationReadFailure), ) + const packageManifests = new Set() const resolverOptions = { cwd, toId: (path: string) => path, + onPackageManifest: (path: string) => + packageManifests.add(physicalKey(path)), ...(options.tsconfigPath === undefined ? {} : { tsconfigPath: resolve(cwd, options.tsconfigPath) }), @@ -191,6 +195,7 @@ export function collectReservationInputs( return { inputs, dependencies, + packageManifests: [...packageManifests].sort(), sources, resolutions: { entries: [...entryTargets].sort(), imports }, } diff --git a/packages/plugin-utils/src/reservation-graph.ts b/packages/plugin-utils/src/reservation-graph.ts new file mode 100644 index 000000000..bfb2496dc --- /dev/null +++ b/packages/plugin-utils/src/reservation-graph.ts @@ -0,0 +1,57 @@ +import { resolve } from 'node:path' + +import { parseImports, type StaticImportGraph } from './import-graph' +import type { ReservationInventory } from './reservation-discovery' + +/** Project only cached physical sources and resolved edges; never discover files. */ +export function reservationGraph( + inventory: ReservationInventory, +): StaticImportGraph { + const sources = [...inventory.sources].sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0, + ) + const files = sources.map(([key]) => resolve(key)) + const fileSet = new Set(files) + const staticImports = new Map>() + const staticImporters = new Map>() + const dynamicImports = new Map>() + const dynamicTargets = new Set() + for (const file of files) { + staticImports.set(file, new Set()) + staticImporters.set(file, new Set()) + dynamicImports.set(file, new Set()) + } + for (const [key, code] of sources) { + const file = resolve(key) + const addEdge: Record< + ReturnType[number]['kind'], + (target: string) => void + > = { + static(target) { + staticImports.get(file)?.add(target) + staticImporters.get(target)?.add(file) + }, + dynamic(target) { + dynamicImports.get(file)?.add(target) + dynamicTargets.add(target) + }, + } + for (const reference of parseImports(file, code)) { + const resolved = inventory.resolutions.imports + .get(key) + ?.get(reference.specifier) + if (resolved === undefined) continue + const target = resolve(resolved) + if (!fileSet.has(target)) continue + addEdge[reference.kind](target) + } + } + return { + files, + fileSet, + staticImports, + staticImporters, + dynamicImports, + dynamicTargets, + } +} diff --git a/packages/plugin-utils/src/shared-slots.ts b/packages/plugin-utils/src/shared-slots.ts index 33dbf135a..ea2080f49 100644 --- a/packages/plugin-utils/src/shared-slots.ts +++ b/packages/plugin-utils/src/shared-slots.ts @@ -25,6 +25,8 @@ export interface ReservationEngine { } export type ReservationBatchOptions = { + /** Synchronous metadata installation after memoization, before scratch extraction. */ + readonly beforeReserve?: (inventory: ReservationInventory) => void readonly singleCss: boolean readonly importMainCssInCode: boolean readonly importMainCssInCss: boolean @@ -32,6 +34,8 @@ export type ReservationBatchOptions = { } export interface SharedSlotReservation { + readonly dependencies: readonly string[] + readonly packageManifests: readonly string[] invalidate(): void reserve( discovery: ReservationDiscoveryOptions, @@ -106,11 +110,18 @@ export function createSharedSlotReservation( let committed: Committed | undefined let dirty = true return { + get dependencies() { + return committed?.inventory.dependencies ?? [] + }, + get packageManifests() { + return committed?.inventory.packageManifests ?? [] + }, invalidate() { dirty = true }, reserve(discovery, options) { const inventory = collectReservationInputs(discovery) + const manifests = committed?.inventory.packageManifests ?? [] const rows: readonly ReservationRow[] = inventory.inputs.map( (input) => [input.filename, input.code, options.cssDir(input)] as const, ) @@ -122,6 +133,10 @@ export function createSharedSlotReservation( committed.importMainCssInCode === options.importMainCssInCode && committed.importMainCssInCss === options.importMainCssInCss && sameAliases(discovery.importAliases, committed.aliases) && + inventory.packageManifests.length === manifests.length && + inventory.packageManifests.every( + (path, index) => path === manifests[index], + ) && sameResolutions( inventory.resolutions, committed.inventory.resolutions, @@ -130,6 +145,7 @@ export function createSharedSlotReservation( sameRows(rows, committed.rows) ) return false + options.beforeReserve?.(inventory) engine.reserveClassSlots( rows, discovery.package, diff --git a/packages/react/src/stores/__tests__/theme-store.test.ts b/packages/react/src/stores/__tests__/theme-store.test.ts index 1544aa265..c3c30d7de 100644 --- a/packages/react/src/stores/__tests__/theme-store.test.ts +++ b/packages/react/src/stores/__tests__/theme-store.test.ts @@ -11,6 +11,48 @@ type ThemeValue = Parameters[0] const darkTheme = 'dark' as unknown as ThemeValue describe('themeStore ssr', () => { + it('should select the inert server store when window is undefined', async () => { + // Given: evaluate the factory without a browser window, preserving its descriptor. + const originalWindowDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'window', + ) + + try { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: undefined, + }) + + // When: a fresh import selects the factory in this process. + const { createThemeStore } = await import('../theme-store?ssr-factory') + + // Then: the real server factory provides an inert subscription lifecycle. + expect(createThemeStore).toBe(createServerThemeStore) + const themeStore = createThemeStore() + const notifications: ThemeValue[] = [] + expect(themeStore.get()).toBeNull() + const unsubscribe = themeStore.subscribe((theme) => { + notifications.push(theme) + }) + expect(notifications).toEqual([]) + expect(themeStore.set(darkTheme)).toBeUndefined() + expect(themeStore.get()).toBeNull() + expect(notifications).toEqual([]) + expect(unsubscribe()).toBeUndefined() + expect(unsubscribe()).toBeUndefined() + expect(themeStore.set(darkTheme)).toBeUndefined() + expect(themeStore.get()).toBeNull() + expect(notifications).toEqual([]) + } finally { + if (originalWindowDescriptor) { + Object.defineProperty(globalThis, 'window', originalWindowDescriptor) + } else { + Reflect.deleteProperty(globalThis, 'window') + } + } + }) + it('should filter mutations by type and target', async () => { // const { createThemeStore } = await import('../theme-store') const themeStore = createServerThemeStore() diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index 82eba9cac..a9cd43b18 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -41,10 +41,12 @@ ], "dependencies": { "@devup-ui/plugin-utils": "workspace:^", - "@devup-ui/wasm": "workspace:^" + "@devup-ui/wasm": "workspace:^", + "parse5": "^8.0.1" }, "devDependencies": { - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "vite": "^8.3.4" }, "peerDependencies": { "@devup-ui/wasm": "*", diff --git a/packages/vite-plugin/src/__tests__/atom-hoist-policy.test.ts b/packages/vite-plugin/src/__tests__/atom-hoist-policy.test.ts new file mode 100644 index 000000000..49be34851 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/atom-hoist-policy.test.ts @@ -0,0 +1,113 @@ +import { join } from 'node:path' + +import * as utils from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import { + fixture, + STYLE, +} from '../../../plugin-utils/src/__tests__/reservation-fixture' +import { createViteReservation } from '../reservation' + +it.each([undefined, 0, -1, NaN, Infinity, -Infinity, 0.5, 1, 2, 3])( + 'installs the numeric policy for %s before scratch, ignoring it in singleCss', + async (atomHoist) => { + // Given + using project = fixture() + for (const name of ['A', 'B']) + project.write(`physical/${name}.js`, `${STYLE}; import '../shared.js'`) + project.write('shared.js', STYLE) + for (const singleCss of [false, true]) { + using canonical = spyOn(wasm, 'importCanonicalMap').mockImplementation( + () => {}, + ) + using routes = spyOn(wasm, 'importFileRoutes').mockImplementation( + () => {}, + ) + using threshold = spyOn(wasm, 'setAtomHoist').mockImplementation(() => {}) + using scan = spyOn(utils, 'buildStaticImportGraph') + const enabled = + !singleCss && + (atomHoist === undefined || + (Number.isFinite(atomHoist) && atomHoist > 0)) + using engine = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => { + expect(canonical).toHaveBeenCalledTimes(enabled ? 1 : 0) + expect(threshold).toHaveBeenCalledTimes(enabled ? 1 : 0) + }) + const slots = createViteReservation({ + package: '@devup-ui/react', + include: [], + importAliases: {}, + singleCss, + ...(atomHoist === undefined ? {} : { atomHoist }), + cssDir: join(project.root, 'df/css'), + distDir: join(project.root, 'df'), + }) + slots.configure({ + root: project.root, + input: ['physical/A.js', 'physical/B.js'], + }) + // When + await slots.reserve() + // Then + expect(engine).toHaveBeenCalledTimes(1) + expect(scan).not.toHaveBeenCalled() + if (enabled) { + expect(threshold).toHaveBeenCalledWith(Math.max(2, atomHoist ?? 2)) + expect(routes.mock.calls[0]?.[0]).toMatchObject({ + [join(project.root, 'shared.js').replaceAll('\\', '/')]: [0, 1], + }) + } else expect(routes).not.toHaveBeenCalled() + expect(engine.mock.calls[0]?.slice(2, 5)).toEqual([ + singleCss, + true, + false, + ]) + } + }, +) + +it('folds canonical reach and skips global buckets before installing the threshold', async () => { + // Given + using project = fixture() + project.write('src/A.js', STYLE) + using _canonicalPlan = spyOn(utils, 'buildCanonicalMap').mockReturnValue({ + child: 'parent', + global: '@global', + }) + using _reachPlan = spyOn(utils, 'computeFileReach').mockReturnValue({ + child: [0], + parent: [0, 1], + global: [0, 1], + }) + const events: string[] = [] + using _canonical = spyOn(wasm, 'importCanonicalMap').mockImplementation( + () => { + events.push('canonical') + }, + ) + using routes = spyOn(wasm, 'importFileRoutes').mockImplementation(() => { + events.push('routes') + }) + using _threshold = spyOn(wasm, 'setAtomHoist').mockImplementation(() => { + events.push('threshold') + }) + using _engine = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => { + events.push('scratch') + }) + const slots = createViteReservation({ + package: '@devup-ui/react', + include: [], + importAliases: {}, + singleCss: false, + cssDir: join(project.root, 'df/css'), + distDir: join(project.root, 'df'), + }) + slots.configure({ root: project.root }) + // When + await slots.reserve() + // Then + expect(routes).toHaveBeenCalledWith({ parent: [0, 1] }) + expect(events).toEqual(['canonical', 'routes', 'threshold', 'scratch']) +}) diff --git a/packages/vite-plugin/src/__tests__/atom-hoist.test.ts b/packages/vite-plugin/src/__tests__/atom-hoist.test.ts index 624aadd6b..72283dd8f 100644 --- a/packages/vite-plugin/src/__tests__/atom-hoist.test.ts +++ b/packages/vite-plugin/src/__tests__/atom-hoist.test.ts @@ -5,11 +5,13 @@ import { runAtomHoistIntegration, } from '../../../next-plugin/src/__tests__/atom-hoist-harness' -it('emits real per-file fallback JSX atoms and private CSS in either transform order and fresh builds', () => { - // Given: two explicit entries reach a shared styled module. - const setup = ` +it.each(['atomHoist: 2,', 'atomHoist: undefined,', ''])( + 'emits hoisted shared JSX atoms and private CSS with option %s', + (option) => { + // Given: two explicit entries reach a shared styled module. + const setup = ` const { DevupUI } = await import(${builtPluginEntry('vite-plugin')}); -const [plugin, restorePlugin] = DevupUI({ atomHoist: 2, cssDir, distDir: join(root, 'df') }); +const [plugin, restorePlugin] = DevupUI({ ${option} cssDir, distDir: join(root, 'df') }); await plugin.configResolved({ root, command: 'build', build: { rollupOptions: { input: [join(root, files[1]), join(root, files[2])] }, } }); @@ -27,12 +29,14 @@ const finish = async () => { plugin.closeBundle(); }; ` - // When: real configResolved, transform, load, and generateBundle hooks run. - const [forward, backward, rebuilt] = runAtomHoistIntegration( - setup, - 'per-file', - ) - // Then: disk writes consume shared dirtiness and all loaded classes agree. - expect(backward).toBe(forward) - expect(rebuilt).toBe(forward) -}, 120_000) + // When: real configResolved, transform, load, and generateBundle hooks run. + const [forward, backward, rebuilt] = runAtomHoistIntegration( + setup, + 'hoisted', + ) + // Then: disk writes consume shared dirtiness and all loaded classes agree. + expect(backward).toBe(forward) + expect(rebuilt).toBe(forward) + }, + 120_000, +) diff --git a/packages/vite-plugin/src/__tests__/cache-invalidation-config-capture-native.cjs b/packages/vite-plugin/src/__tests__/cache-invalidation-config-capture-native.cjs new file mode 100644 index 000000000..83c2cddb3 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/cache-invalidation-config-capture-native.cjs @@ -0,0 +1,76 @@ +const assert = require('node:assert/strict') +const { join } = require('node:path') +const { provenanceFixture, observe, wasm } = require('./provenance-fixture.cjs') +const { gate } = require('./serve-coherence-async-fixture.cjs') + +const root = process.argv[2] +assert(root) +const theme = (/** @type {readonly (string | null)[]} */ size) => + JSON.stringify({ theme: { length: { default: { size } } } }) + +async function main() { + const telemetry = observe() + const state = provenanceFixture(root) + const source = + "import {Box} from '@devup-ui/react'; export const A=()=> ;" + state.write('src/A.tsx', source) + state.write('devup.json', theme(['11px'])) + await state.plugin.configResolved(state.config) + const css = gate('writeFile', join(root, 'df/css/devup-ui.css')) + const old = state.transform(source) + const oldSettled = old.then(() => true) + /** @type {ReturnType | undefined} */ + let config + let refreshed = Promise.resolve() + /** @type {Promise | undefined} */ + let newer + try { + await css.entered + state.write('devup.json', theme(['11px', null, '29px'])) + config = gate('readFile', join(root, 'devup.json')) + refreshed = state.plugin.watchChange(join(root, 'devup.json')) + await config.entered + await state.transform(source) + await state.transform(source) + assert.equal( + telemetry.extractions.length, + 3, + 'active independent config load must bypass retention and reuse', + ) + config.release() + await refreshed + assert.match(wasm.getCss(null, false), /29px/) + assert.equal( + await Promise.race([oldSettled, Promise.resolve(false)]), + false, + 'new semantics must finish before the old CSS write returns', + ) + /** @type {PromiseWithResolvers} */ + const extracted = Promise.withResolvers() + telemetry.onExtract(() => extracted.resolve()) + newer = state.transform(source) + await extracted.promise + assert.equal(telemetry.extractions.length, 4) + css.release() + const result = await newer + await old + assert.deepEqual(await state.transform(source), result) + assert.equal( + telemetry.extractions.length, + 4, + 'obsolete successful completion must not delete or replace the newer flight', + ) + assert.match(wasm.getCss(null, false), /29px/) + assert.match(wasm.getCss(null, false), /@media/) + } finally { + config?.restore() + css.restore() + await Promise.allSettled([old, newer, refreshed]) + await state.plugin.closeBundle() + } +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/cache-invalidation-config-capture.test.ts b/packages/vite-plugin/src/__tests__/cache-invalidation-config-capture.test.ts new file mode 100644 index 000000000..e0806a1f0 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/cache-invalidation-config-capture.test.ts @@ -0,0 +1,13 @@ +import { join } from 'node:path' + +import { it } from 'bun:test' + +import { ownedNativeFixture } from './serve-coherence-owned-native-fixture' + +it('retains only the newer real flight when independent config load finishes before old write', () => { + ownedNativeFixture( + join(import.meta.dir, 'cache-invalidation-config-capture-native.cjs'), + [], + () => {}, + ) +}, 180000) diff --git a/packages/vite-plugin/src/__tests__/cache-invalidation-metadata-native.cjs b/packages/vite-plugin/src/__tests__/cache-invalidation-metadata-native.cjs new file mode 100644 index 000000000..da62ad1f2 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/cache-invalidation-metadata-native.cjs @@ -0,0 +1,140 @@ +const assert = require('node:assert/strict') +const { join } = require('node:path') +const { provenanceFixture, observe, wasm } = require('./provenance-fixture.cjs') + +const mode = process.argv[2] +const hook = process.argv[3] +const root = process.argv[4] +assert(root) +const theme = (/** @type {readonly (string | null)[]} */ sizes) => + JSON.stringify({ theme: { length: { default: { size: sizes } } } }) + +async function main() { + const telemetry = observe() + const state = provenanceFixture(root) + const { plugin, write, transform } = state + let changed = 'devup.json' + let source = + "import {Box} from '@devup-ui/react'; export const A=()=> ;" + switch (mode) { + case 'root': + write('devup.json', theme(['11px'])) + break + case 'inherited': + write('devup.json', '{"extends":["./config/base.json"]}') + changed = 'config/base.json' + write(changed, theme(['11px'])) + break + case 'tailwind': + case 'tailwind-import': + write( + 'devup.json', + JSON.stringify({ tailwind: { css: join(root, 'selected.css') } }), + ) + write( + 'selected.css', + mode === 'tailwind' + ? '@utility metadata-tone { color: red; }' + : '@import "./imported.css";', + ) + changed = mode === 'tailwind' ? 'selected.css' : 'imported.css' + write(changed, '@utility metadata-tone { color: red; }') + source = + "import {Box} from '@devup-ui/react'; export const A=()=> ;" + break + default: + throw new TypeError('Unknown cache-invalidation metadata mode: ' + mode) + } + write('src/A.tsx', source) + try { + await plugin.configResolved(state.config) + const before = await transform(source) + await transform(source) + const count = telemetry.extractions.length + assert.equal( + count, + 1, + 'normal configured fixture must be eligible before metadata edit', + ) + write( + changed, + mode === 'root' || mode === 'inherited' + ? theme(['11px', null, '29px']) + : '@utility metadata-tone { color: teal; }', + ) + const file = join(root, changed) + const modules = new Map([[state.id, { id: state.id }]]) + /** @type {unknown[][]} */ + const invalidated = [] + const graph = { + idToModuleMap: modules, + getModuleById: (/** @type {string} */ id) => modules.get(id), + invalidateModule(/** @type {unknown[]} */ ...args) { + invalidated.push(args) + }, + } + const event = { file, modules: [...modules.values()], timestamp: 1401 } + switch (hook) { + case 'watchChange': + await plugin.watchChange(file) + break + case 'hotUpdate': + await plugin.hotUpdate.call( + { + environment: { + name: 'client', + config: { consumer: 'client' }, + moduleGraph: graph, + hot: { send() {} }, + }, + }, + event, + ) + assert.equal(invalidated.length, 1) + break + case 'handleHotUpdate': + await plugin.handleHotUpdate({ + ...event, + server: { moduleGraph: graph, ws: { send() {} } }, + }) + assert.equal(invalidated.length, 1) + break + default: + throw new TypeError('Unknown cache-invalidation hook: ' + hook) + } + const after = await transform(source) + assert.equal( + telemetry.extractions.length, + count + 1, + 'every metadata hook must invalidate the cached extraction', + ) + const css = wasm.getCss(null, false) + switch (mode) { + case 'root': + case 'inherited': + assert.notDeepEqual(after, before) + assert.match(css, /29px/) + assert.match(css, /@media/) + break + case 'tailwind': + case 'tailwind-import': + assert.match(css, /color:teal/) + break + default: + throw new TypeError('Unknown cache-invalidation metadata mode: ' + mode) + } + await transform(source) + assert.equal( + telemetry.extractions.length, + count + 1, + 'fresh metadata extraction must then be reusable', + ) + } finally { + await plugin.closeBundle() + } +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/cache-invalidation-metadata.test.ts b/packages/vite-plugin/src/__tests__/cache-invalidation-metadata.test.ts new file mode 100644 index 000000000..68578b530 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/cache-invalidation-metadata.test.ts @@ -0,0 +1,17 @@ +import { join } from 'node:path' + +import { it } from 'bun:test' + +import { ownedNativeFixture } from './serve-coherence-owned-native-fixture' + +for (const hook of ['watchChange', 'hotUpdate', 'handleHotUpdate']) { + for (const mode of ['root', 'inherited', 'tailwind', 'tailwind-import']) { + it(`reextracts real cached ${mode} semantics when ${hook} completes`, () => { + ownedNativeFixture( + join(import.meta.dir, 'cache-invalidation-metadata-native.cjs'), + [mode, hook], + () => {}, + ) + }, 180000) + } +} diff --git a/packages/vite-plugin/src/__tests__/cache-invalidation-outside-theme-native.cjs b/packages/vite-plugin/src/__tests__/cache-invalidation-outside-theme-native.cjs new file mode 100644 index 000000000..e70addb64 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/cache-invalidation-outside-theme-native.cjs @@ -0,0 +1,161 @@ +const assert = require('node:assert/strict') +const { rmSync, symlinkSync } = require('node:fs') +const { dirname, join, resolve } = require('node:path') +const { provenanceFixture, observe, wasm } = require('./provenance-fixture.cjs') +const { registrationBarrier } = require('./provenance-registration.cjs') + +const root = process.argv[2] +assert(root) +const theme = (/** @type {readonly (string | null)[]} */ size) => + JSON.stringify({ theme: { length: { default: { size } } } }) + +async function main() { + const telemetry = observe() + const state = provenanceFixture(root, { + devupFile: join(root, 'project/devup.json'), + }) + const source = + "import {Box} from '@devup-ui/react'; export const A=()=> ;" + state.write( + 'project/package.json', + '{"name":"cache-invalidation-outside-host","type":"module"}', + ) + state.write('project/devup.json', '{"extends":["../src/base.json"]}') + state.write('src/base.json', theme(['11px'])) + state.write('src/A.tsx', source) + state.write('node_modules/.keep', '') + symlinkSync( + dirname( + require.resolve('react/package.json', { + paths: [join(__dirname, '../../../react')], + }), + ), + join(root, 'node_modules/react'), + 'junction', + ) + const { createServer } = await import('vite') + /** @type {ReturnType | undefined} */ + let membership + /** @type {{type:string,done:PromiseWithResolvers,environments:Set,timestamp?:number} | undefined} */ + let update + const file = join(root, 'src/base.json') + const server = await createServer({ + root: join(root, 'project'), + configFile: false, + logLevel: 'silent', + build: { rolldownOptions: { input: state.id } }, + server: { port: 0, host: '127.0.0.1', fs: { allow: [root] } }, + plugins: [ + { + name: 'cache-invalidation-outside-theme-observer', + enforce: 'pre', + configureServer(server) { + membership = registrationBarrier(server.watcher, join(root, 'src')) + }, + hotUpdate: { + order: 'post', + handler({ file: changed, type, timestamp }) { + if ( + !update || + resolve(changed) !== resolve(file) || + type !== update.type + ) + return + update.timestamp ??= timestamp + assert.equal(timestamp, update.timestamp) + update.environments.delete(this.environment.name) + if (!update.environments.size) update.done.resolve() + }, + }, + }, + state.plugins, + ], + }) + const delivered = (/** @type {string} */ expected) => + new Promise((done) => { + const listener = ( + /** @type {string} */ event, + /** @type {string} */ actual, + ) => { + if (event !== expected || resolve(actual) !== resolve(file)) return + server.watcher.off('all', listener) + done(undefined) + } + server.watcher.on('all', listener) + }) + try { + await server.listen() + const client = server.environments.client + assert(client && membership) + assert(await client.transformRequest('/@fs/' + state.id)) + await membership.ready + membership.assertReady() + const extract = () => client.pluginContainer.transform(source, state.id) + await extract() + await extract() + for (const action of ['edit', 'delete', 'recreate']) { + const native = delivered( + action === 'edit' ? 'change' : action === 'delete' ? 'unlink' : 'add', + ) + /** @type {PromiseWithResolvers} */ + const done = Promise.withResolvers() + update = { + type: + action === 'edit' + ? 'update' + : action === 'delete' + ? 'delete' + : 'create', + done, + environments: new Set(Object.keys(server.environments)), + } + const count = telemetry.extractions.length + membership.assertReady() + membership.beforeMutation(action) + if (action === 'delete') rmSync(file) + else + state.write( + 'src/base.json', + theme( + action === 'edit' ? ['11px', null, '29px'] : ['17px', null, '37px'], + ), + ) + await Promise.all([native, done.promise]) + await extract() + assert.equal( + telemetry.extractions.length, + count + 1, + 'outside inherited native semantics must invalidate cached input', + ) + const css = wasm.getCss(null, false) + switch (action) { + case 'edit': + assert.match(css, /29px/) + assert.match(css, /@media/) + break + case 'delete': + assert.doesNotMatch(css, /29px/) + break + case 'recreate': + assert.match(css, /37px/) + assert.match(css, /@media/) + break + default: + throw new TypeError('Unknown outside theme action: ' + action) + } + await extract() + assert.equal(telemetry.extractions.length, count + 1) + } + } finally { + try { + await server.close() + } finally { + membership?.restore() + } + } +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/cache-invalidation-outside-theme.test.ts b/packages/vite-plugin/src/__tests__/cache-invalidation-outside-theme.test.ts new file mode 100644 index 000000000..b0e11d8d3 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/cache-invalidation-outside-theme.test.ts @@ -0,0 +1,12 @@ +import { join } from 'node:path' + +import { it } from 'bun:test' + +import { retainedNativeFixture } from './provenance-owned-native-fixture' + +it('reextracts outside inherited theme edit delete recreate on actual native events', () => { + retainedNativeFixture( + join(import.meta.dir, 'cache-invalidation-outside-theme-native.cjs'), + [], + ) +}, 180000) diff --git a/packages/vite-plugin/src/__tests__/cache-invalidation-query-native.cjs b/packages/vite-plugin/src/__tests__/cache-invalidation-query-native.cjs new file mode 100644 index 000000000..487a11a69 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/cache-invalidation-query-native.cjs @@ -0,0 +1,66 @@ +const assert = require('node:assert/strict') +const { mkdirSync, realpathSync, symlinkSync } = require('node:fs') +const { dirname, join } = require('node:path') +const { put } = require('./reservation-native-fixture.cjs') +const { DevupUI } = require('../../dist/' + 'index.cjs') + +const inputRoot = process.argv[2] +assert(inputRoot) +const root = realpathSync.native(inputRoot) +const source = + "import {Box} from '@devup-ui/react'; export const A=;" + +async function main() { + put( + root, + 'package.json', + '{"name":"cache-invalidation-query","type":"module"}', + ) + put(root, 'devup.json', '{}') + put(root, 'src/A.tsx', source) + mkdirSync(join(root, 'node_modules')) + symlinkSync( + dirname( + require.resolve('react/package.json', { + paths: [join(__dirname, '../../../react')], + }), + ), + join(root, 'node_modules/react'), + 'junction', + ) + const { createServer } = await import('vite') + const server = await createServer({ + root, + configFile: false, + server: { watch: null, fs: { allow: [root] } }, + logLevel: 'silent', + plugins: [ + DevupUI({ + devupFile: join(root, 'devup.json'), + distDir: join(root, 'df'), + cssDir: join(root, 'df/css'), + singleCss: true, + }), + ], + }) + try { + for (const query of ['', '?route=nested/value', '?route=/nested/value']) { + const result = await server.transformRequest('/src/A.tsx' + query) + assert(result) + const cssUrl = /import\s*["']([^"']+\/devup-ui\.css\?[^"']+)["']/.exec( + result.code, + )?.[1] + assert(cssUrl && cssUrl.startsWith('/df/css/devup-ui.css?')) + const css = await server.transformRequest(cssUrl) + assert(css) + assert.match(css.code, /color:red/) + } + } finally { + await server.close() + } +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/cache-invalidation-query.test.ts b/packages/vite-plugin/src/__tests__/cache-invalidation-query.test.ts new file mode 100644 index 000000000..1588ff1d4 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/cache-invalidation-query.test.ts @@ -0,0 +1,13 @@ +import { join } from 'node:path' + +import { it } from 'bun:test' + +import { ownedNativeFixture } from './serve-coherence-owned-native-fixture' + +it('loads actual CSS when a full transform query contains path separators', () => { + ownedNativeFixture( + join(import.meta.dir, 'cache-invalidation-query-native.cjs'), + [], + () => {}, + ) +}, 180000) diff --git a/packages/vite-plugin/src/__tests__/compiled-dependencies.test.ts b/packages/vite-plugin/src/__tests__/compiled-dependencies.test.ts new file mode 100644 index 000000000..5262abe64 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/compiled-dependencies.test.ts @@ -0,0 +1,103 @@ +import { resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createCompiledDependencies } from '../compiled-dependencies' + +it.each([ + ['unregistered', 'shared.ts'], + ['client', 'unregistered.ts'], +])( + 'returns no consumers for %s / %s when unregistered', + (environment, file) => { + // Given + const tracker = createCompiledDependencies() + tracker.update('client', 'entry.tsx', ['shared.ts']) + // When + const consumers = tracker.consumers(environment, file) + // Then + expect(consumers).toEqual([]) + }, +) + +it('replaces all previous edges when a consumer is recompiled', () => { + // Given + const tracker = createCompiledDependencies() + tracker.update('client', 'entry.tsx', ['old-a.ts', 'old-b.ts']) + tracker.update('client', 'other.tsx', ['old-a.ts', 'old-b.ts']) + // When + tracker.update('client', 'entry.tsx', ['new.ts']) + // Then + expect(tracker.consumers('client', 'old-a.ts')).toEqual(['other.tsx']) + expect(tracker.consumers('client', 'old-b.ts')).toEqual(['other.tsx']) + expect(tracker.consumers('client', 'new.ts')).toEqual(['entry.tsx']) +}) + +it('removes all previous edges when recompilation has no suppliers', () => { + // Given + const tracker = createCompiledDependencies() + tracker.update('client', 'entry.tsx', ['shared.ts']) + tracker.update('client', 'other.tsx', ['shared.ts']) + // When + tracker.update('client', 'entry.tsx', []) + // Then + expect(tracker.consumers('client', 'shared.ts')).toEqual(['other.tsx']) +}) + +it('resolves supplier paths when registering and looking up consumers', () => { + // Given + const tracker = createCompiledDependencies() + tracker.update('client', 'entry.tsx', ['src/nested/../shared.ts']) + // When + const consumers = tracker.consumers('client', resolve('src/shared.ts')) + // Then + expect(consumers).toEqual(['entry.tsx']) +}) + +it('resolves relative supplier lookups against registered absolute paths', () => { + // Given + const tracker = createCompiledDependencies() + tracker.update('client', 'entry.tsx', [resolve('src/shared.ts')]) + // When + const consumers = tracker.consumers('client', 'src/nested/../shared.ts') + // Then + expect(consumers).toEqual(['entry.tsx']) +}) + +it('isolates recompilation when environments share a physical consumer and supplier', () => { + // Given + const tracker = createCompiledDependencies() + const consumer = resolve('entry.tsx') + const supplier = resolve('shared.ts') + tracker.update('client', consumer, [supplier]) + tracker.update('ssr', consumer, [supplier]) + // When + tracker.update('client', consumer, ['client-only.ts']) + // Then + expect(tracker.consumers('client', supplier)).toEqual([]) + expect(tracker.consumers('ssr', supplier)).toEqual([consumer]) + expect(tracker.consumers('client', 'client-only.ts')).toEqual([consumer]) + expect(tracker.consumers('ssr', 'client-only.ts')).toEqual([]) +}) + +it('unlinks query-bearing consumers across environments while preserving unrelated edges', () => { + // Given + const tracker = createCompiledDependencies() + const consumer = resolve('src/entry.tsx') + const other = resolve('src/entry.tsx.extra') + tracker.update('client', `${consumer}?direct`, ['shared.ts', 'removed.ts']) + tracker.update('client', `${consumer}?raw`, ['shared.ts']) + tracker.update('ssr', `${consumer}?ssr`, ['shared.ts', 'removed.ts']) + tracker.update('ssr', consumer, ['shared.ts']) + tracker.update('client', other, ['shared.ts', 'other.ts', consumer]) + tracker.update('ssr', other, ['shared.ts', 'other.ts', consumer]) + // When + tracker.remove('src/nested/../entry.tsx') + // Then + for (const environment of ['client', 'ssr']) { + expect(tracker.consumers(environment, 'shared.ts')).toEqual([other]) + expect(tracker.consumers(environment, 'removed.ts')).toEqual([]) + expect(tracker.consumers(environment, 'other.ts')).toEqual([other]) + expect(tracker.consumers(environment, consumer)).toEqual([other]) + } +}) diff --git a/packages/vite-plugin/src/__tests__/html-packaging.test.ts b/packages/vite-plugin/src/__tests__/html-packaging.test.ts new file mode 100644 index 000000000..8d6c93d20 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/html-packaging.test.ts @@ -0,0 +1,62 @@ +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { + fixture, + STYLE, +} from '../../../plugin-utils/src/__tests__/reservation-fixture' + +it.each(['cjs', 'mjs'])( + 'awaits public parse5 discovery from the published %s artifact', + (extension) => { + // Given + using project = fixture() + project.write('devup.json', '{}') + project.write('©.js', STYLE) + project.write( + 'index.html', + '', + ) + const filename = resolve(import.meta.dir, `../../dist/index.${extension}`) + const code = readFileSync(filename, 'utf8') + const load = + extension === 'cjs' + ? `require(${JSON.stringify(filename)})` + : `await import(${JSON.stringify(`file:///${filename.replaceAll('\\', '/')}`)})` + // When + const result = spawnSync( + 'node', + [ + '--input-type=module', + '-e', + ` + import { createRequire } from 'node:module'; + import { resolve } from 'node:path'; + const require = createRequire(import.meta.url); + const api = ${load}; + const root = ${JSON.stringify(project.root)}; + const [plugin] = api.DevupUI({ + singleCss: true, devupFile: resolve(root, 'devup.json'), + distDir: resolve(root, 'df'), cssDir: resolve(root, 'df/css') + }); + try { + await plugin.configResolved({ command: 'build', root, publicDir: false }); + const watched = []; + await plugin.buildStart.call({ addWatchFile(file) { watched.push(file); } }); + console.log(resolve(watched.find(file => file.endsWith('/©.js')))); + } finally { + await plugin.closeBundle(); + } + `, + ], + { encoding: 'utf8' }, + ) + // Then + expect(code).toMatch(/import\(["']parse5["']\)/) + expect(result.status).toBe(0) + expect(result.stdout.trim()).toBe(resolve(project.root, '©.js')) + }, +) diff --git a/packages/vite-plugin/src/__tests__/html-parser-bundle.cjs b/packages/vite-plugin/src/__tests__/html-parser-bundle.cjs new file mode 100644 index 000000000..a3003ddc9 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/html-parser-bundle.cjs @@ -0,0 +1,56 @@ +const assert = require('node:assert/strict') +const { readFileSync } = require('node:fs') +const { Module, createRequire } = require('node:module') +const { dirname } = require('node:path') +const { compileFunction, constants } = require('node:vm') + +/** @typedef {typeof import('../html-reservation').htmlReservationEntries} Parser */ + +/** Rebind only the private lexical parser in an in-memory copy of the emitted CJS. + * The published file and its exports remain untouched. + * @param {(parse: Parser) => Parser} wrap + * @returns {() => void} */ +function rebindParser(wrap) { + const filename = require.resolve('../../dist/' + 'index.cjs') + assert.equal(require.cache[filename], undefined) + const code = readFileSync(filename, 'utf8') + const parsers = [ + ...code.matchAll( + /async function ([\w$]+)\([^)]*\)\s*\{\s*(?:const|let)\s*\{[^}]*\}\s*=\s*await import\(["']parse5["']\)/g, + ), + ] + assert.equal(parsers.length, 1, 'one emitted asynchronous HTML parser') + const parser = parsers[0]?.[1] + assert(parser) + const evaluate = compileFunction( + code + + ` +return (wrap) => { + const parse = ${parser}; + ${parser} = wrap(parse); + return () => { ${parser} = parse; }; +};`, + ['exports', 'require', 'module', '__filename', '__dirname'], + { + filename, + importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER, + }, + ) + const loaded = new Module(filename) + loaded.filename = filename + /** @type {(wrap: (parse: Parser) => Parser) => () => void} */ + const bind = evaluate.call( + loaded.exports, + loaded.exports, + createRequire(filename), + loaded, + filename, + dirname(filename), + ) + const restore = bind(wrap) + loaded.loaded = true + require.cache[filename] = loaded + return restore +} + +module.exports = { rebindParser } diff --git a/packages/vite-plugin/src/__tests__/html-reservation.test.ts b/packages/vite-plugin/src/__tests__/html-reservation.test.ts new file mode 100644 index 000000000..1543ab1d3 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/html-reservation.test.ts @@ -0,0 +1,179 @@ +import { join, resolve } from 'node:path' + +import { collectReservationInputs } from '@devup-ui/plugin-utils' +import { expect, it } from 'bun:test' + +import { + fixture, + STYLE, +} from '../../../plugin-utils/src/__tests__/reservation-fixture' +import { htmlReservationEntries } from '../html-reservation' + +it.each([ + ['/©A.js', '/©A.js'], + ['/©B.js', '/©B.js'], + ['/©C.js', '/©C.js'], + ['/a&b.js', '/a&b.js'], + ['/a¬it;.js', '/a¬it;.js'], +])( + 'decodes native HTML attributes when the URL is %s', + async (src, expected) => { + // Given + using fs = fixture() + // When + const result = await htmlReservationEntries( + ``, + { + command: 'build', + root: fs.root, + filename: join(fs.root, 'index.html'), + publicDir: false, + }, + ) + // Then + expect(result).toEqual([ + { request: resolve(fs.root, `.${expected}`), context: fs.root }, + ]) + }, +) + +it.each([ + ['serve', false, '/nested/physical/%C2%A9%20A.js', '/physical/© A.js'], + [ + 'build', + true, + '/nested/physical/%C2%A9%20A.js', + '/nested/physical/%C2%A9%20A.js', + ], + ['serve', false, '/physical/bad%URL.js', '/physical/bad%URL.js'], +] as const)( + 'projects native %s requests independently of substituted DEV=%s', + async (command, dev, src, expected) => { + // Given + using fs = fixture() + // When + const entries = await htmlReservationEntries( + ``, + { + command, + base: '/nested/', + root: fs.root, + filename: join(fs.root, 'index.html'), + publicDir: false, + env: { DEV: dev, BASE_URL: '/nested/' }, + }, + ) + // Then + expect(entries.map(({ request }) => request)).toEqual([ + resolve(fs.root, `.${expected}`), + ]) + }, +) + +it('follows complete-document parsing contexts and first duplicate attributes', async () => { + // Given + using fs = fixture() + const script = + '' + const html = ` + ${script} + + + + + `, + { + command: 'build', + root: fs.root, + filename: join(fs.root, 'pages/index.html'), + publicDir: false, + env: { BASE_URL: '/', NAME: '©A.js' }, + }, + ) + const inventory = collectReservationInputs({ + roots: [], + entries, + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + }) + // Then + expect(inventory.inputs.map(({ physicalPath }) => physicalPath)).toEqual([ + join(fs.root, 'outside/supplier.js'), + join(fs.root, 'outside/©A.js'), + ]) +}) + +it('excludes public external ignored and empty requests while preserving native inline fallbacks', async () => { + // Given + using fs = fixture() + fs.write('public/public.js', STYLE) + const html = ` + + + + + + + + + ` + // When + const entries = await htmlReservationEntries(html, { + command: 'build', + root: fs.root, + filename: join(fs.root, 'index.html'), + publicDir: join(fs.root, 'public'), + }) + // Then + expect(entries.map(({ request }) => request)).toEqual([ + './inline.js', + './external-inline.js', + './empty-inline.js', + ]) +}) + +it('leaves unknown substitutions untouched and stringifies known scalar env values', async () => { + // Given + using fs = fixture() + // When + const entries = await htmlReservationEntries( + '', + { + command: 'build', + root: fs.root, + filename: join(fs.root, 'index.html'), + publicDir: false, + env: { N: 2, B: false }, + }, + ) + // Then + expect(entries.map(({ request }) => request)).toEqual([ + join(fs.root, '%UNKNOWN%/2/false.js'), + ]) +}) diff --git a/packages/vite-plugin/src/__tests__/package-boundary.test.ts b/packages/vite-plugin/src/__tests__/package-boundary.test.ts new file mode 100644 index 000000000..de3019284 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/package-boundary.test.ts @@ -0,0 +1,39 @@ +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { fixture } from '../../../plugin-utils/src/__tests__/reservation-fixture' +import { packageBoundary } from '../package-boundary' + +it.each([ + ['package.json', '\uFEFF{"name":"app"}', true], + ['node_modules/accepted/package.json', '{"name":"accepted"}', true], + ['node_modules/@scope/accepted/package.json', '{"name":"accepted"}', true], + ['node_modules/@scope/package.json', '{"name":"scope"}', false], + ['node_modules/accepted/dist/package.json', '{"name":"dist"}', false], + ['package.json', '{"name":"app","sideEffects":[7]}', false], + ['package.json', '{"name":"app","sideEffects":["*.css"]}', true], + ['package.json', '{"name":"app","sideEffects":false}', true], + ['package.json', '{"name":7}', false], +] as const)( + 'classifies governing scope %s with %s', + (path, manifest, expected) => { + // Given + using project = fixture() + project.write(path, manifest) + const directory = join(project.root, path.slice(0, -'package.json'.length)) + const boundary = packageBoundary() + // When / Then + expect(boundary(directory)).toBe(expected) + expect(boundary(directory)).toBe(expected) + }, +) + +it('skips a directory occupying the manifest path', () => { + // Given + using project = fixture() + mkdirSync(join(project.root, 'package.json')) + // When / Then + expect(packageBoundary()(project.root)).toBe(false) +}) diff --git a/packages/vite-plugin/src/__tests__/plugin-consumers.test.ts b/packages/vite-plugin/src/__tests__/plugin-consumers.test.ts new file mode 100644 index 000000000..453f9da3f --- /dev/null +++ b/packages/vite-plugin/src/__tests__/plugin-consumers.test.ts @@ -0,0 +1,98 @@ +import { join } from 'node:path' + +import { expect, it, mock } from 'bun:test' + +import { environment, lifecycle, watcher } from './plugin-lifecycle-fixture' + +it.each(['hotUpdate', 'handleHotUpdate'] as const)( + 'hard-invalidates live compiled consumers and tolerates missing modules through %s', + async (hook) => { + // Given: real extraction supplies edges; framework fakes only record invalidations. + using state = await lifecycle() + const { project, plugin } = state + project.write('src/tone.ts', 'export const tone="red"') + const supplier = join(project.root, 'src/tone.ts') + const client = join(project.root, 'src/client.tsx') + const server = join(project.root, 'src/server.tsx') + const missing = join(project.root, 'src/missing.tsx') + const code = + 'import {Box} from "@devup-ui/react"; import {tone} from "./tone"; export const A=()=> ' + const addWatchFile = mock() + for (const [name, id] of [ + ['client', client], + ['ssr', server], + ['client', missing], + ]) { + project.write(`src/${name}.tsx`, code) + await plugin.transform.call( + { environment: { name }, addWatchFile }, + code, + id, + ) + } + expect(addWatchFile).toHaveBeenCalledWith(supplier.replaceAll('\\', '/')) + const current = environment('client', [client]) + const event = { file: supplier, modules: [], timestamp: 17 } + // When + switch (hook) { + case 'hotUpdate': + await plugin.hotUpdate.call({ environment: current }, event) + break + case 'handleHotUpdate': + await plugin.handleHotUpdate({ + ...event, + server: { moduleGraph: current.moduleGraph, ws: current.hot }, + }) + break + default: + throw new TypeError(`Unexpected hook ${hook satisfies never}`) + } + // Then + expect(current.moduleGraph.invalidateModule).toHaveBeenCalledTimes(1) + expect(current.moduleGraph.invalidateModule).toHaveBeenCalledWith( + current.moduleGraph.getModuleById(client), + expect.any(Set), + 17, + true, + ) + expect(current.hot.send).not.toHaveBeenCalled() + }, +) + +it('replaces consumer edges per environment and removes all query variants on actual watcher unlink', async () => { + // Given + using state = await lifecycle() + const { project, plugin } = state + project.write('src/tone.ts', 'export const tone="blue"') + const supplier = join(project.root, 'src/tone.ts') + const id = join(project.root, 'src/App.tsx') + const code = + 'import {Box} from "@devup-ui/react"; import {tone} from "./tone"; export const A=()=> ' + project.write('src/App.tsx', code) + const watching = watcher() + plugin.configureServer({ config: state.config, watcher: watching }) + for (const name of ['client', 'ssr']) + await plugin.transform.call( + { environment: { name }, addWatchFile: mock() }, + code, + `${id}?${name}`, + ) + await plugin.transform.call( + { environment: { name: 'client' }, addWatchFile: mock() }, + 'export const A=1', + `${id}?client`, + ) + const client = environment('client', [`${id}?client`]) + const ssr = environment('ssr', [`${id}?ssr`]) + const event = { file: supplier, modules: [], timestamp: 21 } + await plugin.hotUpdate.call({ environment: client }, event) + await plugin.hotUpdate.call({ environment: ssr }, event) + expect(client.moduleGraph.invalidateModule).not.toHaveBeenCalled() + expect(ssr.moduleGraph.invalidateModule).toHaveBeenCalledTimes(1) + ssr.moduleGraph.invalidateModule.mockClear() + // When + watching.emit('all', 'unlink', id) + await plugin.hotUpdate.call({ environment: ssr }, { ...event, timestamp: 22 }) + // Then + expect(ssr.moduleGraph.invalidateModule).not.toHaveBeenCalled() +}) diff --git a/packages/vite-plugin/src/__tests__/plugin-lifecycle-fixture.ts b/packages/vite-plugin/src/__tests__/plugin-lifecycle-fixture.ts new file mode 100644 index 000000000..9d25ff6b1 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/plugin-lifecycle-fixture.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' +import { join } from 'node:path' + +import { mock } from 'bun:test' + +import { fixture } from '../../../plugin-utils/src/__tests__/reservation-fixture' +import { DevupUI } from '../plugin' +import type { ReservationConfig } from '../reservation-inputs' + +type Graph = ReturnType +type Environment = { + name: string + config: { consumer: 'client' | 'server' } + moduleGraph: Graph + hot: { send: ReturnType } +} +type Server = { + config: ReservationConfig + environments?: Record + watcher: ReturnType +} +type LifecyclePlugin = { + configResolved(config: ReservationConfig): Promise + configureServer(server: Server): void + buildApp(builder: { + config: ReservationConfig + environments: NonNullable + }): Promise + buildStart(this: { addWatchFile(file: string): void }): Promise + closeBundle(): void + transform( + this: { environment?: { name: string }; addWatchFile(file: string): void }, + code: string, + id: string, + ): Promise + hotUpdate( + this: { environment: Environment }, + event: { file: string; modules: object[]; timestamp: number }, + ): Promise + handleHotUpdate(event: { + file: string + modules: object[] + timestamp: number + server: { moduleGraph: Graph; ws: { send: ReturnType } } + }): Promise + watchChange(file: string): Promise +} + +// Like the existing plugin tests, provide only the framework fields a hook uses. +// Check the hook boundary rather than casting a partial Vite server/context. +function isLifecyclePlugin(value: unknown): value is LifecyclePlugin { + return ( + typeof value === 'object' && + value !== null && + 'configResolved' in value && + typeof value.configResolved === 'function' && + 'configureServer' in value && + typeof value.configureServer === 'function' && + 'buildApp' in value && + typeof value.buildApp === 'function' && + 'buildStart' in value && + typeof value.buildStart === 'function' && + 'closeBundle' in value && + typeof value.closeBundle === 'function' && + 'transform' in value && + typeof value.transform === 'function' && + 'hotUpdate' in value && + typeof value.hotUpdate === 'function' && + 'handleHotUpdate' in value && + typeof value.handleHotUpdate === 'function' && + 'watchChange' in value && + typeof value.watchChange === 'function' + ) +} + +export function watcher() { + const batches: string[][] = [] + return Object.assign(new EventEmitter(), { + batches, + add(paths: string[]) { + batches.push(paths) + }, + }) +} + +export function graph(ids: readonly string[] = []) { + const idToModuleMap = new Map(ids.map((id) => [id, { id }])) + return { + idToModuleMap, + getModuleById: (id: string) => idToModuleMap.get(id), + invalidateModule: mock(), + } +} + +export function environment( + name: string, + ids: readonly string[] = [], +): Environment { + return { + name, + config: { consumer: 'client' }, + moduleGraph: graph(ids), + hot: { send: mock() }, + } +} + +export async function lifecycle(options: Parameters[0] = {}) { + const project = fixture() + project.write('devup.json', '{}') + const plugins = DevupUI({ + singleCss: true, + debug: true, + distDir: join(project.root, 'df'), + cssDir: join(project.root, 'df/css'), + devupFile: join(project.root, 'devup.json'), + ...options, + }) + assert(Array.isArray(plugins)) + const plugin: unknown = plugins[0] + assert(isLifecyclePlugin(plugin)) + const config = { root: project.root, command: 'serve' as const } + await plugin.configResolved(config) + return { + project, + plugin, + config, + [Symbol.dispose]() { + plugin.closeBundle() + project[Symbol.dispose]() + }, + } +} diff --git a/packages/vite-plugin/src/__tests__/plugin-lifecycle.test.ts b/packages/vite-plugin/src/__tests__/plugin-lifecycle.test.ts new file mode 100644 index 000000000..46b9f52ef --- /dev/null +++ b/packages/vite-plugin/src/__tests__/plugin-lifecycle.test.ts @@ -0,0 +1,141 @@ +import { join } from 'node:path' + +import type { ReservationRow } from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { expect, it, mock, spyOn } from 'bun:test' + +import { STYLE } from '../../../plugin-utils/src/__tests__/reservation-fixture' +import { + environment, + graph, + lifecycle, + watcher, +} from './plugin-lifecycle-fixture' + +it('reserves actual server environment union and detaches each watcher lifetime on replacement and close', async () => { + // Given + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + using state = await lifecycle() + const { project, plugin, config } = state + project.write('physical/A.tsx', STYLE) + project.write('physical/B.tsx', STYLE) + const first = watcher() + const second = watcher() + const environments = Object.fromEntries( + ['A', 'B'].map((name) => [ + name, + { + name, + config: { input: `physical/${name}.tsx` }, + }, + ]), + ) + plugin.configureServer({ config, environments, watcher: first }) + const addWatchFile = mock() + // When + await plugin.buildStart.call({ addWatchFile }) + plugin.configureServer({ config, environments, watcher: second }) + await plugin.buildStart.call({ addWatchFile }) + // Then + const files = reserve.mock.calls.flatMap((call) => + call[0].map((row: ReservationRow) => row[0]), + ) + for (const name of ['A', 'B']) { + const file = join(project.root, `physical/${name}.tsx`).replaceAll( + '\\', + '/', + ) + expect(files).toContain(file) + expect(addWatchFile).toHaveBeenCalledWith(file) + expect(second.batches.flat()).toContain(file) + } + expect(first.listenerCount('all')).toBe(0) + expect(second.listenerCount('all')).toBe(1) + plugin.closeBundle() + expect(second.listenerCount('all')).toBe(0) +}) + +it('reserves actual buildApp environments before buildStart watches the discovered suppliers', async () => { + // Given + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + using state = await lifecycle() + state.project.write('physical/A.tsx', STYLE) + state.project.write('physical/B.tsx', STYLE) + await state.plugin.buildApp({ + config: state.config, + environments: { + client: { name: 'client', config: { input: 'physical/A.tsx' } }, + ssr: { name: 'ssr', config: { input: 'physical/B.tsx' } }, + }, + }) + const addWatchFile = mock() + // When + await state.plugin.buildStart.call({ addWatchFile }) + // Then + for (const name of ['A', 'B']) + expect(addWatchFile).toHaveBeenCalledWith( + join(state.project.root, `physical/${name}.tsx`).replaceAll('\\', '/'), + ) + expect(reserve).toHaveBeenCalledTimes(1) +}) + +it('does not prepare or watch dependencies when extraction is disabled', async () => { + // Given + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + using state = await lifecycle({ extractCss: false }) + const addWatchFile = mock() + state.plugin.configureServer({ config: state.config, watcher: watcher() }) + // When + await state.plugin.buildStart.call({ addWatchFile }) + // Then + expect(reserve).not.toHaveBeenCalled() + expect(addWatchFile).not.toHaveBeenCalled() +}) + +it.each(['watchChange', 'hotUpdate', 'handleHotUpdate'] as const)( + 'watches new Tailwind inputs once when config refresh uses %s', + async (hook) => { + // Given + using state = await lifecycle() + const watching = watcher() + state.plugin.configureServer({ config: state.config, watcher: watching }) + state.project.write('new-tailwind.css', '@theme { --color-brand: red; }') + const file = join(state.project.root, 'new-tailwind.css') + state.project.write( + 'devup.json', + JSON.stringify({ tailwind: { css: file } }), + ) + const event = { + file: join(state.project.root, 'devup.json'), + modules: [], + timestamp: 1, + } + // When + switch (hook) { + case 'watchChange': + await state.plugin.watchChange(event.file) + break + case 'hotUpdate': + await state.plugin.hotUpdate.call( + { environment: environment('client') }, + event, + ) + break + case 'handleHotUpdate': + await state.plugin.handleHotUpdate({ + ...event, + server: { moduleGraph: graph(), ws: { send: mock() } }, + }) + break + default: + throw new TypeError(`Unexpected hook ${hook satisfies never}`) + } + // Then + expect(watching.batches.flat().filter((path) => path === file)).toEqual([ + file, + ]) + expect( + watching.batches.flat().filter((path) => path === event.file), + ).toEqual([event.file]) + }, +) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 1a8f299f9..c066c11d0 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -14,6 +14,7 @@ import { mock, spyOn, } from 'bun:test' +import type { Plugin } from 'vite' import { DevupUI } from '../plugin' @@ -61,6 +62,9 @@ interface ViteTestPlugin { enforce: 'pre' apply: () => boolean closeBundle: () => void + buildApp: NonNullable + buildStart: NonNullable + configureServer: NonNullable config: ( this: { meta?: ConfigHookMeta } | void, userConfig?: ViteConfig, @@ -80,6 +84,7 @@ interface ViteTestPlugin { server: { moduleGraph: { invalidateModule: (...args: unknown[]) => void + idToModuleMap?: ReadonlyMap } ws: { send: (...args: unknown[]) => void } } @@ -148,10 +153,17 @@ function createCodeExtractResult( } as unknown as CodeExtractResult } +const activePlugins: ViteTestPlugin[] = [] + function createPlugins( options?: Parameters[0], ): [ViteTestPlugin, ViteTestRestorePlugin] { - return DevupUI(options) as unknown as [ViteTestPlugin, ViteTestRestorePlugin] + const plugins = DevupUI(options) as unknown as [ + ViteTestPlugin, + ViteTestRestorePlugin, + ] + activePlugins.push(plugins[0]) + return plugins } function createPlugin(options?: Parameters[0]): ViteTestPlugin { @@ -220,6 +232,7 @@ beforeEach(() => { }) afterEach(() => { + for (const plugin of activePlugins.splice(0)) plugin.closeBundle() existsSyncSpy.mockRestore() mkdirSpy.mockRestore() readFileSpy.mockRestore() @@ -252,6 +265,9 @@ describe('devupUIVitePlugin', () => { apply: expect.any(Function), generateBundle: expect.any(Function), configResolved: expect.any(Function), + buildApp: expect.any(Function), + configureServer: expect.any(Function), + buildStart: expect.any(Function), resolveId: expect.any(Function), }) expect(plugin.apply()).toBe(true) @@ -264,6 +280,7 @@ describe('devupUIVitePlugin', () => { }), )('should apply options', async (options) => { const plugin = createPlugin(options) + await plugin.configResolved() expect(setDebugSpy).toHaveBeenCalledWith(options.debug) if (options.extractCss) { expect( @@ -1136,16 +1153,171 @@ describe('devupUIVitePlugin', () => { expect(first).not.toContain('?t=') }) + describe('serve sheet module identity', () => { + const sheets = ['devup-ui.css', 'devup-ui-3.css'] as const + const initialCss = '.a{color:red}' + let nowSpy: ReturnType> + + beforeEach(() => { + nowSpy = spyOn(Date, 'now').mockReturnValue(1_730_000_000_000) + }) + + afterEach(() => { + nowSpy.mockRestore() + }) + + function resolveSheet(plugin: ViteTestPlugin, fileName: string) { + const id = plugin.resolveId(fileName, join('df', 'devup-ui', fileName)) + if (typeof id !== 'string') throw new TypeError('Sheet did not resolve') + return id + } + + async function writeSheet( + plugin: ViteTestPlugin, + fileName: string, + css: string, + ) { + getCssSpy.mockReturnValue(css) + codeExtractSpy.mockReturnValue( + createCodeExtractResult({ + css, + cssFile: fileName, + updatedBaseStyle: fileName === 'devup-ui.css', + }), + ) + await plugin.transform( + `const sheet = ${JSON.stringify([fileName, css])}`, + 'sheet-owner.tsx', + ) + } + + it.each(sheets)( + 'keeps %s identity when only the clock advances before any write', + async (fileName) => { + // Given + const plugin = createPlugin({}) + await plugin.configResolved({ command: 'serve' }) + const first = resolveSheet(plugin, fileName) + + // When + nowSpy.mockReturnValue(1_730_000_000_001) + const second = resolveSheet(plugin, fileName) + + // Then + expect(second).toBe(first) + }, + ) + + it.each(sheets)( + 'keeps %s identity when the same text is transformed again', + async (fileName) => { + // Given + const plugin = createPlugin({}) + await plugin.configResolved({ command: 'serve' }) + await writeSheet(plugin, fileName, initialCss) + const first = resolveSheet(plugin, fileName) + writeFileSpy.mockClear() + + // When + nowSpy.mockReturnValue(1_730_000_000_001) + await writeSheet(plugin, fileName, initialCss) + const second = resolveSheet(plugin, fileName) + + // Then + expect(writeFileSpy).not.toHaveBeenCalled() + expect(second).toBe(first) + }, + ) + + it.each( + sheets.flatMap((fileName) => + [ + { change: 'longer', css: '.a{color:blue}' }, + { change: 'shorter', css: '.a{top:0}' }, + { change: 'same-length', css: '.a{color:sky}' }, + ].map((change) => ({ fileName, ...change })), + ), + )( + 'changes $fileName identity when $change text is written at the same clock time', + async ({ fileName, css }) => { + // Given: prime the load cache too, so length-based identity is exercised. + const plugin = createPlugin({}) + await plugin.configResolved({ command: 'serve' }) + await writeSheet(plugin, fileName, initialCss) + plugin.load(fileName) + const first = resolveSheet(plugin, fileName) + writeFileSpy.mockClear() + + // When + await writeSheet(plugin, fileName, css) + const second = resolveSheet(plugin, fileName) + + // Then + expect(writeFileSpy.mock.calls).toEqual([ + [join(resolve('df', 'devup-ui'), fileName), css, 'utf-8'], + ]) + expect(second).not.toBe(first) + }, + ) + + it.each(sheets)( + 'keeps other sheets independent when %s changes', + async (changedSheet) => { + // Given + const plugin = createPlugin({}) + await plugin.configResolved({ command: 'serve' }) + for (const sheet of sheets) + await writeSheet(plugin, sheet, initialCss) + const otherSheet = + changedSheet === 'devup-ui.css' ? 'devup-ui-3.css' : 'devup-ui.css' + const otherId = resolveSheet(plugin, otherSheet) + + // When + nowSpy.mockReturnValue(1_730_000_000_001) + await writeSheet(plugin, changedSheet, '.a{color:sky}') + + // Then + expect(resolveSheet(plugin, otherSheet)).toBe(otherId) + }, + ) + + it.each(sheets)( + 'keeps the build path for %s when text changes and time advances', + async (fileName) => { + // Given + const plugin = createPlugin({}) + await plugin.configResolved({ command: 'build' }) + await writeSheet(plugin, fileName, initialCss) + const first = resolveSheet(plugin, fileName) + + // When + nowSpy.mockReturnValue(1_730_000_000_001) + await writeSheet(plugin, fileName, '.a{color:sky}') + + // Then + expect(first).toBe(join(resolve('df', 'devup-ui'), fileName)) + expect(resolveSheet(plugin, fileName)).toBe(first) + }, + ) + }) + it('cache-busts the id during dev so a growing sheet invalidates', async () => { const plugin = createPlugin({}) await plugin.configResolved({ command: 'serve' }) + codeExtractSpy.mockReturnValue( + createCodeExtractResult({ cssFile: '', updatedBaseStyle: true }), + ) + await plugin.transform('code', 'layout.tsx') + const importer = join('df', 'devup-ui', 'devup-ui.css') + const first = plugin.resolveId('devup-ui.css', importer) - expect( - plugin.resolveId( - 'devup-ui.css', - join('df', 'devup-ui', 'devup-ui.css'), - ), - ).toContain('?t=') + getCssSpy.mockReturnValue('css code with added styles') + await plugin.transform('const addedStyles = true', 'layout.tsx') + const second = plugin.resolveId('devup-ui.css', importer) + + expect(first).toMatch(/\?v=\d+$/) + expect(second).toMatch(/\?v=\d+$/) + expect(second).not.toBe(first) }) }) @@ -1857,311 +2029,13 @@ export const a =
` expect(bundle).toEqual({}) }) - it('should call setPrefix when prefix option is provided', () => { - DevupUI({ prefix: 'my-prefix' }) + it('should call setPrefix when prefix option is configured', async () => { + const plugin = createPlugin({ prefix: 'my-prefix' }) + await plugin.configResolved() expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix') }) }) -describe('devupUIVitePlugin atom hoisting', () => { - type ConfigResolved = (config: unknown) => Promise - const runConfigResolved = async ( - options: Parameters[0], - config: unknown, - ) => { - const [plugin] = DevupUI(options) as unknown as [ - { configResolved: ConfigResolved }, - ] - await plugin.configResolved(config) - } - - let buildCanonicalMapSpy: ReturnType - let computeFileReachSpy: ReturnType - let importCanonicalMapSpy: ReturnType - let importFileRoutesSpy: ReturnType - let setAtomHoistSpy: ReturnType - - beforeEach(() => { - buildCanonicalMapSpy = spyOn( - pluginUtils, - 'buildCanonicalMap', - ).mockReturnValue({}) - computeFileReachSpy = spyOn( - pluginUtils, - 'computeFileReach', - ).mockReturnValue({}) - importCanonicalMapSpy = spyOn(wasm, 'importCanonicalMap').mockReturnValue( - undefined, - ) - importFileRoutesSpy = spyOn(wasm, 'importFileRoutes').mockReturnValue( - undefined, - ) - setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue(undefined) - }) - - afterEach(() => { - buildCanonicalMapSpy.mockRestore() - computeFileReachSpy.mockRestore() - importCanonicalMapSpy.mockRestore() - importFileRoutesSpy.mockRestore() - setAtomHoistSpy.mockRestore() - }) - - it('does nothing when atomHoist is explicitly zero', async () => { - await runConfigResolved({ atomHoist: 0 }, { root: '/p' }) - expect(buildCanonicalMapSpy).not.toHaveBeenCalled() - expect(setAtomHoistSpy).not.toHaveBeenCalled() - }) - - describe('W66 default regressions', () => { - beforeEach(() => { - computeFileReachSpy.mockReturnValue({ - '/p/src/shared.tsx': [0, 1], - '/p/src/a.tsx': [0], - '/p/src/b.tsx': [1], - }) - }) - - it.each([undefined, 0, -1, NaN, Infinity, -Infinity, 0.5, 1, 2, 3])( - 'W66 availability: keeps per-file mode across environments for request %s', - async (atomHoist) => { - // Given: the same plugin is shared by Vite environment builds. - const warningSpy = spyOn(console, 'warn').mockImplementation(() => {}) - const plugin = createPlugin({ atomHoist }) - try { - // When: configuration and both environment transforms run. - await plugin.configResolved({ root: '/p', command: 'build' }) - for (const consumer of ['client', 'server'] as const) { - await plugin.transform.call( - { environment: { name: consumer, config: { consumer } } }, - 'code', - '/p/src/a.tsx', - ) - } - // Then: no atom-only pre-pass or repeated warning is possible. - expect(buildCanonicalMapSpy).not.toHaveBeenCalled() - expect(computeFileReachSpy).not.toHaveBeenCalled() - expect(importFileRoutesSpy).not.toHaveBeenCalled() - expect(setAtomHoistSpy).not.toHaveBeenCalled() - expect(warningSpy).toHaveBeenCalledTimes( - atomHoist !== undefined && - Number.isFinite(atomHoist) && - atomHoist > 0 - ? 1 - : 0, - ) - } finally { - plugin.closeBundle() - warningSpy.mockRestore() - } - }, - ) - - it.each([ - ['omitted', {}], - ['undefined', { atomHoist: undefined }], - ])( - 'keeps omitted atomHoist %s silent without consulting route reach', - async (_label, options) => { - const warningSpy = spyOn(console, 'warn').mockImplementation(() => {}) - try { - await runConfigResolved(options, { root: '/p' }) - expect(computeFileReachSpy).not.toHaveBeenCalled() - expect(importFileRoutesSpy).not.toHaveBeenCalled() - expect(setAtomHoistSpy).not.toHaveBeenCalled() - expect(warningSpy).not.toHaveBeenCalled() - } finally { - warningSpy.mockRestore() - } - }, - ) - - it.each([0, -1, NaN, Infinity, -Infinity])( - 'keeps legacy OFF for atomHoist %s', - async (atomHoist) => { - // Given: two routes, not a single-entry fallback. - // When: an explicitly disabled numeric option is supplied. - await runConfigResolved({ atomHoist }, { root: '/p' }) - // Then: the atom-only pre-pass has no effects. - expect(buildCanonicalMapSpy).not.toHaveBeenCalled() - expect(computeFileReachSpy).not.toHaveBeenCalled() - expect(importFileRoutesSpy).not.toHaveBeenCalled() - expect(setAtomHoistSpy).not.toHaveBeenCalled() - }, - ) - - it('defers a custom threshold above the minimum until reservation lands', async () => { - // Given: two routes and a custom threshold. - // When: config resolves. - await runConfigResolved({ atomHoist: 3 }, { root: '/p' }) - // Then: unavailable atom hoisting never installs a threshold. - expect(setAtomHoistSpy).not.toHaveBeenCalled() - expect(computeFileReachSpy).not.toHaveBeenCalled() - }) - - it.each([ - ['omitted', {}], - ['undefined', { atomHoist: undefined }], - ['enabled', { atomHoist: 2 }], - ])( - 'ignores %s atomHoist before planning in actual singleCss mode', - async (_label, options) => { - // Given: the real config/transform hooks under explicit OFF. - const off = createPlugin({ singleCss: true, atomHoist: 0 }) - await off.configResolved({ root: '/p' }) - const offConfig = callConfig(off, ROLLUP_META) - await off.transform('code', '/p/src/a.tsx') - const offExtractions = [...codeExtractSpy.mock.calls] - codeExtractSpy.mockClear() - // When: only the requested atom policy changes. - const on = createPlugin({ singleCss: true, ...options }) - await on.configResolved({ root: '/p' }) - await on.transform('code', '/p/src/a.tsx') - const onConfig = callConfig(on, ROLLUP_META) - // Then: no canonical/reach mutation and identical extraction IDs/import flags. - expect(buildCanonicalMapSpy).not.toHaveBeenCalled() - expect(importCanonicalMapSpy).not.toHaveBeenCalled() - expect(computeFileReachSpy).not.toHaveBeenCalled() - expect(importFileRoutesSpy).not.toHaveBeenCalled() - expect(setAtomHoistSpy).not.toHaveBeenCalled() - expect(codeExtractSpy.mock.calls).toEqual(offExtractions) - expect(codeExtractSpy).toHaveBeenCalledWith( - '/p/src/a.tsx', - 'code', - '@devup-ui/react', - expect.any(String), - true, - true, - false, - expect.anything(), - ) - const cssId = resolve('df', 'devup-ui', 'devup-ui.css') - expect( - onConfig.build?.rollupOptions?.output?.manualChunks?.(cssId, ''), - ).toBe( - offConfig.build?.rollupOptions?.output?.manualChunks?.(cssId, ''), - ) - }, - ) - }) - - it('defers atom-only collapse and bucket folding until reservation lands', async () => { - buildCanonicalMapSpy.mockReturnValue({ - '/p/src/child.tsx': '/p/src/parent.tsx', - '/p/src/glob.tsx': '@global', - }) - computeFileReachSpy.mockReturnValue({ - '/p/src/parent.tsx': [0, 1], - '/p/src/child.tsx': [0], - '/p/src/glob.tsx': [0, 1], - '/p/src/r1.tsx': [1], - }) - await runConfigResolved( - { atomHoist: 2 }, - { root: '/p', build: { rollupOptions: { input: { a: 'src/a.tsx' } } } }, - ) - expect(buildCanonicalMapSpy).not.toHaveBeenCalled() - expect(importCanonicalMapSpy).not.toHaveBeenCalled() - expect(computeFileReachSpy).not.toHaveBeenCalled() - expect(importFileRoutesSpy).not.toHaveBeenCalled() - expect(setAtomHoistSpy).not.toHaveBeenCalled() - }) - - it.each([ - ['app', 'app'], - ['src', 'src'], - ])('defers atom reach in %s/ layouts', async (_name, dir) => { - existsSyncSpy.mockImplementation( - (path: string) => path === resolve('/p', dir), - ) - computeFileReachSpy.mockReturnValue({ - '/p/a.tsx': [0], - '/p/b.tsx': [1], - }) - - await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) - - expect(computeFileReachSpy).not.toHaveBeenCalled() - }) - - it('does not infer atom reach when neither conventional dir exists', async () => { - existsSyncSpy.mockReturnValue(false) - computeFileReachSpy.mockReturnValue({ - '/p/a.tsx': [0], - '/p/b.tsx': [1], - }) - - await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) - - expect(computeFileReachSpy).not.toHaveBeenCalled() - }) - - it('defers even the former minimum-clamped threshold', async () => { - computeFileReachSpy.mockReturnValue({ - '/p/src/a.tsx': [0], - '/p/src/b.tsx': [1], - }) - await runConfigResolved({ atomHoist: 1 }, { root: '/p' }) - expect(setAtomHoistSpy).not.toHaveBeenCalled() - expect(computeFileReachSpy).not.toHaveBeenCalled() - }) - - it('stays off when fewer than two routes are reachable', async () => { - computeFileReachSpy.mockReturnValue({ '/p/src/a.tsx': [0] }) - await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) - expect(setAtomHoistSpy).not.toHaveBeenCalled() - }) - - it('does not infer atom reach from HTML entries before PR B', async () => { - computeFileReachSpy.mockReturnValue({ - '/p/src/a.tsx': [0], - '/p/src/b.tsx': [1], - }) - await runConfigResolved( - { atomHoist: 2 }, - { root: '/p', build: { rollupOptions: { input: 'index.html' } } }, - ) - expect(computeFileReachSpy).not.toHaveBeenCalled() - expect(setAtomHoistSpy).not.toHaveBeenCalled() - }) - - it('keeps atom reach deferred for array and string JS entries', async () => { - computeFileReachSpy.mockReturnValue({ - '/p/src/a.tsx': [0], - '/p/src/b.tsx': [1], - }) - await runConfigResolved( - { atomHoist: 2 }, - { root: '/p', build: { rollupOptions: { input: ['src/a.tsx'] } } }, - ) - await runConfigResolved( - { atomHoist: 2 }, - { root: '/p', build: { rollupOptions: { input: 'src/a.tsx' } } }, - ) - expect(computeFileReachSpy).not.toHaveBeenCalled() - expect(setAtomHoistSpy).not.toHaveBeenCalled() - }) - - it('never reaches the unavailable atom pre-pass even if it would fail', async () => { - buildCanonicalMapSpy.mockImplementation(() => { - throw new Error('boom') - }) - await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) - expect(buildCanonicalMapSpy).not.toHaveBeenCalled() - expect(setAtomHoistSpy).not.toHaveBeenCalled() - }) - - it('keeps atom reach deferred when config.root is absent', async () => { - computeFileReachSpy.mockReturnValue({ - '/p/src/a.tsx': [0], - '/p/src/b.tsx': [1], - }) - await runConfigResolved({ atomHoist: 2 }, {}) - expect(computeFileReachSpy).not.toHaveBeenCalled() - expect(setAtomHoistSpy).not.toHaveBeenCalled() - }) -}) - describe('module resolver', () => { it('resolves imports to Vite ids and watches the modules read', async () => { const setModuleResolverSpy = spyOn( diff --git a/packages/vite-plugin/src/__tests__/provenance-admission.cjs b/packages/vite-plugin/src/__tests__/provenance-admission.cjs new file mode 100644 index 000000000..de129dd5d --- /dev/null +++ b/packages/vite-plugin/src/__tests__/provenance-admission.cjs @@ -0,0 +1,88 @@ +const assert = require('node:assert/strict') +const { join } = require('node:path') +const { provenanceFixture, observe, wasm } = require('./provenance-fixture.cjs') +const mode = process.argv[2] +const root = process.argv[3] +assert(root) + +async function main() { + const telemetry = observe() + const state = provenanceFixture(root) + const { plugin, transform } = state + state.write( + 'settings.json', + '{"theme":{"colors":{"default":{"primary":"red"}}}}', + ) + state.write('devup.json', '{"extends":["./settings.json"]}') + await plugin.configResolved(state.config) + const { createServer } = await import('vite') + const server = await createServer({ + root, + configFile: false, + server: { watch: null }, + logLevel: 'silent', + }) + plugin.configureServer(server) + const notify = async (/** @type {string} */ file) => { + server.watcher.emit('all', 'change', file) + switch (mode) { + case 'watchChange': + await plugin.watchChange(file) + break + case 'hotUpdate': + await plugin.hotUpdate.call( + { environment: server.environments.client }, + { file, modules: [], timestamp: 51 }, + ) + break + case 'handleHotUpdate': + await plugin.handleHotUpdate({ + file, + modules: [], + timestamp: 51, + server, + }) + break + default: + throw new TypeError('Unknown admission mode ' + mode) + } + } + try { + // Given: an eligible flight and an actual JSON config dependency. + await transform() + const count = telemetry.extractions.length + const reservations = telemetry.reservations.length + // When: arbitrary unrelated JSON passes native admission and the selected hook. + state.write('arbitrary.json', '{"value":1}') + await notify(join(root, 'arbitrary.json')) + await transform() + // Then: no preparation or extraction is repeated. + assert.equal( + telemetry.reservations.length, + reservations, + 'unrelated JSON must not advance actual reservation', + ) + assert.equal( + telemetry.extractions.length, + count, + 'unrelated JSON repeat must reuse', + ) + // Given / When: the same suffix belongs to an actual inherited dependency. + state.write( + 'settings.json', + '{"theme":{"colors":{"default":{"primary":"blue"}}}}', + ) + await notify(join(root, 'settings.json')) + await transform() + // Then: real theme output changes and the old flight is invalid. + assert.equal(telemetry.extractions.length, count + 1) + assert.match(wasm.getCss(null, false), /--primary:blue/) + } finally { + await plugin.closeBundle() + await server.close() + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/provenance-boundary.cjs b/packages/vite-plugin/src/__tests__/provenance-boundary.cjs new file mode 100644 index 000000000..92b1c20e2 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/provenance-boundary.cjs @@ -0,0 +1,199 @@ +const assert = require('node:assert/strict') +const { rmSync, symlinkSync } = require('node:fs') +const { dirname, join, resolve } = require('node:path') +const { provenanceFixture, observe, wasm } = require('./provenance-fixture.cjs') +const { registrationBarrier } = require('./provenance-registration.cjs') +const mode = process.argv[2] +const root = process.argv[3] +assert(root) + +async function main() { + const telemetry = observe() + const state = provenanceFixture(root, { include: ['accepted'] }) + const { plugin, write, transform } = state + write('package.json', '{"name":"provenance-boundary","type":"module"}') + if (mode === 'exports') { + // Given: package-root exports above a separately named dist scope. + const manifest = (/** @type {string} */ color) => + JSON.stringify({ + name: 'accepted', + type: 'module', + exports: './dist/' + color + '.js', + }) + write('workspace/accepted/package.json', manifest('red')) + write( + 'workspace/accepted/dist/package.json', + '{"name":"named-dist","type":"module"}', + ) + write('workspace/accepted/dist/red.js', "export const tone='red'") + write('workspace/accepted/dist/blue.js', "export const tone='blue'") + write('node_modules/.keep', '') + symlinkSync( + join(root, 'workspace/accepted'), + join(root, 'node_modules/accepted'), + 'junction', + ) + const source = + "import {Box} from '@devup-ui/react'; import {tone} from 'accepted'; import '../workspace/accepted/dist/red.js'; import '../workspace/accepted/dist/blue.js'; export const A=()=> ;" + write('src/A.tsx', source) + try { + await plugin.configResolved(state.config) + await transform(source) + await transform(source) + const rows = telemetry.reservations.at(-1)?.rows + const count = telemetry.extractions.length + // When: real package exports change with identical inventory and consumer. + write('workspace/accepted/package.json', manifest('blue')) + await plugin.watchChange(join(root, 'workspace/accepted/package.json')) + await transform(source) + // Then: actual output changes, not just a revision counter. + assert.equal(telemetry.extractions.length, count + 1) + assert.deepEqual(telemetry.reservations.at(-1)?.rows, rows) + assert.match(wasm.getCss(null, false), /color:blue/) + } finally { + await plugin.closeBundle() + } + return + } + assert.equal(mode, 'outside-native') + // Given: source physically outside Vite's root, watched by actual provenance. + write('project/package.json', '{"name":"provenance-host","type":"module"}') + write('node_modules/.keep', '') + symlinkSync( + dirname( + require.resolve('react/package.json', { + paths: [join(__dirname, '../../../react')], + }), + ), + join(root, 'node_modules/react'), + 'junction', + ) + const { createServer } = await import('vite') + /** @type {PromiseWithResolvers} */ + const ready = Promise.withResolvers() + /** @type {ReturnType | undefined} */ + let membership + /** @type {{type:'create'|'delete',done:PromiseWithResolvers,pending:Set,timestamp?:number}|undefined} */ + let semanticChange + const server = await createServer({ + root: join(root, 'project'), + configFile: false, + logLevel: 'silent', + build: { rolldownOptions: { input: state.id } }, + server: { port: 0, host: '127.0.0.1', fs: { allow: [root] } }, + plugins: [ + { + name: 'provenance-native-registration-observer', + enforce: 'pre', + configureServer(server) { + membership = registrationBarrier(server.watcher, join(root, 'src')) + server.watcher.once('ready', () => ready.resolve()) + }, + hotUpdate: { + order: 'post', + handler({ file, type, timestamp }) { + const change = semanticChange + if ( + !change || + resolve(file) !== resolve(root, 'src/new-directory/B.tsx') || + type !== change.type + ) + return + change.timestamp ??= timestamp + assert.equal(timestamp, change.timestamp) + change.pending.delete(this.environment.name) + console.info( + 'provenance-native semantic ' + + JSON.stringify({ + type, + timestamp, + pending: [...change.pending], + }), + ) + if (!change.pending.size) change.done.resolve() + }, + }, + }, + state.plugins, + ], + }) + /** @param {string} event @param {string} file */ + function delivered(event, file) { + return new Promise((resolveEvent) => { + const listener = ( + /** @type {string} */ actual, + /** @type {string} */ path, + ) => { + if (actual !== event || resolve(path) !== resolve(file)) return + server.watcher.off('all', listener) + resolveEvent(undefined) + } + server.watcher.on('all', listener) + }) + } + try { + await server.listen() + console.info('provenance-native waiting ready') + await ready.promise + const environment = server.environments.client + assert(environment && membership) + const loaded = await environment.transformRequest('/@fs/' + state.id) + assert(loaded) + const input = state.source + const extract = () => environment.pluginContainer.transform(input, state.id) + await extract() + assert( + telemetry.extractions.some( + (item) => item.id === state.id && item.input === input, + ), + ) + // Global ready and actual A loading do not release this separate signal. + await membership.ready + membership.assertReady() + console.info( + 'provenance-native registration ' + JSON.stringify(membership.records), + ) + await extract() + console.info('provenance-native ready') + const dir = join(root, 'src/new-directory') + const file = join(dir, 'B.tsx') + // When: actual native outside-root directory/file create, delete, recreate. + for (const operation of ['create', 'delete', 'recreate']) { + const directory = delivered( + operation === 'delete' ? 'unlinkDir' : 'addDir', + dir, + ) + const member = delivered(operation === 'delete' ? 'unlink' : 'add', file) + /** @type {PromiseWithResolvers} */ + const processed = Promise.withResolvers() + semanticChange = { + type: operation === 'delete' ? 'delete' : 'create', + done: processed, + pending: new Set(Object.keys(server.environments)), + } + const count = telemetry.extractions.length + membership.assertReady() + membership.beforeMutation(operation) + if (operation === 'delete') rmSync(dir, { recursive: true }) + else write('src/new-directory/B.tsx', state.source.replace('red', 'blue')) + console.info('provenance-native waiting ' + operation) + await Promise.all([directory, member, processed.promise]) + console.info('provenance-native delivered ' + operation) + await extract() + // Then: membership dirties real preparation and rejects the old flight. + assert.equal(telemetry.extractions.length, count + 1) + await extract() + assert.equal(telemetry.extractions.length, count + 1) + } + } finally { + try { + await server.close() + } finally { + membership?.restore() + } + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/provenance-fixture.cjs b/packages/vite-plugin/src/__tests__/provenance-fixture.cjs new file mode 100644 index 000000000..c728859dc --- /dev/null +++ b/packages/vite-plugin/src/__tests__/provenance-fixture.cjs @@ -0,0 +1,124 @@ +const assert = require('node:assert/strict') +const { join, resolve } = require('node:path') +const fs = require('node:fs/promises') +const { put } = require('./reservation-native-fixture.cjs') +const { observe } = require('./reservation-native-observer.cjs') +const { rebindParser } = require('./html-parser-bundle.cjs') +const wasm = require('@devup-ui/wasm') + +/** @typedef {import('./serve-coherence-async-fixture.cjs').Hooks & {load(id:string):string|undefined, configureServer(server:import('vite').ViteDevServer):void, hotUpdate(this:unknown,ctx:unknown):Promise, handleHotUpdate(ctx:unknown):Promise}} ProvenanceHooks */ +/** @param {unknown} value @returns {value is ProvenanceHooks} */ +function ProvenanceHooks(value) { + return ( + typeof value === 'object' && + value !== null && + [ + 'configResolved', + 'watchChange', + 'closeBundle', + 'buildStart', + 'transform', + 'load', + 'configureServer', + 'hotUpdate', + 'handleHotUpdate', + ].every( + (key) => key in value && typeof Reflect.get(value, key) === 'function', + ) + ) +} + +/** @param {string} root @param {Partial} [options] */ +function provenanceFixture(root, options = {}) { + const write = (/** @type {string} */ path, /** @type {string} */ text) => + put(root, path, text) + write('devup.json', '{}') + write('package.json', '{"type":"module"}') + const source = + "import {Box} from '@devup-ui/react'; export const A=()=> ;" + write('src/A.tsx', source) + const { DevupUI } = require('../../dist/' + 'index.cjs') + const plugins = DevupUI({ + singleCss: true, + devupFile: join(root, 'devup.json'), + distDir: join(root, 'df'), + cssDir: join(root, 'df/css'), + ...options, + }) + assert(Array.isArray(plugins)) + const plugin = plugins[0] + assert(ProvenanceHooks(plugin)) + const id = resolve(root, 'src/A.tsx').replaceAll('\\', '/') + const context = { + environment: { name: 'client' }, + addWatchFile(/** @type {string} */ _file) {}, + } + const transform = (code = source, fullId = id) => + plugin.transform.call(context, code, fullId) + /** @type {import('../reservation-inputs').ReservationConfig} */ + const config = { root, command: 'serve' } + return { write, source, plugin, plugins, id, context, transform, config } +} + +/** Suspend the real HTML parser result, not a fabricated reservation/revision. */ +function provenanceParserGate() { + /** @type {PromiseWithResolvers} */ + const entered = Promise.withResolvers() + /** @type {PromiseWithResolvers} */ + const released = Promise.withResolvers() + let armed = true + const restoreParser = rebindParser((parse) => async (...args) => { + const result = await parse(...args) + if (armed) { + armed = false + entered.resolve() + await released.promise + } + return result + }) + return { + entered: entered.promise, + release: released.resolve, + restore() { + released.resolve() + restoreParser() + }, + } +} + +/** @param {string} path */ +function provenanceWriteGate(path) { + const write = fs.writeFile + /** @type {PromiseWithResolvers} */ + const entered = Promise.withResolvers() + /** @type {PromiseWithResolvers} */ + const released = Promise.withResolvers() + let armed = true + fs.writeFile = new Proxy(write, { + apply(target, receiver, args) { + const result = Reflect.apply(target, receiver, args) + if (!armed || String(args[0]) !== path) return result + armed = false + return Promise.resolve(result).then(async () => { + entered.resolve() + await released.promise + }) + }, + }) + return { + entered: entered.promise, + release: released.resolve, + restore() { + released.resolve() + fs.writeFile = write + }, + } +} + +module.exports = { + provenanceFixture, + provenanceParserGate, + provenanceWriteGate, + observe, + wasm, +} diff --git a/packages/vite-plugin/src/__tests__/provenance-lifetime.cjs b/packages/vite-plugin/src/__tests__/provenance-lifetime.cjs new file mode 100644 index 000000000..b663c1f4b --- /dev/null +++ b/packages/vite-plugin/src/__tests__/provenance-lifetime.cjs @@ -0,0 +1,168 @@ +const assert = require('node:assert/strict') +const { join } = require('node:path') +const { + provenanceFixture, + provenanceParserGate, + provenanceWriteGate, + observe, + wasm, +} = require('./provenance-fixture.cjs') +const mode = process.argv[2] +const root = process.argv[3] +assert(root) + +async function main() { + const telemetry = observe() + const reserve = wasm.reserveClassSlots + /** @type {(() => void) | undefined} */ + let overtake + wasm.reserveClassSlots = (...args) => { + reserve(...args) + if (overtake) { + queueMicrotask(overtake) + overtake = undefined + } + } + // Given: actual S1 configuration and a real parser suspended inside preparation. + const parser = provenanceParserGate() + const state = provenanceFixture(root) + const { plugin, transform } = state + /** @type {import('vite').ViteDevServer[]} */ + const servers = [] + try { + if (mode === 'pending-replacement') { + state.write( + 'devup.json', + '{"theme":{"colors":{"default":{"primary":"red"}}}}', + ) + const { createServer } = await import('vite') + const first = await createServer({ + root, + configFile: false, + server: { watch: null }, + logLevel: 'silent', + }) + const second = await createServer({ + root, + configFile: false, + server: { watch: null }, + logLevel: 'silent', + }) + servers.push(first, second) + const write = provenanceWriteGate(join(root, 'df/theme.d.ts')) + const configured = plugin.configResolved(state.config) + try { + await write.entered + plugin.configureServer(first) + plugin.configureServer(second) + write.release() + await configured + await transform() + await transform() + assert.equal( + telemetry.extractions.length, + 2, + 'old pending completion cannot ready a replacement session', + ) + } finally { + write.restore() + await configured + } + return + } + await plugin.configResolved(state.config) + if (mode === 'physical' || mode === 'physical-query') { + state.write( + 'index.html', + '', + ) + const a = + "import {globalCss} from '@devup-ui/react'; globalCss({body:{color:'red'}});" + const b = a.replace('red', 'blue') + const old = transform(a) + await parser.entered + await plugin.configResolved(state.config) + /** @type {Promise | undefined} */ + let fresh + overtake = () => { + fresh = transform( + b, + state.id + (mode === 'physical-query' ? '?current' : ''), + ) + } + // When: the prepared fast path overtakes the obsolete pending.finally waiter. + parser.release() + await old + await fresh + assert.deepEqual( + telemetry.extractions.map((row) => row.input), + [b, a], + 'real extraction order must prove overtaking', + ) + assert.match(wasm.getCss(null, false), /body\{color:red\}/) + await transform( + b, + state.id + (mode === 'physical-query' ? '?current' : ''), + ) + // Then: B restores actual global CSS rather than returning a stale cached flight. + assert.equal( + telemetry.extractions.length, + 3, + 'obsolete extraction must invalidate current physical observation', + ) + assert.match(wasm.getCss(null, false), /body\{color:blue\}/) + await transform( + b, + state.id + (mode === 'physical-query' ? '?current' : ''), + ) + assert.equal(telemetry.extractions.length, 3) + } else { + const { createServer } = await import('vite') + const first = await createServer({ + root, + configFile: false, + server: { watch: null }, + logLevel: 'silent', + }) + servers.push(first) + plugin.configureServer(first) + if (mode === 'fresh') await plugin.configResolved(state.config) + const second = await createServer({ + root, + configFile: false, + server: { watch: null }, + logLevel: 'silent', + }) + servers.push(second) + // When: attach a replacement after fresh config, or without one as a control. + plugin.configureServer(second) + await transform() + await transform() + // Then: only the successfully configured fresh session is eligible. + assert.equal( + telemetry.extractions.length, + mode === 'fresh' ? 1 : 2, + 'fresh config session must survive old watcher teardown', + ) + } + } finally { + wasm.reserveClassSlots = reserve + parser.restore() + await plugin.closeBundle() + for (const server of servers) await server.close() + } +} +let settled = false +process.once('beforeExit', () => { + assert(settled, 'real lifetime scenario must settle before process exit') +}) +main().then( + () => { + settled = true + }, + (error) => { + settled = true + console.error(error) + process.exitCode = 1 + }, +) diff --git a/packages/vite-plugin/src/__tests__/provenance-owned-native-fixture.ts b/packages/vite-plugin/src/__tests__/provenance-owned-native-fixture.ts new file mode 100644 index 000000000..e10f76c7d --- /dev/null +++ b/packages/vite-plugin/src/__tests__/provenance-owned-native-fixture.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +/** Owned-tree cleanup contract, with verbatim successful owner stdout retained. */ +export function retainedNativeFixture(helper: string, args: readonly string[]) { + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'provenance-owned-native-')), + ) + const result = spawnSync( + 'node', + [ + resolve( + import.meta.dir, + '../../../next-plugin/src/__tests__/native-webpack-owned.cjs', + ), + '120000', + helper, + ...args, + root, + ], + { encoding: 'utf8', timeout: 150000 }, + ) + const exited = result.stdout.split(/\r?\n/).some((line) => { + const prefix = 'NATIVE-LIFECYCLE ' + if (!line.startsWith(prefix)) return false + const value: unknown = JSON.parse(line.slice(prefix.length)) + return ( + typeof value === 'object' && + value !== null && + 'event' in value && + value.event === 'tree_exit' && + 'active' in value && + Array.isArray(value.active) && + value.active.length === 0 + ) + }) + // Preserve original identities and receipt before either assertion or deletion. + process.stdout.write(result.stdout) + process.stderr.write(result.stderr) + console.info( + 'provenance-native runner ' + JSON.stringify({ status: result.status }), + ) + assert( + exited, + `No owned tree exit for ${root}\n${result.stdout}${result.stderr}`, + ) + try { + assert.equal(result.status, 0, result.stdout + result.stderr) + } finally { + rmSync(root, { recursive: true }) + } +} diff --git a/packages/vite-plugin/src/__tests__/provenance-registration.cjs b/packages/vite-plugin/src/__tests__/provenance-registration.cjs new file mode 100644 index 000000000..28a5b0986 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/provenance-registration.cjs @@ -0,0 +1,178 @@ +const assert = require('node:assert/strict') +const { Session } = require('node:inspector') +const { dirname, resolve } = require('node:path') + +/** Only reads the exact closer's own listener, never a neighboring container listener. + * @param {Function} closer + */ +function directoryScope(closer) { + const session = new Session() + session.connect() + Reflect.set(globalThis, '__ProvenanceRegistrationCloser', closer) + /** @param {string} objectId */ + function properties(objectId) { + /** @type {import('node:inspector').Runtime.GetPropertiesReturnType | undefined} */ + let result + session.post( + 'Runtime.getProperties', + { objectId, ownProperties: true }, + (error, response) => { + if (error) throw error + result = response + }, + ) + assert(result, 'local inspector must complete synchronously') + return [...result.result, ...(result.internalProperties ?? [])] + } + /** @param {string} objectId @param {string} description */ + function scope(objectId, description) { + const scopes = properties(objectId).find( + (item) => item.name === '[[Scopes]]', + )?.value?.objectId + assert(scopes) + return properties(scopes).find( + (item) => item.value?.description === description, + )?.value?.objectId + } + try { + /** @type {string | undefined} */ + let id + session.post( + 'Runtime.evaluate', + { + expression: 'globalThis.__ProvenanceRegistrationCloser', + objectGroup: 'provenance-registration', + }, + (error, response) => { + if (error) throw error + assert(!response.exceptionDetails) + id = response.result.objectId + }, + ) + assert(id) + const registration = scope(id, 'Closure (setFsWatchListener)') + if (!registration) return + const listener = properties(registration).find( + (item) => item.name === 'listener', + )?.value?.objectId + assert(listener) + const directory = scope(listener, 'Closure (_handleDir)') + if (!directory) return + const values = properties(directory) + const dir = values.find((item) => item.name === 'dir')?.value?.value + const target = values.find((item) => item.name === 'target')?.value + assert(typeof dir === 'string' && target) + assert(target.type === 'undefined' || target.type === 'string') + return { + dir, + target: target.type === 'undefined' ? undefined : String(target.value), + } + } finally { + session.post('Runtime.releaseObjectGroup', { + objectGroup: 'provenance-registration', + }) + Reflect.deleteProperty(globalThis, '__ProvenanceRegistrationCloser') + session.disconnect() + } +} + +/** Installed solely by the outside-native scenario; no watch requests are authored here. + * @param {import('vite').ViteDevServer['watcher']} watcher + * @param {string} directory + */ +function registrationBarrier(watcher, directory) { + const original = Reflect.get(watcher, '_addPathCloser') + assert(typeof original === 'function') + /** @type {PromiseWithResolvers} */ + const unrestricted = Promise.withResolvers() + /** @type {PromiseWithResolvers} */ + const targeted = Promise.withResolvers() + /** @type {{sequence:number,returnedSequence:number,signalSequence:number,path:string,dir:string,target:string|undefined,accepted:boolean,unrestrictedPending:boolean}[]} */ + const records = [] + let sequence = 0 + let accepted = false + const hadOwn = Object.hasOwn(watcher, '_addPathCloser') + const observer = new Proxy(original, { + apply(method, receiver, args) { + const returned = Reflect.apply(method, receiver, args) + const [path, closer] = args + if (typeof path !== 'string' || typeof closer !== 'function') + return returned + if ( + resolve(path) !== resolve(directory) && + resolve(dirname(path)) !== resolve(directory) + ) + return returned + const returnedSequence = ++sequence + // Queue only observer work; the backend caller receives its real return first. + queueMicrotask(() => { + try { + const closers = Reflect.get(watcher, '_closers') + assert( + closers instanceof Map && + closers + .get(require('node:path').normalize(path)) + ?.includes(closer), + ) + const value = directoryScope(closer) + if (!value || resolve(value.dir) !== resolve(directory)) return + const qualifies = value.target === undefined + const classifiedSequence = ++sequence + const signalSequence = ++sequence + records.push({ + sequence: classifiedSequence, + returnedSequence, + signalSequence, + path, + ...value, + accepted: qualifies, + unrestrictedPending: !accepted, + }) + if (qualifies) { + accepted = true + unrestricted.resolve() + } else { + targeted.resolve() + } + } catch (error) { + unrestricted.reject(error) + targeted.reject(error) + } + }) + return returned + }, + }) + Reflect.set(watcher, '_addPathCloser', observer) + return { + ready: Promise.all([unrestricted.promise, targeted.promise]), + records, + /** @param {string} operation */ + beforeMutation(operation) { + console.info( + 'provenance-native mutation ' + + JSON.stringify({ + sequence: ++sequence, + operation, + releaseCause: + 'actual-unrestricted-and-targeted-registration-signals', + records, + }), + ) + }, + assertReady() { + assert( + accepted, + 'only actual unrestricted registration releases membership', + ) + assert( + records.some((item) => item.target !== undefined && !item.accepted), + 'actual targeted registration must be rejected', + ) + }, + restore() { + if (hadOwn) Reflect.set(watcher, '_addPathCloser', original) + else Reflect.deleteProperty(watcher, '_addPathCloser') + }, + } +} +module.exports = { registrationBarrier } diff --git a/packages/vite-plugin/src/__tests__/provenance-relevance.test.ts b/packages/vite-plugin/src/__tests__/provenance-relevance.test.ts new file mode 100644 index 000000000..4ddaef8dd --- /dev/null +++ b/packages/vite-plugin/src/__tests__/provenance-relevance.test.ts @@ -0,0 +1,231 @@ +import { rmSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import { + fixture, + STYLE, +} from '../../../plugin-utils/src/__tests__/reservation-fixture' +import { createCompiledDependencies } from '../compiled-dependencies' +import * as htmlReservation from '../html-reservation' +import { createViteReservation } from '../reservation' +import { createServerWatch } from '../server-watch' + +function provenanceReservation(root: string) { + return createViteReservation({ + package: '@devup-ui/react', + include: [], + importAliases: {}, + singleCss: true, + cssDir: join(root, 'df/css'), + distDir: join(root, 'df'), + }) +} + +it('leaves actual revision unchanged when unrelated arbitrary JSON is admitted', async () => { + // Given + resetBuildState() + using project = fixture() + project.write('src/A.js', STYLE) + const reservation = provenanceReservation(project.root) + reservation.configure({ root: project.root }) + await reservation.reserve() + const revision = reservation.revision + // When + reservation.invalidate(join(project.root, 'arbitrary.json'), 42) + await reservation.reserve() + // Then + expect(reservation.revision).toBe(revision) + expect(reservation.prepared).toBe(revision) +}) + +for (const request of ['pages/missing.html', 'external/data.json']) { + it(`admits explicit ${request} before first preparation`, () => { + // Given + using project = fixture() + const reservation = provenanceReservation(project.root) + reservation.configure({ root: project.root, input: request }) + const revision = reservation.revision + // When + reservation.invalidate(resolve(project.root, request)) + // Then + expect(reservation.revision).toBe(revision + 1) + }) +} + +for (const event of ['addDir', 'unlinkDir']) { + it(`dirties native membership when outside-root ${event} arrives`, () => { + // Given + using project = fixture() + const reservation = provenanceReservation(project.root) + let deliver: (event: string, file: string) => void = () => {} + const watcher = { + add() {}, + on(_event: 'all', listener: typeof deliver) { + deliver = listener + }, + off() {}, + } + const watching = createServerWatch(watcher, reservation, () => {}) + const revision = reservation.revision + // When + deliver(event, resolve(project.root, '../outside-candidate')) + // Then + expect(reservation.revision).toBe(revision + 1) + watching.close() + }) +} + +for (const file of [ + 'tsconfig.json', + 'package.json', + 'bun.lock', + 'config/explicit.json', +]) { + it(`admits initial ${file} before published dependencies exist`, () => { + // Given + using project = fixture() + const reservation = provenanceReservation(project.root) + reservation.configure({ + root: project.root, + configFileDependencies: ['config/explicit.json'], + }) + const revision = reservation.revision + // When + reservation.invalidate(join(project.root, file), 13) + reservation.invalidate(join(project.root, file), 13) + // Then + expect(reservation.revision).toBe(revision + 1) + }) +} + +it('discards first HTML candidate when it changes during the real parser await', async () => { + // Given + resetBuildState() + using project = fixture() + project.write('outside/A.js', STYLE) + project.write('outside/B.js', STYLE) + project.write( + 'index.html', + '', + ) + const reservation = provenanceReservation(project.root) + reservation.configure({ root: project.root }) + const entered = Promise.withResolvers() + const released = Promise.withResolvers() + const parse = htmlReservation.htmlReservationEntries + using parser = spyOn( + htmlReservation, + 'htmlReservationEntries', + ).mockImplementationOnce(async (...args) => { + const result = await parse(...args) + entered.resolve() + await released.promise + return result + }) + const pending = reservation.reserve() + await entered.promise + // When + project.write( + 'index.html', + '', + ) + reservation.invalidate(join(project.root, 'index.html')) + released.resolve() + await pending + // Then + expect(parser).toHaveBeenCalledTimes(2) + expect(reservation.dependencies.map((path) => resolve(path))).toContain( + join(project.root, 'outside/B.js'), + ) + expect(reservation.dependencies.map((path) => resolve(path))).not.toContain( + join(project.root, 'outside/A.js'), + ) +}) + +it('discovers a missing explicit HTML entry when it is created after preparation', async () => { + // Given + resetBuildState() + using project = fixture() + project.write('outside/A.js', STYLE) + const reservation = provenanceReservation(project.root) + reservation.configure({ root: project.root, input: 'pages/entry.html' }) + await reservation.reserve() + // When + project.write( + 'pages/entry.html', + '', + ) + reservation.invalidate(join(project.root, 'pages/entry.html')) + await reservation.reserve() + // Then + expect(reservation.dependencies.map((path) => resolve(path))).toContain( + join(project.root, 'outside/A.js'), + ) +}) + +for (const operation of ['create', 'delete', 'recreate']) { + it(`admits closer missing manifest ${operation} from exact provenance`, async () => { + // Given + resetBuildState() + using project = fixture() + project.write('package.json', '{"name":"outer"}') + project.write('src/deep/A.js', STYLE) + const reservation = provenanceReservation(project.root) + reservation.configure({ root: project.root }) + await reservation.reserve() + const file = join(project.root, 'src/package.json') + expect(reservation.dependencies.map((path) => resolve(path))).toContain( + file, + ) + if (operation !== 'create') { + project.write('src/package.json', '{"name":"closer"}') + reservation.invalidate(file) + await reservation.reserve() + } + if (operation === 'recreate') { + rmSync(file) + reservation.invalidate(file) + await reservation.reserve() + } + const revision = reservation.revision + // When + if (operation === 'delete') rmSync(file) + else project.write('src/package.json', '{"name":"closer"}') + reservation.invalidate(file) + await reservation.reserve() + // Then + expect(reservation.revision).toBe(revision + 1) + expect(reservation.prepared).toBe(revision + 1) + }) +} + +for (const suffix of ['json', 'css', 'cjs']) { + it(`admits cross-environment compiled ${suffix} supplier without blanket suffix admission`, () => { + // Given + using project = fixture() + const compiled = createCompiledDependencies() + const supplier = join(project.root, `outside/supplier.${suffix}`) + compiled.update('rsc', 'consumer.tsx?server', [supplier]) + const reservation = createViteReservation({ + package: '@devup-ui/react', + include: [], + importAliases: {}, + singleCss: true, + cssDir: join(project.root, 'df/css'), + distDir: join(project.root, 'df'), + relevant: compiled.hasSupplier, + }) + const revision = reservation.revision + // When + reservation.invalidate(join(project.root, `arbitrary.${suffix}`), 9) + expect(reservation.revision).toBe(revision) + reservation.invalidate(supplier, 9) + // Then + expect(reservation.revision).toBe(revision + 1) + compiled.remove('consumer.tsx') + expect(compiled.hasSupplier(supplier)).toBe(false) + }) +} diff --git a/packages/vite-plugin/src/__tests__/provenance-repairs.test.ts b/packages/vite-plugin/src/__tests__/provenance-repairs.test.ts new file mode 100644 index 000000000..c9d8dedbb --- /dev/null +++ b/packages/vite-plugin/src/__tests__/provenance-repairs.test.ts @@ -0,0 +1,51 @@ +import { join } from 'node:path' + +import { it } from 'bun:test' + +import { retainedNativeFixture } from './provenance-owned-native-fixture' +import { ownedNativeFixture } from './serve-coherence-owned-native-fixture' + +for (const mode of [ + 'fresh', + 'replacement', + 'pending-replacement', + 'physical', + 'physical-query', +]) { + it(`preserves real extraction lifetime when ${mode} occurs`, () => { + // Given / When: execute the actual facade in an owned Node process. + ownedNativeFixture( + join(import.meta.dir, 'provenance-lifetime.cjs'), + [mode], + () => { + // Then: child assertions pass before confirmed tree_exit/active[] cleanup. + }, + ) + }, 180000) +} + +for (const mode of ['watchChange', 'hotUpdate', 'handleHotUpdate']) { + it(`distinguishes unrelated JSON from actual dependencies through ${mode}`, () => { + ownedNativeFixture( + join(import.meta.dir, 'provenance-admission.cjs'), + [mode], + () => {}, + ) + }, 180000) +} + +for (const mode of ['exports', 'outside-native']) { + it(`preserves provenance when real ${mode} changes`, () => { + if (mode === 'outside-native') { + retainedNativeFixture(join(import.meta.dir, 'provenance-boundary.cjs'), [ + mode, + ]) + return + } + ownedNativeFixture( + join(import.meta.dir, 'provenance-boundary.cjs'), + [mode], + () => {}, + ) + }, 180000) +} diff --git a/packages/vite-plugin/src/__tests__/reservation-hoist.test.ts b/packages/vite-plugin/src/__tests__/reservation-hoist.test.ts new file mode 100644 index 000000000..ab54accd1 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-hoist.test.ts @@ -0,0 +1,261 @@ +import { rmSync } from 'node:fs' +import { join } from 'node:path' + +import * as utils from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import { + fixture, + STYLE, +} from '../../../plugin-utils/src/__tests__/reservation-fixture' +import * as htmlReservation from '../html-reservation' +import { createViteReservation } from '../reservation' +import type { ReservationEnvironment } from '../reservation-inputs' + +function reservation(root: string) { + return createViteReservation({ + package: '@devup-ui/react', + include: [], + importAliases: {}, + singleCss: false, + cssDir: join(root, 'df/css'), + distDir: join(root, 'df'), + }) +} + +it.each([false, true])( + 'installs the complete environment union before first scratch with reversed order %s', + async (reverse) => { + // Given + using project = fixture() + project.write( + 'outside/A.js', + `${STYLE}; import '../shared.js'; import('../lazy.js')`, + ) + project.write('outside/B.js', `${STYLE}; import '../shared.js'`) + project.write('shared.js', STYLE) + project.write('lazy.js', "import './shared.js'") + project.write('src/unused.js', "import('../unused-lazy.js')") + project.write('unused-lazy.js', STYLE) + project.write( + 'index.html', + '', + ) + using routes = spyOn(wasm, 'importFileRoutes').mockImplementation(() => {}) + using _canonical = spyOn(wasm, 'importCanonicalMap').mockImplementation( + () => {}, + ) + using threshold = spyOn(wasm, 'setAtomHoist').mockImplementation(() => {}) + using plan = spyOn(utils, 'reservationGraph') + using engine = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => { + expect(routes.mock.calls[0]?.[0]).toMatchObject({ + [join(project.root, 'shared.js').replaceAll('\\', '/')]: [0, 1, 2], + }) + expect(threshold).toHaveBeenCalledWith(2) + }) + const slots = reservation(project.root) + const environments: Readonly>[] = [ + { client: {} }, + { server: { build: { ssr: 'outside/B.js' } } }, + ] + for (const environment of reverse + ? environments.toReversed() + : environments) + slots.configure({ root: project.root, environments: environment }) + slots.configure({ + root: project.root, + environments: { duplicate: { input: 'outside/A.js' } }, + }) + // When + const first = slots.reserve() + expect(slots.reserve()).toBe(first) + await first + await slots.reserve() + project.write('outside/C.js', `${STYLE}; import '../shared.js'`) + slots.configure({ + root: project.root, + environments: { later: { input: 'outside/C.js' } }, + }) + await slots.reserve() + // Then + expect(plan).toHaveBeenCalledTimes(1) + expect(engine).toHaveBeenCalledTimes(2) + expect(routes).toHaveBeenCalledTimes(1) + expect(routes.mock.calls[0]?.[0]).not.toHaveProperty( + join(project.root, 'unused-lazy.js').replaceAll('\\', '/'), + ) + }, +) + +it('discards stale async candidates before planning and retries engine failure without reinstallation', async () => { + // Given + using project = fixture() + project.write('outside/A.js', STYLE) + project.write('outside/B.js', STYLE) + project.write('index.html', '') + const parse = htmlReservation.htmlReservationEntries + const gate = Promise.withResolvers() + using _parser = spyOn( + htmlReservation, + 'htmlReservationEntries', + ).mockImplementationOnce(async (html, options) => { + await gate.promise + return parse(html, options) + }) + using graph = spyOn(utils, 'reservationGraph') + using _canonical = spyOn(wasm, 'importCanonicalMap').mockImplementation( + () => {}, + ) + using routes = spyOn(wasm, 'importFileRoutes').mockImplementation(() => {}) + using threshold = spyOn(wasm, 'setAtomHoist').mockImplementation(() => {}) + using engine = spyOn(wasm, 'reserveClassSlots').mockImplementationOnce(() => { + throw new TypeError('scratch failure') + }) + const slots = reservation(project.root) + slots.configure({ root: project.root }) + // When + const first = slots.reserve() + project.write( + 'index.html', + '', + ) + slots.invalidate(join(project.root, 'index.html')) + gate.resolve() + await expect(first).rejects.toThrow(TypeError) + engine.mockImplementation(() => {}) + await slots.reserve() + // Then + expect(graph).toHaveBeenCalledTimes(1) + expect(threshold).toHaveBeenCalledTimes(1) + expect(routes).toHaveBeenCalledTimes(1) + expect(engine).toHaveBeenCalledTimes(2) +}) + +it.each(['empty', 'single'])( + 'latches the initial %s outcome instead of promoting watch-time names', + async (initial) => { + // Given + using project = fixture() + if (initial === 'single') project.write('src/A.js', STYLE) + using graph = spyOn(utils, 'reservationGraph') + using canonical = spyOn(wasm, 'importCanonicalMap').mockImplementation( + () => {}, + ) + using routes = spyOn(wasm, 'importFileRoutes').mockImplementation(() => {}) + using threshold = spyOn(wasm, 'setAtomHoist').mockImplementation(() => {}) + using _engine = spyOn(wasm, 'reserveClassSlots').mockImplementation( + () => {}, + ) + const slots = reservation(project.root) + slots.configure({ root: project.root }) + await slots.reserve() + // When + project.write('src/A.js', STYLE) + project.write('src/B.js', STYLE) + slots.invalidate() + await slots.reserve() + // Then + expect(graph).toHaveBeenCalledTimes(1) + expect(canonical).toHaveBeenCalledTimes(1) + expect(routes).not.toHaveBeenCalled() + expect(threshold).not.toHaveBeenCalled() + }, +) + +it('does not partially install metadata when candidate planning fails and retries the preparation', async () => { + // Given + using project = fixture() + project.write('src/A.js', STYLE) + using planner = spyOn(utils, 'planAtomHoist').mockImplementationOnce(() => { + throw new TypeError('candidate failure') + }) + using canonical = spyOn(wasm, 'importCanonicalMap').mockImplementation( + () => {}, + ) + using engine = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + const slots = reservation(project.root) + slots.configure({ root: project.root }) + // When + await expect(slots.reserve()).rejects.toThrow(TypeError) + // Then + expect(canonical).not.toHaveBeenCalled() + expect(engine).not.toHaveBeenCalled() + planner.mockRestore() + await slots.reserve() + expect(canonical).toHaveBeenCalledTimes(1) +}) + +it.each([false, true])( + 'retains real counter leaves and frozen placement through watch history in reverse %s', + async (reverse) => { + // Given + using project = fixture() + const source = (value: string) => + `import {css} from '@devup-ui/react'; export const cls=css({${value}})` + const shared = source("color:'orchid'") + const privateStyle = source('opacity:.25') + project.write('A.js', "import './S.js'; import './P.js'") + project.write('B.js', "import './S.js'") + project.write('S.js', shared) + project.write('P.js', privateStyle) + wasm.resetBuildState() + wasm.setDebug(true) + const slots = reservation(project.root) + slots.configure({ root: project.root, input: ['A.js', 'B.js'] }) + const extract = (file: string, code: string) => + wasm.codeExtract( + join(project.root, file).replaceAll('\\', '/'), + code, + '@devup-ui/react', + './df/css', + false, + true, + false, + {}, + ) + try { + await slots.reserve() + extract('S.js', shared).free() + extract('P.js', privateStyle).free() + const files = wasm.exportFileMap() + const classes = wasm.exportClassMap() + expect(wasm.getCss(null, false)).toContain('color:orchid') + expect(wasm.getCss(null, false)).not.toContain('opacity:.25') + // When + for (const file of reverse ? ['D.js', 'C.js'] : ['C.js', 'D.js']) + project.write(file, source("outlineColor:'navy'")) + project.write('B.js', "import './S.js'; import './P.js'") + slots.configure({ + root: project.root, + input: ['A.js', 'B.js', 'C.js', 'D.js'], + }) + await slots.reserve() + extract('P.js', privateStyle).free() + extract('C.js', source("outlineColor:'navy'")).free() + project.write('S.js', source("color:'plum'")) + slots.invalidate(join(project.root, 'S.js')) + await slots.reserve() + extract('S.js', source("color:'plum'")).free() + rmSync(join(project.root, 'P.js')) + slots.invalidate(join(project.root, 'P.js')) + await slots.reserve() + project.write('P.js', privateStyle) + slots.invalidate(join(project.root, 'P.js')) + await slots.reserve() + extract('P.js', privateStyle).free() + // Then + expect(JSON.parse(wasm.exportFileMap())).toMatchObject(JSON.parse(files)) + expect(JSON.parse(wasm.exportClassMap())).toMatchObject( + JSON.parse(classes), + ) + expect(wasm.getCss(null, false)).toContain('color:plum') + expect(wasm.getCss(null, false)).not.toContain('opacity:.25') + expect(wasm.getCss(null, false)).not.toContain('outline-color:navy') + expect(wasm.getUnreservedClassSlotCount()).toBe(0) + } finally { + wasm.resetBuildState() + wasm.setDebug(true) + } + }, +) diff --git a/packages/vite-plugin/src/__tests__/reservation-inputs.test.ts b/packages/vite-plugin/src/__tests__/reservation-inputs.test.ts new file mode 100644 index 000000000..a1b07f865 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-inputs.test.ts @@ -0,0 +1,139 @@ +import * as fs from 'node:fs' +import { join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { fixture } from '../../../plugin-utils/src/__tests__/reservation-fixture' +import { + environmentInputs, + physicalEnvironmentEntries, +} from '../reservation-inputs' + +it.each([ + [{}, ['index.html']], + [{ input: 'top.js' }, ['top.js']], + [{ input: { a: 'A.js', b: 'B.js' } }, ['A.js', 'B.js']], + [{ input: ['A.js', 'B.js'] }, ['A.js', 'B.js']], + [ + { input: 'wrong.js', build: { rolldownOptions: { input: 'bundle.js' } } }, + ['bundle.js'], + ], + [{ build: { rollupOptions: { input: 'legacy.js' } } }, ['legacy.js']], + [ + { build: { ssr: 'ssr.js', rolldownOptions: { input: 'wrong.js' } } }, + ['ssr.js'], + ], + [{ build: { lib: { entry: 'lib.js' }, ssr: 'wrong.js' } }, ['lib.js']], + [ + { + build: { + lib: { entry: 'wrong.js' }, + rolldownOptions: { input: 'bundle.js' }, + }, + }, + ['bundle.js'], + ], + [{ build: { lib: { entry: ['A.js', 'B.js'] } } }, ['A.js', 'B.js']], + [{ build: { lib: { entry: { a: 'A.js' } } } }, ['A.js']], +])('uses native effective input precedence for %j', (environment, expected) => { + // Given / When / Then + expect(environmentInputs(environment)).toEqual(expected) +}) + +it('applies native env define overrides before HTML parsing and keeps missing HTML watchable', async () => { + // Given + using project = fixture() + project.write( + 'index.html', + '', + ) + // When + const result = await physicalEnvironmentEntries( + { + root: project.root, + env: { BASE_URL: '/', NAME: 'wrong' }, + define: { + 'import.meta.env.NAME': '"right"', + 'import.meta.env.N': 3, + 'import.meta.env.BOOL': 'true', + unrelated: false, + }, + }, + { input: ['index.html', 'missing.html', 'static.js'] }, + ) + // Then + expect(result.entries.map(({ request }) => request)).toEqual([ + join(project.root, 'right/3/true.js'), + join(project.root, 'static.js'), + ]) + expect(result.html).toEqual([ + join(project.root, 'index.html'), + join(project.root, 'missing.html'), + ]) +}) + +it('preserves non-JSON define strings in HTML substitutions', async () => { + // Given + using project = fixture() + project.write('index.html', '') + // When + const result = await physicalEnvironmentEntries( + { root: project.root, define: { 'import.meta.env.NAME': 'raw' } }, + {}, + ) + // Then + expect(result.entries.map(({ request }) => request)).toEqual([ + join(project.root, 'raw.js'), + ]) +}) + +it.each(['ENOENT', 'EISDIR', 'EACCES', 'EPERM', 'ENOTDIR'])( + 'keeps unreadable HTML watchable and discovers later entries after %s', + async (code) => { + // Given + using project = fixture() + project.write( + 'index.html', + '', + ) + using read = spyOn(fs, 'readFileSync').mockImplementationOnce(() => { + throw Object.assign(new Error('HTML input is unavailable'), { code }) + }) + + // When + const result = await physicalEnvironmentEntries( + { root: project.root }, + { input: ['unavailable.html', 'index.html', 'static.js'] }, + ) + + // Then + expect(read).toHaveBeenNthCalledWith( + 1, + join(project.root, 'unavailable.html'), + 'utf8', + ) + expect(result).toEqual({ + entries: [ + { request: join(project.root, 'entry.js'), context: project.root }, + { request: join(project.root, 'static.js'), context: project.root }, + ], + html: [ + join(project.root, 'unavailable.html'), + join(project.root, 'index.html'), + ], + }) + }, +) + +it('propagates unexpected physical HTML read failures rather than marking preparation ready', async () => { + // Given + using project = fixture() + using read = spyOn(fs, 'readFileSync').mockImplementation(() => { + throw new TypeError('unexpected read failure') + }) + // When / Then + await expect( + physicalEnvironmentEntries({ root: project.root }, {}), + ).rejects.toThrow(TypeError) + expect(read).toHaveBeenCalledTimes(1) +}) diff --git a/packages/vite-plugin/src/__tests__/reservation-native-boundary.cjs b/packages/vite-plugin/src/__tests__/reservation-native-boundary.cjs new file mode 100644 index 000000000..3ad599b21 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native-boundary.cjs @@ -0,0 +1,173 @@ +const assert = require('node:assert/strict') +const { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, +} = require('node:fs') +const { tmpdir } = require('node:os') +const { dirname, join, resolve } = require('node:path') +const { put } = require('./reservation-native-fixture.cjs') +const { observe } = require('./reservation-native-observer.cjs') + +async function main() { + const root = realpathSync( + mkdtempSync(join(tmpdir(), 'devup-watch-boundary-')), + ) + const write = (/** @type {string} */ path, /** @type {string} */ code) => + put(root, path, code) + write('package.json', '{"name":"boundary-native","type":"module"}') + write('devup.json', '{}') + write('index.html', '') + write( + 'src/A.tsx', + "import {Box} from '@devup-ui/react'; import {tone} from 'accepted'; import '../workspace/accepted/dist/red.js'; import '../workspace/accepted/dist/blue.js'; export const A=()=> ;", + ) + write( + 'workspace/accepted/package.json', + '{"name":"accepted","type":"module","exports":"./dist/red.js"}', + ) + write( + 'workspace/accepted/dist/package.json', + '{"name":"nearer-dist","type":"module"}', + ) + write('workspace/accepted/dist/red.js', "export const tone='red'") + write('workspace/accepted/dist/blue.js', "export const tone='blue'") + mkdirSync(join(root, 'node_modules')) + symlinkSync( + join(root, 'workspace/accepted'), + join(root, 'node_modules/accepted'), + 'junction', + ) + process.chdir(root) + const telemetry = observe() + /** @type {{DevupUI: typeof import('../index').DevupUI}} */ + const { DevupUI } = require('../../dist/' + 'index.cjs') + const { createServer } = await import('vite') + /** @type {PromiseWithResolvers} */ + const ready = Promise.withResolvers() + /** @type {Set} */ + const registered = new Set() + /** @type {Map>} */ + const updates = new Map() + const key = (/** @type {string} */ path) => + resolve(path).replaceAll('\\', '/') + const server = await createServer({ + configFile: false, + root, + logLevel: 'error', + oxc: { jsx: { runtime: 'classic' } }, + optimizeDeps: { noDiscovery: true, include: [] }, + server: { host: '127.0.0.1', port: 0, fs: { strict: false } }, + plugins: [ + { + name: 'boundary-native-observer', + enforce: 'pre', + configureServer(server) { + const add = server.watcher.add.bind(server.watcher) + server.watcher.add = (paths) => { + for (const path of typeof paths === 'string' ? [paths] : paths) + registered.add(key(path)) + return add(paths) + } + server.watcher.once('ready', () => ready.resolve()) + }, + hotUpdate: { + order: 'post', + handler({ file }) { + if (this.environment.name === 'client') + updates.get(key(file))?.resolve() + }, + }, + }, + DevupUI({ + include: ['accepted'], + singleCss: true, + devupFile: join(root, 'devup.json'), + distDir: join(root, 'df'), + cssDir: join(root, 'df/css'), + }), + ], + }) + /** @type {{root:string,events:string[],registered:string[],reservations?:number}} */ + const evidence = { root, events: [], registered: [] } + try { + await server.listen() + await ready.promise + await server.environments.client.transformRequest('/src/A.tsx') + const above = key(join(dirname(root), 'package.json')) + assert( + !registered.has(above), + 'cold named fixture must not register shared Temp manifest', + ) + const metadata = 'workspace/accepted/package.json' + assert( + registered.has(key(join(root, metadata))), + 'physical package-entry manifest retained above dist scope', + ) + assert( + registered.has(key(join(root, 'src/package.json'))), + 'closer missing scope retained', + ) + const initial = telemetry.reservations.at(-1)?.rows + assert(initial) + /** @param {string} path @param {() => void} operation */ + async function change(path, operation) { + /** @type {PromiseWithResolvers} */ + const notification = Promise.withResolvers() + updates.set(key(join(root, path)), notification) + operation() + await notification.promise + updates.delete(key(join(root, path))) + evidence.events.push(path) + const before = telemetry.reservations.length + await server.environments.client.transformRequest( + `/src/A.tsx?boundary=${evidence.events.length}`, + ) + assert( + telemetry.reservations.length > before, + 'native metadata event invalidates preparation', + ) + } + await change('src/package.json', () => + write('src/package.json', '{"name":"closer","type":"module"}'), + ) + await change('src/package.json', () => + rmSync(join(root, 'src/package.json')), + ) + await change('src/package.json', () => + write('src/package.json', '{"name":"closer","type":"module"}'), + ) + await change(metadata, () => + write( + metadata, + '{"name":"accepted","type":"module","exports":"./dist/blue.js"}', + ), + ) + assert.deepEqual( + telemetry.reservations.at(-1)?.rows, + initial, + 'exports switch keeps identical source inventory', + ) + await change('workspace/accepted/dist/blue.js', () => + write('workspace/accepted/dist/blue.js', "export const tone='navy'"), + ) + assert( + telemetry.reservations + .at(-1) + ?.rows.some((row) => row[1] === "export const tone='navy'"), + ) + evidence.registered = [...registered] + evidence.reservations = telemetry.reservations.length + console.info(JSON.stringify(evidence)) + } finally { + await server.close() + process.chdir(__dirname) + rmSync(root, { recursive: true, force: true }) + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/reservation-native-boundary.test.ts b/packages/vite-plugin/src/__tests__/reservation-native-boundary.test.ts new file mode 100644 index 000000000..79fdbde8e --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native-boundary.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { resolve } from 'node:path' + +import { it } from 'bun:test' + +it('preserves actual-input native manifest and supplier events inside the named watch boundary', () => { + // Given / When + const result = spawnSync( + 'node', + [ + resolve( + import.meta.dir, + '../../../next-plugin/src/__tests__/native-webpack-owned.cjs', + ), + '120000', + resolve(import.meta.dir, 'reservation-native-boundary.cjs'), + ], + { encoding: 'utf8', timeout: 150000 }, + ) + // Then + assert.equal(result.status, 0, result.stdout + result.stderr) +}, 180000) diff --git a/packages/vite-plugin/src/__tests__/reservation-native-fixture.cjs b/packages/vite-plugin/src/__tests__/reservation-native-fixture.cjs new file mode 100644 index 000000000..bd117fba0 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native-fixture.cjs @@ -0,0 +1,237 @@ +const { + existsSync, + mkdirSync, + writeFileSync, + rmSync, + symlinkSync, +} = require('node:fs') +const { dirname, join } = require('node:path') + +/** @param {string} root @param {string} path @param {string} code */ +function put(root, path, code) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), code) +} +/** @param {string} name @param {string} color */ +function style(name, color) { + return `import {Box} from '@devup-ui/react'; export const ${name}=(value:string)=>; console.log(${name})` +} +/** @param {string} root @param {string} scenario @param {string} mode */ +function fixture(root, scenario, mode) { + if (scenario === 'history') + for (const name of ['newA', 'newB']) + rmSync(join(root, `src/${name}.tsx`), { force: true }) + const write = (/** @type {string} */ path, /** @type {string} */ code) => + put(root, path, code) + write('devup.json', '{}') + write( + 'package.json', + JSON.stringify({ name: 'native-fixture', type: 'module' }), + ) + write('physical/A.tsx', style('A', 'red')) + write('physical/B.tsx', style('B', 'blue')) + write( + 'index.html', + '', + ) + write( + 'unused.tsx', + "import {css} from '@devup-ui/react'; css({color:window.never})", + ) + write( + 'src/unused-invalid.tsx', + "import {css} from '@devup-ui/react'; css({color:window.never})", + ) + write( + 'src/unused-valid.tsx', + "import {css} from '@devup-ui/react'; css({color:'sentinel-unreachable'})", + ) + for (const directory of ['out', 'df']) { + write(`${directory}/stale.tsx`, style('StaleOutput', 'sentinel-stale')) + write( + `${directory}/quoted.js`, + `const fake = "import './stale.tsx'"; console.log(fake)`, + ) + } + write( + 'node_modules/accepted/package.json', + JSON.stringify({ + name: 'accepted', + type: 'module', + exports: './dist/index.js', + }), + ) + write( + 'node_modules/accepted/unused.js', + "import {css} from '@devup-ui/react'; css({color:window.never})", + ) + write( + 'node_modules/accepted/dist/index.js', + "import {css} from '@devup-ui/react'; export const accepted=css({borderColor:'gold'})", + ) + /** @type {import('vite').BuildOptions} */ + let build = {} + let base = '/' + let paths = ['/physical/A.tsx', '/physical/B.tsx'] + switch (scenario) { + case 'hoist-lazy': + paths = require('./reservation-native-lazy-fixture.cjs').lazyFixture( + root, + write, + ) + break + case 'hoist': + if (!existsSync(join(root, 'node_modules/react'))) + symlinkSync( + dirname( + require.resolve('react/package.json', { + paths: [join(__dirname, '../../../react')], + }), + ), + join(root, 'node_modules/react'), + 'junction', + ) + write( + 'shared/S.tsx', + "import {styled} from '@devup-ui/react'; export const Shared=styled('div',{color:'orchid',p:7})", + ) + for (const [name, opacity] of [ + ['A', '.25'], + ['B', '.75'], + ]) + write( + `physical/${name}.tsx`, + `import {Box} from '@devup-ui/react'; import {Shared} from '../shared/S'; export const ${name}=(value:string)=><>; console.log(${name})`, + ) + paths.push('/shared/S.tsx') + break + case 'percent': + case 'devfalse': + write('physical/© A.tsx', style('A', 'red')) + write( + 'index.html', + '', + ) + paths = ['/physical/%C2%A9%20A.tsx', '/physical/B.tsx'] + break + case 'base': + base = '/nested/base/' + write( + 'index.html', + mode === 'serve' + ? '' + : '', + ) + paths = ['/nested/base/physical/A.tsx', '/nested/base/physical/B.tsx'] + break + case 'declared': + write( + 'pages/declared.html', + '', + ) + build = { rolldownOptions: { input: 'pages/declared.html' } } + break + case 'multiple': + case 'mixed': + write( + 'pages/a.html', + '', + ) + write( + 'pages/b.html', + '', + ) + write('root.js', 'import "./physical/B.tsx"') + build = { + rolldownOptions: { + input: + scenario === 'mixed' + ? ['pages/a.html', 'root.js'] + : ['pages/a.html', 'pages/b.html'], + }, + } + break + case 'inline': + write( + 'index.html', + '', + ) + break + case 'root': + case 'lib': + case 'ssr': + case 'packages': + write( + 'root.js', + `import './physical/A.tsx'; import './physical/B.tsx'; ${scenario === 'packages' ? "import {accepted} from 'accepted'; import {style} from '@vanilla-extract/css'; console.log(accepted,style({outlineColor:'purple'}))" : ''}`, + ) + build = + scenario === 'lib' + ? { lib: { entry: 'root.js', formats: ['es'] } } + : scenario === 'ssr' + ? { ssr: 'root.js' } + : { rolldownOptions: { input: 'root.js' } } + break + case 'environments': + case 'serve-ssr': + write('server.tsx', style('Server', 'navy')) + break + case 'hmr': + case 'retained': + case 'linked': + case 'history': + write( + 'src/A.tsx', + `import {Box} from '@devup-ui/react'; import {tone} from '${scenario === 'linked' ? 'accepted' : '../supplier.js'}'; ${['retained', 'linked'].includes(scenario) ? 'export const runtimeTone=()=>tone;' : ''} export const A=(value:string)=>; console.log(A); import.meta.hot?.accept()`, + ) + write('supplier.js', "export const tone='maroon'") + write('index.html', '') + paths = ['/src/A.tsx'] + if (scenario === 'linked') { + write( + 'workspace/accepted/package.json', + JSON.stringify({ + name: 'accepted', + type: 'module', + exports: './dist/index.js', + }), + ) + write( + 'workspace/accepted/dist/index.js', + "import {css} from '@devup-ui/react'; export const tone='maroon'; export const compiled=css({borderColor:'gold'})", + ) + write( + 'workspace/accepted/unused.js', + "import {css} from '@devup-ui/react'; css({color:window.never})", + ) + rmSync(join(root, 'node_modules/accepted'), { + recursive: true, + force: true, + }) + symlinkSync( + join(root, 'workspace/accepted'), + join(root, 'node_modules/accepted'), + 'junction', + ) + } + break + case 'error': + write( + 'root.js', + "import {css} from '@devup-ui/react'; console.log(css({color:window.reachable}))", + ) + build = { rolldownOptions: { input: 'root.js' } } + break + case 'virtual': + write('root.js', "import 'virtual:styled'") + build = { rolldownOptions: { input: 'root.js' } } + break + case 'default': + case 'stale': + break + default: + throw new TypeError(`Unknown scenario ${scenario}`) + } + return { write, build, base, paths } +} +module.exports = { fixture, style, put } diff --git a/packages/vite-plugin/src/__tests__/reservation-native-hoist-checks.cjs b/packages/vite-plugin/src/__tests__/reservation-native-hoist-checks.cjs new file mode 100644 index 000000000..5edefaa8d --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native-hoist-checks.cjs @@ -0,0 +1,180 @@ +const assert = require('node:assert/strict') +const { readFileSync, readdirSync } = require('node:fs') +const { resolve } = require('node:path') +const wasm = require('@devup-ui/wasm') + +/** @param {string} css */ +function declarations(css) { + return [...css.matchAll(/\.([\w-]+)\{([^{}]+)\}/g)].map((match) => ({ + name: match[1], + value: match[2], + })) +} + +/** @param {string} code */ +function classes(code) { + return [ + ...code.matchAll(/className\s*=\s*(?:\{\s*\[\s*)?["']([^"']+)["']/g), + ].flatMap((match) => (match[1] ?? '').split(/\s+/)) +} + +/** @param {{root:string,mode:string,single:string,http:Record,telemetry:ReturnType,lazy?:boolean}} context */ +function checkHoist({ root, mode, single, http, telemetry, lazy }) { + const snapshot = telemetry.snapshot() + const base = declarations(snapshot.css) + const shared = base.filter((row) => + /color:orchid|padding:28px/.test(row.value ?? ''), + ) + assert.equal( + shared.length, + 2, + 'real shared supplier declarations exist once in base', + ) + assert( + shared.every((row) => /^[a-z0-9]+$/.test(row.name ?? '')), + 'shared global short names', + ) + if (single !== 'single') + assert( + snapshot.extractions.some((row) => row.updatedBaseStyle), + 'live shared dirtiness', + ) + const disk = readFileSync(resolve(root, 'df/devup-ui/devup-ui.css'), 'utf8') + for (const row of shared) + assert(disk.includes(`.${row.name}{`), 'live base publication') + for (const [name, opacity] of [ + ['A', '.25'], + ['B', '.75'], + ]) { + const output = snapshot.extractions.find((row) => + row.id.endsWith(`/physical/${name}.tsx`), + ) + assert(output) + assert(output.map) + const code = output.code + const ids = [...code.matchAll(/devup-ui-(\d+)\.css/g)].map((match) => + Number(match[1]), + ) + const local = ids.map((id) => wasm.getCss(id, false)).join('\n') + const rules = declarations(single === 'single' ? snapshot.css : local) + const privateRule = rules.find((row) => + row.value?.includes(`opacity:${opacity}`), + ) + assert(privateRule) + assert( + single === 'single' || + /^[a-z0-9]+-[a-z0-9]+$/.test(privateRule.name ?? ''), + 'private local short name', + ) + assert( + classes(code).includes(privateRule.name ?? ''), + 'private generated reference resolves', + ) + if (single !== 'single') + assert( + !/color:orchid|padding:28px/.test(local), + 'no shared duplication locally', + ) + const visible = declarations(snapshot.css + '\n' + local) + for (const name of classes(code)) + assert.equal( + visible.filter((row) => row.name === name).length, + 1, + 'every private class resolves once', + ) + for (const match of local.matchAll(/var\((--[\w-]+)\)/g)) + assert( + code.includes(`"${match[1]}"`), + 'local variable reference has an emitted definition', + ) + } + const supplier = snapshot.extractions.find((row) => + row.id.endsWith('/shared/S.tsx'), + ) + assert(supplier) + for (const row of shared) + assert( + classes(supplier.code).includes(row.name ?? ''), + 'shared generated supplier reference resolves', + ) + const delivered = + mode === 'serve' + ? Object.entries(http) + .filter(([path]) => /\.css(?:\?|$)/.test(path)) + .map(([, css]) => css) + .join('\n') + : readdirSync(resolve(root, 'out')) + .filter((path) => path.endsWith('.css')) + .map((path) => readFileSync(resolve(root, 'out', path), 'utf8')) + .join('\n') + if (lazy) { + const supplier = snapshot.extractions.find((row) => + row.id.endsWith('/shared/P.tsx'), + ) + assert(supplier) + assert(supplier.map) + const ids = [...supplier.code.matchAll(/devup-ui-(\d+)\.css/g)].map( + (match) => Number(match[1]), + ) + const local = ids.map((id) => wasm.getCss(id, false)).join('\n') + const privateRules = declarations( + single === 'single' ? snapshot.css : local, + ).filter((row) => /border-color:teal|padding:36px/.test(row.value ?? '')) + assert.equal( + privateRules.length, + 2, + 'private supplier retains both declarations', + ) + if (single !== 'single') + assert( + !/border-color:teal|padding:36px/.test(snapshot.css + disk), + 'unreachable lazy target cannot promote private supplier', + ) + for (const row of privateRules) { + assert.match( + row.name ?? '', + single === 'single' ? /^[a-z0-9]+$/ : /^[a-z0-9]+-[a-z0-9]+$/, + ) + assert(classes(supplier.code).includes(row.name ?? '')) + assert( + delivered.includes(`.${row.name}{`), + 'delivered private supplier reference resolves', + ) + } + for (const name of ['src/unused-lazy.js', 'E.js']) { + assert( + !snapshot.extractions.some((row) => row.id.endsWith(`/${name}`)), + 'unreachable dynamic chain stays uncompiled', + ) + } + assert( + snapshot.reservations.some((batch) => + batch.rows.some((row) => row[0].endsWith('/shared/P.tsx')), + ), + ) + const entry = snapshot.extractions.find((row) => + row.id.endsWith('/physical/A.tsx'), + ) + assert(entry) + assert(entry.dependencies.some((path) => path.endsWith('/shared/tone.js'))) + assert(entry.code.includes('"../shared/P"')) + assert(entry.code.includes('"../shared/S"')) + } + for (const row of shared) + assert( + delivered.includes(`.${row.name}{`), + 'delivered shared declaration resolves', + ) + if (mode === 'serve') { + const sheets = Object.entries(http).filter(([path]) => + /devup-ui.*\.css/.test(path), + ) + assert(sheets.length > 0, 'actual stylesheet HTTP responses') + for (const row of shared) + assert( + sheets.some(([, css]) => css.includes(`.${row.name}{`)), + 'HTTP shared reference resolves', + ) + } +} +module.exports = { checkHoist } diff --git a/packages/vite-plugin/src/__tests__/reservation-native-installed.cjs b/packages/vite-plugin/src/__tests__/reservation-native-installed.cjs new file mode 100644 index 000000000..4cd2a5bb0 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native-installed.cjs @@ -0,0 +1,111 @@ +const assert = require('node:assert/strict') +const { writeFileSync } = require('node:fs') +const { resolve } = require('node:path') +const { fixture, put } = require('./reservation-native-fixture.cjs') +const { observe } = require('./reservation-native-observer.cjs') +const { text } = require('./reservation-native-receipt.cjs') +const root = text(process.argv[2]) +const receipt = text(process.argv[3]) +process.chdir(root) +const project = fixture(root, 'packages', 'build') +const telemetry = observe() +/** @type {{DevupUI:(options:{distDir:string,cssDir:string,devupFile:string,singleCss:boolean,include:string[]})=>import('vite').PluginOption}} */ +const { DevupUI } = require('../../dist/' + 'index.cjs') +/** @type {Record[]} */ +const runs = [] +/** @param {string} phase */ +function save(phase) { + writeFileSync( + receipt, + JSON.stringify({ phase, runs, telemetry: telemetry.snapshot() }, null, 2), + ) +} +async function main() { + const { build, createServer, version } = await import('vite') + for (const color of ['gold', 'teal']) { + put( + root, + 'node_modules/accepted/dist/index.js', + `import {css} from '@devup-ui/react'; export const accepted=css({borderColor:'${color}'})`, + ) + /** @type {Record} */ + const run = { color, version } + runs.push(run) + /** @returns {import('vite').InlineConfig} */ + const config = () => ({ + root, + configFile: false, + logLevel: 'error', + oxc: { jsx: { runtime: 'classic' } }, + plugins: [ + DevupUI({ + distDir: resolve(root, 'df'), + cssDir: resolve(root, 'df/devup-ui'), + devupFile: resolve(root, 'devup.json'), + singleCss: true, + include: ['accepted'], + }), + ], + build: { ...project.build, minify: false, write: false }, + server: { host: '127.0.0.1', port: 0, fs: { strict: false } }, + optimizeDeps: { noDiscovery: true, include: [] }, + }) + save('fresh-build-enter') + const built = await build(config()) + assert(!Array.isArray(built) && 'output' in built) + const assets = built.output.map((item) => + item.type === 'chunk' ? item.code : String(item.source), + ) + assert(assets.some((asset) => asset.includes(`border-color:${color}`))) + run.assets = assets + save('native-restart-enter') + const server = await createServer(config()) + try { + await server.listen() + const address = server.httpServer?.address() + assert(address && typeof address === 'object') + const base = `http://127.0.0.1:${address.port}` + const response = await fetch( + base + '/node_modules/accepted/dist/index.js', + ) + assert.equal(response.status, 200) + const code = await response.text() + run.code = code + /** @type {Record} */ + const http = {} + run.http = http + for (const match of code.matchAll( + /['"](\/[^'"\n]*devup-ui(?:-\d+)?\.css[^'"\n]*)['"]/g, + )) { + assert(match[1]) + const path = match[1] + '&restart' + const css = await fetch(base + path) + assert.equal(css.status, 200) + http[path] = await css.text() + } + assert( + Object.values(http).some((css) => + css.includes(`border-color:${color}`), + ), + ) + assert.equal(telemetry.snapshot().count, 0) + save('outcome-before-close') + } catch (error) { + run.error = + error instanceof Error + ? { message: error.message, stack: error.stack } + : String(error) + save('error-before-close') + throw error + } finally { + save('close-enter') + await server.close() + run.closed = true + save('close-exit') + } + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/reservation-native-lazy-fixture.cjs b/packages/vite-plugin/src/__tests__/reservation-native-lazy-fixture.cjs new file mode 100644 index 000000000..094fe6e64 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native-lazy-fixture.cjs @@ -0,0 +1,50 @@ +const { existsSync, symlinkSync } = require('node:fs') +const { dirname, join } = require('node:path') + +/** @param {string} root @param {(path:string,code:string)=>void} write */ +function lazyFixture(root, write) { + if (!existsSync(join(root, 'node_modules/react'))) + symlinkSync( + dirname( + require.resolve('react/package.json', { + paths: [join(__dirname, '../../../react')], + }), + ), + join(root, 'node_modules/react'), + 'junction', + ) + write( + 'index.html', + '', + ) + write('root.js', "import './physical/A.tsx'; import('./physical/B.tsx')") + write('aux.js', "import './physical/B.tsx'; import './shared/P.tsx'") + write( + 'shared/S.tsx', + "import {styled} from '@devup-ui/react'; export const Shared=styled('div',{color:'orchid',p:7})", + ) + write( + 'shared/P.tsx', + "import {styled} from '@devup-ui/react'; export const Private=styled('div',{borderColor:'teal',p:9}); console.log(Private)", + ) + write( + 'physical/B.tsx', + "import {Box} from '@devup-ui/react'; import {Shared} from '../shared/S'; export const B=(value:string)=><>; console.log(B)", + ) + write('src/unused-lazy.js', "import('../E.js')") + write('E.js', "import './shared/P.tsx'") + write('shared/tone.js', "export const tone='silver'") + write( + 'physical/A.tsx', + "import {Box} from '@devup-ui/react'; import {Shared} from '../shared/S'; import {Private} from '../shared/P'; import {tone} from '../shared/tone'; export const A=(value:string)=><>; console.log(A)", + ) + return [ + '/root.js', + '/aux.js', + '/physical/A.tsx', + '/physical/B.tsx', + '/shared/S.tsx', + '/shared/P.tsx', + ] +} +module.exports = { lazyFixture } diff --git a/packages/vite-plugin/src/__tests__/reservation-native-matrix.cjs b/packages/vite-plugin/src/__tests__/reservation-native-matrix.cjs new file mode 100644 index 000000000..d5aeeaa6f --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native-matrix.cjs @@ -0,0 +1,258 @@ +const assert = require('node:assert/strict') +const { writeFileSync } = require('node:fs') +const { basename, resolve } = require('node:path') +const { observe } = require('./reservation-native-observer.cjs') +const { fixture } = require('./reservation-native-fixture.cjs') +const { runServer } = require('./reservation-native-server.cjs') +const { text } = require('./reservation-native-receipt.cjs') +const { checkHoist } = require('./reservation-native-hoist-checks.cjs') + +const args = process.argv.slice(2) +const root = text(args[0]), + scenario = text(args[1]), + order = text(args[2]), + mode = text(args[3]), + single = text(args[4]), + receipt = text(args[5]) +process.chdir(root) +const project = fixture(root, scenario, mode) +const telemetry = observe() +/** @type {{DevupUI:(options:{distDir:string,cssDir:string,devupFile:string,singleCss:boolean,include:string[],atomHoist?:number})=>import('vite').PluginOption}} */ +const { DevupUI } = require('../../dist/' + 'index.cjs') +const gate = Promise.withResolvers() +telemetry.onExtract((id) => { + if (basename(id).endsWith(`${order[0]}.tsx`)) gate.resolve(undefined) +}) +/** @type {Record} */ +const assets = {} +/** @type {Record} */ +const http = {} +/** @type {Record} */ +const details = {} +/** @type {{phase:string,extractions:number}[]} */ +const phaseExtractionCounts = [] +if (scenario === 'linked') details.phaseExtractionCounts = phaseExtractionCounts +const watcherReady = Promise.withResolvers() +/** @type {PromiseWithResolvers} */ +const nativeUpdate = Promise.withResolvers() +/** @param {string} phase */ +function checkpoint(phase) { + details.phase = phase + if (scenario === 'linked') + phaseExtractionCounts.push({ + phase, + extractions: telemetry.extractions.length, + }) + writeFileSync( + receipt, + JSON.stringify({ ...telemetry.snapshot(), assets, http, details }, null, 2), + ) +} + +async function main() { + const { build, createBuilder, version } = await import('vite') + details.version = version + /** @type {import('vite').Plugin} */ + const observer = { + name: 'native-qualification', + enforce: 'pre', + configureServer(server) { + server.watcher.once('ready', () => watcherReady.resolve(undefined)) + }, + hotUpdate: { + order: 'post', + handler({ file, modules, timestamp }) { + if ( + file.endsWith('/supplier.js') || + file.endsWith('/workspace/accepted/dist/index.js') + ) { + details.nativeHmr = { + file, + timestamp, + modules: modules.map((module) => module.id), + } + if (this.environment.name === 'client') + nativeUpdate.resolve(timestamp) + } + }, + }, + async transform(_code, id) { + if ( + basename(id).endsWith(`${order[1]}.tsx`) && + !['hmr', 'retained', 'history', 'linked'].includes(scenario) + ) + await gate.promise + }, + resolveId(id) { + if (id === 'virtual:styled') return '\0virtual-styled.js' + }, + load(id) { + if (id === '\0virtual-styled.js') + return "import {css} from '@devup-ui/react'; console.log(css({color:'chartreuse',padding:'79px'}))" + }, + generateBundle(_options, bundle) { + for (const item of Object.values(bundle)) + assets[`${this.environment.name}/${item.fileName}`] = + item.type === 'chunk' ? item.code : String(item.source) + }, + } + /** @type {import('vite').InlineConfig} */ + const config = { + configFile: false, + root, + base: project.base, + logLevel: 'error', + define: scenario === 'devfalse' ? { 'import.meta.env.DEV': false } : {}, + oxc: { jsx: { runtime: 'classic' } }, + plugins: [ + observer, + DevupUI({ + distDir: resolve(root, 'df'), + cssDir: resolve(root, 'df/devup-ui'), + devupFile: resolve(root, 'devup.json'), + singleCss: single === 'single', + include: ['accepted'], + ...(scenario === 'hoist-lazy' ? { atomHoist: 3 } : {}), + }), + ], + build: { + ...project.build, + minify: false, + sourcemap: true, + outDir: 'out', + emptyOutDir: scenario !== 'stale', + rolldownOptions: { + ...project.build.rolldownOptions, + output: { + entryFileNames: '[name].js', + chunkFileNames: '[name].js', + assetFileNames: '[name][extname]', + }, + }, + }, + server: { host: '127.0.0.1', port: 0, fs: { strict: false } }, + optimizeDeps: { noDiscovery: true, include: [] }, + } + try { + if (scenario === 'environments') { + config.environments = { + rsc: { + consumer: 'server', + build: { ssr: 'server.tsx', outDir: 'out/rsc' }, + }, + client: { consumer: 'client', build: { outDir: 'out/client' } }, + } + config.builder = { + sharedPlugins: true, + async buildApp(builder) { + for (const name of order === 'AB' + ? ['rsc', 'client'] + : ['client', 'rsc']) { + const environment = builder.environments[name] + assert(environment) + await builder.build(environment) + } + }, + } + const builder = await createBuilder(config) + await builder.buildApp() + } else if (mode === 'build') { + await build(config) + } else { + await runServer(config, { + scenario, + root, + order, + paths: project.paths, + base: project.base, + telemetry, + http, + details, + ready: watcherReady.promise, + nativeUpdate: nativeUpdate.promise, + checkpoint, + }) + } + } catch (error) { + details.error = + error instanceof Error + ? { name: error.name, message: error.message, stack: error.stack } + : String(error) + if (scenario !== 'error') throw error + } finally { + writeFileSync( + receipt, + JSON.stringify( + { ...telemetry.snapshot(), assets, http, details, version }, + null, + 2, + ), + ) + } + const { reservations, extractions, count, css } = telemetry.snapshot() + assert(reservations.length > 0) + if (scenario === 'error') { + assert(details.error) + assert.match(JSON.stringify(details.error), /root\.js:1:\d+/) + assert.match(JSON.stringify(details.error), /window\.reachable/) + assert(!css.includes('window.reachable')) + return + } + if (scenario === 'virtual') { + assert( + extractions.some( + (row) => row.id.includes('virtual-styled') && row.after > row.before, + ), + ) + assert(count > 0) + return + } + assert.equal(count, 0, `${scenario} physical residual`) + assert(extractions.every((row) => row.before === 0 && row.after === 0)) + assert( + extractions.some((row) => row.code.includes('--')), + 'generated variable keys observed', + ) + assert(extractions.some((row) => row.updatedBaseStyle || row.cssFile)) + assert.equal(telemetry.events[0], 'reserve') + if (scenario === 'hoist') checkHoist({ root, mode, single, http, telemetry }) + if (scenario === 'hoist-lazy') + checkHoist({ root, mode, single, http, telemetry, lazy: true }) + if (scenario === 'environments' || scenario === 'serve-ssr') { + const rows = reservations[0]?.rows.map((row) => row[0]) + assert(rows) + for (const name of ['physical/A.tsx', 'physical/B.tsx', 'server.tsx']) + assert(rows.includes(resolve(root, name).replaceAll('\\', '/'))) + } + assert(!css.includes('window.never')) + assert(!css.includes('sentinel-unreachable')) + assert(!css.includes('sentinel-stale')) + for (const batch of reservations) + assert( + batch.rows.every( + (row) => !row[0].includes('/out/') && !row[0].includes('/df/'), + ), + ) + if ( + !['hmr', 'retained', 'history', 'linked', 'error', 'virtual'].includes( + scenario, + ) + ) { + const arrived = extractions.flatMap((row) => + basename(row.id).endsWith('A.tsx') + ? ['A'] + : basename(row.id).endsWith('B.tsx') + ? ['B'] + : [], + ) + assert.deepEqual( + [...new Set(arrived)], + [...order], + 'imposed native physical arrival order', + ) + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/reservation-native-matrix.test.ts b/packages/vite-plugin/src/__tests__/reservation-native-matrix.test.ts new file mode 100644 index 000000000..9d77826cf --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native-matrix.test.ts @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { it } from 'bun:test' + +import { parseReceipt } from './reservation-native-receipt.cjs' + +const helper = resolve(import.meta.dir, 'reservation-native-matrix.cjs') +const owner = resolve( + import.meta.dir, + '../../../next-plugin/src/__tests__/native-webpack-owned.cjs', +) + +it('qualifies installed compiled package changes on fresh build and native restart', () => { + const temp = + process.platform === 'win32' && process.env.LOCALAPPDATA + ? join(process.env.LOCALAPPDATA, 'Temp') + : tmpdir() + const root = realpathSync(mkdtempSync(join(temp, 'devup-vite-installed-'))) + const receipt = process.env.VITE_SLOT_RECEIPTS + ? join(process.env.VITE_SLOT_RECEIPTS, 'installed-restart.json') + : join(root, 'installed-restart.json') + if (process.env.VITE_SLOT_RECEIPTS) + mkdirSync(process.env.VITE_SLOT_RECEIPTS, { recursive: true }) + try { + const result = spawnSync( + 'node', + [ + owner, + '120000', + resolve(import.meta.dir, 'reservation-native-installed.cjs'), + root, + receipt, + ], + { encoding: 'utf8', timeout: 150000 }, + ) + if (process.env.VITE_SLOT_RECEIPTS) + writeFileSync( + join(process.env.VITE_SLOT_RECEIPTS, 'installed-restart.log'), + result.stdout + result.stderr, + ) + assert.equal(result.status, 0, result.stdout + result.stderr) + const output: unknown = JSON.parse(readFileSync(receipt, 'utf8')) + assert( + output && + typeof output === 'object' && + 'phase' in output && + output.phase === 'close-exit', + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 180000) + +function run( + root: string, + scenario: string, + variant: { + readonly order: string + readonly mode: string + readonly single: string + }, +) { + const { order, mode, single } = variant + const name = `${scenario}-${mode}-${single}-${order}` + const receipt = join(root, `${name}.json`) + const result = spawnSync( + 'node', + [owner, '120000', helper, root, scenario, order, mode, single, receipt], + { encoding: 'utf8', timeout: 150000 }, + ) + if (process.env.VITE_SLOT_RECEIPTS) { + mkdirSync(process.env.VITE_SLOT_RECEIPTS, { recursive: true }) + writeFileSync( + join(process.env.VITE_SLOT_RECEIPTS, `${name}.log`), + result.stdout + result.stderr, + ) + try { + writeFileSync( + join(process.env.VITE_SLOT_RECEIPTS, `${name}.json`), + readFileSync(receipt), + ) + } catch (error) { + if (!( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + )) + throw error + } + } + assert.equal(result.status, 0, result.stdout + result.stderr) + return parseReceipt(readFileSync(receipt, 'utf8')) +} + +for (const scenario of [ + 'default', + 'hoist', + 'hoist-lazy', + 'percent', + 'devfalse', + 'base', + 'declared', + 'multiple', + 'mixed', + 'inline', + 'root', + 'lib', + 'ssr', + 'packages', + 'environments', + 'serve-ssr', + 'hmr', + 'retained', + 'linked', + 'history', + 'virtual', + 'error', + 'stale', +]) { + it(`qualifies native ${scenario} without physical residual or scratch publication`, () => { + // Given: one physical root shared by opposite-order isolated engine processes. + const temp = + process.platform === 'win32' && process.env.LOCALAPPDATA + ? join(process.env.LOCALAPPDATA, 'Temp') + : tmpdir() + const root = realpathSync(mkdtempSync(join(temp, 'devup-vite-matrix-'))) + try { + for (const mode of [ + 'hmr', + 'retained', + 'linked', + 'history', + 'serve-ssr', + 'percent', + 'devfalse', + ].includes(scenario) + ? ['serve'] + : [ + 'virtual', + 'error', + 'environments', + 'declared', + 'multiple', + 'mixed', + 'inline', + 'root', + 'lib', + 'ssr', + 'packages', + 'stale', + ].includes(scenario) + ? ['build'] + : ['build', 'serve']) { + for (const single of ['single', 'per-file']) { + // When: genuine Vite loading releases physical transforms in reverse order. + const ab = run(root, scenario, { order: 'AB', mode, single }) + const ba = run(root, scenario, { order: 'BA', mode, single }) + // Then: generated class/variable keys and source maps are identical. + assert.equal(ab.classMap, ba.classMap) + assert.equal(ab.fileMap, ba.fileMap) + const outputs = (value: typeof ab) => + value.extractions + .map((row) => [ + row.id, + row.code, + row.map, + row.cssFile, + row.dependencies, + row.updatedBaseStyle, + ]) + .sort() + assert.deepEqual(outputs(ab), outputs(ba)) + if (scenario !== 'environments') + assert.deepEqual(ab.assets, ba.assets) + assert.equal(ab.css, ba.css) + if (scenario === 'history') { + const rows = (value: typeof ab) => + value.reservations.find( + (batch) => + batch.rows.some((row) => row[0]?.endsWith('/newA.tsx')) && + batch.rows.some((row) => row[0]?.endsWith('/newB.tsx')), + )?.rows + assert.deepEqual(rows(ab), rows(ba)) + } + } + } + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, 600000) +} diff --git a/packages/vite-plugin/src/__tests__/reservation-native-observer.cjs b/packages/vite-plugin/src/__tests__/reservation-native-observer.cjs new file mode 100644 index 000000000..1cfbcdbb4 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native-observer.cjs @@ -0,0 +1,82 @@ +const assert = require('node:assert/strict') +const wasm = require('@devup-ui/wasm') + +/** @typedef {readonly [string, string, string]} Row */ +/** @typedef {Readonly>} Aliases */ +/** @typedef {[readonly Row[], string, boolean, boolean, boolean, Aliases]} ReserveArgs */ +/** @typedef {[string, string, string, string, boolean, boolean, boolean, Aliases]} ExtractArgs */ +/** @typedef {{id:string,input:string,code:string,css?:string,map?:string,cssFile?:string,dependencies:readonly string[],updatedBaseStyle:boolean,before:number,after:number}} Extraction */ +/** @typedef {{rows:readonly Row[],flags:readonly unknown[],classMap:string,fileMap:string}} Reservation */ + +/** Observe the real facade, materializing every getter before returning its live Output. */ +function observe() { + /** @type {string[]} */ + const events = [] + /** @type {Reservation[]} */ + const reservations = [] + /** @type {Extraction[]} */ + const extractions = [] + const reserve = wasm.reserveClassSlots + /** @param {ReserveArgs} args */ + wasm.reserveClassSlots = (...args) => { + const sheet = wasm.exportSheet() + reserve(...args) + assert.equal( + wasm.exportSheet(), + sheet, + 'reservation publishes no scratch CSS', + ) + reservations.push({ + rows: args[0], + flags: args.slice(1), + classMap: wasm.exportClassMap(), + fileMap: wasm.exportFileMap(), + }) + events.push('reserve') + } + const extract = wasm.codeExtract + /** @type {((id:string)=>void) | undefined} */ + let onExtract + /** @param {ExtractArgs} args */ + wasm.codeExtract = (...args) => { + const before = wasm.getUnreservedClassSlotCount() + const output = extract(...args) + const { code, css, map, cssFile, dependencies, updatedBaseStyle } = output + extractions.push({ + id: args[0], + input: args[1], + code, + ...(css === undefined ? {} : { css }), + ...(map === undefined ? {} : { map }), + ...(cssFile === undefined ? {} : { cssFile }), + dependencies, + updatedBaseStyle, + before, + after: wasm.getUnreservedClassSlotCount(), + }) + events.push(`extract:${args[0]}`) + onExtract?.(args[0]) + return output + } + return { + events, + reservations, + extractions, + /** @param {(id:string)=>void} callback */ + onExtract(callback) { + onExtract = callback + }, + snapshot() { + return { + events, + reservations, + extractions, + count: wasm.getUnreservedClassSlotCount(), + css: wasm.getCss(null, false), + classMap: wasm.exportClassMap(), + fileMap: wasm.exportFileMap(), + } + }, + } +} +module.exports = { observe } diff --git a/packages/vite-plugin/src/__tests__/reservation-native-physical.cjs b/packages/vite-plugin/src/__tests__/reservation-native-physical.cjs new file mode 100644 index 000000000..58cc510fd --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native-physical.cjs @@ -0,0 +1,192 @@ +const assert = require('node:assert/strict') +const { mkdirSync, writeFileSync } = require('node:fs') +const { basename, dirname, resolve } = require('node:path') +const { observe } = require('./reservation-native-observer.cjs') + +/** @param {string | undefined} value */ +function required(value) { + assert(value) + return value +} +const root = resolve(required(process.argv[2])) +const scenario = required(process.argv[3]) +const order = required(process.argv[4]) +const receipt = required(process.argv[5]) +/** @param {string} path @param {string} code */ +function put(path, code) { + const file = resolve(root, path) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, code) +} +/** @param {string} color */ +function style(color) { + return `import {css} from '@devup-ui/react'; console.log(css({color:'${color}'}))` +} +put('devup.json', '{}') +put('package.json', '{"type":"module"}') +put( + 'index.html', + scenario === 'classic' + ? "\n" + : '', +) +put('src/Initial.js', style('maroon')) +put('outside/A.js', style('red')) +put('outside/B.js', style('blue')) +process.chdir(root) +const telemetry = observe() +/** @type {{DevupUI:(options:{distDir:string,cssDir:string,devupFile:string,singleCss:boolean})=>import('vite').PluginOption}} */ +const { DevupUI } = require('../../dist/' + 'index.cjs') +const gate = Promise.withResolvers() +telemetry.onExtract((id) => { + if (basename(id) === `${order[0]}.js`) gate.resolve(undefined) +}) +/** @type {Record} */ +const details = {} +/** @param {string} phase */ +function record(phase) { + writeFileSync( + receipt, + JSON.stringify( + { phase, scenario, order, root, details, ...telemetry.snapshot() }, + null, + 2, + ), + ) +} +async function main() { + const { build, createServer, version } = await import('vite') + details.version = version + /** @type {import('vite').InlineConfig} */ + const config = { + configFile: false, + root, + logLevel: 'error', + plugins: [ + { + name: 'physical-arrival', + enforce: 'pre', + async transform(_code, id) { + if (scenario === 'classic' && basename(id) === `${order[1]}.js`) + await gate.promise + }, + }, + DevupUI({ + distDir: resolve(root, 'df'), + cssDir: scenario === 'classic' ? resolve(root, 'df/css') : root, + devupFile: resolve(root, 'devup.json'), + singleCss: true, + }), + ], + build: { outDir: 'out', minify: false, sourcemap: true }, + server: { host: '127.0.0.1', port: 0 }, + optimizeDeps: { noDiscovery: true, include: [] }, + } + if (scenario === 'classic') { + try { + const result = await build(config) + assert(!Array.isArray(result) && 'output' in result) + details.assets = Object.fromEntries( + result.output.map((item) => [ + item.fileName, + item.type === 'chunk' ? item.code : String(item.source), + ]), + ) + record('outcome-before-close') + const arrivals = telemetry.extractions.filter((row) => + ['A.js', 'B.js'].includes(basename(row.id)), + ) + assert.equal( + arrivals.length, + 0, + 'native classic imports are assets, not compiled modules', + ) + const html = result.output.find((item) => item.fileName === 'index.html') + assert(html && html.type === 'asset') + const urls = [ + ...String(html.source).matchAll( + /data:text\/javascript;base64,([^']+)/g, + ), + ] + assert.deepEqual( + urls.map((match) => + Buffer.from(required(match[1]), 'base64').toString(), + ), + [style('red'), style('blue')], + ) + assert( + telemetry.reservations.every((batch) => + batch.rows.every( + (row) => !['A.js', 'B.js'].includes(basename(row[0])), + ), + ), + ) + } catch (error) { + details.error = error instanceof Error ? error.message : String(error) + record('error') + throw error + } + return + } + const server = await createServer(config) + const ready = Promise.withResolvers() + server.watcher.once('ready', () => ready.resolve(undefined)) + try { + await server.listen() + await ready.promise + const initial = + await server.environments.client?.transformRequest('/src/Initial.js') + assert(initial?.code.includes('console.log')) + assert.equal(telemetry.snapshot().count, 0) + const additions = Promise.withResolvers() + /** @type {Set} */ + const seen = new Set() + server.watcher.on('add', (file) => { + if (['A.js', 'B.js'].includes(basename(file))) { + seen.add(basename(file)) + if (seen.size === 2) additions.resolve(undefined) + } + }) + const before = telemetry.reservations.length + put('src/B.js', style('blue')) + put('src/A.js', style('red')) + await additions.promise + details.watchAdds = [...seen] + for (const name of order) { + const result = await server.environments.client?.transformRequest( + `/src/${name}.js`, + ) + assert(result?.code.includes('console.log')) + } + details.reservationsBefore = before + record('outcome-before-close') + assert.equal( + telemetry.snapshot().count, + 0, + 'cssDir=root new-source physical residual', + ) + assert( + telemetry.reservations + .slice(before) + .some((batch) => + ['A.js', 'B.js'].every((name) => + batch.rows.some( + (row) => + row[0] === resolve(root, 'src', name).replaceAll('\\', '/'), + ), + ), + ), + ) + } catch (error) { + details.error = error instanceof Error ? error.message : String(error) + record('error-before-close') + throw error + } finally { + await server.close() + record('close-exit') + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/reservation-native-physical.test.ts b/packages/vite-plugin/src/__tests__/reservation-native-physical.test.ts new file mode 100644 index 000000000..85e56fcd2 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native-physical.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { it } from 'bun:test' + +for (const scenario of ['event', 'classic']) { + it(`checks native physical ${scenario} boundaries without inventing compilation support`, () => { + // Given: isolated engines and roots for each order; the helper asserts live facade outcomes. + for (const order of scenario === 'event' ? ['AB', 'BA'] : ['AB']) { + const temp = + process.platform === 'win32' && process.env.LOCALAPPDATA + ? join(process.env.LOCALAPPDATA, 'Temp') + : tmpdir() + const root = realpathSync(mkdtempSync(join(temp, 'devup-vite-physical-'))) + const receipts = process.env.VITE_SLOT_RECEIPTS + if (receipts) mkdirSync(receipts, { recursive: true }) + try { + // When + const result = spawnSync( + 'node', + [ + resolve( + import.meta.dir, + '../../../next-plugin/src/__tests__/native-webpack-owned.cjs', + ), + '20000', + resolve(import.meta.dir, 'reservation-native-physical.cjs'), + root, + scenario, + order, + receipts + ? join(receipts, `physical-${scenario}-${order}.json`) + : join(root, 'receipt.json'), + ], + { encoding: 'utf8', timeout: 30000 }, + ) + if (receipts) + writeFileSync( + join(receipts, `physical-${scenario}-${order}.log`), + result.stdout + result.stderr, + ) + // Then + assert.equal(result.status, 0, result.stdout + result.stderr) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + }, 90000) +} diff --git a/packages/vite-plugin/src/__tests__/reservation-native-receipt.cjs b/packages/vite-plugin/src/__tests__/reservation-native-receipt.cjs new file mode 100644 index 000000000..243e87e99 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native-receipt.cjs @@ -0,0 +1,80 @@ +const assert = require('node:assert/strict') + +/** @param {unknown} value @returns {Record} */ +function record(value) { + assert(value !== null && typeof value === 'object' && !Array.isArray(value)) + return Object.fromEntries(Object.entries(value)) +} +/** @param {unknown} value */ +function text(value) { + assert.equal(typeof value, 'string') + assert(typeof value === 'string') + return value +} +/** @param {unknown} value */ +function number(value) { + assert(typeof value === 'number') + return value +} +/** @param {unknown} value */ +function boolean(value) { + assert(typeof value === 'boolean') + return value +} +/** @param {unknown} value @returns {readonly unknown[]} */ +function array(value) { + assert(Array.isArray(value)) + return value +} +/** @param {unknown} value */ +function strings(value) { + return array(value).map(text) +} +/** @param {unknown} value */ +function dictionary(value) { + return Object.fromEntries( + Object.entries(record(value)).map(([key, val]) => [key, text(val)]), + ) +} +/** @param {unknown} value */ +function extraction(value) { + const row = record(value) + return { + id: text(row.id), + input: text(row.input), + code: text(row.code), + css: row.css === undefined ? undefined : text(row.css), + map: row.map === undefined ? undefined : text(row.map), + cssFile: row.cssFile === undefined ? undefined : text(row.cssFile), + dependencies: strings(row.dependencies), + updatedBaseStyle: boolean(row.updatedBaseStyle), + before: number(row.before), + after: number(row.after), + } +} +/** @param {unknown} value */ +function reservation(value) { + const row = record(value) + return { + rows: array(row.rows).map(strings), + flags: array(row.flags), + classMap: text(row.classMap), + fileMap: text(row.fileMap), + } +} +/** @param {string} raw */ +function parseReceipt(raw) { + const value = record(JSON.parse(raw)) + return { + events: strings(value.events), + reservations: array(value.reservations).map(reservation), + extractions: array(value.extractions).map(extraction), + classMap: text(value.classMap), + fileMap: text(value.fileMap), + css: text(value.css), + count: number(value.count), + assets: value.assets === undefined ? undefined : dictionary(value.assets), + http: value.http === undefined ? undefined : dictionary(value.http), + } +} +module.exports = { parseReceipt, record, text, array, strings } diff --git a/packages/vite-plugin/src/__tests__/reservation-native-revision.cjs b/packages/vite-plugin/src/__tests__/reservation-native-revision.cjs new file mode 100644 index 000000000..8644f5f0a --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native-revision.cjs @@ -0,0 +1,192 @@ +const assert = require('node:assert/strict') +const { readFileSync } = require('node:fs') +const { resolve } = require('node:path') +const { put, style } = require('./reservation-native-fixture.cjs') +const { record } = require('./reservation-native-receipt.cjs') + +/** @param {unknown} before @param {unknown} after */ +function retained(before, after) { + for (const [key, value] of Object.entries(record(before))) { + const current = record(after)[key] + if (typeof value === 'object' && value !== null) retained(value, current) + else assert.deepEqual(current, value) + } +} + +/** @typedef {ReturnType} Telemetry */ +/** @param {{server:import('vite').ViteDevServer,base:string,root:string,scenario:string,order:string,telemetry:Telemetry,http:Record,details:Record,nativeUpdate:Promise,checkpoint:(phase:string)=>void}} context */ +async function revision({ + server, + base, + root, + scenario, + order, + telemetry, + http, + details, + checkpoint, + nativeUpdate, +}) { + const response = await fetch(base + '/src/A.tsx') + assert.equal(response.status, 200) + http.initial = await response.text() + if (scenario === 'retained') + assert.match(http.initial, /import.*tone.*supplier/) + const supplier = + scenario === 'linked' ? 'workspace/accepted/dist/index.js' : 'supplier.js' + details.watched = server.watcher.getWatched() + const initial = { + ...telemetry.snapshot(), + events: [...telemetry.events], + reservations: [...telemetry.reservations], + extractions: [...telemetry.extractions], + } + details.initial = initial + const original = readFileSync(resolve(root, 'src/A.tsx'), 'utf8') + const client = server.environments.client + assert(client) + checkpoint('initial-supplier-analysis-enter') + await client.transformRequest('/' + supplier) + checkpoint('initial-supplier-analysis-return') + if (scenario === 'linked') { + // Public one-shot initial static crawl only, not post-HMR quiescence: + // https://vite.dev/guide/api-javascript#vitedevserver + checkpoint('initial-crawl-enter') + await client.waitForRequestsIdle() + checkpoint('initial-crawl-return') + } + /** @param {string} code @param {string} suffix */ + async function sheets(code, suffix) { + for (const match of code.matchAll( + /['"]([^'"\n]*devup-ui(?:-\d+)?\.css[^'"\n]*)['"]/g, + )) { + assert(match[1]) + const path = match[1] + suffix + checkpoint('revision-css-http-enter') + const response = await fetch(base + path) + assert.equal(response.status, 200) + http[path] = await response.text() + checkpoint('revision-css-http-return') + } + } + await sheets(http.initial, '&before') + const changed = Promise.withResolvers() + server.watcher.on('change', (file) => { + if (resolve(file) === resolve(root, supplier)) changed.resolve(undefined) + }) + const message = Promise.withResolvers() + /** @type {number | undefined} */ + let timestamp + /** @type {import('vite').UpdatePayload[]} */ + const messages = [] + function match() { + if ( + timestamp !== undefined && + messages.some((payload) => + payload.updates.some( + (update) => + update.timestamp === timestamp && update.path === '/src/A.tsx', + ), + ) + ) + message.resolve(timestamp) + } + const send = client.hot.send.bind(client.hot) + /** @param {string | import('vite').HMRPayload} payload @param {unknown} [data] */ + client.hot.send = (payload, data) => { + if (typeof payload === 'string') send(payload, data) + else { + send(payload) + if (payload.type === 'update') { + messages.push(payload) + match() + } + } + } + if (scenario === 'linked') checkpoint('supplier-mutation-enter') + put( + root, + supplier, + scenario === 'linked' + ? "import {css} from '@devup-ui/react'; export const tone='teal'; export const compiled=css({borderColor:'gold'})" + : "export const tone='teal'", + ) + if (scenario === 'linked') checkpoint('supplier-mutation-return') + await changed.promise + timestamp = await nativeUpdate + match() + details.hotMessage = await message.promise + checkpoint('matching-supplier-A-update') + const before = telemetry.extractions.length + checkpoint('unchanged-A-transform-enter') + const transformed = await client.transformRequest('/src/A.tsx') + assert(transformed) + http.supplier = transformed.code + checkpoint('unchanged-A-transform-return') + const response2 = await fetch(base + '/src/A.tsx') + assert.equal(response2.status, 200) + http.supplierHttp = await response2.text() + if (scenario === 'linked') checkpoint('unchanged-A-http-return') + await sheets(http.supplierHttp, '&after') + assert.equal(readFileSync(resolve(root, 'src/A.tsx'), 'utf8'), original) + assert( + telemetry.extractions + .slice(before) + .some((row) => row.css?.includes('teal')), + ) + assert( + telemetry.reservations.some((batch) => + batch.rows.some((row) => row[1].includes("tone='teal'")), + ), + ) + if (scenario === 'history') { + const additions = order === 'AB' ? ['newA', 'newB'] : ['newB', 'newA'] + const waiting = additions.map((name) => { + const event = Promise.withResolvers() + server.watcher.on('add', (path) => { + details.latestAdd = path + if (resolve(path) === resolve(root, `src/${name}.tsx`)) + event.resolve(undefined) + }) + return event.promise + }) + for (const name of additions) + put( + root, + `src/${name}.tsx`, + style(name, name === 'newA' ? 'orange' : 'purple'), + ) + details.operation = 'history-add-events' + checkpoint('history-add-events-enter') + await Promise.all(waiting) + checkpoint('history-add-events-return') + // Both writes are present before either public transformRequest begins. + for (const name of additions) { + details.operation = `transform-${name}` + checkpoint('history-transform-enter') + const output = await client.transformRequest(`/src/${name}.tsx`) + assert(output) + http[name] = output.code + checkpoint('history-transform-return') + } + const batch = telemetry.reservations.find( + (item) => + item.rows.some((row) => row[0].endsWith('/newA.tsx')) && + item.rows.some((row) => row[0].endsWith('/newB.tsx')), + ) + assert(batch) + assert.deepEqual( + batch.rows.map((row) => row[0]), + batch.rows.map((row) => row[0]).sort(), + ) + retained( + JSON.parse(initial.fileMap), + JSON.parse(telemetry.snapshot().fileMap), + ) + retained( + JSON.parse(initial.classMap), + JSON.parse(telemetry.snapshot().classMap), + ) + } +} +module.exports = { revision } diff --git a/packages/vite-plugin/src/__tests__/reservation-native-server.cjs b/packages/vite-plugin/src/__tests__/reservation-native-server.cjs new file mode 100644 index 000000000..6795f19d6 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native-server.cjs @@ -0,0 +1,89 @@ +const assert = require('node:assert/strict') +const { revision } = require('./reservation-native-revision.cjs') + +/** @typedef {ReturnType} Telemetry */ +/** @param {import('vite').InlineConfig} config @param {{scenario:string,root:string,order:string,paths:readonly string[],base:string,telemetry:Telemetry,http:Record,details:Record,ready:Promise,nativeUpdate:Promise,checkpoint:(phase:string)=>void}} context */ +async function runServer(config, context) { + const { scenario, root, order, paths, telemetry, http, details, checkpoint } = + context + const { createServer } = await import('vite') + if (scenario === 'serve-ssr') + config.environments = { + ssr: { consumer: 'server', build: { ssr: 'server.tsx' } }, + client: { consumer: 'client' }, + } + const server = await createServer(config) + try { + await server.listen() + await context.ready + checkpoint('native-ready') + const address = server.httpServer?.address() + assert(address && typeof address === 'object') + const base = `http://127.0.0.1:${address.port}` + if (scenario === 'serve-ssr') { + const environment = server.environments.ssr + assert(environment) + const result = await environment.transformRequest('/server.tsx') + assert(result) + http.ssr = result.code + details.beforeHtml = telemetry.snapshot() + } + if (['hmr', 'retained', 'history', 'linked'].includes(scenario)) { + await revision({ + server, + base, + root, + scenario, + order, + telemetry, + http, + details, + nativeUpdate: context.nativeUpdate, + checkpoint, + }) + } else { + const responses = await Promise.all( + paths.map(async (path) => { + const response = await fetch(base + path) + assert.equal(response.status, 200) + return [path, await response.text()] + }), + ) + Object.assign(http, Object.fromEntries(responses)) + const response = await fetch(base + context.base) + assert.equal(response.status, 200) + http.html = await response.text() + } + checkpoint('modules-http-complete') + for (const path of new Set( + Object.values(http).flatMap((code) => + [ + ...code.matchAll( + /['"](\/[^'"\n]*devup-ui(?:-\d+)?\.css[^'"\n]*)['"]/g, + ), + ].flatMap((match) => (match[1] ? [match[1]] : [])), + ), + )) { + details.pendingCssRequest = path + '&direct' + checkpoint('css-http-enter') + const response = await fetch(base + path + '&direct') + assert.equal(response.status, 200) + http[path] = await response.text() + checkpoint('css-http-return') + } + checkpoint('outcome-before-close') + } catch (error) { + details.error = + error instanceof Error + ? { message: error.message, stack: error.stack } + : String(error) + checkpoint('error-before-close') + throw error + } finally { + checkpoint('close-enter') + await server.close() + details.closed = true + checkpoint('close-exit') + } +} +module.exports = { runServer } diff --git a/packages/vite-plugin/src/__tests__/reservation-native.cjs b/packages/vite-plugin/src/__tests__/reservation-native.cjs new file mode 100644 index 000000000..c318c37a2 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native.cjs @@ -0,0 +1,128 @@ +const assert = require('node:assert/strict') +const { writeFileSync } = require('node:fs') +const { basename, resolve } = require('node:path') +const wasm = require('@devup-ui/wasm') +const { observe } = require('./reservation-native-observer.cjs') + +const { text } = require('./reservation-native-receipt.cjs') +const args = process.argv.slice(2) +const root = text(args[0]), + order = text(args[1]), + mode = text(args[2]), + single = text(args[3]), + receipt = text(args[4]) +process.chdir(root) +const telemetry = observe() +const { events, reservations, extractions } = telemetry +const gate = Promise.withResolvers() +telemetry.onExtract((id) => { + if (basename(id).endsWith(`${order[0]}.js`)) gate.resolve(undefined) +}) +/** @type {{DevupUI:(options: {distDir:string,cssDir:string,devupFile:string,singleCss:boolean})=>import('vite').PluginOption}} */ +const { DevupUI } = require('../../dist/' + 'index.cjs') + +async function main() { + const { build, createServer } = await import('vite') + /** @type {import('vite').Plugin} */ + const observer = { + name: 'native-slot-arrival-gate', + enforce: 'pre', + async transform(_code, id) { + if (basename(id).endsWith(`${order[1]}.js`)) await gate.promise + }, + } + /** @type {import('vite').InlineConfig} */ + const config = { + configFile: false, + root, + logLevel: 'error', + plugins: [ + observer, + DevupUI({ + distDir: resolve(root, 'df'), + cssDir: resolve(root, 'df/devup-ui'), + devupFile: resolve(root, 'devup.json'), + singleCss: single === 'single', + }), + ], + build: { + minify: false, + sourcemap: true, + outDir: 'out', + rolldownOptions: { + output: { + entryFileNames: '[name].js', + chunkFileNames: '[name].js', + assetFileNames: '[name][extname]', + }, + }, + }, + server: { host: '127.0.0.1', port: 0, fs: { strict: false } }, + optimizeDeps: { noDiscovery: true, include: [] }, + } + /** @type {Record | undefined} */ + let assets, http + if (mode === 'build') { + const result = await build(config) + assert(!Array.isArray(result) && 'output' in result) + assets = Object.fromEntries( + result.output.map((item) => [ + item.fileName, + item.type === 'chunk' ? item.code : String(item.source), + ]), + ) + } else { + const server = await createServer(config) + try { + await server.listen() + const address = server.httpServer?.address() + assert(address && typeof address === 'object') + const base = `http://127.0.0.1:${address.port}` + const responses = await Promise.all( + ['/src/A.js', '/src/B.js'].map(async (path) => { + const response = await fetch(base + path) + assert.equal(response.status, 200) + return [path, await response.text()] + }), + ) + http = Object.fromEntries(responses) + for (const path of new Set( + Object.values(http).flatMap((code) => + [ + ...code.matchAll( + /['"]([^'"\n]*devup-ui(?:-\d+)?\.css[^'"\n]*)['"]/g, + ), + ].flatMap((match) => (match[1] ? [match[1]] : [])), + ), + )) { + const response = await fetch(base + path + '&direct') + assert.equal(response.status, 200) + http[path] = await response.text() + } + } finally { + await server.close() + } + } + writeFileSync( + receipt, + JSON.stringify( + { + assets, + http, + events, + reservations, + extractions, + count: wasm.getUnreservedClassSlotCount(), + css: wasm.getCss(null, false), + classMap: wasm.exportClassMap(), + fileMap: wasm.exportFileMap(), + }, + null, + 2, + ), + ) +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/reservation-native.test.js b/packages/vite-plugin/src/__tests__/reservation-native.test.js new file mode 100644 index 000000000..72eb02890 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-native.test.js @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +import { it } from 'bun:test' + +import { parseReceipt } from './reservation-native-receipt.cjs' + +const helper = resolve(import.meta.dir, 'reservation-native.cjs') +const owner = resolve( + import.meta.dir, + '../../../next-plugin/src/__tests__/native-webpack-owned.cjs', +) + +it('reserves native Vite build and serve slots before opposite physical arrivals', () => { + const root = mkdtempSync(join(tmpdir(), 'devup-vite-slots-')) + /** @param {string} path @param {string} text */ + const put = (path, text) => { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), text) + } + put('index.html', '') + put('src/entry.js', 'import "./A.js"; import "./B.js"') + put( + 'src/A.js', + 'import {css} from "@devup-ui/react"; console.log(css({color:"red",padding:"13px"}))', + ) + put( + 'src/B.js', + 'import {css} from "@devup-ui/react"; console.log(css({color:"blue",margin:"17px"}))', + ) + put('devup.json', '{}') + try { + for (const mode of ['build', 'serve']) { + for (const single of ['single', 'per-file']) { + const runs = [] + for (const order of ['AB', 'BA']) { + const receipt = join(root, `${mode}-${single}-${order}.json`) + const result = spawnSync( + 'node', + [owner, '120000', helper, root, order, mode, single, receipt], + { encoding: 'utf8', timeout: 150000 }, + ) + assert.equal(result.status, 0, result.stdout + result.stderr) + const raw = readFileSync(receipt, 'utf8') + if (process.env.VITE_SLOT_RECEIPTS) { + mkdirSync(process.env.VITE_SLOT_RECEIPTS, { recursive: true }) + writeFileSync( + join( + process.env.VITE_SLOT_RECEIPTS, + `${mode}-${single}-${order}.json`, + ), + raw, + ) + } + runs.push(parseReceipt(raw)) + } + const [ab, ba] = runs + assert(ab && ba) + // Given identical physical inputs, When genuine native arrivals reverse, + // Then engine getter outputs, counters and final build bytes agree. + assert.deepEqual( + ab.extractions.map((row) => [row.id, row.code, row.map]).sort(), + ba.extractions.map((row) => [row.id, row.code, row.map]).sort(), + ) + assert.equal(ab.classMap, ba.classMap) + assert.equal(ab.fileMap, ba.fileMap) + assert.equal(ab.count, 0) + assert.equal(ba.count, 0) + assert.deepEqual(ab.assets, ba.assets) + } + } + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 600000) + +it('reserves decoded static HTML script inputs outside conventional source roots', () => { + const root = mkdtempSync(join(tmpdir(), 'devup-vite-html-slots-')) + mkdirSync(join(root, 'outside')) + writeFileSync( + join(root, 'index.html'), + '', + ) + writeFileSync( + join(root, 'outside/©A.js'), + 'import {css} from "@devup-ui/react"; console.log(css({color:"red",padding:"13px"}))', + ) + writeFileSync( + join(root, 'outside/©B.js'), + 'import {css} from "@devup-ui/react"; console.log(css({color:"blue",margin:"17px"}))', + ) + writeFileSync(join(root, 'devup.json'), '{}') + try { + const runs = [] + for (const order of ['AB', 'BA']) { + const receipt = join(root, `html-${order}.json`) + const result = spawnSync( + 'node', + [owner, '120000', helper, root, order, 'build', 'single', receipt], + { encoding: 'utf8', timeout: 150000 }, + ) + assert.equal(result.status, 0, result.stdout + result.stderr) + const raw = readFileSync(receipt, 'utf8') + if (process.env.VITE_SLOT_RECEIPTS) { + mkdirSync(process.env.VITE_SLOT_RECEIPTS, { recursive: true }) + writeFileSync( + join(process.env.VITE_SLOT_RECEIPTS, `html-${order}.json`), + raw, + ) + writeFileSync( + join(process.env.VITE_SLOT_RECEIPTS, `html-${order}-lifecycle.log`), + result.stdout + result.stderr, + ) + } + runs.push(parseReceipt(raw)) + } + const [ab, ba] = runs + assert(ab && ba) + assert.equal( + ab.extractions.filter((row) => /©[AB]\.js$/.test(row.id)).length, + 2, + ) + assert.equal( + ba.extractions.filter((row) => /©[AB]\.js$/.test(row.id)).length, + 2, + ) + assert.equal(ab.count, 0) + assert.equal(ba.count, 0) + assert.equal(ab.classMap, ba.classMap) + assert.deepEqual(ab.assets, ba.assets) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 300000) diff --git a/packages/vite-plugin/src/__tests__/reservation-physical.test.ts b/packages/vite-plugin/src/__tests__/reservation-physical.test.ts new file mode 100644 index 000000000..4b603a44a --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-physical.test.ts @@ -0,0 +1,131 @@ +import { join } from 'node:path' + +import type { ReservationRow } from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import { + fixture, + STYLE, +} from '../../../plugin-utils/src/__tests__/reservation-fixture' +import { createViteReservation } from '../reservation' + +function reservation(root: string, location: 'root' | 'nested') { + return createViteReservation({ + package: '@devup-ui/react', + include: [], + importAliases: {}, + singleCss: true, + cssDir: location === 'root' ? root : join(root, 'df/css'), + distDir: join(root, 'df'), + }) +} + +it.each(['root', 'nested'] as const)( + 'ignores only owned generated files when cssDir is %s', + async (location) => { + // Given + using project = fixture() + project.write('src/Initial.js', STYLE) + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation( + () => {}, + ) + const slots = reservation(project.root, location) + slots.configure({ root: project.root }) + await slots.reserve() + const cssDir = + location === 'root' ? project.root : join(project.root, 'df/css') + // When + for (const path of [ + join(cssDir, 'devup-ui.css'), + join(cssDir, 'devup-ui-12.css'), + join(project.root, 'df/compat.d.ts'), + join(project.root, 'df/theme.d.ts'), + join(project.root, 'df/.gitignore'), + ]) + slots.invalidate(path, 1) + await slots.reserve() + // Then + expect(reserve).toHaveBeenCalledTimes(1) + }, +) + +it.each(['root', 'nested'] as const)( + 'includes newly created TSX and coalesces sibling timestamps when cssDir is %s', + async (location) => { + // Given + using project = fixture() + project.write('src/Initial.js', STYLE) + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation( + () => {}, + ) + const slots = reservation(project.root, location) + slots.configure({ root: project.root }) + await slots.reserve() + project.write('src/New.tsx', STYLE) + // When + slots.invalidate(join(project.root, 'src/New.tsx'), 2) + await slots.reserve() + slots.invalidate(join(project.root, 'src/New.tsx'), 2) + await slots.reserve() + // Then + expect(reserve).toHaveBeenCalledTimes(2) + expect( + reserve.mock.calls.at(-1)?.[0].map((row: ReservationRow) => row[0]), + ).toContain(join(project.root, 'src/New.tsx').replaceAll('\\', '/')) + }, +) + +it.each([ + 'df/Entry.js', + 'df/New.tsx', + 'df/tsconfig.json', + 'devup.json', + 'src/devup-ui.css', + 'df/nested/theme.d.ts', + 'df/css/other.css', +])( + 'preserves physical events for %s inside output-directory labels', + async (path) => { + // Given + using project = fixture() + project.write('df/Entry.js', `${STYLE}; import './supplier.js'`) + project.write('df/supplier.js', 'export const tone="red"') + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation( + () => {}, + ) + const slots = reservation(project.root, 'root') + slots.configure({ + root: project.root, + input: 'df/Entry.js', + configFileDependencies: [path], + }) + await slots.reserve() + // When + slots.invalidate(join(project.root, path), 3) + await slots.reserve() + // Then + expect(reserve).toHaveBeenCalledTimes(2) + }, +) + +it('refreshes a real supplier under distDir without changing its exact source entry', async () => { + // Given + using project = fixture() + project.write('df/Entry.js', `${STYLE}; import './supplier.js'`) + project.write('df/supplier.js', 'export const tone="red"') + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + const slots = reservation(project.root, 'nested') + slots.configure({ root: project.root, input: 'df/Entry.js' }) + await slots.reserve() + // When + project.write('df/supplier.js', 'export const tone="blue"') + slots.invalidate(join(project.root, 'df/supplier.js'), 4) + await slots.reserve() + // Then + expect( + reserve.mock.calls + .at(-1)?.[0] + .some((row: ReservationRow) => row[1] === 'export const tone="blue"'), + ).toBe(true) +}) diff --git a/packages/vite-plugin/src/__tests__/reservation-recorder.test.ts b/packages/vite-plugin/src/__tests__/reservation-recorder.test.ts new file mode 100644 index 000000000..963e50417 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-recorder.test.ts @@ -0,0 +1,105 @@ +import { join } from 'node:path' + +import { + createSharedSlotReservation, + getBuildOwnership, +} from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import { + fixture, + flags, + recorder, + STYLE, +} from '../../../plugin-utils/src/__tests__/reservation-fixture' +import { createViteReservation } from '../reservation' + +it('forwards the actual Vite reservation batch without publishing scratch CSS', async () => { + expect(getBuildOwnership().running).toBe(0) + wasm.resetBuildState() + using project = fixture() + project.write('src/A.tsx', STYLE) + const captured = recorder() + const { reserveClassSlots } = wasm + const reserve = reserveClassSlots.bind(wasm) + using observed = spyOn(wasm, 'reserveClassSlots').mockImplementation( + (...args) => { + captured.engine.reserveClassSlots(...args) + reserve(...args) + }, + ) + const reservation = createViteReservation({ + package: '@devup-ui/react', + include: [], + importAliases: {}, + singleCss: true, + cssDir: join(project.root, 'df/css'), + distDir: join(project.root, 'df'), + }) + const sheet = wasm.exportSheet() + reservation.configure({ root: project.root }) + await reservation.reserve() + expect(observed).toHaveBeenCalledTimes(1) + expect(captured.packets).toHaveLength(1) + expect(captured.packets[0]).toEqual({ + rows: [ + [ + join(project.root, 'src/A.tsx').replaceAll('\\', '/'), + STYLE, + './../df/css', + ], + ], + packageName: '@devup-ui/react', + singleCss: true, + importMainCssInCode: true, + importMainCssInCss: false, + aliases: {}, + }) + expect(wasm.exportSheet()).toBe(sheet) +}) + +it('preserves the batch contract when shared-slot CSS flags supply the directory', () => { + expect(getBuildOwnership().running).toBe(0) + wasm.resetBuildState() + using project = fixture() + project.write('src/A.tsx', STYLE) + const captured = recorder() + const { reserveClassSlots } = wasm + const reserve = reserveClassSlots.bind(wasm) + const reservation = createSharedSlotReservation({ + reserveClassSlots(...args) { + captured.engine.reserveClassSlots(...args) + reserve(...args) + }, + }) + const sheet = wasm.exportSheet() + reservation.reserve( + { + roots: [join(project.root, 'src')], + entries: [], + include: [], + cwd: project.root, + package: '@devup-ui/react', + importAliases: {}, + toId: (path) => path.replaceAll('\\', '/'), + }, + flags, + ) + expect(captured.packets).toHaveLength(1) + expect(captured.packets[0]).toEqual({ + rows: [ + [ + join(project.root, 'src/A.tsx').replaceAll('\\', '/'), + STYLE, + '../df/devup-ui', + ], + ], + packageName: '@devup-ui/react', + singleCss: true, + importMainCssInCode: true, + importMainCssInCss: false, + aliases: {}, + }) + expect(wasm.exportSheet()).toBe(sheet) +}) diff --git a/packages/vite-plugin/src/__tests__/reservation-watch-boundary.test.ts b/packages/vite-plugin/src/__tests__/reservation-watch-boundary.test.ts new file mode 100644 index 000000000..6406b3ace --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation-watch-boundary.test.ts @@ -0,0 +1,149 @@ +import { rmSync, symlinkSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import { + fixture, + STYLE, +} from '../../../plugin-utils/src/__tests__/reservation-fixture' +import { createViteReservation } from '../reservation' + +function reservation(root: string) { + const slots = createViteReservation({ + package: '@devup-ui/react', + include: ['accepted'], + importAliases: {}, + singleCss: true, + cssDir: join(root, 'df/css'), + distDir: join(root, 'df'), + }) + slots.configure({ root, input: 'src/A.js' }) + return slots +} + +it('bounds named scope candidates without dropping closer missing manifests or config inputs', async () => { + // Given + using project = fixture() + project.write('package.json', '{"name":"watch-fixture"}') + project.write('src/deep/A.js', STYLE) + using _reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + const slots = reservation(project.root) + // When + await slots.reserve() + // Then + expect(slots.dependencies).toContain( + join(project.root, 'src/deep/package.json'), + ) + expect(slots.dependencies).toContain(join(project.root, 'src/package.json')) + expect(slots.dependencies).toContain(join(project.root, 'package.json')) + expect(slots.dependencies).toContain(join(project.root, 'tsconfig.json')) + expect(slots.dependencies).not.toContain( + join(dirname(project.root), 'package.json'), + ) +}) + +it.each(['{"type":"module"}', '{', 'null', '{"name":""}'])( + 'continues past a non-governing manifest %s', + async (manifest) => { + // Given + using project = fixture() + project.write('package.json', '{"name":"watch-fixture"}') + project.write('src/package.json', manifest) + project.write('src/A.js', STYLE) + using _reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation( + () => {}, + ) + const slots = reservation(project.root) + // When + await slots.reserve() + // Then + expect(slots.dependencies).toContain(join(project.root, 'src/package.json')) + expect(slots.dependencies).toContain(join(project.root, 'package.json')) + expect(slots.dependencies).not.toContain( + join(dirname(project.root), 'package.json'), + ) + }, +) + +it('recomputes the boundary after closer creation, deletion and recreation', async () => { + // Given + using project = fixture() + project.write('package.json', '{"name":"watch-fixture"}') + project.write('src/A.js', STYLE) + using _reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + const slots = reservation(project.root) + await slots.reserve() + // When / Then: closer creation remains observable. + project.write('src/package.json', '{"name":"closer"}') + slots.invalidate(join(project.root, 'src/package.json')) + await slots.reserve() + expect(slots.dependencies).not.toContain( + join(dirname(project.root), 'package.json'), + ) + rmSync(join(project.root, 'src/package.json')) + rmSync(join(project.root, 'package.json')) + slots.invalidate(join(project.root, 'package.json')) + await slots.reserve() + expect(slots.dependencies).toContain( + join(dirname(project.root), 'package.json'), + ) + project.write('package.json', '{"name":"watch-fixture"}') + slots.invalidate(join(project.root, 'package.json')) + await slots.reserve() + expect(slots.dependencies).not.toContain( + join(dirname(project.root), 'package.json'), + ) +}) + +it('retains linked package-root exports above a nearer named dist scope with the same source inventory', async () => { + // Given + using project = fixture() + project.write('package.json', '{"name":"watch-fixture"}') + project.write( + 'src/A.js', + `${STYLE}; import 'accepted'; import '../workspace/accepted/dist/red.js'; import '../workspace/accepted/dist/blue.js'`, + ) + project.write( + 'workspace/accepted/package.json', + '{"name":"accepted","exports":"./dist/red.js"}', + ) + project.write( + 'workspace/accepted/dist/package.json', + '{"name":"nearer-dist"}', + ) + for (const color of ['red', 'blue']) + project.write( + `workspace/accepted/dist/${color}.js`, + `export const tone='${color}'`, + ) + project.write('node_modules/.keep', '') + symlinkSync( + join(project.root, 'workspace/accepted'), + join(project.root, 'node_modules/accepted'), + 'junction', + ) + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + const slots = reservation(project.root) + await slots.reserve() + const rows = reserve.mock.calls[0]?.[0] + // When + project.write( + 'workspace/accepted/package.json', + '{"name":"accepted","exports":"./dist/blue.js"}', + ) + const metadata = join( + project.root, + 'workspace/accepted/package.json', + ).replaceAll('\\', '/') + expect(slots.dependencies).toContain(metadata) + slots.invalidate(metadata) + await slots.reserve() + // Then + expect(reserve).toHaveBeenCalledTimes(2) + expect(reserve.mock.calls[1]?.[0]).toEqual(rows) + expect(slots.dependencies).not.toContain( + join(dirname(project.root), 'package.json'), + ) +}) diff --git a/packages/vite-plugin/src/__tests__/reservation.test.ts b/packages/vite-plugin/src/__tests__/reservation.test.ts new file mode 100644 index 000000000..ac49d7f89 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/reservation.test.ts @@ -0,0 +1,193 @@ +import * as fs from 'node:fs' +import { join } from 'node:path' + +import * as utils from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import { + fixture, + STYLE, +} from '../../../plugin-utils/src/__tests__/reservation-fixture' +import * as htmlReservation from '../html-reservation' +import { createViteReservation } from '../reservation' + +function reservation(root: string) { + return createViteReservation({ + package: '@devup-ui/react', + include: [], + importAliases: {}, + singleCss: true, + cssDir: join(root, 'df/css'), + distDir: join(root, 'df'), + }) +} + +it('does no fresh inventory walk when concurrent or subsequent transforms see a clean revision', async () => { + // Given + using project = fixture() + project.write('src/A.js', STYLE) + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + const slots = reservation(project.root) + slots.configure({ root: project.root }) + using walk = spyOn(fs, 'readdirSync') + // When + const first = slots.reserve() + expect(slots.reserve()).toBe(first) + await first + const walks = walk.mock.calls.length + await slots.reserve() + await slots.reserve() + // Then + expect(walks).toBeGreaterThan(0) + expect(walk.mock.calls).toHaveLength(walks) + expect(reserve).toHaveBeenCalledTimes(1) +}) + +it('refreshes supplier-only changes once and coalesces sibling native HMR timestamps', async () => { + // Given + using project = fixture() + project.write('src/A.js', `${STYLE}; import '../outside/supplier.js'`) + project.write('outside/supplier.js', 'export const n=1') + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + const slots = reservation(project.root) + slots.configure({ root: project.root }) + const watched = new Set() + slots.watch((paths) => { + for (const path of paths) watched.add(path) + }) + await slots.reserve() + // When + project.write('outside/supplier.js', 'export const n=2') + slots.invalidate(join(project.root, 'outside/supplier.js'), 10) + await slots.reserve() + slots.invalidate(join(project.root, 'outside/supplier.js'), 10) + await slots.reserve() + // Then + expect( + watched.has( + join(project.root, 'outside/supplier.js').replaceAll('\\', '/'), + ), + ).toBe(true) + expect(slots.dependencies).toContain( + join(project.root, 'outside/supplier.js').replaceAll('\\', '/'), + ) + expect(reserve).toHaveBeenCalledTimes(2) + expect( + reserve.mock.calls + .at(-1)?.[0] + .some((row: utils.ReservationRow) => row[1] === 'export const n=2'), + ).toBe(true) +}) + +it('unions later default client HTML with server library and mixed explicit inputs before the first reservation', async () => { + // Given + using project = fixture() + for (const name of ['A', 'B', 'C', 'D']) + project.write(`outside/${name}.js`, STYLE) + project.write('index.html', '') + project.write( + 'pages/custom.html', + '', + ) + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + const slots = reservation(project.root) + slots.configure({ + root: project.root, + environments: { + rsc: { build: { ssr: 'outside/D.js' } }, + client: {}, + mixed: { input: ['pages/custom.html', 'outside/C.js'] }, + }, + }) + slots.configure({ + root: project.root, + environments: { + rsc: { build: { lib: { entry: 'outside/D.js', formats: ['es'] } } }, + }, + }) + // When + await slots.reserve() + // Then + expect( + reserve.mock.calls[0]?.[0].map((row: utils.ReservationRow) => row[0]), + ).toEqual( + ['A', 'B', 'C', 'D'].map((name) => + join(project.root, `outside/${name}.js`).replaceAll('\\', '/'), + ), + ) +}) + +it('discards a stale async HTML candidate and shares the refreshed preparation with every waiter', async () => { + // Given + using project = fixture() + project.write('outside/A.js', STYLE) + project.write('outside/B.js', STYLE) + project.write('index.html', '') + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + const slots = reservation(project.root) + slots.configure({ root: project.root }) + const gate = Promise.withResolvers() + const parse = htmlReservation.htmlReservationEntries + using parser = spyOn( + htmlReservation, + 'htmlReservationEntries', + ).mockImplementationOnce(async (html, options) => { + await gate.promise + return parse(html, options) + }) + // When + const pending = slots.reserve() + project.write('index.html', '') + slots.invalidate(join(project.root, 'index.html')) + const sibling = slots.reserve() + gate.resolve() + await Promise.all([pending, sibling]) + // Then + expect(parser).toHaveBeenCalledTimes(2) + expect(reserve).toHaveBeenCalledTimes(1) + expect( + reserve.mock.calls[0]?.[0].map((row: utils.ReservationRow) => row[0]), + ).toEqual([join(project.root, 'outside/B.js').replaceAll('\\', '/')]) +}) + +it('ignores Devup-generated outputs but refreshes configuration and resolution events', async () => { + // Given + using project = fixture() + project.write('src/A.js', STYLE) + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + const slots = reservation(project.root) + slots.configure({ root: project.root }) + await slots.reserve() + // When + slots.invalidate(join(project.root, 'df/css/devup-ui.css'), 1) + slots.invalidate(join(project.root, 'df/theme.d.ts'), 2) + await slots.reserve() + expect(reserve).toHaveBeenCalledTimes(1) + slots.invalidate(join(project.root, 'tsconfig.json'), 3) + await slots.reserve() + slots.configure({ root: project.root, input: 'src/A.js' }) + await slots.reserve() + // Then + expect(reserve).toHaveBeenCalledTimes(3) +}) + +it('propagates preparation errors and retries the same dirty revision without publishing it clean', async () => { + // Given + using project = fixture() + project.write('src/A.js', STYLE) + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementationOnce( + () => { + throw new TypeError('engine failure') + }, + ) + const slots = reservation(project.root) + slots.configure(undefined) + await slots.reserve() + slots.configure({ root: project.root }) + // When / Then + await expect(slots.reserve()).rejects.toThrow(TypeError) + reserve.mockImplementation(() => {}) + await slots.reserve() + expect(reserve).toHaveBeenCalledTimes(2) +}) diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-admission-native.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-admission-native.cjs new file mode 100644 index 000000000..4d7d6cfa9 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-admission-native.cjs @@ -0,0 +1,86 @@ +const assert = require('node:assert/strict') +const { join } = require('node:path') +const wasm = require('@devup-ui/wasm') +const { project, gate } = require('./serve-coherence-async-fixture.cjs') +const mode = process.argv[2] +assert(mode === 'config' || mode === 'tailwind' || mode === 'tailwind-import') +/** @param {string} size */ +const theme = (size) => + JSON.stringify({ theme: { length: { default: { size } } } }) + +async function main() { + // Given: completed old inputs, then a newly selected input already under src. + const state = project() + state.write('old.json', theme('11px')) + state.write('src/latest.json', theme('29px')) + state.write('old.css', '@utility coherence-tone { color: red; }') + state.write('src/tone.css', '@utility coherence-tone { color: blue; }') + state.write( + 'src/latest.css', + mode === 'tailwind-import' + ? '@import "./tone.css";' + : '@utility coherence-tone { color: blue; }', + ) + state.write( + 'devup.json', + JSON.stringify({ + extends: ['./old.json'], + tailwind: { css: join(state.root, 'old.css') }, + }), + ) + await state.plugin.configResolved(state.config) + state.write( + 'devup.json', + JSON.stringify({ + extends: ['./src/latest.json'], + tailwind: { css: join(state.root, 'src/latest.css') }, + }), + ) + const suspended = gate('writeFile', join(state.root, 'df/theme.d.ts')) + let refresh = Promise.resolve() + let event = Promise.resolve() + try { + refresh = state.plugin.watchChange(join(state.root, 'devup.json')) + await suspended.entered + assert.match(wasm.getCss(null, false), /--size:29px/) + // When: the selected input event arrives BEFORE old write completion. + state.write('src/latest.json', theme('47px')) + const changed = + mode === 'config' + ? 'src/latest.json' + : mode === 'tailwind' + ? 'src/latest.css' + : 'src/tone.css' + if (mode !== 'config') + state.write(changed, '@utility coherence-tone { color: teal; }') + event = state.plugin.watchChange(join(state.root, changed)) + suspended.release() + await Promise.all([refresh, event]) + // Then: event admission must reapply actual newest bytes, not only reserve. + assert.match( + wasm.getCss(null, false), + /--size:47px/, + 'active discovered input event must enqueue the next refresh', + ) + if (mode !== 'config') { + const source = + "import {Box} from '@devup-ui/react'; export const B=()=> ;" + const output = await state.plugin.transform.call( + { addWatchFile() {} }, + source, + join(state.root, 'src/B.tsx'), + ) + assert(output) + assert.match(wasm.getCss(null, false), /color:teal/) + } + console.info('serve-coherence-final-complete') + } finally { + suspended.restore() + await Promise.all([refresh, event]) + await state.close() + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-admission.test.ts b/packages/vite-plugin/src/__tests__/serve-coherence-admission.test.ts new file mode 100644 index 000000000..5769b2fac --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-admission.test.ts @@ -0,0 +1,73 @@ +import * as fs from 'node:fs/promises' +import { join } from 'node:path' + +import { getCss } from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import { environment, lifecycle, watcher } from './plugin-lifecycle-fixture' + +for (const mode of ['config', 'tailwind', 'tailwind-import'] as const) { + it(`admits discovered ${mode} events while declaration output is pending`, async () => { + // Given + using state = await lifecycle() + const theme = (size: string) => + JSON.stringify({ theme: { length: { default: { size } } } }) + state.project.write('src/latest.json', theme('29px')) + state.project.write( + 'src/tone.css', + '@utility coherence-tone { color: blue; }', + ) + state.project.write('src/latest.css', '@import "./tone.css";') + state.project.write( + 'devup.json', + JSON.stringify({ + extends: ['./src/latest.json'], + tailwind: { css: join(state.project.root, 'src/latest.css') }, + }), + ) + const watching = watcher() + state.plugin.configureServer({ config: state.config, watcher: watching }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const write = fs.writeFile + let armed = true + using _writing = spyOn(fs, 'writeFile').mockImplementation( + async (file, data, options) => { + if (armed && file === join(state.project.root, 'df/theme.d.ts')) { + armed = false + entered.resolve() + await release.promise + } + return write(file, data, options) + }, + ) + const refresh = state.plugin.watchChange( + join(state.project.root, 'devup.json'), + ) + await entered.promise + const activeWatches = watching.batches.flat() + state.project.write('src/latest.json', theme('47px')) + const file = join( + state.project.root, + mode === 'config' + ? 'src/latest.json' + : mode === 'tailwind' + ? 'src/latest.css' + : 'src/tone.css', + ) + const client = environment('client', ['/src/consumer.tsx']) + // When + const event = state.plugin.hotUpdate.call( + { environment: client }, + { file, modules: [], timestamp: 1 }, + ) + release.resolve() + await Promise.all([refresh, event]) + // Then + expect(getCss(null, false)).toContain('--size:47px') + expect(activeWatches).toContain(file) + expect(client.moduleGraph.invalidateModule.mock.calls.length).toBe( + mode === 'config' ? 0 : 1, + ) + }) +} diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-async-fixture.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-async-fixture.cjs new file mode 100644 index 000000000..895e9f077 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-async-fixture.cjs @@ -0,0 +1,107 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs/promises') +const { mkdtempSync, realpathSync, rmSync } = require('node:fs') +const { syncBuiltinESMExports } = require('node:module') +const { tmpdir } = require('node:os') +const { join } = require('node:path') +const { put } = require('./reservation-native-fixture.cjs') + +/** @typedef {import('../reservation-inputs').ReservationConfig} Config */ +/** @typedef {{configResolved(config:Config):Promise, watchChange(file:string):Promise, closeBundle():void|Promise, buildStart(this:{addWatchFile(file:string):void}):Promise, transform(this:{addWatchFile(file:string):void},code:string,id:string):Promise}} Hooks */ +/** @param {unknown} value @returns {value is Hooks} */ +function hooks(value) { + return ( + typeof value === 'object' && + value !== null && + [ + 'configResolved', + 'watchChange', + 'closeBundle', + 'buildStart', + 'transform', + ].every( + (key) => key in value && typeof Reflect.get(value, key) === 'function', + ) + ) +} + +function project() { + const root = realpathSync( + mkdtempSync(join(tmpdir(), 'serve-coherence-async-')), + ) + /** @param {string} path @param {string} text */ + const write = (path, text) => put(root, path, text) + write('package.json', '{"name":"async-native","type":"module"}') + write('devup.json', '{}') + write('index.html', '') + const source = + 'import {Box} from \'@devup-ui/react\'; export const A=()=> ;' + write('src/A.tsx', source) + /** @type {{DevupUI:typeof import('../plugin').DevupUI}} */ + const { DevupUI } = require('../../dist/' + 'index.cjs') + const plugins = DevupUI({ + devupFile: join(root, 'devup.json'), + distDir: join(root, 'df'), + cssDir: join(root, 'df/css'), + singleCss: true, + }) + assert(Array.isArray(plugins)) + const plugin = plugins[0] + assert(hooks(plugin)) + /** @type {Config} */ + const config = { root, command: 'serve' } + return { + root, + write, + source, + plugin, + config, + async close() { + await plugin.closeBundle() + rmSync(root, { recursive: true, force: true }) + }, + } +} + +/** Gate one actual filesystem operation, preserving its real value and errors. + * @param {'readFile'|'writeFile'} method @param {string} path */ +function gate(method, path) { + /** @type {PromiseWithResolvers} */ + const entered = Promise.withResolvers() + /** @type {PromiseWithResolvers} */ + const release = Promise.withResolvers() + const original = fs[method] + let armed = true + let calls = 0 + Object.defineProperty(fs, method, { + value: new Proxy(original, { + apply(target, receiver, args) { + const result = Reflect.apply(target, receiver, args) + if (String(args[0]) !== path) return result + calls++ + if (!armed) return result + armed = false + return Promise.resolve(result).then(async (value) => { + entered.resolve() + await release.promise + return value + }) + }, + }), + }) + syncBuiltinESMExports() + return { + entered: entered.promise, + release: release.resolve, + get calls() { + return calls + }, + restore() { + release.resolve() + Object.defineProperty(fs, method, { value: original }) + syncBuiltinESMExports() + }, + } +} + +module.exports = { project, gate, hooks } diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-async-lifetime.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-async-lifetime.cjs new file mode 100644 index 000000000..ee3e8c630 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-async-lifetime.cjs @@ -0,0 +1,87 @@ +const assert = require('node:assert/strict') +const { join } = require('node:path') +const wasm = require('@devup-ui/wasm') +/** @type {typeof import('../../../plugin-utils/src/index')} */ +const utils = require('@devup-ui/plugin-utils') +const { project, gate } = require('./serve-coherence-async-fixture.cjs') +const { rebindParser } = require('./html-parser-bundle.cjs') +const stage = process.argv[2] +assert(['before-refresh', 'load', 'buildStart', 'transform'].includes(stage)) + +async function main() { + // Given: A is active; B has real engine-mutating work suspended at an await. + /** @type {PromiseWithResolvers} */ + const entered = Promise.withResolvers() + /** @type {PromiseWithResolvers} */ + const release = Promise.withResolvers() + let restoreParser = () => {} + if (stage === 'buildStart' || stage === 'transform') { + restoreParser = rebindParser((parsing) => async (...args) => { + entered.resolve() + await release.promise + return parsing(...args) + }) + } + const a = project() + await a.plugin.configResolved(a.config) + const b = project() + b.write('devup.json', '{"theme":{"length":{"default":{"size":"73px"}}}}') + let suspended + let work = Promise.resolve() + let closing + try { + if (stage === 'before-refresh' || stage === 'load') { + suspended = gate( + stage === 'load' ? 'readFile' : 'writeFile', + join(b.root, stage === 'load' ? 'devup.json' : 'df/compat.d.ts'), + ) + work = b.plugin.configResolved(b.config) + await suspended.entered + } else { + await b.plugin.configResolved(b.config) + work = + stage === 'buildStart' + ? b.plugin.buildStart.call({ addWatchFile() {} }) + : b.plugin.transform + .call({ addWatchFile() {} }, b.source, join(b.root, 'src/A.tsx')) + .then(() => {}) + await entered.promise + } + const overlap = utils.getBuildOwnership() + assert.equal(overlap.running, 2) + // When: close is requested while B can still mutate the shared facade. + closing = b.plugin.closeBundle() + const during = utils.getBuildOwnership() + suspended?.release() + release.resolve() + await work + await closing + // Then: no apparent exclusive A ownership before B's last mutation finishes. + assert.equal( + during.running, + 2, + 'pending B must retain ownership until its work drains', + ) + assert.equal(during.generation, overlap.generation) + assert.equal(utils.getBuildOwnership().running, 1) + assert.equal(utils.getBuildOwnership().generation, overlap.generation + 1) + assert.match( + wasm.getCss(null, false), + /--size:73px/, + 'configured B still applies to the actual shared engine', + ) + console.info('serve-coherence-async-complete') + } finally { + suspended?.restore() + release.resolve() + await work + await closing + restoreParser() + await b.close() + await a.close() + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-async-native.test.ts b/packages/vite-plugin/src/__tests__/serve-coherence-async-native.test.ts new file mode 100644 index 000000000..87b2d87cb --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-async-native.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { resolve } from 'node:path' + +import { it } from 'bun:test' + +for (const [probe, stage] of [ + ['refresh', 'load'], + ['refresh', 'write'], + ['lifetime', 'before-refresh'], + ['lifetime', 'load'], + ['lifetime', 'buildStart'], + ['lifetime', 'transform'], +]) { + it(`keeps real async coherence when ${probe} pauses at ${stage}`, () => { + // Given / When + const result = spawnSync( + 'node', + [ + resolve( + import.meta.dir, + '../../../next-plugin/src/__tests__/native-webpack-owned.cjs', + ), + '120000', + resolve(import.meta.dir, `serve-coherence-async-${probe}.cjs`), + stage, + ], + { encoding: 'utf8', timeout: 150000 }, + ) + // Then + assert.equal(result.status, 0, result.stdout + result.stderr) + assert.match( + result.stdout, + /serve-coherence-async-complete/, + result.stdout + result.stderr, + ) + }, 180000) +} diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-async-ownership.test.ts b/packages/vite-plugin/src/__tests__/serve-coherence-async-ownership.test.ts new file mode 100644 index 000000000..49f478c83 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-async-ownership.test.ts @@ -0,0 +1,45 @@ +import * as fs from 'node:fs/promises' +import { join } from 'node:path' + +import { getBuildOwnership } from '@devup-ui/plugin-utils' +import { expect, it, spyOn } from 'bun:test' + +import { lifecycle } from './plugin-lifecycle-fixture' + +it('drains admitted configuration and rejects new transforms when closing', async () => { + // Given + using state = await lifecycle() + const before = getBuildOwnership() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const write = fs.writeFile + using _writing = spyOn(fs, 'writeFile').mockImplementation( + async (file, data, options) => { + if (file === join(state.project.root, 'df/compat.d.ts')) { + entered.resolve() + await release.promise + } + return write(file, data, options) + }, + ) + const pending = state.plugin.configResolved(state.config) + await entered.promise + // When + const closed = state.plugin.closeBundle() + const during = getBuildOwnership() + const rejected = await state.plugin.transform.call( + { addWatchFile() {} }, + '', + join(state.project.root, 'src/late.tsx'), + ) + release.resolve() + await pending + await closed + // Then + expect(during).toEqual(before) + expect(rejected).toBeUndefined() + expect(getBuildOwnership()).toEqual({ + running: before.running - 1, + generation: before.generation + 1, + }) +}) diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-async-refresh.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-async-refresh.cjs new file mode 100644 index 000000000..e3fac874a --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-async-refresh.cjs @@ -0,0 +1,93 @@ +const assert = require('node:assert/strict') +const { join } = require('node:path') +const wasm = require('@devup-ui/wasm') +const { project, gate } = require('./serve-coherence-async-fixture.cjs') +const stage = process.argv[2] +assert(stage === 'load' || stage === 'write') +/** @param {string} size */ +const theme = (size) => + JSON.stringify({ theme: { length: { default: { size } } } }) + +async function main() { + // Given: an old refresh suspended with actual old bytes or after theme application. + const state = project() + await state.plugin.configResolved(state.config) + state.write('old.json', theme('11px')) + state.write('latest.json', theme('29px')) + state.write('old.css', '@utility serve-coherence-tone { color: red; }') + state.write('latest.css', '@utility serve-coherence-tone { color: blue; }') + state.write( + 'devup.json', + JSON.stringify({ + extends: ['./old.json'], + tailwind: { css: join(state.root, 'old.css') }, + }), + ) + const suspended = gate( + stage === 'load' ? 'readFile' : 'writeFile', + join(state.root, stage === 'load' ? 'devup.json' : 'df/theme.d.ts'), + ) + let old = Promise.resolve() + let latest = Promise.resolve() + let nextRead + try { + old = state.plugin.watchChange(join(state.root, 'devup.json')) + await suspended.entered + state.write( + 'devup.json', + JSON.stringify({ + extends: ['./latest.json'], + tailwind: { css: join(state.root, 'latest.css') }, + }), + ) + if (stage === 'write') + nextRead = gate('readFile', join(state.root, 'devup.json')) + // When: the next request arrives before the old transaction can finish. + latest = state.plugin.watchChange(join(state.root, 'devup.json')) + // A concurrent implementation can finish the newer request first. A serial + // implementation cannot enter its read until release; neither branch sleeps. + if (stage === 'load' && suspended.calls > 1) await latest + nextRead?.release() + if (nextRead && nextRead.calls > 0) await latest + suspended.release() + await Promise.all([old, latest]) + // Then: newest theme and newest provenance must describe the same transaction. + assert.match( + wasm.getCss(null, false), + /--size:29px/, + 'newest requested theme must win', + ) + state.write('latest.json', theme('47px')) + await state.plugin.watchChange(join(state.root, 'latest.json')) + assert.match( + wasm.getCss(null, false), + /--size:47px/, + 'latest inherited input must still trigger refresh', + ) + state.write('latest.json', theme('59px')) + state.write('latest.css', '@utility serve-coherence-tone { color: teal; }') + await state.plugin.watchChange(join(state.root, 'latest.css')) + assert.match( + wasm.getCss(null, false), + /--size:59px/, + 'latest Tailwind input must still trigger refresh', + ) + const result = await state.plugin.transform.call( + { addWatchFile() {} }, + state.source, + join(state.root, 'src/A.tsx'), + ) + assert(result) + assert.match(wasm.getCss(null, false), /59px/) + console.info('serve-coherence-async-complete') + } finally { + nextRead?.restore() + suspended.restore() + await Promise.all([old, latest]) + await state.close() + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-build-lease-native.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-build-lease-native.cjs new file mode 100644 index 000000000..e83d4cc40 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-build-lease-native.cjs @@ -0,0 +1,93 @@ +const assert = require('node:assert/strict') +const { join } = require('node:path') +const wasm = require('@devup-ui/wasm') +/** @type {typeof import('../../../plugin-utils/src/index')} */ +const utils = require('@devup-ui/plugin-utils') +const { observe } = require('./reservation-native-observer.cjs') +const { project, gate } = require('./serve-coherence-async-fixture.cjs') +const mode = process.argv[2] +assert(mode === 'overlap' || mode === 'solo' || mode === 'pending') +const telemetry = observe() + +async function main() { + // Given: a real build lease was closed after producing frozen state. + const a = mode === 'solo' ? undefined : project() + if (a) await a.plugin.configResolved(a.config) + const b = project() + b.write('devup.json', '{"theme":{"length":{"default":{"size":"73px"}}}}') + const config = { ...b.config, command: /** @type {const} */ ('build') } + await b.plugin.configResolved(config) + const context = { addWatchFile() {} } + await b.plugin.buildStart.call(context) + await b.plugin.transform.call(context, b.source, join(b.root, 'src/A.tsx')) + const maps = [wasm.exportClassMap(), wasm.exportFileMap(), wasm.exportSheet()] + await b.plugin.closeBundle() + const ended = utils.getBuildOwnership() + const count = telemetry.extractions.length + /** @type {ReturnType | undefined} */ + let extractionLease + telemetry.onExtract(() => { + extractionLease = utils.getBuildOwnership() + }) + let suspended + let pending = Promise.resolve() + let closing + try { + // When: another environment/build-watch cycle admits work on the same plugin. + if (mode === 'pending') { + suspended = gate('writeFile', join(b.root, 'df/compat.d.ts')) + pending = b.plugin.configResolved(config) + const entered = await Promise.race([ + suspended.entered.then(() => true), + pending.then(() => false), + ]) + assert(entered, 'later build configuration must be admitted') + const resumed = utils.getBuildOwnership() + closing = b.plugin.closeBundle() + assert.deepEqual( + utils.getBuildOwnership(), + resumed, + 'pending resumed B retains its lease', + ) + suspended.release() + await pending + await closing + assert.equal(utils.getBuildOwnership().running, ended.running) + } + const output = await b.plugin.transform.call( + context, + b.source, + join(b.root, 'src/A.tsx'), + ) + // Then: actual extraction happens under a renewed lease, without resetting history. + assert(output, 'later build environment transform must not be rejected') + assert.equal(telemetry.extractions.length, count + 1) + assert(extractionLease) + assert.equal(extractionLease.running, ended.running + 1) + assert(extractionLease.generation > ended.generation) + assert.deepEqual( + [wasm.exportClassMap(), wasm.exportFileMap(), wasm.exportSheet()], + maps, + 'resuming build ownership must not reset frozen maps or sheet', + ) + await b.plugin.buildStart.call(context) + assert.deepEqual( + utils.getBuildOwnership(), + extractionLease, + 'an active lease must not be acquired twice', + ) + await b.plugin.closeBundle() + assert.equal(utils.getBuildOwnership().running, ended.running) + console.info('serve-coherence-final-complete') + } finally { + suspended?.restore() + await pending + await closing + await b.close() + await a?.close() + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-build-lease.test.ts b/packages/vite-plugin/src/__tests__/serve-coherence-build-lease.test.ts new file mode 100644 index 000000000..9d9f1e8be --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-build-lease.test.ts @@ -0,0 +1,60 @@ +import * as fs from 'node:fs/promises' +import { join } from 'node:path' + +import { getBuildOwnership } from '@devup-ui/plugin-utils' +import { expect, it, spyOn } from 'bun:test' + +import { lifecycle } from './plugin-lifecycle-fixture' + +for (const pending of [false, true]) { + it(`renews a closed build lease before ${pending ? 'pending configuration' : 'extraction'}`, async () => { + // Given + using state = await lifecycle() + const config = { ...state.config, command: 'build' as const } + await state.plugin.configResolved(config) + const before = getBuildOwnership() + await state.plugin.closeBundle() + const context = { addWatchFile() {} } + // When + if (pending) { + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const write = fs.writeFile + using _writing = spyOn(fs, 'writeFile').mockImplementation( + async (file, data, options) => { + if (file === join(state.project.root, 'df/compat.d.ts')) { + entered.resolve() + await release.promise + } + return write(file, data, options) + }, + ) + const configuring = state.plugin.configResolved(config) + const admitted = await Promise.race([ + entered.promise.then(() => true), + configuring.then(() => false), + ]) + expect(admitted).toBe(true) + const closed = state.plugin.closeBundle() + const during = getBuildOwnership() + release.resolve() + await configuring + await closed + expect(during.running).toBe(before.running) + } + const output = await state.plugin.transform.call( + context, + "import {Box} from '@devup-ui/react';export const A=()=> ;", + join(state.project.root, 'src/A.tsx'), + ) + // Then + expect(output).toBeDefined() + const resumed = getBuildOwnership() + expect(resumed.running).toBe(before.running) + expect(resumed.generation).toBeGreaterThan(before.generation) + await state.plugin.buildStart.call(context) + expect(getBuildOwnership()).toEqual(resumed) + await state.plugin.closeBundle() + expect(getBuildOwnership().running).toBe(before.running - 1) + }) +} diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-cache-fixture.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-cache-fixture.cjs new file mode 100644 index 000000000..31fa1eb26 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-cache-fixture.cjs @@ -0,0 +1,58 @@ +const assert = require('node:assert/strict') +const { join, resolve } = require('node:path') +const { put } = require('./reservation-native-fixture.cjs') +const { hooks, gate } = require('./serve-coherence-async-fixture.cjs') +const { observe } = require('./reservation-native-observer.cjs') +const wasm = require('@devup-ui/wasm') + +/** @typedef {import('./serve-coherence-async-fixture.cjs').Hooks & {load(id:string):string|undefined}} CacheHooks */ +/** @param {unknown} value @returns {value is CacheHooks} */ +function cacheHooks(value) { + return hooks(value) && 'load' in value && typeof value.load === 'function' +} +/** @param {string} root @param {Partial} [options] */ +function fixture(root, options = {}) { + const write = (/** @type {string} */ path, /** @type {string} */ text) => + put(root, path, text) + write('devup.json', '{}') + write('package.json', '{"type":"module"}') + const source = + "import {Box} from '@devup-ui/react'; export const A=()=> ;" + write('src/A.tsx', source) + const { DevupUI } = require('../../dist/' + 'index.cjs') + const plugins = DevupUI({ + singleCss: true, + devupFile: join(root, 'devup.json'), + distDir: join(root, 'df'), + cssDir: join(root, 'df/css'), + ...options, + }) + assert(Array.isArray(plugins)) + const plugin = plugins[0] + assert(cacheHooks(plugin)) + const id = resolve(root, 'src/A.tsx').replaceAll('\\', '/') + /** @type {string[]} */ + const watches = [] + const context = { + environment: { name: 'client' }, + addWatchFile(/** @type {string} */ file) { + watches.push(file) + }, + } + const transform = (code = source, fullId = id) => + plugin.transform.call(context, code, fullId) + /** @type {import('../reservation-inputs').ReservationConfig} */ + const config = { root, command: 'serve' } + return { + root, + write, + source, + plugin, + id, + context, + watches, + transform, + config, + } +} +module.exports = { fixture, gate, observe, wasm } diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-cache-lifetime.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-cache-lifetime.cjs new file mode 100644 index 000000000..bc1092fc2 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-cache-lifetime.cjs @@ -0,0 +1,96 @@ +const assert = require('node:assert/strict') +const { join } = require('node:path') +const { + fixture, + gate, + observe, + wasm, +} = require('./serve-coherence-cache-fixture.cjs') +const mode = process.argv[2] +const root = process.argv[3] +assert(root) +const telemetry = observe() + +async function main() { + // Given: a real owned serve session, with an actual sheet write held pending. + const state = fixture(root) + const { plugin, transform, source } = state + await plugin.configResolved(state.config) + const suspended = gate('writeFile', join(root, 'df/css/devup-ui.css')) + const first = transform() + try { + await suspended.entered + const count = telemetry.extractions.length + // When: one lifetime or semantic boundary occurs before that write completes. + switch (mode) { + case 'flight': { + const second = transform() + suspended.release() + assert.deepEqual(await second, await first) + assert.equal( + telemetry.extractions.length, + count, + 'pending identical calls must join one flight', + ) + break + } + case 'reconfigure': { + await plugin.configResolved(state.config) + await transform() + suspended.release() + await first + await transform() + assert.equal( + telemetry.extractions.length, + count + 1, + 'old completion must not replace the new session flight', + ) + break + } + case 'refresh': { + state.write( + 'devup.json', + '{"theme":{"colors":{"default":{"primary":"blue"}}}}', + ) + await plugin.watchChange(join(root, 'devup.json')) + suspended.release() + await first + await transform() + assert.equal( + telemetry.extractions.length, + count + 1, + 'pre-refresh capture cannot be retained after old write completes', + ) + assert.match(wasm.getCss(null, false), /--primary:blue/) + break + } + case 'close': { + const closed = plugin.closeBundle() + suspended.release() + await first + await closed + assert.equal( + await transform(), + undefined, + 'serve close must remain terminal', + ) + break + } + default: + throw new TypeError('Unknown lifetime mode ' + mode) + } + // Then: only a current successful flight may serve the next identical call. + if (mode === 'flight') { + await transform(source) + assert.equal(telemetry.extractions.length, count) + } + } finally { + suspended.restore() + await first + await plugin.closeBundle() + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-cache-metadata.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-cache-metadata.cjs new file mode 100644 index 000000000..420c50bf5 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-cache-metadata.cjs @@ -0,0 +1,167 @@ +const assert = require('node:assert/strict') +const { rmSync } = require('node:fs') +const { join } = require('node:path') +const { + fixture, + observe, + wasm, +} = require('./serve-coherence-cache-fixture.cjs') +const mode = process.argv[2] +const root = process.argv[3] +assert(root) +const telemetry = observe() +const theme = (/** @type {readonly (string|null)[]} */ sizes) => + JSON.stringify({ theme: { length: { default: { size: sizes } } } }) +const alias = (/** @type {string} */ color) => + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@tone': ['./src/' + color + '.js'] }, + }, + }) +const manifest = (/** @type {string} */ color) => + JSON.stringify({ + name: '@cache/tone', + type: 'module', + exports: './' + color + '.js', + }) + +async function main() { + // Given: unchanged consumer bytes and both resolution candidates in inventory. + const state = fixture(root, { include: ['@cache/tone'] }) + const { write, plugin, transform } = state + let source = + "import {Box} from '@devup-ui/react'; export const A=()=> ;" + let changed = 'devup.json' + switch (mode) { + case 'root': + write('devup.json', theme(['11px'])) + break + case 'inherited': + write('devup.json', '{"extends":["./config/base.json"]}') + changed = 'config/base.json' + write(changed, theme(['11px'])) + break + case 'tailwind': + case 'tailwind-import': + write( + 'devup.json', + JSON.stringify({ tailwind: { css: join(root, 'selected.css') } }), + ) + write( + 'selected.css', + mode === 'tailwind' + ? '@utility cache-tone { color: red; }' + : '@import "./imported.css";', + ) + changed = mode === 'tailwind' ? 'selected.css' : 'imported.css' + write(changed, '@utility cache-tone { color: red; }') + source = + "import {Box} from '@devup-ui/react'; export const A=()=> ;" + break + case 'alias': + case 'exports': + source = + "import {Box} from '@devup-ui/react'; import {tone} from '" + + (mode === 'alias' ? '@tone' : '@cache/tone') + + "'; export const A=()=> ;" + source += + "import './red.js'; import './blue.js'; import '../node_modules/@cache/tone/red.js'; import '../node_modules/@cache/tone/blue.js';" + write('src/red.js', "export const tone='red'") + write('src/blue.js', "export const tone='blue'") + write('node_modules/@cache/tone/red.js', "export const tone='red'") + write('node_modules/@cache/tone/blue.js', "export const tone='blue'") + write( + 'src/inventory.ts', + "import './red.js'; import './blue.js'; import '@cache/tone/red.js'; import '@cache/tone/blue.js';", + ) + changed = + mode === 'alias' + ? 'tsconfig.json' + : 'node_modules/@cache/tone/package.json' + write(changed, mode === 'alias' ? alias('red') : manifest('red')) + break + default: + throw new TypeError('Unknown metadata mode ' + mode) + } + write('src/A.tsx', source) + try { + await plugin.configResolved(state.config) + const before = await transform(source) + await transform(source) + const count = telemetry.extractions.length + const rows = telemetry.reservations.at(-1)?.rows + // When: the actual selected semantic file changes, but full id and source do not. + switch (mode) { + case 'root': + case 'inherited': + write(changed, theme(['11px', null, '29px'])) + break + case 'tailwind': + case 'tailwind-import': + write(changed, '@utility cache-tone { color: teal; }') + break + case 'alias': + write(changed, alias('blue')) + break + case 'exports': + write(changed, manifest('blue')) + break + default: + throw new TypeError('Unknown metadata mode ' + mode) + } + await plugin.watchChange(join(root, changed)) + const after = await transform(source) + // Then: the real facade must run again and publish actual changed semantics. + assert.equal( + telemetry.extractions.length, + count + 1, + 'metadata must invalidate eligible cached input', + ) + const css = wasm.getCss(null, false) + switch (mode) { + case 'root': + case 'inherited': + assert.notDeepEqual(after, before) + assert.match(css, /29px/) + assert.match(css, /@media/) + if (mode === 'inherited') { + rmSync(join(root, changed)) + await plugin.watchChange(join(root, changed)) + await transform(source) + assert.doesNotMatch(wasm.getCss(null, false), /29px/) + write(changed, theme(['17px', null, '37px'])) + await plugin.watchChange(join(root, changed)) + await transform(source) + assert.match(wasm.getCss(null, false), /37px/) + } + break + case 'tailwind': + case 'tailwind-import': + assert.match(css, /color:teal/) + break + case 'alias': + case 'exports': + assert.deepEqual( + telemetry.reservations.at(-1)?.rows, + rows, + 'metadata edit must keep inventory unchanged', + ) + assert.match(css, /color:blue/) + assert( + telemetry.extractions + .at(-1) + ?.dependencies.some((file) => file.endsWith('/blue.js')), + ) + break + default: + throw new TypeError('Unknown metadata mode ' + mode) + } + } finally { + await plugin.closeBundle() + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-cache-native.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-cache-native.cjs new file mode 100644 index 000000000..2c3f90efe --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-cache-native.cjs @@ -0,0 +1,92 @@ +const assert = require('node:assert/strict') +const { join } = require('node:path') +const { + fixture, + observe, + wasm, +} = require('./serve-coherence-cache-fixture.cjs') +const mode = process.argv[2] +const root = process.argv[3] +assert(root) +const telemetry = observe() + +async function main() { + // Given: an exclusively owned, configured serve plugin using the real facade. + const state = fixture(root) + const { plugin, source, id, transform } = state + try { + await plugin.configResolved({ + root, + command: mode === 'build' ? 'build' : 'serve', + }) + const first = await transform() + const count = telemetry.extractions.length + // When: the exact full transform id and whole input repeat without changes. + const repeated = await transform() + // Then: no second real extraction, while the real sheet remains available. + assert.equal( + telemetry.extractions.length, + mode === 'build' ? count + 1 : count, + 'exact input reuse must be serve-only', + ) + assert(repeated) + assert.match(wasm.getCss(null, false), /color:red/) + switch (mode) { + case 'basic': + case 'build': + break + case 'input': { + const blue = source.replace("'red'", "'blue'") + await transform(blue, id + '?alternate') + await transform() + assert.equal(telemetry.extractions.length, count + 2) + assert.match(wasm.getCss(null, false), /color:red/) + break + } + case 'query': { + await transform(source, id + '?alternate') + assert.equal(telemetry.extractions.length, count + 1) + await transform(source, id + '?alternate') + assert.equal(telemetry.extractions.length, count + 1) + break + } + case 'sheet': { + const blue = source.replace("'red'", "'blue'") + await transform(blue, id.replace('A.tsx', 'B.tsx')) + await transform() + assert.equal( + telemetry.extractions.length, + count + 1, + 'unrelated first observation must not evict A', + ) + const css = plugin.load(join(root, 'df/css/devup-ui.css')) + assert(css) + assert.match(css, /color:red/) + assert.match(css, /color:blue/) + break + } + case 'membership': { + const classes = JSON.parse(wasm.exportClassMap()) + const files = JSON.parse(wasm.exportFileMap()) + state.write('src/B.tsx', source.replace("'red'", "'blue'")) + await plugin.watchChange(join(root, 'src/B.tsx')) + const after = await transform() + assert.deepEqual(after, first) + assert.equal(telemetry.extractions.length, count + 1) + const nextClasses = JSON.parse(wasm.exportClassMap()) + const nextFiles = JSON.parse(wasm.exportFileMap()) + assert.partialDeepStrictEqual(nextClasses, classes) + assert.partialDeepStrictEqual(nextFiles, files) + break + } + default: + throw new TypeError('Unknown cache mode: ' + mode) + } + } finally { + await plugin.closeBundle() + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-cache-native.test.ts b/packages/vite-plugin/src/__tests__/serve-coherence-cache-native.test.ts new file mode 100644 index 000000000..31dfe8f47 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-cache-native.test.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { ownedNativeFixture } from './serve-coherence-owned-native-fixture' + +for (const mode of [ + 'basic', + 'input', + 'query', + 'sheet', + 'build', + 'membership', +]) { + it(`qualifies real cache ${mode} when the owned session transforms`, () => { + // Given / When: the owned Node child executes two real facade transforms. + ownedNativeFixture( + join(import.meta.dir, 'serve-coherence-cache-native.cjs'), + [mode], + (root) => { + // Then: the fixture survives until the original owner confirms tree exit. + expect(readFileSync(join(root, 'src/A.tsx'), 'utf8')).toContain('Box') + }, + ) + }, 180000) +} + +for (const mode of [ + 'root', + 'inherited', + 'tailwind', + 'tailwind-import', + 'alias', + 'exports', +]) { + it(`invalidates real cached output when ${mode} metadata changes`, () => { + ownedNativeFixture( + join(import.meta.dir, 'serve-coherence-cache-metadata.cjs'), + [mode], + () => {}, + ) + }, 180000) +} + +for (const mode of ['flight', 'reconfigure', 'refresh', 'close']) { + it(`protects real pending output when ${mode} occurs`, () => { + ownedNativeFixture( + join(import.meta.dir, 'serve-coherence-cache-lifetime.cjs'), + [mode], + () => {}, + ) + }, 180000) +} +it('bypasses reuse when a second actual configuration overlaps', () => { + ownedNativeFixture( + join(import.meta.dir, 'serve-coherence-cache-ownership.cjs'), + [], + () => {}, + ) +}, 180000) diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-cache-ownership.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-cache-ownership.cjs new file mode 100644 index 000000000..e9ce2e45e --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-cache-ownership.cjs @@ -0,0 +1,56 @@ +const assert = require('node:assert/strict') +const { join } = require('node:path') +const { + fixture, + observe, + wasm, +} = require('./serve-coherence-cache-fixture.cjs') +const { getBuildOwnership } = require('@devup-ui/plugin-utils') +const root = process.argv[2] +assert(root) +const telemetry = observe() +async function main() { + // Given: A is exclusive and has a completed cache entry. + const a = fixture(root, { + prefix: 'cacheA', + shorthands: { cacheSpace: ['marginLeft'] }, + }) + const source = + "import {Box} from '@devup-ui/react'; export const A=()=> ;" + a.write('src/A.tsx', source) + await a.plugin.configResolved(a.config) + await a.transform(source) + const count = telemetry.extractions.length + const b = fixture(join(root, 'B'), { + prefix: 'cacheB', + shorthands: { cacheSpace: ['paddingRight'] }, + }) + try { + // When: B actually configures distinct engine options while both owners live. + await b.plugin.configResolved(b.config) + assert.equal(getBuildOwnership().running, 2) + await a.transform(source) + await a.transform(source) + assert.equal( + telemetry.extractions.length, + count + 2, + 'overlap must bypass both lookup and publication', + ) + assert.match(telemetry.extractions.at(-1)?.code ?? '', /cacheB/) + assert.match(wasm.getCss(null, false), /padding-right:12px/) + await b.plugin.closeBundle() + assert.equal(getBuildOwnership().running, 1) + await a.transform(source) + await a.transform(source) + // Then: return to exclusive forces one fresh extraction, using actual shared B options. + assert.equal(telemetry.extractions.length, count + 3) + assert.match(telemetry.extractions.at(-1)?.code ?? '', /cacheB/) + } finally { + await b.plugin.closeBundle() + await a.plugin.closeBundle() + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-config-refresh.test.ts b/packages/vite-plugin/src/__tests__/serve-coherence-config-refresh.test.ts new file mode 100644 index 000000000..0816fba5e --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-config-refresh.test.ts @@ -0,0 +1,80 @@ +import { rmSync } from 'node:fs' +import { join } from 'node:path' + +import { getCss } from '@devup-ui/wasm' +import { expect, it, mock } from 'bun:test' + +import { + environment, + graph, + lifecycle, + watcher, +} from './plugin-lifecycle-fixture' + +for (const hook of ['watchChange', 'hotUpdate', 'handleHotUpdate'] as const) { + for (const action of ['edit', 'delete', 'recreate', 'root-delete'] as const) { + it(`refreshes actual config inputs when ${hook} receives ${action}`, async () => { + // Given + using state = await lifecycle() + const theme = (size: string) => + JSON.stringify({ + theme: { length: { default: { refreshSize: size } } }, + }) + state.project.write('devup.json', '{"extends":["./base.json"]}') + if (action !== 'recreate') state.project.write('base.json', theme('11px')) + await state.plugin.watchChange(join(state.project.root, 'devup.json')) + const watching = watcher() + state.plugin.configureServer({ config: state.config, watcher: watching }) + const path = action === 'root-delete' ? 'devup.json' : 'base.json' + const file = join(state.project.root, path) + expect(watching.batches.flat()).toContain(file) + switch (action) { + case 'edit': + case 'recreate': + state.project.write(path, theme('29px')) + break + case 'delete': + case 'root-delete': + rmSync(file) + break + default: + throw new TypeError(`Unexpected action ${action satisfies never}`) + } + const event = { file, modules: [], timestamp: 1 } + // When + switch (hook) { + case 'watchChange': + await state.plugin.watchChange(file) + break + case 'hotUpdate': + await state.plugin.hotUpdate.call( + { environment: environment('client') }, + event, + ) + break + case 'handleHotUpdate': + await state.plugin.handleHotUpdate({ + ...event, + server: { moduleGraph: graph(), ws: { send: mock() } }, + }) + break + default: + throw new TypeError(`Unexpected hook ${hook satisfies never}`) + } + // Then + const css = getCss(null, false) + switch (action) { + case 'edit': + case 'recreate': + expect(css).toContain('--refreshSize:29px') + break + case 'delete': + case 'root-delete': + expect(css).not.toContain('--refreshSize:') + break + default: + throw new TypeError(`Unexpected action ${action satisfies never}`) + } + }) + } +} diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-final-native.test.ts b/packages/vite-plugin/src/__tests__/serve-coherence-final-native.test.ts new file mode 100644 index 000000000..855d101b6 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-final-native.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { resolve } from 'node:path' + +import { it } from 'bun:test' + +for (const [probe, mode] of [ + ['admission', 'config'], + ['admission', 'tailwind'], + ['admission', 'tailwind-import'], + ['build-lease', 'overlap'], + ['build-lease', 'solo'], + ['build-lease', 'pending'], +]) { + it(`preserves real prerequisite behavior when ${probe} ${mode}`, () => { + // Given / When + const result = spawnSync( + 'node', + [ + resolve( + import.meta.dir, + '../../../next-plugin/src/__tests__/native-webpack-owned.cjs', + ), + '120000', + resolve(import.meta.dir, `serve-coherence-${probe}-native.cjs`), + mode, + ], + { encoding: 'utf8', timeout: 150000 }, + ) + // Then + assert.equal(result.status, 0, result.stdout + result.stderr) + assert.match( + result.stdout, + /serve-coherence-final-complete/, + result.stdout + result.stderr, + ) + }, 180000) +} diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-owned-native-fixture.ts b/packages/vite-plugin/src/__tests__/serve-coherence-owned-native-fixture.ts new file mode 100644 index 000000000..e9df4da67 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-owned-native-fixture.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +export function ownedNativeFixture( + helper: string, + args: readonly string[], + inspect: (root: string) => void, +) { + const root = realpathSync( + mkdtempSync(join(tmpdir(), 'serve-coherence-owned-native-')), + ) + const result = spawnSync( + 'node', + [ + resolve( + import.meta.dir, + '../../../next-plugin/src/__tests__/native-webpack-owned.cjs', + ), + '120000', + helper, + ...args, + root, + ], + { encoding: 'utf8', timeout: 150000 }, + ) + const exited = result.stdout.split(/\r?\n/).some((line) => { + const prefix = 'NATIVE-LIFECYCLE ' + if (!line.startsWith(prefix)) return false + const value: unknown = JSON.parse(line.slice(prefix.length)) + return ( + typeof value === 'object' && + value !== null && + 'event' in value && + value.event === 'tree_exit' && + 'active' in value && + Array.isArray(value.active) && + value.active.length === 0 + ) + }) + // An unconfirmed owner exit is fatal: do not race its unknown live handles. + assert( + exited, + `No owned tree exit for ${root}\n${result.stdout}${result.stderr}`, + ) + try { + assert.equal(result.status, 0, result.stdout + result.stderr) + inspect(root) + } finally { + rmSync(root, { recursive: true }) + } +} diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-prereq-native.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-prereq-native.cjs new file mode 100644 index 000000000..3a54f9211 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-prereq-native.cjs @@ -0,0 +1,184 @@ +const assert = require('node:assert/strict') +const { readFileSync, rmSync } = require('node:fs') +const { join, resolve } = require('node:path') +const { pathToFileURL } = require('node:url') +const { put } = require('./reservation-native-fixture.cjs') +const { observe } = require('./reservation-native-observer.cjs') +const wasm = require('@devup-ui/wasm') +const key = (/** @type {string} */ path) => resolve(path).replaceAll('\\', '/') +const mode = process.argv[2] +assert(['theme', 'alias'].includes(mode)) +const root = process.argv[3] +assert(root) +const write = (/** @type {string} */ path, /** @type {string} */ text) => + put(root, path, text) +const telemetry = observe() +const theme = (/** @type {readonly (string|null)[]} */ length) => + JSON.stringify({ theme: { length: { default: { size: length } } } }) +const alias = (/** @type {string} */ color) => + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@tone': ['./src/' + color + '.js'] }, + }, + }) + +async function main() { + // Given: both suppliers exist; only metadata changes after configuration. + write('package.json', '{"name":"prereq-native","type":"module"}') + write( + 'devup.json', + mode === 'theme' ? '{"extends":["./config/base.json"]}' : '{}', + ) + write('config/base.json', theme(['11px'])) + write('tsconfig.json', alias('red')) + write('src/red.js', "export const tone='red'") + write('src/blue.js', "export const tone='blue'") + const source = + mode === 'theme' + ? 'import {Box} from \'@devup-ui/react\'; export const A=()=> ;' + : "import {Box} from '@devup-ui/react'; import {tone} from '@tone'; import './red.js'; import './blue.js'; export const A=()=> ;" + write('src/A.tsx', source) + write('index.html', '') + process.chdir(root) + /** @type {{DevupUI:typeof import('../plugin').DevupUI}} */ + const { DevupUI } = await import( + pathToFileURL(resolve(__dirname, '../../dist/index.mjs')).href + ) + const { createServer } = await import('vite') + let configResolved = 0 + /** @type {PromiseWithResolvers} */ + const ready = Promise.withResolvers() + /** @type {Map>} */ + const notifications = new Map() + /** @type {Set} */ + const registrations = new Set() + const server = await createServer({ + root, + configFile: false, + logLevel: 'error', + oxc: { jsx: { runtime: 'classic' } }, + optimizeDeps: { noDiscovery: true, include: [] }, + server: { host: '127.0.0.1', port: 0, fs: { strict: false } }, + plugins: [ + { + name: 'prereq-native-observer', + enforce: 'pre', + configResolved() { + configResolved++ + }, + configureServer(server) { + const add = server.watcher.add.bind(server.watcher) + server.watcher.add = (paths) => { + for (const path of typeof paths === 'string' ? [paths] : paths) + registrations.add(key(path)) + return add(paths) + } + server.watcher.once('ready', () => ready.resolve()) + }, + hotUpdate: { + order: 'post', + handler({ file }) { + if (this.environment.name === 'client') + notifications.get(key(file))?.resolve() + }, + }, + }, + DevupUI({ + singleCss: true, + devupFile: join(root, 'devup.json'), + distDir: join(root, 'df'), + cssDir: join(root, 'df/css'), + }), + ], + }) + async function extract(/** @type {string} */ query) { + const count = telemetry.extractions.length + const result = await server.environments.client.transformRequest( + '/src/A.tsx' + query, + ) + assert(result) + const output = telemetry.extractions + .slice(count) + .findLast((row) => row.id === key(join(root, 'src/A.tsx'))) + assert(output, 'must actually extract unchanged consumer') + return { + output, + css: wasm.getCss(null, false), + rows: telemetry.reservations.at(-1)?.rows, + } + } + async function change( + /** @type {string} */ path, + /** @type {()=>void} */ operation, + ) { + /** @type {PromiseWithResolvers} */ + const done = Promise.withResolvers() + notifications.set(key(join(root, path)), done) + operation() + await done.promise + notifications.delete(key(join(root, path))) + } + try { + await server.listen() + await ready.promise + const before = await extract('') + const changed = mode === 'theme' ? 'config/base.json' : 'tsconfig.json' + // When: a real filesystem event reaches the unchanged configured plugin. + await change(changed, () => + write( + changed, + mode === 'theme' ? theme(['11px', null, '29px']) : alias('blue'), + ), + ) + const after = await extract('?prereq=metadata') + // Then: inventory and consumer bytes stay fixed, but real output reflects metadata. + assert.equal(readFileSync(join(root, 'src/A.tsx'), 'utf8'), source) + assert.deepEqual(after.rows, before.rows) + assert.equal(configResolved, 1) + if (mode === 'theme') { + await change('devup.json', () => + write( + 'devup.json', + '{"extends":["./config/base.json"],"control":true}', + ), + ) + const control = await extract('?prereq=root-refresh') + assert.notEqual(control.output.code, before.output.code) + assert.match(control.css, /29px/) + assert.match(control.css, /@media/) + assert.match( + after.css, + /29px/, + 'inherited theme edit must apply responsive token expansion', + ) + assert.notEqual(after.output.code, before.output.code) + assert(registrations.has(key(join(root, changed)))) + await change(changed, () => rmSync(join(root, changed))) + const deleted = await extract('?prereq=deleted') + assert.doesNotMatch(deleted.css, /29px/) + await change(changed, () => write(changed, theme(['17px', null, '37px']))) + const recreated = await extract('?prereq=recreated') + assert.match(recreated.css, /37px/) + assert.match(recreated.css, /@media/) + } else { + assert.match(before.css, /color:red/) + assert( + before.output.dependencies.some((path) => path.endsWith('/red.js')), + ) + assert( + after.output.dependencies.some((path) => path.endsWith('/blue.js')), + 'live resolver must select blue supplier after paths edit', + ) + assert.match(after.output.css ?? '', /color:blue/) + } + console.info(JSON.stringify({ mode, configResolved, before, after })) + } finally { + await server.close() + process.chdir(__dirname) + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-prereq-native.test.ts b/packages/vite-plugin/src/__tests__/serve-coherence-prereq-native.test.ts new file mode 100644 index 000000000..daccbdb42 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-prereq-native.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { it } from 'bun:test' + +import { ownedNativeFixture } from './serve-coherence-owned-native-fixture' + +for (const [probe, ...args] of [ + ['native', 'theme'], + ['native', 'alias'], + ['ownership', 'esm', 'esm'], + ['ownership', 'esm', 'cjs'], + ['ownership', 'cjs', 'cjs'], + ['ownership', 'cjs', 'esm'], +]) { + it(`preserves real prerequisite behavior when ${probe} ${args.join(' ')}`, () => { + // Given / When: real built plugins, filesystem events and WASM Output. + ownedNativeFixture( + resolve(import.meta.dir, `serve-coherence-prereq-${probe}.cjs`), + args, + (root) => { + // Then: only the parent may remove the populated fixture after tree exit. + assert( + existsSync(join(root, 'src/A.tsx')), + 'parent fixture must survive the owned child', + ) + }, + ) + }, 180000) +} diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-prereq-ownership.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-prereq-ownership.cjs new file mode 100644 index 000000000..9d0d8dbce --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-prereq-ownership.cjs @@ -0,0 +1,144 @@ +const assert = require('node:assert/strict') +const { join, resolve } = require('node:path') +const { pathToFileURL } = require('node:url') +const { put } = require('./reservation-native-fixture.cjs') +const { observe } = require('./reservation-native-observer.cjs') +const { hooks } = require('./serve-coherence-async-fixture.cjs') +const wasm = require('@devup-ui/wasm') +const root = process.argv[4] +assert(root) +const telemetry = observe() +const [first = 'esm', second = 'esm'] = process.argv.slice(2) +/** @returns {Promise<{DevupUI:typeof import('../plugin').DevupUI}>} */ +async function load(/** @type {string} */ format) { + return format === 'esm' + ? import(pathToFileURL(resolve(__dirname, '../../dist/index.mjs')).href) + : require('../../dist/' + 'index.cjs') +} +async function main() { + // Given: A owns nonempty real engine state under its own options. + put(root, 'package.json', '{"name":"ownership-native","type":"module"}') + put(root, 'devup.json', '{}') + put(root, 'index.html', '') + const source = + "import {Box} from '@devup-ui/react'; export const A=()=> ;" + put(root, 'src/A.tsx', source) + process.chdir(root) + const owner = await load(first) + const { createServer } = await import('vite') + const options = { + singleCss: true, + devupFile: join(root, 'devup.json'), + distDir: join(root, 'df'), + cssDir: join(root, 'df/css'), + } + /** @type {PromiseWithResolvers} */ + const ready = Promise.withResolvers() + let configResolved = 0 + const server = await createServer({ + root, + configFile: false, + logLevel: 'error', + oxc: { jsx: { runtime: 'classic' } }, + optimizeDeps: { noDiscovery: true, include: [] }, + server: { host: '127.0.0.1', port: 0, fs: { strict: false } }, + plugins: [ + owner.DevupUI({ + ...options, + prefix: 'WA', + shorthands: { probe: ['color'] }, + }), + { + name: 'ownership-native-observer', + configResolved() { + configResolved++ + }, + configureServer(server) { + server.watcher.once('ready', () => ready.resolve()) + }, + }, + ], + }) + /** @type {ReturnType} */ + let other + try { + await server.listen() + await ready.promise + await server.environments.client.transformRequest('/src/A.tsx') + const before = telemetry.snapshot() + const sheet = wasm.exportSheet() + const cjsOwnership = require('@devup-ui/plugin-utils').getBuildOwnership + const esmOwnership = (await import('@devup-ui/plugin-utils')) + .getBuildOwnership + const lease = cjsOwnership() + assert.equal(lease.running, 1) + assert.deepEqual(esmOwnership(), lease) + const initial = telemetry.extractions.at(-1) + assert(initial) + const code = initial.code + assert.notEqual(before.classMap, '{}') + assert.match(before.css, /color:orchid/) + assert.match(code, /WA/) + // When: B is only constructed, including across module formats. + const factory = await load(second) + other = factory.DevupUI({ + ...options, + prefix: 'WB', + shorthands: { probe: ['backgroundColor'] }, + }) + const constructed = telemetry.snapshot() + // Then: construction cannot wipe or mutate A's sheet/maps/options. + assert.equal( + constructed.classMap, + before.classMap, + 'construction must preserve active class map', + ) + assert.equal(constructed.fileMap, before.fileMap) + assert.equal(constructed.css, before.css) + assert.equal(constructed.count, before.count) + assert.equal(wasm.exportSheet(), sheet) + assert.deepEqual(cjsOwnership(), { + running: 2, + generation: lease.generation + 1, + }) + assert.deepEqual(esmOwnership(), cjsOwnership()) + await server.environments.client.transformRequest( + '/src/A.tsx?prereq=overlap', + ) + const after = telemetry.snapshot() + const output = telemetry.extractions.at(-1) + assert(output) + assert.equal(output.input, source) + assert.equal( + output.code, + code, + 'A must retain its own prefix and shorthand semantics', + ) + assert.equal(after.css, before.css) + assert.equal(after.classMap, before.classMap) + assert.equal(after.fileMap, before.fileMap) + assert.equal(configResolved, 1) + assert(Array.isArray(other)) + const otherPlugin = other[0] + assert(hooks(otherPlugin)) + await otherPlugin.closeBundle() + other = undefined + assert.deepEqual(esmOwnership(), { + running: 1, + generation: lease.generation + 2, + }) + console.info(JSON.stringify({ first, second, code, css: after.css })) + } finally { + if (Array.isArray(other)) { + const otherPlugin = other[0] + assert(hooks(otherPlugin)) + await otherPlugin.closeBundle() + } + await server.close() + process.chdir(__dirname) + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-resolver-refresh.test.ts b/packages/vite-plugin/src/__tests__/serve-coherence-resolver-refresh.test.ts new file mode 100644 index 000000000..bb143a031 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-resolver-refresh.test.ts @@ -0,0 +1,72 @@ +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import { fixture } from '../../../plugin-utils/src/__tests__/reservation-fixture' +import { createViteReservation } from '../reservation' + +it('refreshes live alias resolution before dirty reservation without refreshing clean preparation', async () => { + // Given + using project = fixture() + const paths = (color: string) => + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@tone': [`./src/${color}.js`] }, + }, + }) + const source = + "import {Box} from '@devup-ui/react'; import {tone} from '@tone'; import './red.js'; import './blue.js'; export const A=()=> ;" + project.write('src/A.tsx', source) + project.write('src/red.js', "export const tone='red'") + project.write('src/blue.js', "export const tone='blue'") + project.write('tsconfig.json', paths('red')) + const slots = createViteReservation({ + package: '@devup-ui/react', + include: [], + importAliases: {}, + singleCss: true, + cssDir: join(project.root, 'df/css'), + distDir: join(project.root, 'df'), + }) + slots.configure({ root: project.root }) + using resolver = spyOn(wasm, 'setModuleResolver') + await slots.reserve() + const id = join(project.root, 'src/A.tsx').replaceAll('\\', '/') + using before = wasm.codeExtract( + id, + source, + '@devup-ui/react', + '../df/css', + true, + true, + false, + {}, + ) + expect(before.dependencies).toContain( + join(project.root, 'src/red.js').replaceAll('\\', '/'), + ) + await slots.reserve() + expect(resolver).toHaveBeenCalledTimes(1) + project.write('tsconfig.json', paths('blue')) + slots.invalidate(join(project.root, 'tsconfig.json')) + // When + await slots.reserve() + using after = wasm.codeExtract( + id, + source, + '@devup-ui/react', + '../df/css', + true, + true, + false, + {}, + ) + // Then + expect(after.dependencies).toContain( + join(project.root, 'src/blue.js').replaceAll('\\', '/'), + ) + expect(after.code).not.toBe(before.code) + expect(resolver).toHaveBeenCalledTimes(2) +}) diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-teardown-boundary.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-teardown-boundary.cjs new file mode 100644 index 000000000..04b3ae8d8 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-teardown-boundary.cjs @@ -0,0 +1,49 @@ +const assert = require('node:assert/strict') +const { fork } = require('node:child_process') +const { rmSync, writeFileSync } = require('node:fs') +const { join } = require('node:path') +const mode = process.argv[2] +const root = process.argv[3] +assert(root) +assert.equal(process.platform, 'win32') + +async function main() { + if (mode === 'holder') { + process.chdir(root) + process.on('message', () => {}) + assert(process.send) + process.send('holding-directory') + return + } + assert.equal(mode, 'owner') + const holder = fork(__filename, ['holder', root], { + stdio: ['ignore', 'inherit', 'inherit', 'ipc'], + }) + /** @type {Promise} */ + const ready = new Promise((resolve, reject) => { + holder.once('error', reject) + holder.once('message', resolve) + }) + assert.equal(await ready, 'holding-directory') + let denied = '' + assert.throws( + () => rmSync(root, { recursive: true }), + (error) => { + if ( + !(error instanceof Error) || + !('code' in error) || + typeof error.code !== 'string' + ) + return false + denied = error.code + return ['EPERM', 'EBUSY', 'EACCES'].includes(denied) + }, + ) + writeFileSync(join(root, 'boundary.json'), JSON.stringify({ denied, root })) + // Exit the root while its real descendant is still owned by the native job. + process.exit(0) +} +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-teardown-boundary.test.ts b/packages/vite-plugin/src/__tests__/serve-coherence-teardown-boundary.test.ts new file mode 100644 index 000000000..d39991e9e --- /dev/null +++ b/packages/vite-plugin/src/__tests__/serve-coherence-teardown-boundary.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { it } from 'bun:test' + +import { ownedNativeFixture } from './serve-coherence-owned-native-fixture' + +it.skipIf(process.platform !== 'win32')( + 'removes a Windows held-directory fixture only after its owned tree exits', + () => { + // Given / When: a real descendant denies the old live-owner deletion boundary. + ownedNativeFixture( + resolve(import.meta.dir, 'serve-coherence-teardown-boundary.cjs'), + ['owner'], + (root) => { + // Then: receipt inspection and parent deletion happen only after owned exit. + const receipt: unknown = JSON.parse( + readFileSync(join(root, 'boundary.json'), 'utf8'), + ) + assert(typeof receipt === 'object' && receipt !== null) + assert('denied' in receipt && typeof receipt.denied === 'string') + assert(['EPERM', 'EBUSY', 'EACCES'].includes(receipt.denied)) + assert('root' in receipt) + assert.equal(receipt.root, root) + }, + ) + }, + 180000, +) diff --git a/packages/vite-plugin/src/__tests__/server-watch.test.ts b/packages/vite-plugin/src/__tests__/server-watch.test.ts new file mode 100644 index 000000000..771a9be9f --- /dev/null +++ b/packages/vite-plugin/src/__tests__/server-watch.test.ts @@ -0,0 +1,179 @@ +import { EventEmitter } from 'node:events' +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import { + fixture, + STYLE, +} from '../../../plugin-utils/src/__tests__/reservation-fixture' +import { createCompiledDependencies } from '../compiled-dependencies' +import { createViteReservation } from '../reservation' +import { createServerWatch } from '../server-watch' + +function setup(root: string) { + const reservation = createViteReservation({ + package: '@devup-ui/react', + include: [], + importAliases: {}, + singleCss: true, + cssDir: join(root, 'df/css'), + distDir: join(root, 'df'), + }) + reservation.configure({ + root, + configFileDependencies: [join(root, 'devup.json')], + }) + const batches: string[][] = [] + const watcher = Object.assign(new EventEmitter(), { + add(paths: string[]) { + batches.push(paths) + }, + }) + const compiled = createCompiledDependencies() + return { reservation, batches, watcher, compiled } +} + +it('submits no prior paths or empty batches when dirty preparation and config refresh repeat', async () => { + // Given + using project = fixture() + project.write('src/A.js', `${STYLE}; import '../supplier.js'`) + project.write('supplier.js', 'export const tone=1') + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + const state = setup(project.root) + const watching = createServerWatch( + state.watcher, + state.reservation, + state.compiled.remove, + ) + try { + watching.add([ + join(project.root, 'devup.json'), + join(project.root, 'tailwind.css'), + ]) + await state.reservation.reserve() + const initial = state.batches.flat() + // When + state.watcher.emit('all', 'change', join(project.root, 'supplier.js')) + await state.reservation.reserve() + watching.add([ + join(project.root, 'devup.json'), + join(project.root, 'tailwind.css'), + ]) + await state.reservation.reserve() + // Then + expect(state.batches.flat()).toEqual(initial) + expect(initial.length).toBe(new Set(initial).size) + expect(state.batches.every((batch) => batch.length > 0)).toBe(true) + expect(reserve).toHaveBeenCalledTimes(2) + } finally { + watching.close() + } +}) + +it('registers new supplier and configuration inputs once when the real inventory changes', async () => { + // Given + using project = fixture() + project.write('src/A.js', STYLE) + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + const state = setup(project.root) + const watching = createServerWatch( + state.watcher, + state.reservation, + state.compiled.remove, + ) + try { + await state.reservation.reserve() + const before = state.batches.length + project.write('src/new-supplier.js', 'export const tone=2') + project.write('src/A.js', `${STYLE}; import './new-supplier.js'`) + project.write('new-config.json', '{}') + project.write('new-tailwind.css', '@theme { --color-brand: red; }') + const config = join(project.root, 'new-config.json') + const tailwind = join(project.root, 'new-tailwind.css') + // When + state.reservation.configure({ + root: project.root, + configFileDependencies: [config], + }) + watching.add([config, tailwind, config]) + await state.reservation.reserve() + state.watcher.emit('all', 'change', config) + await state.reservation.reserve() + watching.add([config, tailwind]) + // Then + expect(state.batches.slice(before).flat()).toEqual([ + config, + tailwind, + join(project.root, 'src/new-supplier.js').replaceAll('\\', '/'), + ]) + expect(reserve).toHaveBeenCalledTimes(3) + } finally { + watching.close() + } +}) + +it('replays committed inputs to a new server lifetime without retaining the old listener', async () => { + // Given + using project = fixture() + project.write('src/A.js', STYLE) + using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) + const state = setup(project.root) + const first = createServerWatch( + state.watcher, + state.reservation, + state.compiled.remove, + ) + await state.reservation.reserve() + first.close() + const before = state.batches.length + const next = Object.assign(new EventEmitter(), { + add(paths: string[]) { + nextBatches.push(paths) + }, + }) + const nextBatches: string[][] = [] + // When + const second = createServerWatch( + next, + state.reservation, + state.compiled.remove, + ) + try { + state.watcher.emit('all', 'change', join(project.root, 'src/A.js')) + await state.reservation.reserve() + expect(reserve).toHaveBeenCalledTimes(1) + next.emit('all', 'change', join(project.root, 'src/A.js')) + await state.reservation.reserve() + // Then + expect(nextBatches).toEqual([[...state.reservation.dependencies]]) + expect(state.batches).toHaveLength(before) + expect(reserve).toHaveBeenCalledTimes(2) + } finally { + second.close() + } +}) + +it('removes compiled consumer edges only on an actual unlink event', () => { + // Given + using project = fixture() + const state = setup(project.root) + const supplier = join(project.root, 'supplier.js') + state.compiled.update('client', 'A.js', [supplier]) + const watching = createServerWatch( + state.watcher, + state.reservation, + state.compiled.remove, + ) + try { + state.watcher.emit('all', 'change', supplier) + expect(state.compiled.consumers('client', supplier)).toEqual(['A.js']) + // When + state.watcher.emit('all', 'unlink', 'A.js') + // Then + expect(state.compiled.consumers('client', supplier)).toEqual([]) + } finally { + watching.close() + } +}) diff --git a/packages/vite-plugin/src/__tests__/source-cache-dependencies.test.ts b/packages/vite-plugin/src/__tests__/source-cache-dependencies.test.ts new file mode 100644 index 000000000..bc4d4a9da --- /dev/null +++ b/packages/vite-plugin/src/__tests__/source-cache-dependencies.test.ts @@ -0,0 +1,84 @@ +import { unlinkSync } from 'node:fs' + +import * as wasm from '@devup-ui/wasm' +import { expect, it, mock } from 'bun:test' + +import { environment, watcher } from './plugin-lifecycle-fixture' +import { + cacheFixture, + captureBoundary, + extractionObserver, + SOURCE, + writeGate, +} from './source-cache-fixture' +it('registers cross-environment hit edges before readiness and removes physically unlinked query consumers', async () => { + // Given: actual extraction and write are pending in the client environment. + using state = await cacheFixture() + const watching = watcher() + state.plugin.configureServer({ config: state.config, watcher: watching }) + using extracts = extractionObserver() + using write = writeGate(state.cssFile) + const id = state.id + '?consumer' + const clientWatch = mock() + const client = environment('client', [id]) + const ssr = environment('ssr', [id]) + const first = state.plugin.transform.call( + { environment: { name: 'client' }, addWatchFile: clientWatch }, + SOURCE, + id, + ) + let second: Promise | undefined + try { + await write.entered + using captured = captureBoundary() + const ssrWatch = mock() + // When: an identical in-flight hit registers in SSR while ready is held. + second = state.plugin.transform.call( + { environment: { name: 'ssr' }, addWatchFile: ssrWatch }, + SOURCE, + id, + ) + await captured.reached + expect(clientWatch).toHaveBeenCalledWith(state.supplier) + expect(ssrWatch).toHaveBeenCalledWith(state.supplier) + expect(extracts).toHaveBeenCalledTimes(1) + state.project.write('src/tone.ts', 'export const tone="teal"') + const event = { file: state.supplier, modules: [], timestamp: 1301 } + await state.plugin.hotUpdate.call({ environment: client }, event) + await state.plugin.hotUpdate.call({ environment: ssr }, event) + // Then: BOTH edges affect real hotUpdate before either caller can finish. + for (const current of [client, ssr]) { + expect(current.moduleGraph.invalidateModule).toHaveBeenCalledTimes(1) + expect(current.moduleGraph.invalidateModule).toHaveBeenCalledWith( + current.moduleGraph.getModuleById(id), + expect.any(Set), + 1301, + true, + ) + } + write.release() + expect(await second).toEqual(await first) + await state.transform(SOURCE, id) + expect(extracts).toHaveBeenCalledTimes(2) + expect(wasm.getCss(null, false)).toMatch(/color:teal/) + await state.plugin.transform.call( + { environment: { name: 'ssr' }, addWatchFile: ssrWatch }, + SOURCE, + id, + ) + expect(extracts).toHaveBeenCalledTimes(2) + unlinkSync(state.id) + watching.emit('all', 'unlink', state.id) + for (const current of [client, ssr]) { + current.moduleGraph.invalidateModule.mockClear() + await state.plugin.hotUpdate.call( + { environment: current }, + { ...event, timestamp: 1302 }, + ) + expect(current.moduleGraph.invalidateModule).not.toHaveBeenCalled() + } + } finally { + write.release() + await Promise.allSettled([first, second]) + } +}) diff --git a/packages/vite-plugin/src/__tests__/source-cache-extraction.test.ts b/packages/vite-plugin/src/__tests__/source-cache-extraction.test.ts new file mode 100644 index 000000000..977538362 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/source-cache-extraction.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'bun:test' + +import { + cacheFixture, + extractionObserver, + rejectionBoundary, +} from './source-cache-fixture' + +it('propagates real extraction errors and does not retain the failed input', async () => { + using state = await cacheFixture() + using extracts = extractionObserver() + const invalid = + 'import {css} from "@devup-ui/react"; export const invalid=css({color:window.never});' + await expect(state.transform(invalid)).rejects.toThrow() + await expect(state.transform(invalid)).rejects.toThrow() + expect(extracts).toHaveBeenCalledTimes(2) + const result = await state.transform() + expect(result).toBeDefined() + expect(await state.transform()).toEqual(result) + expect(extracts).toHaveBeenCalledTimes(3) +}) + +it('preserves actual fulfilled values at the rejection-observation boundary', async () => { + const result = rejectionBoundary(Promise.resolve('actual fulfilled value')) + expect(await result()).toBe('actual fulfilled value') +}) diff --git a/packages/vite-plugin/src/__tests__/source-cache-fixture.ts b/packages/vite-plugin/src/__tests__/source-cache-fixture.ts new file mode 100644 index 000000000..c5cfd6942 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/source-cache-fixture.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict' +import * as fs from 'node:fs/promises' +import { join } from 'node:path' + +import * as utils from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { spyOn } from 'bun:test' + +import { lifecycle } from './plugin-lifecycle-fixture' + +export const SOURCE = + 'import {Box} from "@devup-ui/react"; import {tone} from "./tone"; export const A=()=> ' + +export async function cacheFixture(prepare = true) { + assert.equal( + utils.getBuildOwnership().running, + 0, + 'cache schedule needs its own real engine', + ) + const state = await lifecycle() + assert.equal( + utils.getBuildOwnership().running, + 1, + 'only this Vite lease may be active', + ) + state.project.write('src/tone.ts', 'export const tone="red"') + state.project.write('src/A.tsx', SOURCE) + const id = join(state.project.root, 'src/A.tsx').replaceAll('\\', '/') + const supplier = join(state.project.root, 'src/tone.ts').replaceAll('\\', '/') + const cssFile = join(state.project.root, 'df/css/devup-ui.css') + const context = { environment: { name: 'client' }, addWatchFile() {} } + if (prepare) await state.plugin.buildStart.call(context) + return { + ...state, + id, + supplier, + cssFile, + context, + transform(code = SOURCE, fullId = id) { + return state.plugin.transform.call(context, code, fullId) + }, + } +} + +/** Forward the real call, including its real rejection; hold only settlement. */ +export function writeGate(path: string) { + const { writeFile } = fs + const original = writeFile.bind(fs) + const entered = Promise.withResolvers() + const released = Promise.withResolvers() + let armed = true + const spy = spyOn(fs, 'writeFile').mockImplementation((...args) => { + const result = original(...args) + if (!armed || String(args[0]) !== path) return result + armed = false + return result.finally(async () => { + entered.resolve() + await released.promise + }) + }) + return { + entered: entered.promise, + release: released.resolve, + spy, + [Symbol.dispose]() { + released.resolve() + spy.mockRestore() + }, + } +} + +export function extractionObserver() { + const { codeExtract } = wasm + const original = codeExtract.bind(wasm) + return spyOn(wasm, 'codeExtract').mockImplementation((...args) => + original(...args), + ) +} + +/** Observe now, assert later: Bun's rejects matcher eagerly waits when invoked. */ +export function rejectionBoundary(work: Promise) { + const settled = work.then( + (value) => () => value, + (error: unknown) => () => { + throw error + }, + ) + return async () => (await settled)() +} + +/** Observe the synchronous capture turn finishing, even if hit registration is omitted. */ +export function captureBoundary() { + const { getBuildOwnership } = utils + const original = getBuildOwnership.bind(utils) + const reached = Promise.withResolvers() + const spy = spyOn(utils, 'getBuildOwnership').mockImplementation(() => { + const ownership = original() + queueMicrotask(reached.resolve) + return ownership + }) + return { + reached: reached.promise, + [Symbol.dispose]() { + spy.mockRestore() + }, + } +} + +export function transformedCode(value: unknown): string { + assert(typeof value === 'object' && value !== null) + assert('code' in value && typeof value.code === 'string') + return value.code +} + +export function cssLoader(value: unknown): (id: string) => string | undefined { + assert(typeof value === 'object' && value !== null) + assert('load' in value && typeof value.load === 'function') + const load = value.load + return (id) => { + const result: unknown = load.call(value, id) + assert(result === undefined || typeof result === 'string') + return result + } +} diff --git a/packages/vite-plugin/src/__tests__/source-cache-flight.test.ts b/packages/vite-plugin/src/__tests__/source-cache-flight.test.ts new file mode 100644 index 000000000..d1ea54b14 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/source-cache-flight.test.ts @@ -0,0 +1,99 @@ +import { mkdirSync, rmSync } from 'node:fs' + +import { expect, it, mock } from 'bun:test' + +import { + cacheFixture, + captureBoundary, + extractionObserver, + rejectionBoundary, + SOURCE, + writeGate, +} from './source-cache-fixture' +it('shares one real extraction when identical callers overlap a real write', async () => { + // Given + using state = await cacheFixture() + using extracts = extractionObserver() + using write = writeGate(state.cssFile) + const first = state.transform() + let second: Promise | undefined + try { + await write.entered + using captured = captureBoundary() + const addWatchFile = mock() + // When: a second call reaches dependency registration before write release. + second = state.plugin.transform.call({ addWatchFile }, SOURCE, state.id) + await captured.reached + expect(addWatchFile).toHaveBeenCalledWith(state.supplier) + expect(extracts).toHaveBeenCalledTimes(1) + write.release() + // Then: both results and the later completed hit use the same extraction. + expect(await second).toEqual(await first) + await state.transform() + expect(extracts).toHaveBeenCalledTimes(1) + expect( + write.spy.mock.calls.filter((args) => String(args[0]) === state.cssFile), + ).toHaveLength(1) + } finally { + write.release() + await Promise.allSettled([first, second]) + } +}) + +it('propagates a real filesystem write rejection and extracts again on retry', async () => { + // Given: writing CSS onto a directory causes the actual platform write error. + using state = await cacheFixture() + mkdirSync(state.cssFile) + using extracts = extractionObserver() + using write = writeGate(state.cssFile) + const first = state.transform() + const rejected = rejectionBoundary(first) + try { + await write.entered + // When: let that genuine write error reach the flight owner. + rmSync(state.cssFile, { recursive: true }) + write.release() + await expect(rejected()).rejects.toBeInstanceOf(Error) + const retry = await state.transform() + // Then: the rejected entry is not retained as failure or success. + expect(retry).toBeDefined() + expect(extracts).toHaveBeenCalledTimes(2) + await state.transform() + expect(extracts).toHaveBeenCalledTimes(2) + } finally { + write.release() + await Promise.allSettled([first]) + } +}) + +it('keeps a newer same-ID flight when the older real write rejects', async () => { + // Given: A's actual failed write is settled internally but held at its boundary. + using state = await cacheFixture() + mkdirSync(state.cssFile) + using extracts = extractionObserver() + using write = writeGate(state.cssFile) + const first = state.transform() + const rejected = rejectionBoundary(first) + let newer: Promise | undefined + try { + await write.entered + rmSync(state.cssFile, { recursive: true }) + const b = SOURCE.replace('color={tone}', 'bg={tone}') + using captured = captureBoundary() + const addWatchFile = mock() + // When: B creates its own same-ID flight while A's rejection is still held. + newer = state.plugin.transform.call({ addWatchFile }, b, state.id) + await captured.reached + expect(addWatchFile).toHaveBeenCalledWith(state.supplier) + expect(extracts).toHaveBeenCalledTimes(2) + write.release() + await expect(rejected()).rejects.toBeInstanceOf(Error) + const result = await newer + // Then: A may delete only A, not the newer B flight or physical observation. + expect(await state.transform(b)).toEqual(result) + expect(extracts).toHaveBeenCalledTimes(2) + } finally { + write.release() + await Promise.allSettled([first, newer]) + } +}) diff --git a/packages/vite-plugin/src/__tests__/source-cache-input.test.ts b/packages/vite-plugin/src/__tests__/source-cache-input.test.ts new file mode 100644 index 000000000..4c0832aba --- /dev/null +++ b/packages/vite-plugin/src/__tests__/source-cache-input.test.ts @@ -0,0 +1,137 @@ +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import * as htmlReservation from '../html-reservation' +import { + cacheFixture, + extractionObserver, + transformedCode, +} from './source-cache-fixture' +for (const query of ['', '?alternate']) + it(`restores the latest whole physical input after A B A without events (${query})`, async () => { + // Given: same physical file, distinct whole inputs with a common styled body. + using state = await cacheFixture() + using extracts = extractionObserver() + const a = + 'import {globalCss} from "@devup-ui/react"; globalCss({body:{color:"red"}}); export const marker="A"' + const b = a.replace('red', 'blue').replace('marker="A"', 'marker="B"') + const first = await state.transform(a) + await state.transform(a) + expect(extracts).toHaveBeenCalledTimes(1) + // When: no watch event accompanies either physical input transition. + const middle = await state.transform(b, state.id + query) + const restored = await state.transform(a) + // Then: both transitions extract, returning the corresponding whole source. + expect(extracts).toHaveBeenCalledTimes(3) + expect(transformedCode(middle)).toContain('"B"') + expect(restored).toEqual(first) + expect(wasm.getCss(null, false)).toMatch(/body\{color:red\}/) + await state.transform(a) + expect(extracts).toHaveBeenCalledTimes(3) + }) + +it('compares the whole input when only non-style source changes without events', async () => { + // Given + using state = await cacheFixture() + using extracts = extractionObserver() + const a = 'export const marker="A";' + const b = 'export const marker="B";' + const first = await state.transform(a) + // When + const changed = await state.transform(b) + const restored = await state.transform(a) + // Then + expect(transformedCode(changed)).toMatch(/marker\s*=\s*["']B["']/) + expect(restored).toEqual(first) + expect(extracts).toHaveBeenCalledTimes(3) +}) + +it('extracts each full query ID once even when physical file and whole input match', async () => { + // Given + using state = await cacheFixture() + using extracts = extractionObserver() + await state.transform() + // When: only the full query ID changes (no import-base/path-separator concern). + const firstQuery = await state.transform(undefined, state.id + '?variant=one') + const secondQuery = await state.transform( + undefined, + state.id + '?variant=two', + ) + // Then: first observations remain distinct; later hits reuse each full ID. + expect(extracts).toHaveBeenCalledTimes(3) + expect(await state.transform(undefined, state.id + '?variant=one')).toEqual( + firstQuery, + ) + expect(await state.transform(undefined, state.id + '?variant=two')).toEqual( + secondQuery, + ) + expect(extracts).toHaveBeenCalledTimes(3) +}) + +it('observes obsolete extraction input after a current query overtakes real preparation', async () => { + // Given: suspend the actual parser result, not a reservation or fabricated output. + const { reserveClassSlots } = wasm + const reserve = reserveClassSlots.bind(wasm) + let overtake: (() => void) | undefined + using _reservation = spyOn(wasm, 'reserveClassSlots').mockImplementation( + (...args) => { + reserve(...args) + if (overtake) { + queueMicrotask(overtake) + overtake = undefined + } + }, + ) + using state = await cacheFixture(false) + state.project.write( + 'index.html', + '', + ) + await state.plugin.watchChange(state.id) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const { htmlReservationEntries } = htmlReservation + const parse = htmlReservationEntries.bind(htmlReservation) + let armed = true + using _parser = spyOn( + htmlReservation, + 'htmlReservationEntries', + ).mockImplementation(async (...args) => { + const result = await parse(...args) + if (armed) { + armed = false + entered.resolve() + await release.promise + } + return result + }) + using extracts = extractionObserver() + const a = + 'import {globalCss} from "@devup-ui/react"; globalCss({body:{color:"red"}});' + const b = a.replace('red', 'blue') + const old = state.transform(a) + let fresh: Promise | undefined + try { + await entered.promise + await state.plugin.configResolved(state.config) + overtake = () => { + fresh = state.transform(b, state.id + '?current') + } + // When: prepared fast-path B runs before obsolete A's pending continuation. + release.resolve() + await old + expect(fresh).toBeDefined() + await fresh + expect(extracts.mock.calls.map((args) => args[1])).toEqual([b, a]) + expect(wasm.getCss(null, false)).toMatch(/body\{color:red\}/) + await state.transform(b, state.id + '?current') + // Then: obsolete A updated the shared physical observation, forcing B restore. + expect(extracts).toHaveBeenCalledTimes(3) + expect(wasm.getCss(null, false)).toMatch(/body\{color:blue\}/) + await state.transform(b, state.id + '?current') + expect(extracts).toHaveBeenCalledTimes(3) + } finally { + release.resolve() + await Promise.allSettled([old, fresh]) + } +}) diff --git a/packages/vite-plugin/src/__tests__/source-cache-sheet.test.ts b/packages/vite-plugin/src/__tests__/source-cache-sheet.test.ts new file mode 100644 index 000000000..d9c7001dc --- /dev/null +++ b/packages/vite-plugin/src/__tests__/source-cache-sheet.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs' + +import * as wasm from '@devup-ui/wasm' +import { expect, it } from 'bun:test' + +import { + cacheFixture, + cssLoader, + extractionObserver, + writeGate, +} from './source-cache-fixture' +it('fully bypasses extraction reuse when identical build inputs repeat', async () => { + // Given: actual build configuration of the source plugin and real facade. + using state = await cacheFixture() + await state.plugin.configResolved({ ...state.config, command: 'build' }) + await state.plugin.buildStart.call(state.context) + using extracts = extractionObserver() + using writes = writeGate(state.cssFile) + writes.release() + // When + const first = await state.transform() + const second = await state.transform() + // Then: build calls both run extraction; actual CSS is persisted on disk. + expect(second).toEqual(first) + expect(extracts).toHaveBeenCalledTimes(2) + expect(readFileSync(state.cssFile, 'utf8')).toMatch(/color:red/) +}) + +it('loads the live accumulating sheet after A B hit-A without replaying CSS writes', async () => { + // Given + using state = await cacheFixture() + const load = cssLoader(state.plugin) + using extracts = extractionObserver() + using writes = writeGate(state.cssFile) + writes.release() + const a = await state.transform() + const before = load(state.cssFile) + expect(before).toMatch(/color:red/) + const b = + 'import {globalCss} from "@devup-ui/react"; globalCss({body:{background:"blue"}});' + const bId = state.id.replace('A.tsx', 'B.tsx') + state.project.write('src/B.tsx', b) + await state.transform(b, bId) + const writesBeforeHit = writes.spy.mock.calls.length + const diskBeforeHit = readFileSync(state.cssFile, 'utf8') + // When: hit A after B contributed to the shared engine and disk sheet. + expect(await state.transform()).toEqual(a) + const live = load(state.cssFile) + // Then: no extraction/write replay, and load reads B's actual current CSS. + expect(extracts).toHaveBeenCalledTimes(2) + expect(writes.spy.mock.calls).toHaveLength(writesBeforeHit) + expect(readFileSync(state.cssFile, 'utf8')).toBe(diskBeforeHit) + expect(live).toBe(wasm.getCss(null, false)) + expect(live).toMatch(/color:red/) + expect(live).toMatch(/background:blue/) + expect(diskBeforeHit).toMatch(/background:blue/) +}) diff --git a/packages/vite-plugin/src/compiled-dependencies.ts b/packages/vite-plugin/src/compiled-dependencies.ts new file mode 100644 index 000000000..7559d29d0 --- /dev/null +++ b/packages/vite-plugin/src/compiled-dependencies.ts @@ -0,0 +1,34 @@ +import { resolve } from 'node:path' + +/** Live extraction edges, replaced whenever a consumer is compiled again. */ +export function createCompiledDependencies() { + const environments = new Map>>() + return { + update(environment: string, id: string, dependencies: readonly string[]) { + let consumers = environments.get(environment) + if (!consumers) { + consumers = new Map() + environments.set(environment, consumers) + } + consumers.set(id, new Set(dependencies.map((file) => resolve(file)))) + }, + consumers(environment: string, file: string): readonly string[] { + const path = resolve(file) + return [...(environments.get(environment) ?? [])].flatMap( + ([id, dependencies]) => (dependencies.has(path) ? [id] : []), + ) + }, + hasSupplier(file: string): boolean { + const path = resolve(file) + return [...environments.values()].some((consumers) => + [...consumers.values()].some((dependencies) => dependencies.has(path)), + ) + }, + remove(file: string) { + for (const consumers of environments.values()) + for (const id of consumers.keys()) + if (resolve(id.split('?')[0] ?? id) === resolve(file)) + consumers.delete(id) + }, + } +} diff --git a/packages/vite-plugin/src/html-reservation.ts b/packages/vite-plugin/src/html-reservation.ts new file mode 100644 index 000000000..72a8ef878 --- /dev/null +++ b/packages/vite-plugin/src/html-reservation.ts @@ -0,0 +1,108 @@ +import { existsSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +import { parseImports } from '@devup-ui/plugin-utils' +import type { DefaultTreeAdapterTypes } from 'parse5' + +export type HtmlReservationEntry = { + readonly request: string + readonly context: string +} + +export type HtmlReservationOptions = { + readonly command: 'build' | 'serve' + readonly base?: string + readonly root: string + readonly filename: string + readonly publicDir: string | false + readonly env?: Readonly> +} + +/** Physical discovery only: native HTML compilation and diagnostics stay native. */ +export async function htmlReservationEntries( + html: string, + options: HtmlReservationOptions, +): Promise { + // Keep this public import asynchronous in BOTH published module formats. + const { parse } = await import('parse5') + const source = html.replace(/%(\S+?)%/g, (text: string, key: string) => { + const value = options.env?.[key] + return value === undefined ? text : String(value) + }) + const entries: HtmlReservationEntry[] = [] + const context = dirname(options.filename) + function add(request: string, scriptSource: boolean): void { + if (/^(?:[a-z][\w+.-]*:|\/\/|#)/i.test(request)) return + let path = request.split(/[?#]/)[0] + if (!path) return + // Dev HTTP middleware decodes URLs and removes the served base; build resolution does neither. + if (options.command === 'serve') { + try { + path = decodeURI(path) + } catch (error) { + if (!(error instanceof URIError)) throw error + } + const base = options.base + if ( + typeof base === 'string' && + base.startsWith('/') && + path.startsWith(base) + ) + path = `/${path.slice(base.length)}` + } + if ( + path.startsWith('/') && + options.publicDir && + existsSync(resolve(options.publicDir, `.${path}`)) + ) + return + entries.push({ + request: path.startsWith('/') + ? resolve(options.root, `.${path}`) + : scriptSource + ? resolve(context, path) + : path, + context, + }) + } + function visit(node: DefaultTreeAdapterTypes.Node): void { + if ('tagName' in node && node.tagName === 'template' && 'content' in node) { + visit(node.content) + return + } + if ('tagName' in node && node.tagName === 'script') { + const attributes = new Map( + node.attrs + .filter((attribute) => attribute.prefix === undefined) + .map(({ name, value }) => [name, value]), + ) + if ( + attributes.get('type') === 'module' && + !attributes.has('vite-ignore') + ) { + const src = attributes.get('src') + if (src) add(src, true) + // Even an excluded src can have native-recognized inline contents. + if ( + !src || + /^(?:[a-z][\w+.-]*:|\/\/|#)/i.test(src) || + (src.startsWith('/') && + options.publicDir && + existsSync(resolve(options.publicDir, `.${src.split(/[?#]/)[0]}`))) + ) { + const text = node.childNodes + .flatMap((child) => ('value' in child ? [child.value] : [])) + .join('') + for (const { specifier } of parseImports( + `${options.filename}.js`, + text, + )) + add(specifier, false) + } + } + } + if ('childNodes' in node) for (const child of node.childNodes) visit(child) + } + visit(parse(source, { scriptingEnabled: false })) + return entries +} diff --git a/packages/vite-plugin/src/package-boundary.ts b/packages/vite-plugin/src/package-boundary.ts new file mode 100644 index 000000000..7259a522f --- /dev/null +++ b/packages/vite-plugin/src/package-boundary.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs' +import { basename, dirname, resolve } from 'node:path' + +/** Per-preparation classification: missing/invalid scopes must remain candidates. */ +export function packageBoundary() { + const cache = new Map() + return (directory: string): boolean => { + const cached = cache.get(directory) + if (cached !== undefined) return cached + const parent = dirname(directory) + const packageRoot = + basename(parent) === 'node_modules' + ? !basename(directory).startsWith('@') + : basename(parent).startsWith('@') && + basename(dirname(parent)) === 'node_modules' + let boundary = false + if ( + !directory.replaceAll('\\', '/').includes('/node_modules/') || + packageRoot + ) { + try { + const manifest: unknown = JSON.parse( + readFileSync(resolve(directory, 'package.json'), 'utf8').replace( + /^\uFEFF/, + '', + ), + ) + boundary = + typeof manifest === 'object' && + manifest !== null && + 'name' in manifest && + typeof manifest.name === 'string' && + manifest.name.length > 0 && + (!('sideEffects' in manifest) || + !Array.isArray(manifest.sideEffects) || + manifest.sideEffects.every( + (value: unknown) => typeof value === 'string', + )) + } catch (error) { + if (!(error instanceof Error)) throw error + // Vite skips unreadable or unparseable package scopes as well. + boundary = false + } + } + cache.set(directory, boundary) + return boundary + } +} diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 77528c7d4..c00efad46 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -12,6 +12,7 @@ import { createThemeInterfaceArgs, type CustomShorthands, extractedNeedles, + getBuildOwnership, getFileNumByFilename, type ImportAliases, loadDevupConfig, @@ -43,6 +44,10 @@ import type { UserConfig, } from 'vite' +import { createCompiledDependencies } from './compiled-dependencies' +import { createViteReservation } from './reservation' +import { createServerWatch } from './server-watch' + /** * CSS entry files emitted by devup-ui: `devup-ui.css`, `devup-ui-3.css`, ... * @@ -54,6 +59,29 @@ const DEVUP_CSS_FILE_RE = /^devup-ui(-\d+)?\.css$/ const SOURCE_DIR_CANDIDATES = ['src', 'app'] +type SourceInput = { readonly code: string } +type ExtractionCapture = { + readonly session: ExtractionSession + readonly file: string + readonly input: SourceInput + readonly ownership: number + readonly reservation: number + readonly config: number +} +type ExtractionFlight = { + readonly capture: ExtractionCapture + readonly result: { readonly code: string; readonly map: string | undefined } + readonly dependencies: readonly string[] + readonly ready: Promise +} +type ExtractionSession = { + ready: boolean + readonly flights: Map +} +function createExtractionSession(): ExtractionSession { + return { ready: false, flights: new Map() } +} + /** * Source roots to scan, or an empty list when none of the conventional layouts * are present. Scanning the project root instead would sweep in config files @@ -237,9 +265,8 @@ export interface DevupUIPluginOptions { prefix?: string shorthands?: CustomShorthands /** - * Reserved for route-aware atom hoisting after PR B adds shared class-slot - * reservation. Until then, Vite keeps per-file names and warns - * once for explicit finite positive requests. Ignored in singleCss mode. + * Hoist canonical buckets reached by at least this many module boundaries. + * Defaults to 2; 0 disables. Frozen for this build; ignored in singleCss mode. */ atomHoist?: number /** @@ -252,14 +279,15 @@ export interface DevupUIPluginOptions { async function writeDataFiles( options: Omit, -): Promise { - let tailwindFiles: string[] = [] + inputs: Set, + publishTailwind: (files: string[]) => void, +): Promise { try { - const config = await loadDevupConfig(options.devupFile) + const config = await loadDevupConfig(options.devupFile, inputs) const theme = config.theme ?? {} registerTheme(withTailwindCss(theme, config)) - tailwindFiles = tailwindCssFiles(config) + publishTailwind(tailwindCssFiles(config)) const interfaceCode = getThemeInterface( ...createThemeInterfaceArgs(options.package), ) @@ -283,7 +311,6 @@ async function writeDataFiles( if (!options.singleCss) { await writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) } - return tailwindFiles } export function DevupUI({ @@ -297,31 +324,71 @@ export function DevupUI({ singleCss = false, prefix, shorthands, - atomHoist, + atomHoist = 2, importAliases: userImportAliases, }: Partial = {}): PluginOption { // A build starts from its own options: whatever an earlier build in this // process left in the engine (prefix, hoisting, routes, buckets, numbers, // styles) is gone unless another build is still running. - const endBuild = beginBuild({ resetBuildState }) - if ( - !singleCss && - atomHoist !== undefined && - Number.isFinite(atomHoist) && - atomHoist > 0 + const ownsEngine = getBuildOwnership().running === 0 + let endBuild: (() => void) | undefined = beginBuild({ resetBuildState }) + let closing = false + let ownedWork = 0 + let drained: PromiseWithResolvers | undefined + function releaseBuild() { + endBuild?.() + endBuild = undefined + } + function own( + work: (this: This, ...args: Args) => Promise, ) { - console.warn( - '[devup-ui] Vite atomHoist is unavailable until shared class-slot reservation lands in PR B; keeping per-file names, while existing shared slots remain arrival-ordered.', - ) + return async function (this: This, ...args: Args): Promise { + if (closing && isServe) return + if (!endBuild) { + endBuild = beginBuild({}) + closing = false + drained = undefined + } + ownedWork++ + try { + return await work.apply(this, args) + } finally { + ownedWork-- + if (closing && ownedWork === 0) { + releaseBuild() + drained?.resolve() + } + } + } + } + function configureEngine() { + registerShorthands(shorthands ?? {}) + setDebug(debug) + setPrefix(prefix ?? null) } - registerShorthands(shorthands ?? {}) - setDebug(debug) - setPrefix(prefix ?? null) + if (ownsEngine) configureEngine() const importAliases = mergeImportAliases( userImportAliases, readJsxImportSource(), ) + const reservation = createViteReservation({ + package: libPackage, + include, + importAliases, + singleCss, + atomHoist, + cssDir, + distDir, + relevant: (file) => + isConfigFile(file) || + isTailwindFile(file) || + compiledDependencies.hasSupplier(file), + }) const cssMap = new Map() + const compiledDependencies = createCompiledDependencies() + let stopWatching: (() => void) | undefined + let watcherSession: ExtractionSession | undefined + let watchConfigInputs: (() => void) | undefined let resolvedConfig: ResolvedConfig | undefined // Set by the client `generateBundle`, run by the late hook of the sibling // plugin once @vitejs/plugin-rsc has forwarded. @@ -332,28 +399,97 @@ export function DevupUI({ // module transformed again writes its sheet again, and the reload that // signal causes transforms it once more: signal only a changed sheet. const writtenCss = new Map() + const sheetRevisions = new Map() const stateWriter = createStateWriter((path, content, encoding) => encoding ? writeFile(path, content, encoding) : writeFile(path, content), ) // The files the project's Tailwind CSS is read from, which change what a // Tailwind class compiles to let tailwindFiles: string[] = [] + let configFiles = new Set([resolve(devupFile)]) + let activeConfigFiles = configFiles + let activeTailwindFiles: string[] = [] + const configState = { generation: 0, refreshing: 0 } + let extractionSession = createExtractionSession() + const latestInputs = new Map() + function rotateExtractionSession() { + latestInputs.clear() + return (extractionSession = createExtractionSession()) + } + function currentCapture(capture: ExtractionCapture) { + const ownership = getBuildOwnership() + return ( + isServe && + !!endBuild && + !closing && + capture.session === extractionSession && + extractionSession.ready && + latestInputs.get(capture.file) === capture.input && + ownership.running === 1 && + ownership.generation === capture.ownership && + configState.refreshing === 0 && + configState.generation === capture.config && + reservation.revision === capture.reservation && + reservation.prepared === capture.reservation + ) + } + let refreshTail = Promise.resolve() + const isConfigFile = (file: string) => + configFiles.has(resolve(file)) || activeConfigFiles.has(resolve(file)) + async function refreshConfig() { + configState.generation++ + configState.refreshing++ + const previous = refreshTail + const completed = Promise.withResolvers() + refreshTail = completed.promise + const inputs = new Set([resolve(devupFile)]) + try { + await previous + activeConfigFiles = inputs + await writeDataFiles( + { + package: libPackage, + cssDir, + devupFile, + distDir, + singleCss, + }, + inputs, + (files) => { + activeTailwindFiles = files + watchConfigInputs?.() + }, + ) + } finally { + configFiles = inputs + tailwindFiles = activeTailwindFiles + activeTailwindFiles = [] + configState.refreshing-- + configState.generation++ + reservation.invalidate() + completed.resolve() + watchConfigInputs?.() + } + } const isTailwindFile = (file: string) => - tailwindFiles.some( + [...tailwindFiles, ...activeTailwindFiles].some( (tailwindFile) => resolve(tailwindFile) === resolve(file), ) function writeCssFile(fileName: string, css: string): Promise { if (writtenCss.get(fileName) === css) return Promise.resolve() writtenCss.set(fileName, css) + sheetRevisions.set(fileName, (sheetRevisions.get(fileName) ?? 0) + 1) return stateWriter.write(join(cssDir, fileName), css, 'utf-8') } - const plugin: Plugin = { + const plugin = { name: 'devup-ui', // The WASM sheet and transform state are intentionally shared. Vite // otherwise recreates this plugin for every environment build, which makes // each environment independently emit the same CSS asset. sharedDuringBuild: true, async configResolved(config) { + const session = rotateExtractionSession() + if (!ownsEngine) configureEngine() resolvedConfig = config isServe = config?.command === 'serve' const projectRoot = config?.root ?? process.cwd() @@ -372,13 +508,7 @@ export function DevupUI({ createCompatTypes(importAliases), 'utf-8', ) - tailwindFiles = await writeDataFiles({ - package: libPackage, - cssDir, - devupFile, - distDir, - singleCss, - }) + await refreshConfig() try { // Numbers come from the sorted paths of every file the build can @@ -398,6 +528,54 @@ export function DevupUI({ } catch { // Best-effort; on failure numbering falls back to arrival order. } + reservation.configure(config) + if (extractionSession === session) session.ready = true + }, + async buildApp(builder) { + for (const environment of Object.values(builder.environments)) { + reservation.configure({ + ...builder.config, + environments: { + [environment.name]: environment.config, + }, + }) + } + }, + configureServer(server) { + if (watcherSession === extractionSession) rotateExtractionSession() + stopWatching?.() + watcherSession = extractionSession + for (const environment of Object.values(server.environments ?? {})) { + reservation.configure({ + ...server.config, + environments: { + [environment.name]: environment.config, + }, + }) + } + const watching = createServerWatch( + server.watcher, + reservation, + compiledDependencies.remove, + ) + watchConfigInputs = () => + watching.add([ + ...configFiles, + ...activeConfigFiles, + ...tailwindFiles, + ...activeTailwindFiles, + ]) + watchConfigInputs() + stopWatching = () => { + watching.close() + watchConfigInputs = undefined + } + }, + async buildStart() { + if (!extractCss) return + await reservation.reserve() + for (const dependency of reservation.dependencies) + this.addWatchFile(dependency) }, config(this: { meta?: ConfigHookMeta } | void, userConfig: UserConfig) { const theme = getDefaultTheme() @@ -406,11 +584,6 @@ export function DevupUI({ define['process.env.DEVUP_UI_DEFAULT_THEME'] = JSON.stringify(theme) } const ret: Omit = { - server: { - watch: { - ignored: [`!${devupFile}`], - }, - }, define, optimizeDeps: { exclude: [...include, '@devup-ui/components', '@devup-editor/react'], @@ -428,21 +601,18 @@ export function DevupUI({ return true }, closeBundle() { - endBuild() + rotateExtractionSession() + closing = true + stopWatching?.() + if (ownedWork === 0) return releaseBuild() + drained ??= Promise.withResolvers() + return drained.promise }, async watchChange(id) { - if ( - (resolve(id) === resolve(devupFile) && existsSync(devupFile)) || - (isTailwindFile(id) && existsSync(id)) - ) { + reservation.invalidate(id) + if (isConfigFile(id) || (isTailwindFile(id) && existsSync(id))) { try { - tailwindFiles = await writeDataFiles({ - package: libPackage, - cssDir, - devupFile, - distDir, - singleCss, - }) + await own(refreshConfig)() } catch (error) { console.error(error) } @@ -451,7 +621,18 @@ export function DevupUI({ // Runs once per environment. Vite 6+ ignores `handleHotUpdate` on a plugin // that defines this hook, so the devup.json reload lives here as well. async hotUpdate({ file, modules, timestamp }) { + reservation.invalidate(file, timestamp) const { environment } = this + for (const id of compiledDependencies.consumers(environment.name, file)) { + const consumer = environment.moduleGraph.getModuleById(id) + if (consumer) + environment.moduleGraph.invalidateModule( + consumer, + new Set(), + timestamp, + true, + ) + } if (environment.config.consumer === 'server') { // A module runner cannot apply CSS, so Vite answers a sheet change // with a full reload: the render restarts mid-request, and the modules @@ -463,20 +644,13 @@ export function DevupUI({ ? [] : undefined } - const isDevupFile = - resolve(file) === resolve(devupFile) && existsSync(devupFile) + const isDevupFile = isConfigFile(file) const isTailwind = isTailwindFile(file) && existsSync(file) if (!isDevupFile && !isTailwind) { return } - tailwindFiles = await writeDataFiles({ - package: libPackage, - cssDir, - devupFile, - distDir, - singleCss, - }) + await own(refreshConfig)() const invalidatedModules = new Set() // A Tailwind class compiles from the definitions of the Tailwind CSS, so @@ -496,20 +670,24 @@ export function DevupUI({ }, // Vite 5 fallback: Vite 6+ does not call this hook when `hotUpdate` exists. async handleHotUpdate({ file, server, modules, timestamp }) { - const isDevupFile = - resolve(file) === resolve(devupFile) && existsSync(devupFile) + reservation.invalidate(file, timestamp) + for (const id of compiledDependencies.consumers('client', file)) { + const consumer = server.moduleGraph.getModuleById(id) + if (consumer) + server.moduleGraph.invalidateModule( + consumer, + new Set(), + timestamp, + true, + ) + } + const isDevupFile = isConfigFile(file) const isTailwind = isTailwindFile(file) && existsSync(file) if (!isDevupFile && !isTailwind) { return } - tailwindFiles = await writeDataFiles({ - package: libPackage, - cssDir, - devupFile, - distDir, - singleCss, - }) + await own(refreshConfig)() const invalidatedModules = new Set() for (const mod of isTailwind @@ -532,17 +710,11 @@ export function DevupUI({ resolve(importer ? join(dirname(importer), id) : id) === resolve(join(cssDir, fileName)) ) { - // Dev re-resolves through a changing id so a growing sheet invalidates - // the module. A build must not: a per-resolution id forks one file into - // several modules and makes output hashes differ between identical - // builds. if (!isServe) return join(cssDir, fileName) + // Only changed writes mint a module; `v` survives Vite's timestamp stripping. return join( cssDir, - `${fileName}?t=${ - Date.now().toString() + - (cssMap.get(getFileNumByFilename(fileName))?.length ?? 0) - }`, + `${fileName}?v=${sheetRevisions.get(fileName) ?? 0}`, ) } }, @@ -557,6 +729,7 @@ export function DevupUI({ }, enforce: 'pre', async transform(code, id) { + const session = extractionSession if (!extractCss) return const fileName = id.split('?')[0] @@ -564,6 +737,35 @@ export function DevupUI({ if (createNodeModulesExcludeRegex(include).test(fileName)) { return } + await reservation.reserve() + + const file = resolve(fileName) + let input = latestInputs.get(file) + if (isServe && input?.code !== code) { + input = { code } + latestInputs.set(file, input) + } + const capture = input && { + session, + file, + input, + ownership: getBuildOwnership().generation, + reservation: reservation.revision, + config: configState.generation, + } + const eligible = capture && currentCapture(capture) + const cached = eligible ? session.flights.get(id) : undefined + if (cached && currentCapture(cached.capture)) { + compiledDependencies.update( + this.environment?.name ?? 'client', + id, + cached.dependencies, + ) + for (const dependency of cached.dependencies) + this.addWatchFile(dependency) + await cached.ready + return cached.result + } let rel = relative(dirname(id), cssDir).replaceAll('\\', '/') if (!rel.startsWith('./')) rel = `./${rel}` @@ -586,6 +788,11 @@ export function DevupUI({ false, importAliases, ) + compiledDependencies.update( + this.environment?.name ?? 'client', + id, + dependencies, + ) for (const dependency of dependencies) this.addWatchFile(dependency) const promises: Promise[] = [] @@ -601,11 +808,29 @@ export function DevupUI({ // `css` is only set when this transform added styles to the sheet. if (css) promises.push(writeCssFile(basename(cssFile), css)) } - await Promise.all(promises) - return { - code: extractedCode, - map, + const result = { code: extractedCode, map } + if (!eligible) { + await Promise.all(promises) + return result } + const flight: ExtractionFlight = { + capture, + result, + dependencies: [...dependencies], + ready: Promise.all(promises).then( + () => { + if (session.flights.get(id) === flight && !currentCapture(capture)) + session.flights.delete(id) + }, + (error: unknown) => { + if (session.flights.get(id) === flight) session.flights.delete(id) + throw error + }, + ), + } + session.flights.set(id, flight) + await flight.ready + return result }, async generateBundle(_options, bundle) { if (!extractCss) return @@ -642,7 +867,7 @@ export function DevupUI({ } } }, - } + } satisfies Plugin const restorePlugin: Plugin = { name: 'devup-ui:restore-forwarded-css', sharedDuringBuild: true, @@ -655,5 +880,13 @@ export function DevupUI({ }, }, } - return [plugin, restorePlugin] + return [ + { + ...plugin, + configResolved: own(plugin.configResolved), + buildStart: own(plugin.buildStart), + transform: own(plugin.transform), + }, + restorePlugin, + ] } diff --git a/packages/vite-plugin/src/reservation-inputs.ts b/packages/vite-plugin/src/reservation-inputs.ts new file mode 100644 index 000000000..802dde59f --- /dev/null +++ b/packages/vite-plugin/src/reservation-inputs.ts @@ -0,0 +1,110 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import type { BuildOptions } from 'vite' + +import { + htmlReservationEntries, + type HtmlReservationEntry, +} from './html-reservation' + +export type ReservationEnvironment = { + readonly input?: string | string[] | Record + readonly build?: Pick< + BuildOptions, + 'lib' | 'ssr' | 'rolldownOptions' | 'rollupOptions' + > +} + +export type ReservationConfig = ReservationEnvironment & { + readonly command?: 'build' | 'serve' + readonly base?: string + readonly root?: string + readonly publicDir?: string | false + readonly env?: Readonly> + readonly define?: Readonly> + readonly environments?: Readonly> + readonly configFileDependencies?: readonly string[] +} + +export function environmentInputs( + environment: ReservationEnvironment, +): readonly string[] { + const build = environment.build + const bundler = build?.rolldownOptions?.input ?? build?.rollupOptions?.input + const input = build?.lib + ? (bundler ?? build.lib.entry) + : typeof build?.ssr === 'string' + ? build.ssr + : (bundler ?? environment.input ?? 'index.html') + return typeof input === 'string' + ? [input] + : Array.isArray(input) + ? input + : Object.values(input ?? {}) +} + +function htmlEnv(config: ReservationConfig) { + const env = { ...config.env } + for (const [key, value] of Object.entries(config.define ?? {})) { + if (!key.startsWith('import.meta.env.')) continue + let replacement = typeof value === 'string' ? value : JSON.stringify(value) + if (typeof value === 'string') { + try { + const parsed: unknown = JSON.parse(value) + if (typeof parsed === 'string') replacement = parsed + } catch (error) { + if (!(error instanceof SyntaxError)) throw error + } + } + env[key.slice(16)] = replacement + } + return env +} + +export async function physicalEnvironmentEntries( + config: ReservationConfig, + environment: ReservationEnvironment, +): Promise<{ + readonly entries: readonly HtmlReservationEntry[] + readonly html: readonly string[] +}> { + const root = config.root ?? process.cwd() + const entries: HtmlReservationEntry[] = [] + const html: string[] = [] + for (const request of environmentInputs(environment)) { + const filename = resolve(root, request) + if (!/\.html$/i.test(filename)) { + entries.push({ request: filename, context: root }) + continue + } + html.push(filename) + let source: string + try { + source = readFileSync(filename, 'utf8') + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || + error.code === 'EISDIR' || + error.code === 'EACCES' || + error.code === 'EPERM' || + error.code === 'ENOTDIR') + ) + continue + throw error + } + entries.push( + ...(await htmlReservationEntries(source, { + command: config.command ?? 'build', + base: config.base ?? '/', + root, + filename, + publicDir: config.publicDir ?? false, + env: htmlEnv(config), + })), + ) + } + return { entries, html } +} diff --git a/packages/vite-plugin/src/reservation.ts b/packages/vite-plugin/src/reservation.ts new file mode 100644 index 000000000..f9538714e --- /dev/null +++ b/packages/vite-plugin/src/reservation.ts @@ -0,0 +1,243 @@ +import { existsSync } from 'node:fs' +import { dirname, relative, resolve } from 'node:path' + +import { + buildCanonicalMap, + computeFileReach, + computeReachableFiles, + createModuleResolver, + createSharedSlotReservation, + planAtomHoist, + reservationGraph, + type WasmImportAliases, +} from '@devup-ui/plugin-utils' +import { + importCanonicalMap, + importFileRoutes, + reserveClassSlots, + setAtomHoist, + setModuleResolver, +} from '@devup-ui/wasm' + +import type { HtmlReservationEntry } from './html-reservation' +import { packageBoundary } from './package-boundary' +import { + environmentInputs, + physicalEnvironmentEntries, + type ReservationConfig, + type ReservationEnvironment, +} from './reservation-inputs' + +export function createViteReservation(options: { + readonly package: string + readonly include: readonly string[] + readonly importAliases: WasmImportAliases + readonly singleCss: boolean + readonly atomHoist?: number + readonly cssDir: string + readonly distDir: string + readonly relevant?: (file: string) => boolean +}) { + const slots = createSharedSlotReservation({ reserveClassSlots }) + const environments = new Map< + string, + { config: ReservationConfig; environment: ReservationEnvironment } + >() + const notifications = new Map() + let revision = 0 + let prepared = -1 + let pending: Promise | undefined + let dependencies: readonly string[] = [] + const explicitInputs = new Set() + let watch: ((paths: readonly string[]) => void) | undefined + let installed = false + function invalidate(file?: string, timestamp?: number) { + if (file) { + const path = resolve(file.split('?')[0] ?? file) + const generated = relative(resolve(options.distDir), path) + const css = relative(resolve(options.cssDir), path) + if ( + ['compat.d.ts', 'theme.d.ts', '.gitignore'].includes(generated) || + /^devup-ui(?:-\d+)?\.css$/.test(css) + ) + return + if ( + !/\.(tsx|ts|js|mjs|jsx)$/i.test(path) && + !explicitInputs.has(path) && + !dependencies.some((dependency) => resolve(dependency) === path) && + !options.relevant?.(path) + ) + return + if (timestamp !== undefined) { + if (notifications.get(path) === timestamp) return + notifications.set(path, timestamp) + } + } + revision++ + slots.invalidate() + } + async function prepare() { + while (prepared !== revision) { + const current = revision + const entries: HtmlReservationEntry[] = [] + const watched = new Set() + const roots = new Set() + let cwd: string | undefined + for (const { config, environment } of environments.values()) { + const root = config.root ?? process.cwd() + cwd ??= root + for (const name of ['src', 'app']) { + const path = resolve(root, name) + watched.add(path) + if (existsSync(path)) roots.add(path) + } + const physical = await physicalEnvironmentEntries(config, environment) + entries.push(...physical.entries) + for (const path of [ + ...physical.html, + ...(config.configFileDependencies ?? []), + resolve(root, 'tsconfig.json'), + resolve(root, 'package.json'), + resolve(root, 'bun.lock'), + ]) + watched.add(path) + } + // An event during the parser await supersedes this entire candidate batch. + if (current !== revision) continue + setModuleResolver( + createModuleResolver({ + cwd: cwd ?? process.cwd(), + toId: (path) => path.replaceAll('\\', '/'), + }), + ) + slots.reserve( + { + roots: [...roots], + entries, + include: options.include, + cwd: cwd ?? process.cwd(), + package: options.package, + importAliases: options.importAliases, + toId: (path) => path.replaceAll('\\', '/'), + }, + { + beforeReserve(inventory) { + if (installed) return + const threshold = options.atomHoist ?? 2 + if ( + !options.singleCss && + Number.isFinite(threshold) && + threshold > 0 + ) { + const graph = reservationGraph(inventory) + const root = cwd ?? process.cwd() + const graphOptions = { + graph, + cwd: root, + srcDir: root, + keyBy: 'absolute' as const, + } + const canonical = buildCanonicalMap(graphOptions) + const entries = inventory.resolutions.entries.map((entry) => + resolve(entry), + ) + if (entries.length) { + const reachable = computeReachableFiles({ + graph, + srcDir: root, + entries, + }) + entries.push( + ...reachable.filter((file) => graph.dynamicTargets.has(file)), + ) + } + const reach = computeFileReach({ ...graphOptions, entries }) + const plan = planAtomHoist(canonical, reach, threshold) + importCanonicalMap(canonical) + if (plan) { + importFileRoutes(plan.reachByBucket) + setAtomHoist(plan.threshold) + } + } + // Scratch may freeze even when the engine later throws. Never reinstall. + installed = true + }, + singleCss: options.singleCss, + importMainCssInCode: true, + importMainCssInCss: false, + cssDir: ({ filename }) => { + const directory = relative( + dirname(filename), + options.cssDir, + ).replaceAll('\\', '/') + return directory.startsWith('./') ? directory : `./${directory}` + }, + }, + ) + for (const path of slots.packageManifests) watched.add(path) + const isBoundary = packageBoundary() + for (const path of slots.dependencies) { + watched.add(path) + watched.add(dirname(path)) + let directory = dirname(path) + while (directory !== dirname(directory)) { + watched.add(resolve(directory, 'package.json')) + if (isBoundary(directory)) break + directory = dirname(directory) + } + } + dependencies = [...watched] + watch?.(dependencies) + prepared = current + } + } + return { + configure(config: ReservationConfig | undefined) { + if (!config) return + const values = Object.entries(config.environments ?? {}) + for (const [name, environment] of values.length + ? values + : [['default', config] as const]) { + environments.set(`${config.root ?? process.cwd()}:${name}`, { + config, + environment, + }) + } + explicitInputs.clear() + for (const { config, environment } of environments.values()) { + const root = config.root ?? process.cwd() + for (const file of [ + ...environmentInputs(environment), + ...(config.configFileDependencies ?? []), + 'tsconfig.json', + 'package.json', + 'bun.lock', + ]) + explicitInputs.add(resolve(root, file)) + } + invalidate() + }, + invalidate, + get revision() { + return revision + }, + get prepared() { + return prepared + }, + watch(callback: (paths: readonly string[]) => void) { + watch = callback + watch(dependencies) + }, + get dependencies() { + return dependencies + }, + reserve(): Promise { + if (!environments.size || prepared === revision) return Promise.resolve() + if (!pending) + pending = prepare().finally(() => { + pending = undefined + }) + return pending + }, + } +} diff --git a/packages/vite-plugin/src/server-watch.ts b/packages/vite-plugin/src/server-watch.ts new file mode 100644 index 000000000..7ec67d388 --- /dev/null +++ b/packages/vite-plugin/src/server-watch.ts @@ -0,0 +1,39 @@ +import type { createViteReservation } from './reservation' + +interface Watcher { + add(paths: string[]): unknown + on(event: 'all', listener: (event: string, file: string) => void): unknown + off(event: 'all', listener: (event: string, file: string) => void): unknown +} + +export function createServerWatch( + watcher: Watcher, + reservation: Pick< + ReturnType, + 'watch' | 'invalidate' + >, + unlink: (file: string) => void, +) { + const registered = new Set() + const add = (paths: readonly string[]) => { + const added = [...new Set(paths)].filter((path) => !registered.has(path)) + if (!added.length) return + watcher.add(added) + for (const path of added) registered.add(path) + } + const changed = (event: string, file: string) => { + reservation.invalidate( + event === 'addDir' || event === 'unlinkDir' ? undefined : file, + ) + if (event === 'unlink') unlink(file) + } + reservation.watch(add) + watcher.on('all', changed) + return { + add, + close() { + watcher.off('all', changed) + reservation.watch(() => {}) + }, + } +} diff --git a/packages/webpack-plugin/src/__tests__/config-errors.test.ts b/packages/webpack-plugin/src/__tests__/config-errors.test.ts new file mode 100644 index 000000000..266a4dbc5 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/config-errors.test.ts @@ -0,0 +1,40 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import { DevupUIWebpackPlugin } from '../plugin' + +it('clears the theme and creates the CSS directory when configuration cannot be read', () => { + const root = mkdtempSync(join(tmpdir(), 'devup-webpack-config-')) + const config = join(root, 'devup.json') + const cssDir = join(root, 'dist/css') + mkdirSync(config) + wasm.registerTheme({ colors: { default: { brand: '#123456' } } }) + expect(wasm.getDefaultTheme()).toBe('default') + const registered = spyOn(wasm, 'registerTheme') + const logged = spyOn(console, 'error').mockReturnValue(undefined) + try { + const plugin = new DevupUIWebpackPlugin({ + devupFile: config, + distDir: join(root, 'dist'), + cssDir, + watch: false, + }) + + plugin.writeDataFiles() + + expect(registered).toHaveBeenCalledTimes(1) + expect(registered).toHaveBeenCalledWith({}) + expect(wasm.getDefaultTheme()).toBeUndefined() + expect(logged).toHaveBeenCalledTimes(1) + expect(logged).toHaveBeenCalledWith(expect.any(Error)) + expect(existsSync(cssDir)).toBe(true) + } finally { + registered.mockRestore() + logged.mockRestore() + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index a032e8f6f..3507e4c2e 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -222,19 +222,25 @@ describe('devupUIWebpackPlugin', () => { } }) }) - it('W66 default regressions: should apply default options', () => { - expect(new DevupUIWebpackPlugin({}).options).toEqual({ - include: [], - package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), - devupFile: 'devup.json', - distDir: 'df', - watch: false, - debug: false, - singleCss: false, - atomHoist: 2, - }) - }) + it.each([true, false])( + 'W66 default regressions: should apply default options (omitted: %s)', + (omitted) => { + const plugin = omitted + ? new DevupUIWebpackPlugin() + : new DevupUIWebpackPlugin({}) + expect(plugin.options).toEqual({ + include: [], + package: '@devup-ui/react', + cssDir: resolve('df', 'devup-ui'), + devupFile: 'devup.json', + distDir: 'df', + watch: false, + debug: false, + singleCss: false, + atomHoist: 2, + }) + }, + ) it('also reserves at native watchRun when watch persistence is disabled', async () => { const manager = {