diff --git a/.changepacks/changepack_log_w74-native-engine.json b/.changepacks/changepack_log_w74-native-engine.json new file mode 100644 index 000000000..21802622e --- /dev/null +++ b/.changepacks/changepack_log_w74-native-engine.json @@ -0,0 +1,12 @@ +{ + "changes": { + "packages/plugin-utils/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch" + }, + "note": "Add shared native/WASM compiler selection, structural compiler types and exact native platform-load diagnostics for build plugins.", + "date": "2026-10-30T17:29:00.000Z" +} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bb13c06ff..abbacd756 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - main + - feat/napi-binding # Pull requests and every job below run read-only. Only the `release` and # `deploy-pages` jobs get write permissions, and they run on pushes to main diff --git a/apps/vinext-rsc-css-repro/vite.config.ts b/apps/vinext-rsc-css-repro/vite.config.ts index 9f163aa1a..d6059775e 100644 --- a/apps/vinext-rsc-css-repro/vite.config.ts +++ b/apps/vinext-rsc-css-repro/vite.config.ts @@ -2,9 +2,19 @@ import { DevupUI } from '@devup-ui/vite-plugin' import vinext from 'vinext' import { defineConfig } from 'vite' +const engine = process.env.DEVUP_ENGINE +switch (engine) { + case undefined: + case 'native': + case 'wasm': + break + default: + throw new TypeError(`Invalid DEVUP_ENGINE: ${JSON.stringify(engine)}`) +} + export default defineConfig({ plugins: [ - DevupUI({ singleCss: process.env.DEVUP_SINGLE_CSS === '1' }), + DevupUI({ engine, singleCss: process.env.DEVUP_SINGLE_CSS === '1' }), vinext(), ], }) diff --git a/bun.lock b/bun.lock index cdf8adeff..ccfda13b1 100644 --- a/bun.lock +++ b/bun.lock @@ -570,9 +570,6 @@ "@types/bun": "^1.4.4", "typescript": "^7.0.2", }, - "peerDependencies": { - "@devup-ui/wasm": "*", - }, }, "packages/components": { "name": "@devup-ui/components", @@ -631,7 +628,6 @@ "typescript": "^7.0.2", }, "peerDependencies": { - "@devup-ui/wasm": "*", "@devup-ui/webpack-plugin": "*", "next": "*", }, @@ -639,6 +635,10 @@ "packages/plugin-utils": { "name": "@devup-ui/plugin-utils", "version": "1.0.17", + "dependencies": { + "@devup-ui/binding": "workspace:^", + "@devup-ui/wasm": "workspace:^", + }, "devDependencies": { "typescript": "^7.0.2", }, @@ -689,7 +689,6 @@ "typescript": "^7.0.2", }, "peerDependencies": { - "@devup-ui/wasm": "*", "@rsbuild/core": "*", }, }, @@ -706,7 +705,6 @@ "vite": "^8.3.5", }, "peerDependencies": { - "@devup-ui/wasm": "*", "vite": "*", }, }, @@ -721,9 +719,6 @@ "@types/webpack": "^5.28.5", "typescript": "^7.0.2", }, - "peerDependencies": { - "@devup-ui/wasm": "*", - }, }, }, "packages": { @@ -989,6 +984,12 @@ "@dual-bundle/import-meta-resolve": ["@dual-bundle/import-meta-resolve@4.2.1", "", {}, "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg=="], + "@eloqnt/config": ["@eloqnt/config@0.1.1", "", {}, "sha512-YRDigSaiJEzKDfzTth5kG+L/PKfB3mo8h7yNzpXExPfvQmcD05WoZT57PdhJE0jI+Vva6eb0OCU8hnHHgdME3g=="], + + "@eloqnt/format-json": ["@eloqnt/format-json@0.1.0", "", { "dependencies": { "@eloqnt/config": "^0.1.0" } }, "sha512-4Pkb+HyzgWNJXoU48aJhUyNZSjZvMGooPCNG1ZFmBm55xTiNNPQCNXH66Ob6sXS6RvbWZrRpCf+Uo/Jz9K9g+A=="], + + "@eloqnt/format-po": ["@eloqnt/format-po@0.1.1", "", { "dependencies": { "@eloqnt/config": "^0.1.1", "po-parser": "^2.2.0" } }, "sha512-aLtmIoqXGEaeTT0RcsLN8sZ3aIrsT/c/dVs28zrLhDjO3+vA3KNP+r98N0xq8w/k36+/GIUt4uZNbqcA71GVWw=="], + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], @@ -1109,6 +1110,14 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + "@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.1.7", "", {}, "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA=="], + + "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@3.5.22", "", { "dependencies": { "@formatjs/icu-skeleton-parser": "2.1.12" } }, "sha512-YuwN+dDKgssfuYwtOyGQD0CSn7K46g9cv1Fp0VgKpjZDDFvv8b8crzP990IcJhRPWHhKzCGsbUSJVIguyyFA5A=="], + + "@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@2.1.12", "", {}, "sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA=="], + + "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.8.14", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.7" } }, "sha512-8O7V38QmbB11+ryJ3KM/znrzXtiqgpXzqNoqDsndHZZ5/zDKTTL+cf5F0mzXkQEwhx8IlWnqYvEovf2S5EwLTw=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@21.0.4", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^21.0.4" } }, "sha512-Bbr0hW9hhDhBf3Jp0EyDlkCbFgbr0z2jd3BMKYOYW+fOkPM4Nx195GMXyCtRCGEgoNn6i09FabXrSkNFm2ONzg=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -1557,6 +1566,8 @@ "@rspack/plugin-react-refresh": ["@rspack/plugin-react-refresh@2.0.2", "", { "peerDependencies": { "@rspack/core": "^2.0.0", "react-refresh": ">=0.10.0 <1.0.0" }, "optionalPeers": ["@rspack/core"] }, "sha512-dGNZiCxQxgAUI9sah7gd8u+O7OJZRCmqtEJNDOd8xW5RqcieC86F7p5qcShyw6onH5pKf57evpr2VjGbaFGkZg=="], + "@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="], + "@shuding/opentype.js": ["@shuding/opentype.js@1.4.0-beta.0", "", { "dependencies": { "fflate": "^0.7.3", "string.prototype.codepointat": "^0.2.1" }, "bin": { "ot": "bin/ot" } }, "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA=="], "@storybook/addon-docs": ["@storybook/addon-docs@10.6.3", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/icons": "^2.0.2", "@storybook/react-dom-shim": "10.6.3", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0", "unplugin": "^2.3.5" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.6.3" }, "optionalPeers": ["@types/react"] }, "sha512-365f712au4w/xvFqEKY6TIvTeGGAY7FKRB8zP4CFa3VSv9oyxHaq/ublFg4YDbCeRagppwl1PZ6k4+PoUor3+A=="], @@ -2175,6 +2186,8 @@ "confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], + "content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "core-js-compat": ["core-js-compat@3.51.0", "", { "dependencies": { "browserslist": "^4.29.3" } }, "sha512-52r6fkXgxv4GLMjmFcMWRx7X9bw071xTTdrDcdyPGRqU2oE+9pCG0bAOMifSBdLw0JRBZKEF0q3B55qPlLuE8g=="], @@ -2461,6 +2474,8 @@ "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + "icu-minify": ["icu-minify@4.14.9", "", { "dependencies": { "@formatjs/icu-messageformat-parser": "^3.4.0" } }, "sha512-PmO+fNnC3K3mfPKTnm3RrPERrhQZnjm1AlpyZQ0ShbXi8Y/kYZ3YOde8TEjCLz9k3P2DmzkBn8lykGrCAi1Ajw=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -2479,6 +2494,8 @@ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "intl-messageformat": ["intl-messageformat@11.2.15", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.7", "@formatjs/icu-messageformat-parser": "3.5.18" } }, "sha512-W4VNytsXyWgW5lvVchmsb9xgA5rSKqoflu3/IQ9UCtB0z4R+Bx9MoEkwFT/+30RCl/KzUoH0QGGe9lppF5SuvQ=="], + "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], "ipaddr.js": ["ipaddr.js@2.5.0", "", {}, "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w=="], @@ -2717,6 +2734,8 @@ "natural-orderby": ["natural-orderby@5.0.0", "", {}, "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], "next": ["next@16.4.3", "", { "dependencies": { "@next/env": "16.4.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.4.3", "@next/swc-darwin-x64": "16.4.3", "@next/swc-linux-arm64-gnu": "16.4.3", "@next/swc-linux-arm64-musl": "16.4.3", "@next/swc-linux-x64-gnu": "16.4.3", "@next/swc-linux-x64-musl": "16.4.3", "@next/swc-win32-arm64-msvc": "16.4.3", "@next/swc-win32-x64-msvc": "16.4.3", "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA=="], @@ -2735,6 +2754,10 @@ "next-example": ["next-example@workspace:apps/next"], + "next-intl": ["next-intl@4.14.9", "", { "dependencies": { "@eloqnt/config": "^0.1.1", "@eloqnt/format-json": "^0.1.0", "@eloqnt/format-po": "^0.1.1", "@formatjs/intl-localematcher": "^0.8.1", "@parcel/watcher": "^2.4.1", "@swc/core": "~1.16.0", "icu-minify": "^4.14.9", "negotiator": "^1.0.0", "next-intl-swc-plugin-extractor": "4.14.9", "use-intl": "^4.14.9" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-uauRtUC+TrYNA1OL/QcwL9irndLrI9jK3Wdaa9W0B5AeKh9cqVdNeT0IWgLnhBqBRrVwppVdI0M+C1+JdGPQ3w=="], + + "next-intl-swc-plugin-extractor": ["next-intl-swc-plugin-extractor@4.14.9", "", {}, "sha512-8jAkmDwQJo6RDavBHm9QRl1YTn2WXf+rI4SVhMaZxZcxopnwZic6fKTXOoNIo3kzC+a6vJySkOkXmHTVFLAYtg=="], + "next-kuma-ui-benchmark": ["next-kuma-ui-benchmark@workspace:benchmark/next-kuma-ui"], "next-mui-benchmark": ["next-mui-benchmark@workspace:benchmark/next-mui"], @@ -2841,6 +2864,8 @@ "playwright-core": ["playwright-core@1.64.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ=="], + "po-parser": ["po-parser@2.2.0", "", {}, "sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g=="], + "postcss": ["postcss@8.5.31", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="], "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], @@ -3147,6 +3172,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-intl": ["use-intl@4.14.9", "", { "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", "icu-minify": "^4.14.9", "intl-messageformat": "^11.1.0" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-37ktvdCCz+6JY2VFPl/GV69Ds4ahiUT2c2xEZZRZYxdgGLxFsUbdxzjQVRm3YMIOUU0VPs6zvlifb4Yjk4IxIg=="], + "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -3775,6 +3802,8 @@ "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "intl-messageformat/@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@3.5.18", "", { "dependencies": { "@formatjs/icu-skeleton-parser": "2.1.11" } }, "sha512-wX1efcL8d7K5QapOTRsP/OLTA/kpwe4LwqWyNxDNg5iRs8i9jJbCyxB5P8jc4KOW5oiLzz2DJr+BRY5xMV9+bA=="], + "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "jsonc-eslint-parser/verkit": ["verkit@0.3.2", "", {}, "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg=="], @@ -5801,6 +5830,8 @@ "eslint-plugin-react-hooks/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "intl-messageformat/@formatjs/icu-messageformat-parser/@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@2.1.11", "", {}, "sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA=="], + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "parse-json/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -6832,49 +6863,5 @@ "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "pkg-dir/find-up/locate-path/p-locate/p-limit/yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], - - "@eloqnt/config": ["@eloqnt/config@0.1.1", "", {}, "sha512-YRDigSaiJEzKDfzTth5kG+L/PKfB3mo8h7yNzpXExPfvQmcD05WoZT57PdhJE0jI+Vva6eb0OCU8hnHHgdME3g=="], - - "@eloqnt/format-json": ["@eloqnt/format-json@0.1.0", "", { "dependencies": { "@eloqnt/config": "^0.1.0" } }, "sha512-4Pkb+HyzgWNJXoU48aJhUyNZSjZvMGooPCNG1ZFmBm55xTiNNPQCNXH66Ob6sXS6RvbWZrRpCf+Uo/Jz9K9g+A=="], - - "@eloqnt/format-po": ["@eloqnt/format-po@0.1.1", "", { "dependencies": { "@eloqnt/config": "^0.1.1", "po-parser": "^2.2.0" } }, "sha512-aLtmIoqXGEaeTT0RcsLN8sZ3aIrsT/c/dVs28zrLhDjO3+vA3KNP+r98N0xq8w/k36+/GIUt4uZNbqcA71GVWw=="], - - "@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.1.7", "", {}, "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA=="], - - "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@3.5.22", "", { "dependencies": { "@formatjs/icu-skeleton-parser": "2.1.12" } }, "sha512-YuwN+dDKgssfuYwtOyGQD0CSn7K46g9cv1Fp0VgKpjZDDFvv8b8crzP990IcJhRPWHhKzCGsbUSJVIguyyFA5A=="], - - "@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@2.1.12", "", {}, "sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA=="], - - "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.8.14", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.7" } }, "sha512-8O7V38QmbB11+ryJ3KM/znrzXtiqgpXzqNoqDsndHZZ5/zDKTTL+cf5F0mzXkQEwhx8IlWnqYvEovf2S5EwLTw=="], - - "@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="], - - "@typescript-eslint/rule-tester": ["@typescript-eslint/rule-tester@8.72.0", "", { "dependencies": { "@typescript-eslint/parser": "8.72.0", "@typescript-eslint/typescript-estree": "8.72.0", "@typescript-eslint/utils": "8.72.0", "ajv": "^6.12.6", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "4.6.2", "semver": "^7.7.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3DkhIwJJ1EgssS9r613P8mqraY/c0uSg9e5rQdYPKf71poFqIrN/6agYEtTzI9SvVLgFqfbCwpKLr0zRBij0UQ=="], - - "ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], - - "ajv-keywords/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - - "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], - - "icu-minify": ["icu-minify@4.14.9", "", { "dependencies": { "@formatjs/icu-messageformat-parser": "^3.4.0" } }, "sha512-PmO+fNnC3K3mfPKTnm3RrPERrhQZnjm1AlpyZQ0ShbXi8Y/kYZ3YOde8TEjCLz9k3P2DmzkBn8lykGrCAi1Ajw=="], - - "intl-messageformat": ["intl-messageformat@11.2.15", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.7", "@formatjs/icu-messageformat-parser": "3.5.18" } }, "sha512-W4VNytsXyWgW5lvVchmsb9xgA5rSKqoflu3/IQ9UCtB0z4R+Bx9MoEkwFT/+30RCl/KzUoH0QGGe9lppF5SuvQ=="], - - "intl-messageformat/@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@3.5.18", "", { "dependencies": { "@formatjs/icu-skeleton-parser": "2.1.11" } }, "sha512-wX1efcL8d7K5QapOTRsP/OLTA/kpwe4LwqWyNxDNg5iRs8i9jJbCyxB5P8jc4KOW5oiLzz2DJr+BRY5xMV9+bA=="], - - "intl-messageformat/@formatjs/icu-messageformat-parser/@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@2.1.11", "", {}, "sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA=="], - - "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], - - "next-intl": ["next-intl@4.14.9", "", { "dependencies": { "@eloqnt/config": "^0.1.1", "@eloqnt/format-json": "^0.1.0", "@eloqnt/format-po": "^0.1.1", "@formatjs/intl-localematcher": "^0.8.1", "@parcel/watcher": "^2.4.1", "@swc/core": "~1.16.0", "icu-minify": "^4.14.9", "negotiator": "^1.0.0", "next-intl-swc-plugin-extractor": "4.14.9", "use-intl": "^4.14.9" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-uauRtUC+TrYNA1OL/QcwL9irndLrI9jK3Wdaa9W0B5AeKh9cqVdNeT0IWgLnhBqBRrVwppVdI0M+C1+JdGPQ3w=="], - - "next-intl-swc-plugin-extractor": ["next-intl-swc-plugin-extractor@4.14.9", "", {}, "sha512-8jAkmDwQJo6RDavBHm9QRl1YTn2WXf+rI4SVhMaZxZcxopnwZic6fKTXOoNIo3kzC+a6vJySkOkXmHTVFLAYtg=="], - - "po-parser": ["po-parser@2.2.0", "", {}, "sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g=="], - - "use-intl": ["use-intl@4.14.9", "", { "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", "icu-minify": "^4.14.9", "intl-messageformat": "^11.1.0" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-37ktvdCCz+6JY2VFPl/GV69Ds4ahiUT2c2xEZZRZYxdgGLxFsUbdxzjQVRm3YMIOUU0VPs6zvlifb4Yjk4IxIg=="], } } diff --git a/bun.setup.ts b/bun.setup.ts index f0939bb02..3a13af6ad 100644 --- a/bun.setup.ts +++ b/bun.setup.ts @@ -3,11 +3,12 @@ import './bun.matrix' import { afterEach, beforeEach, spyOn } from 'bun:test' import { resetWasmForTesting } from './packages/next-plugin/src/wasm' -import { createModuleResolver } from './packages/plugin-utils/src' +import { createModuleResolver, loadCompiler } from './packages/plugin-utils/src' const { resetStateForTesting, setDebug, setModuleResolver } = await import('./bindings/devup-ui-wasm/pkg/index.js') const testModuleResolver = createModuleResolver() +const nativeCompiler = loadCompiler() const nativeBunPlugin = Bun.plugin export const bunRegistration = Object.assign(spyOn(Bun, 'plugin'), { clearAll: nativeBunPlugin.clearAll, @@ -21,6 +22,9 @@ try { } function resetTestState() { + nativeCompiler.resetStateForTesting() + nativeCompiler.setDebug(true) + nativeCompiler.setModuleResolver(testModuleResolver) resetStateForTesting() setDebug(true) setModuleResolver(testModuleResolver) diff --git a/e2e/binding/package/build-host.mjs b/e2e/binding/package/build-host.mjs new file mode 100644 index 000000000..dc024c282 --- /dev/null +++ b/e2e/binding/package/build-host.mjs @@ -0,0 +1,99 @@ +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { targets } from './matrix.mjs' + +const root = fileURLToPath(new URL('../../../', import.meta.url)) +const actualHost = { + platform: process.platform, + arch: process.arch, + report: process.report.getReport, +} +const actualIo = { + run: execFileSync, + now: () => performance.now(), + emit: (record) => console.info(JSON.stringify(record)), +} + +export class HostBuildTargetError extends Error { + constructor(host, cause) { + super( + `Unsupported or indeterminate native host: ${host.platform}/${host.arch}`, + { cause }, + ) + this.name = 'HostBuildTargetError' + this.platform = host.platform + this.arch = host.arch + } +} + +export function selectHostTarget(host = actualHost) { + const candidates = targets.filter( + (row) => row.os === host.platform && row.cpu === host.arch, + ) + if (candidates.length === 0) throw new HostBuildTargetError(host) + if (host.platform !== 'linux') return candidates[0] + try { + const report = host.report() + if ( + typeof report !== 'object' || + report === null || + typeof report.header !== 'object' || + report.header === null + ) { + throw new HostBuildTargetError(host) + } + const libc = report.header.glibcVersionRuntime ? 'glibc' : 'musl' + return candidates.find((row) => row.libc === libc) + } catch (cause) { + if (cause instanceof HostBuildTargetError) throw cause + throw new HostBuildTargetError(host, cause) + } +} + +export function buildHost(host = actualHost, io = actualIo) { + const row = selectHostTarget(host) + if (!row) throw new HostBuildTargetError(host) + const context = { + event: 'devup.host-native-build', + host: `${host.platform}/${host.arch}`, + target: row.target, + suffix: row.suffix, + node: process.version, + job: process.env.GITHUB_JOB ?? null, + run: process.env.GITHUB_RUN_ID ?? null, + } + const started = io.now() + io.emit({ ...context, phase: 'start' }) + let status = 'failed' + try { + io.run( + process.execPath, + [ + resolve(root, 'bindings/devup-ui-binding/build.mjs'), + '--target', + row.target, + ], + { + cwd: root, + env: process.env, + stdio: 'inherit', + }, + ) + status = 'success' + } finally { + io.emit({ + ...context, + phase: 'finish', + status, + elapsedMs: io.now() - started, + }) + } +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) + buildHost() diff --git a/e2e/binding/package/build-host.test.mjs b/e2e/binding/package/build-host.test.mjs new file mode 100644 index 000000000..f7ad1d529 --- /dev/null +++ b/e2e/binding/package/build-host.test.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict' +import { resolve } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +import { + buildHost, + HostBuildTargetError, + selectHostTarget, +} from './build-host.mjs' +import { targets } from './matrix.mjs' + +const root = fileURLToPath(new URL('../../../', import.meta.url)) + +for (const row of targets) { + test(`selects canonical host triple for ${row.suffix}`, () => { + // Given + let reports = 0 + const host = { + platform: row.os, + arch: row.cpu, + report() { + reports++ + return { + header: row.libc === 'glibc' ? { glibcVersionRuntime: '2.39' } : {}, + } + }, + } + // When + const selected = selectHostTarget(host) + // Then + assert.equal(selected, row) + assert.equal(reports, row.os === 'linux' ? 1 : 0) + }) +} + +for (const [platform, arch] of [ + ['freebsd', 'x64'], + ['win32', 'ia32'], + ['linux', 'riscv64'], +]) { + test(`rejects unsupported ${platform}/${arch} before any spawn`, () => { + // Given + const calls = [] + const host = { platform, arch, report: () => calls.push('report') } + const io = { + run: () => calls.push('run'), + now: () => calls.push('clock'), + emit: () => calls.push('emit'), + } + // When / Then + assert.throws(() => buildHost(host, io), HostBuildTargetError) + assert.deepEqual(calls, []) + }) +} + +test('preserves a Linux report failure without spawning', () => { + // Given + const failure = new Error('report unavailable') + const calls = [] + const host = { + platform: 'linux', + arch: 'arm64', + report() { + throw failure + }, + } + const io = { + run: () => calls.push('run'), + now: () => 0, + emit: () => calls.push('emit'), + } + // When / Then + assert.throws( + () => buildHost(host, io), + (error) => error instanceof HostBuildTargetError && error.cause === failure, + ) + assert.deepEqual(calls, []) +}) + +for (const report of [null, '', {}, { header: null }, { header: 'bad' }]) { + test(`rejects indeterminate report ${JSON.stringify(report)}`, () => { + // Given + const host = { platform: 'linux', arch: 'x64', report: () => report } + // When / Then + assert.throws(() => selectHostTarget(host), HostBuildTargetError) + }) +} + +test('delegates to the existing driver and emits measured completion', () => { + // Given + const calls = [] + const records = [] + let ticks = 0 + const host = { + platform: 'win32', + arch: 'arm64', + report() { + assert.fail('non-Linux host') + }, + } + const io = { + run: (...args) => calls.push(args), + now: () => 100 + ticks++ * 75, + emit: (record) => records.push(record), + } + // When + buildHost(host, io) + // Then + assert.deepEqual(calls, [ + [ + process.execPath, + [ + resolve(root, 'bindings/devup-ui-binding/build.mjs'), + '--target', + 'aarch64-pc-windows-msvc', + ], + { + cwd: root, + env: process.env, + stdio: 'inherit', + }, + ], + ]) + assert.equal(records[0].phase, 'start') + assert.deepEqual(records[1], { + ...records[0], + phase: 'finish', + status: 'success', + elapsedMs: 75, + }) + assert.equal(records[1].host, 'win32/arm64') + assert.equal(records[1].target, 'aarch64-pc-windows-msvc') +}) + +test('propagates driver failure and emits a measured failed finish', () => { + // Given + const failure = new Error('driver failed') + const records = [] + let ticks = 0 + const host = { + platform: 'darwin', + arch: 'x64', + report() { + assert.fail('non-Linux host') + }, + } + const io = { + run() { + throw failure + }, + now: () => ticks++ * 25, + emit: (record) => records.push(record), + } + // When / Then + assert.throws( + () => buildHost(host, io), + (error) => error === failure, + ) + assert.deepEqual(records[1], { + ...records[0], + phase: 'finish', + status: 'failed', + elapsedMs: 25, + }) +}) diff --git a/e2e/plugins/engine-consumer.mjs b/e2e/plugins/engine-consumer.mjs new file mode 100644 index 000000000..eae588f6a --- /dev/null +++ b/e2e/plugins/engine-consumer.mjs @@ -0,0 +1,3080 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import Module, { createRequire } from 'node:module' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { inspect } from 'node:util' + +export function viteRealpath(filename) { + try { + return realpathSync.native(filename) + } catch (error) { + if (error?.code === 'EISDIR') return realpathSync(filename) + throw error + } +} + +export async function collectViteCases(cases, visit) { + const results = [] + for (const name of cases) { + try { + results.push({ name, ok: true, value: await visit(name) }) + } catch (error) { + results.push({ name, ok: false, error: errorEvidence(error) }) + } + } + return results +} + +export function completedReceipt(receipts, completed, id) { + assert( + completed.has(id) && receipts.has(id), + `Missing complete requested-boundary receipt: ${id}`, + ) + return receipts.get(id) +} + +export const webpackDistFiles = [ + 'index.cjs', + 'index.mjs', + 'loader.cjs', + 'loader.mjs', + 'css-loader.cjs', + 'css-loader.mjs', +] + +export const initialLayerGate = ` +const pending = new Map() +let requested = false +let open = false +function releaseReady() { + if (!requested || !pending.has('A') || !pending.has('B')) return + open = true + for (const name of ['A', 'B']) pending.get(name)() + pending.clear() +} +module.exports = function(source) { + const layer = /[\\\\/]([AB])\\.css\\.ts$/.exec(this.resourcePath)?.[1] + if (!open && layer) { + const callback = this.async() + pending.set(layer, () => callback(null, source)) + releaseReady() + return + } + return source +} +module.exports.release = () => { + requested = true + releaseReady() +} +` + +export function assertWebpackDist(directory) { + for (const name of webpackDistFiles) { + const code = readFileSync(join(directory, name), 'utf8') + assert.doesNotMatch(code, /['"]@devup-ui\/(?:wasm|binding)(?:['"/])/, name) + assert.match(code, /@devup-ui\/plugin-utils/, name) + } +} + +export function assertRsbuildDist(directory) { + for (const name of ['index.cjs', 'index.mjs']) { + const code = readFileSync(join(directory, name), 'utf8') + assert.doesNotMatch(code, /['"]@devup-ui\/(?:wasm|binding)(?:['"/])/) + assert.match(code, /@devup-ui\/plugin-utils/) + } +} + +export function assertBunDist(directory) { + assertRsbuildDist(directory) + for (const name of ['register.cjs', 'register.mjs']) { + const code = readFileSync(join(directory, name), 'utf8') + assert.doesNotMatch(code, /['"]@devup-ui\/(?:wasm|binding)(?:['"/])/) + assert.match(code, /@devup-ui\/plugin-utils/) + } +} + +export function assertRawParity(expected, actual) { + assert.deepEqual(comparisonData(actual), comparisonData(expected)) +} + +export function assertViteParity(expected, actual, oppositeSingleCss = false) { + // Opposite admission changes intermediate return CSS, never published CSS. + if (!oppositeSingleCss) return assertRawParity(expected, actual) + const project = (receipt) => { + const copy = structuredClone(receipt) + for (const phase of copy.phases) + for (const output of Object.values(phase.outputs)) { + assert(Object.hasOwn(output, 'css')) + delete output.css + } + return copy + } + assertRawParity(project(expected), project(actual)) +} + +// Native/native AND WASM/WASM A/A reproduce this intermediate drift. +// Only extraction-return CSS at generations[*].outputs[*], never published CSS. +const rsbuildIntermediateFields = { + receipt: 'compilationProofs', + output: 'css', +} + +export function assertRsbuildParity(expected, actual) { + const omit = (record, field) => { + assert(Object.hasOwn(record, field), `Missing raw Rsbuild ${field}`) + return Object.fromEntries( + Object.entries(record).filter(([key]) => key !== field), + ) + } + const project = (receipt) => ({ + ...omit(receipt, rsbuildIntermediateFields.receipt), + generations: receipt.generations.map((generation) => ({ + ...generation, + outputs: Object.fromEntries( + Object.entries(generation.outputs).map(([file, output]) => [ + file, + omit(output, rsbuildIntermediateFields.output), + ]), + ), + })), + }) + assertRawParity(project(expected), project(actual)) +} + +// Identity tokens are process-local proofs, never function source or symbol text. +// Cross-process identity-bearing diagnostics deliberately fail closed. +const identityScope = randomUUID() +const identities = new Map() + +function identity(value) { + if (!identities.has(value)) identities.set(value, identities.size) + return { scope: identityScope, id: identities.get(value) } +} + +function prototypeNames(value) { + const names = [] + for ( + let proto = Object.getPrototypeOf(value); + proto; + proto = Object.getPrototypeOf(proto) + ) { + names.push(proto.constructor?.name) + } + return names +} + +// Assignment audit: loader.ts preserves Errors; both bindings construct realm +// Errors without assigning these runtime creation fields. Descriptors and key +// order still compare exactly; `code` and diagnostic locations are not exempt. +const runtimeErrorKeys = new Set([ + 'stack', + 'originalLine', + 'originalColumn', + 'line', + 'column', + 'sourceURL', +]) +const optionalBunCreationKeys = new Set( + process.versions.bun ? ['originalLine', 'originalColumn'] : [], +) + +function assertOptionalCreationData(property, kind, valueType) { + assert.equal(kind, 'data') + assert.equal(property.configurable, true) + assert.equal(property.enumerable, false) + assert.equal(property.writable, true) + assert.equal(valueType, 'number') +} + +function diagnosticKeys(value) { + return Reflect.ownKeys(value).filter((key) => { + if (!(value instanceof Error) || !optionalBunCreationKeys.has(key)) + return true + const property = Object.getOwnPropertyDescriptor(value, key) + assertOptionalCreationData( + property, + 'value' in property ? 'data' : 'accessor', + typeof property.value, + ) + return false + }) +} + +function stackPrefix(stack, strict = true) { + if (typeof stack !== 'string') { + if (strict) assert.fail('Runtime stack must be a string') + return undefined + } + const lines = stack.split('\n') + const frame = lines.findIndex( + (line) => /^\s+at\s/.test(line) || /@.*:\d+(?::\d+)?$/.test(line), + ) + if (frame < 0) { + if (strict) + assert.fail('Unrecognized runtime stack shape; inspect raw evidence') + return undefined + } + return lines.slice(0, frame).join('\n') +} + +export function errorEvidence(error) { + const seen = new Map() + const encode = (value) => { + if (value === null) return { type: 'null' } + const type = typeof value + switch (type) { + case 'undefined': + return { type } + case 'number': + return { + type, + value: Object.is(value, -0) ? '-0' : String(value), + } + case 'bigint': + return { type, value: String(value) } + case 'string': + case 'boolean': + return { type, value } + case 'symbol': + return { + type, + identity: identity(value), + description: encode(value.description), + registryKey: encode(Symbol.keyFor(value)), + } + case 'function': + case 'object': + break + default: + assert.fail(`Unsupported diagnostic value type: ${type}`) + } + if (seen.has(value)) return { reference: seen.get(value) } + const id = seen.size + seen.set(value, id) + const isError = value instanceof Error + const functionValue = type === 'function' + const exotic = + !isError && + !functionValue && + !Array.isArray(value) && + Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null + return { + id, + type: isError ? 'error' : type, + ...(functionValue || exotic ? { identity: identity(value) } : {}), + ...(functionValue + ? { source: Function.prototype.toString.call(value) } + : {}), + ...(exotic + ? { + inspection: inspect(value, { + showHidden: true, + depth: null, + getters: false, + customInspect: false, + maxArrayLength: null, + maxStringLength: null, + }), + } + : {}), + constructor: encode(value.constructor?.name), + prototypes: prototypeNames(value).map(encode), + properties: Reflect.ownKeys(value).map((key) => { + const descriptor = Object.getOwnPropertyDescriptor(value, key) + const runtime = isError && runtimeErrorKeys.has(key) + const result = { + key: encode(key), + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + kind: 'value' in descriptor ? 'data' : 'accessor', + runtime, + } + if ('value' in descriptor) { + result.writable = descriptor.writable + result.value = encode(descriptor.value) + } else { + result.get = encode(descriptor.get) + result.set = encode(descriptor.set) + } + if (runtime) { + // Retain both the raw descriptor and the runtime-observed value. + const observed = Reflect.get(value, key) + result.observed = encode(observed) + result.observedType = typeof observed + if (key === 'stack') result.prefix = stackPrefix(observed, false) + } + return result + }), + } + } + return { diagnostic: 'full-error-v1', raw: encode(error) } +} + +function comparisonData(value) { + if (value === null || typeof value !== 'object') return value + if (Array.isArray(value)) return value.map(comparisonData) + if (value.diagnostic === 'full-error-v1') { + const project = (node) => { + if (node === null || typeof node !== 'object') return node + if (Array.isArray(node)) return node.map(project) + if (node.type === 'error') { + assert( + node.properties.some( + ({ key, runtime }) => + key.type === 'string' && key.value === 'stack' && runtime, + ), + 'Missing runtime stack on Error; inspect raw evidence', + ) + const properties = node.properties.filter((property) => { + if ( + !property.runtime || + property.key.type !== 'string' || + !optionalBunCreationKeys.has(property.key.value) + ) + return true + assertOptionalCreationData( + property, + property.kind, + property.value?.type, + ) + assert.equal(property.observedType, 'number') + return false + }) + return Object.fromEntries( + Object.entries(node).map(([key, item]) => [ + key, + project(key === 'properties' ? properties : item), + ]), + ) + } + if (node.runtime === true) { + const { value, get, set, observed, ...contract } = node + if (node.key.type === 'string' && node.key.value === 'stack') { + assert.equal(node.observedType, 'string') + assert.equal( + typeof node.prefix, + 'string', + 'Unrecognized runtime stack', + ) + } + // Only runtime-created values/accessors are excluded, not descriptors. + return { + ...contract, + valueType: value?.type, + getType: get?.type, + setType: set?.type, + } + } + return Object.fromEntries( + Object.entries(node).map(([key, item]) => [key, project(item)]), + ) + } + return project(value.raw) + } + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, comparisonData(item)]), + ) +} + +export function assertErrorParity(expected, actual) { + if ( + expected?.diagnostic === 'full-error-v1' || + actual?.diagnostic === 'full-error-v1' + ) { + assert.equal(expected?.diagnostic, 'full-error-v1') + assert.equal(actual?.diagnostic, 'full-error-v1') + return assertRawParity(expected, actual) + } + const left = new Map() + const right = new Map() + const compare = (a, b) => { + assert.equal(typeof a, typeof b) + if (a === null || b === null || typeof a !== 'object') { + // Includes symbol values, functions and accessor functions: live identity. + assert.equal(a, b) + return + } + if (left.has(a) || right.has(b)) { + assert.equal(left.get(a), b) + assert.equal(right.get(b), a) + return + } + left.set(a, b) + right.set(b, a) + assert.equal(a.constructor?.name, b.constructor?.name) + assert.deepEqual(prototypeNames(a), prototypeNames(b)) + assert.equal(a instanceof Error, b instanceof Error) + const keys = diagnosticKeys(a) + // Symbol keys are compared by identity, not description or registry text. + assert.deepEqual(keys, diagnosticKeys(b)) + if (a instanceof Error) { + assert(Object.hasOwn(a, 'stack')) + assert(Object.hasOwn(b, 'stack')) + } else if ( + !Array.isArray(a) && + Object.getPrototypeOf(a) !== Object.prototype && + Object.getPrototypeOf(a) !== null + ) { + // Compare built-in internal slots as well as their own descriptors below. + assert.deepEqual(a, b) + } + for (const key of keys) { + const x = Object.getOwnPropertyDescriptor(a, key) + const y = Object.getOwnPropertyDescriptor(b, key) + assert.equal(x.configurable, y.configurable) + assert.equal(x.enumerable, y.enumerable) + assert.equal('value' in x, 'value' in y) + assert.equal(x.writable, y.writable) + if (a instanceof Error && runtimeErrorKeys.has(key)) { + assert.equal(typeof x.value, typeof y.value) + assert.equal(typeof x.get, typeof y.get) + assert.equal(typeof x.set, typeof y.set) + const av = Reflect.get(a, key) + const bv = Reflect.get(b, key) + assert.equal(typeof av, typeof bv) + if (key === 'stack') assert.equal(stackPrefix(av), stackPrefix(bv)) + } else if ('value' in x) { + compare(x.value, y.value) + } else { + assert.equal(x.get, y.get) + assert.equal(x.set, y.set) + } + } + } + compare(expected, actual) +} + +export function diagnosticHook(hook, receive) { + return async function (...args) { + try { + return await hook.apply(this, args) + } catch (error) { + receive(errorEvidence(error), error) + throw error + } + } +} + +export function observeRejection(work) { + const settled = work.then( + (value) => () => value, + (error) => () => { + throw error + }, + ) + return async () => (await settled)() +} + +function put(path, content) { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) +} + +function tree(directory) { + return Object.fromEntries( + readdirSync(directory, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => { + const absolute = join(entry.parentPath, entry.name) + return [absolute, readFileSync(absolute, 'utf8')] + }) + .sort(([a], [b]) => a.localeCompare(b)), + ) +} + +export function assertViteBuckets(phase, color) { + for (const name of ['A', 'B']) { + const code = phase.requested.transforms[`/src/${name}.js`].code + const urls = [ + ...code.matchAll(/["']([^"'\n]*devup-ui(?:-\d+)?\.css[^"'\n]*)["']/g), + ].map((match) => match[1]) + const numbered = urls.filter((url) => /devup-ui-\d+\.css/.test(url)) + const buckets = numbered.length ? numbered : urls + assert(buckets.length > 0, `Missing mapped ${name} CSS import`) + for (const url of buckets) { + const file = url.split('?')[0].split('/').at(-1) + const disk = Object.entries(phase.disk).find(([path]) => + path.replaceAll('\\', '/').endsWith(`/${file}`), + ) + assert(disk, `Missing mapped disk bucket ${file}`) + assert.match(disk[1], color) + assert.match(phase.requested.http[`${url}&direct`].body, color) + } + } +} + +async function consumeVite(context) { + const { + directory, + scenario, + selection, + singleCss, + destination, + create, + selected, + outputs, + calls, + utils, + observations, + } = context + const phases = [] + let failure + let clock = 2000000000000 + Date.now = () => clock + const { build } = await import('vite') + const save = () => { + const replacer = (_, value) => + value === undefined ? { undefined: true } : value + put( + `${destination}.trace.json`, + JSON.stringify({ observations }, replacer, 2), + ) + put(destination, JSON.stringify({ phases, failure }, replacer, 2)) + } + const prepare = () => { + for (const name of ['src', 'df', 'dist']) + rmSync(join(directory, name), { recursive: true, force: true }) + put(join(directory, 'devup.json'), '{"extends":["./theme.json"]}') + put( + join(directory, 'theme.json'), + '{"theme":{"length":{"default":{"size":"17px"}}}}', + ) + put( + join(directory, 'index.html'), + '', + ) + put(join(directory, 'src/tokens.js'), 'export const color="maroon";') + for (const name of ['A', 'B']) { + put( + join(directory, `src/${name}.js`), + `import {css} from '@devup-ui/react'; import {color} from './tokens.js'; export const cls=css({color,w:'$size',insetX:'19px',p:${name === 'A' ? 1 : 2}}); if(import.meta.hot) import.meta.hot.accept();`, + ) + } + put( + join(directory, 'src/entry.js'), + 'import {cls as a} from "./A.js"; import {cls as b} from "./B.js"; document.body.className=a+" "+b;', + ) + } + const snapshot = (name, requested = {}) => { + const disk = tree(join(directory, 'df')) + const state = { + css: selected.getCss(null, false), + sheet: selected.exportSheet(), + classes: selected.exportClassMap(), + files: selected.exportFileMap(), + canonical: selected.exportCanonicalMap(), + residual: selected.getUnreservedClassSlotCount(), + prefix: selected.getPrefix(), + debug: selected.isDebug(), + theme: selected.getDefaultTheme(), + } + const phase = { + name, + requested, + disk, + state, + outputs: Object.fromEntries( + [...outputs].sort(([a], [b]) => a.localeCompare(b)), + ), + calls: { ...calls }, + } + phases.push(phase) + save() + for (const [path, css] of Object.entries(disk)) { + if (path.endsWith('.css')) + assert.equal( + css, + selected.getCss(utils.getFileNumByFilename(path), false), + path, + ) + } + assert.equal(state.residual, 0) + return phase + } + const config = () => { + const arrival = Promise.withResolvers() + const first = scenario === 'opposite' ? 'B' : 'A' + return { + root: directory, + configFile: false, + logLevel: 'error', + plugins: [ + { + name: 'vite-arrival-gate', + enforce: 'pre', + async transform(_code, id) { + if (/\/src\/[AB]\.js$/.test(id) && !id.endsWith(`/${first}.js`)) + await arrival.promise + }, + }, + ...create({ + ...selection, + singleCss, + atomHoist: 0, + shorthands: { insetX: ['left', 'right'] }, + }), + { + name: 'vite-arrival-release', + enforce: 'post', + transform(_code, id) { + if (id.endsWith(`/src/${first}.js`)) arrival.resolve() + }, + }, + ], + optimizeDeps: { noDiscovery: true, include: [] }, + server: { + host: '127.0.0.1', + port: Number(process.env.DEVUP_ENGINE_VITE_PORT), + strictPort: true, + }, + build: { + minify: false, + cssMinify: false, + outDir: 'dist', + rolldownOptions: { + output: { + entryFileNames: '[name].js', + chunkFileNames: '[name].js', + assetFileNames: '[name][extname]', + }, + }, + }, + } + } + try { + prepare() + if (scenario === 'late') { + const retained = create({ ...selection, singleCss })[0] + await retained.closeBundle() + const other = create({ + engine: selection.engine === 'wasm' ? 'native' : 'wasm', + }) + await other[0].closeBundle() + const before = calls.getCss + retained.load(join(directory, 'df/devup-ui/devup-ui.css')) + assert.equal(calls.getCss, before + 1) + await retained.closeBundle() + assert.equal(utils.getBuildOwnership().running, 0) + return { late: true, observations, calls: { ...calls } } + } + for (const sourcemap of [false, true]) { + selected.resetStateForTesting() + outputs.clear() + const options = config() + const result = await build({ + ...options, + build: { ...options.build, sourcemap }, + }) + assert(!Array.isArray(result) && 'output' in result) + const assets = Object.fromEntries( + result.output.map((item) => [ + item.fileName, + item.type === 'chunk' + ? { code: item.code, map: item.map } + : { source: String(item.source) }, + ]), + ) + snapshot(`build-map-${sourcemap}`, { + assets, + emitted: tree(join(directory, 'dist')), + }) + assert.equal( + Object.keys(assets).some((name) => name.endsWith('.js.map')), + sourcemap, + ) + const publishedCss = result.output + .filter( + (item) => item.type === 'asset' && item.fileName.endsWith('.css'), + ) + .map((item) => String(item.source)) + .join('\n') + assert(publishedCss.length > 0) + for (const name of ['A', 'B']) + assert( + outputs.has(join(directory, `src/${name}.js`).replaceAll('\\', '/')), + ) + for (const [id, output] of outputs) { + if (/\/src\/[AB]\.js$/.test(id)) + assert.match(output.code, /\bcls\s*=\s*["'][^"']+["']/) + for (const match of output.code.matchAll( + /\bcls\s*=\s*["']([^"']+)["']/g, + )) { + if (/\/src\/[AB]\.js$/.test(id)) { + const css = assets[output.cssFile.split('/').at(-1)]?.source + for (const side of ['left', 'right']) + assert( + match[1] + .split(' ') + .some((name) => css?.includes(`.${name}{${side}:19px}`)), + ) + } + for (const name of match[1].split(' ')) + assert( + publishedCss.includes(`.${name}`), + `Missing published class ${name}`, + ) + } + } + for (const item of result.output) { + if (item.type === 'chunk') + assert.doesNotMatch( + item.code, + /@devup-ui\/(?:wasm|binding|plugin-utils)|Cannot run on the runtime/, + ) + } + assert.equal(utils.getBuildOwnership().running, 0) + } + return { phases } + } catch (error) { + failure = errorEvidence(error) + throw error + } finally { + save() + assert.equal(utils.getBuildOwnership().running, 0) + } +} + +async function viteErrors(context) { + const { directory, create, singleCss, utils, destination } = context + const { createServer } = await import('vite') + const pairs = [] + const results = await collectViteCases( + ['located', 'resolver', 'registration', 'reservation'], + async (kind) => { + const errors = [] + const bundler = [] + const raw = [] + const disks = [] + const violations = [] + const causes = [] + const identities = [] + for (const engine of ['native', 'wasm']) { + const cause = new Error(`Vite ${kind} cause`, { + cause: new Error('nested cause'), + }) + Object.defineProperty(cause, 'hidden', { value: 'resolver detail' }) + causes.push(errorEvidence(cause)) + const backend = utils.loadCompiler(engine) + backend.resetStateForTesting() + const originals = new Map() + const immediate = [] + let server, owner, before + for (const name of ['src', 'df', 'dist']) + rmSync(join(directory, name), { recursive: true, force: true }) + put(join(directory, 'devup.json'), '{}') + put( + join(directory, 'src/error.js'), + 'import {css} from "@devup-ui/react"; import {color} from "./throws.js"; export const x=css({color});', + ) + put(join(directory, 'src/throws.js'), 'export const color="maroon";') + const inject = (name, replacement) => { + originals.set(name, backend[name]) + backend[name] = replacement + } + try { + const plugins = create({ + engine, + singleCss, + atomHoist: 0, + extractCss: true, + }) + owner = plugins[0] + for (const name of ['configResolved', 'buildStart', 'transform']) { + owner[name] = diagnosticHook(owner[name], (evidence, error) => { + errors.push(error) + immediate.push({ hook: name, evidence }) + }) + } + if (kind === 'resolver') { + const setter = backend.setModuleResolver + inject('setModuleResolver', () => + setter(() => { + throw cause + }), + ) + } + if (kind === 'registration') + inject('registerTheme', () => { + throw cause + }) + if (kind === 'reservation') + inject('reserveClassSlots', () => { + throw cause + }) + const invalid = { + name: 'vite-located-error', + enforce: 'pre', + transform(code, id) { + if (id.endsWith('/error.js') && kind === 'located') + return 'import {Box} from "@devup-ui/react"; export const x=;' + return code + }, + } + server = await createServer({ + root: directory, + configFile: false, + cacheDir: join(directory, '.vite'), + logLevel: 'error', + plugins: [invalid, ...plugins], + optimizeDeps: { noDiscovery: true, include: [] }, + }) + await server.environments.client.pluginContainer.buildStart() + before = tree(join(directory, 'df')) + try { + await server.environments.client.transformRequest('/src/error.js') + } catch (error) { + bundler.push(error) + } + assert.deepEqual(tree(join(directory, 'df')), before) + } catch (error) { + bundler.push(error) + if (!errors.includes(error)) violations.push(errorEvidence(error)) + } finally { + raw.push(immediate) + identities.push({ + thrown: errors.at(-1) === cause, + cause: errors.at(-1)?.cause === cause, + }) + disks.push({ engine, before, after: tree(join(directory, 'df')) }) + try { + try { + await server?.close() + } finally { + await owner?.closeBundle() + } + } catch (error) { + violations.push(errorEvidence(error)) + } finally { + for (const [name, method] of originals) backend[name] = method + backend.resetStateForTesting() + } + } + } + const pair = { + kind, + raw, + disks, + violations, + bundler: bundler.map(errorEvidence), + causes, + identities, + } + pairs.push(pair) + if (errors.length === 2) { + const keys = raw.map((rows) => + rows + .at(-1) + .evidence.raw.properties.map(({ key }) => JSON.stringify(key)), + ) + pair.nativeOnly = keys[0].filter((key) => !keys[1].includes(key)) + if (pair.nativeOnly.length) pair.owner = '#796' + } + put(destination, JSON.stringify({ pairs }, null, 2)) + assert.deepEqual(violations, []) + assert.equal(errors.length, 2) + if (kind === 'registration') + for (const disk of disks) + assert(!Object.keys(disk.after).some((file) => file.endsWith('.css'))) + assertErrorParity(raw[0].at(-1).evidence, raw[1].at(-1).evidence) + assertRawParity(raw[0], raw[1]) + assertRawParity(identities[0], identities[1]) + if (kind === 'resolver') + assert.deepEqual(identities[0], { thrown: false, cause: false }) + if (kind === 'registration' || kind === 'reservation') + for (const observed of identities) assert.equal(observed.thrown, true) + return pair + }, + ) + put(destination, JSON.stringify({ pairs, results }, null, 2)) + assert( + results.every((result) => result.ok), + 'Vite error diagnostics failed; inspect all pairs', + ) + return { pairs, results } +} + +async function consumeRsbuild(context) { + const { + directory, + scenario, + selection, + singleCss, + destination, + require, + create, + selected, + outputs, + calls, + utils, + } = context + const { createRsbuild } = require('@rsbuild/core') + const sourcePath = join(directory, 'src/entry.js') + const received = [] + const callbacks = [] + const served = new Map() + const generations = [] + const compilationProofs = [] + const proofs = new WeakMap() + let handledSource, pendingSource, failure, port, watchReady + let owner, closeBuild, lastCompilation + let additionalPasses = 0 + let deleted = 0 + let layersReleased = false + let completed + const nextCompile = ( + source = readFileSync(join(directory, 'src/B.css.ts'), 'utf8'), + ) => { + pendingSource = source + return new Promise((resolve, reject) => { + completed = { resolve, reject } + if (failure) reject(failure) + }) + } + const hold = scenario === 'additional' + const ordered = hold || scenario === 'dev' + const sourceOrder = ['base.css.ts', 'A.css.ts', 'B.css.ts'].map((name) => + join(directory, 'src', name), + ) + const stages = new Map( + sourceOrder.map((path) => [path, Promise.withResolvers()]), + ) + const pendingCss = new Set() + const unblock = () => { + for (const stage of stages.values()) stage.resolve() + if (hold) require(gateFile).release() + } + const gateFile = join(directory, 'hold.cjs') + put(gateFile, initialLayerGate) + if (hold) put(join(directory, 'src/empty.js'), 'export const empty = 1;') + const saveFailure = (error, stats) => { + const compilations = stats + ? (stats.stats ?? [stats]).map((child) => child.compilation) + : lastCompilation + ? [lastCompilation] + : [] + put( + destination, + JSON.stringify( + { + failure: errorEvidence(error), + compilationProofs, + received: received.map(errorEvidence), + bundlerDiagnostics: compilations.map((compilation) => + compilation.errors.map(errorEvidence), + ), + }, + null, + 2, + ), + ) + } + const assetsOf = (compilation) => + Object.fromEntries( + compilation + .getAssets() + .map(({ name, source }) => [name, String(source.source())]) + .sort(([a], [b]) => a.localeCompare(b)), + ) + const snapshot = (stats, source = handledSource, boundary = true) => { + const revisionColor = source.match(/borderColor:\s*["']([^"']+)["']/)[1] + if (stats.hasErrors()) saveFailure(undefined, stats) + assert(!stats.hasErrors(), stats.toString({ all: false, errors: true })) + const children = stats.stats ?? [stats] + assert.equal(children.length, 1) + const assets = assetsOf(children[0].compilation) + const css = selected.getCss(null, false) + assert.match(css, /cyan/) + assert.match( + css, + revisionColor === 'goldenrod' ? /goldenrod/ : /rebeccapurple/, + ) + for (const [path, value] of served) { + assert.equal( + value, + selected.getCss(utils.getFileNumByFilename(path), true), + ) + } + if (boundary) assert(served.size > 0) + const shippedCss = Object.entries(assets) + .filter(([name]) => name.endsWith('.css')) + .map(([, value]) => value) + .join('\n') + assert.match(shippedCss, /border-color:\s*#0ff\b/) + const border = { + goldenrod: '#daa520', + rebeccapurple: '#639', + teal: '(?:teal|#008080)', + }[revisionColor] + assert.match(shippedCss, new RegExp(`border-color:\\s*${border}\\b`)) + assert.match( + shippedCss, + new RegExp( + `body\\s*\\{[^}]*background-color:\\s*${revisionColor === 'goldenrod' ? '#daa520' : '#639'}\\b`, + ), + ) + const b = outputs + .get(join(directory, 'src/B.css.ts')) + .code.match(/\blayer\s*=\s*["']([^"']+)["']/) + assert(b) + for (const cls of b[1].split(' ')) { + assert.match( + shippedCss, + new RegExp(`\\.${cls}\\s*\\{[^}]*border-color:\\s*${border}\\b`), + ) + assert( + Object.entries(assets).some( + ([name, code]) => name.endsWith('.js') && code.includes(cls), + ), + ) + } + for (const [name, value] of Object.entries(assets)) { + if (name.endsWith('.js')) { + assert.doesNotMatch( + value, + /Cannot run on the runtime|@devup-ui\/(?:binding|wasm)/, + ) + } + } + if (!boundary) return + return { + assets, + outputs: Object.fromEntries( + [...outputs].sort(([a], [b]) => a.localeCompare(b)), + ), + css, + served: [...served].sort(([a], [b]) => a.localeCompare(b)), + sheet: selected.exportSheet(), + classes: selected.exportClassMap(), + files: selected.exportFileMap(), + canonical: selected.exportCanonicalMap(), + disk: tree(join(directory, 'df')), + residual: selected.getUnreservedClassSlotCount(), + } + } + const compiled = ({ stats }) => { + try { + assert(!stats.hasErrors(), stats.toString({ all: false, errors: true })) + const compilation = stats.compilation + const proof = proofs.get(compilation) + proof.needAdditionalPass = compilation.needAdditionalPass + proof.remaining = assetsOf(compilation) + if (compilation.needAdditionalPass) { + assert(Object.values(proof.removed).some((bytes) => bytes.length > 0)) + assert.deepEqual(proof.remaining, {}) + assert.deepEqual(proof.emitted, {}) + return + } + const boundary = Boolean( + completed && !watchReady && proof.source === pendingSource && !failure, + ) + const generation = snapshot(stats, proof.source, boundary) + assert.deepEqual(proof.remaining, proof.emitted) + if (!boundary) return + watchReady = () => { + completed.resolve({ + ...generation, + source: proof.source, + emitted: proof.emitted, + }) + completed = undefined + watchReady = undefined + } + } catch (error) { + failure ??= error + saveFailure(failure, stats) + if (!completed) throw failure + completed.reject(failure) + } + } + const configure = async (options) => { + const instance = create(options) + return createRsbuild({ + cwd: directory, + config: { + mode: scenario === 'dev' ? 'development' : 'production', + plugins: [ + { + ...instance, + async setup(api) { + await instance.setup({ + ...api, + onCloseBuild(fn) { + closeBuild = fn + api.onCloseBuild(fn) + }, + transform(options, handler) { + callbacks.push(handler) + api.transform(options, async (input) => { + try { + const path = input.resourcePath + const index = sourceOrder.indexOf(path) + if (ordered && index > 0) + await stages.get(sourceOrder[index - 1]).promise + if (ordered && path.endsWith('.css')) + await stages.get( + sourceOrder[hold && !layersReleased ? 0 : 2], + ).promise + const result = await handler(input) + if ( + hold && + !layersReleased && + (path === sourcePath || index === 0) + ) + pendingCss.add( + resolve(dirname(path), outputs.get(path).cssFile), + ) + stages.get(path)?.resolve() + if ( + input.resourcePath === join(directory, 'src/B.css.ts') + ) { + handledSource = input.code + proofs.get(lastCompilation).source = handledSource + } + if (input.resourcePath.endsWith('.css')) { + served.set(input.resourcePath, result) + } + return result + } catch (error) { + unblock() + received.push(error) + saveFailure(error) + throw error + } + }) + }, + }) + }, + }, + { + name: 'engine-proof', + setup(api) { + api.modifyRspackConfig((config) => { + if (hold) { + config.entry = () => ({ main: { import: [sourcePath] } }) + config.module.rules.push({ + test: /\.css\.ts$/, + enforce: 'pre', + use: [gateFile], + }) + } + config.plugins.push({ + apply(compiler) { + owner = compiler + const watchFileSystem = compiler.watchFileSystem + const watch = watchFileSystem.watch.bind(watchFileSystem) + watchFileSystem.watch = (...args) => { + const result = watch(...args) + watchReady?.() + return result + } + if (scenario !== 'errors') + compiler.hooks.done.tap('EngineProof', (stats) => + compiled({ stats }), + ) + compiler.hooks.failed.tap('EngineProof', (error) => { + unblock() + failure ??= error + saveFailure(failure) + completed?.reject(failure) + }) + compiler.hooks.afterEmit.tap( + 'EngineProof', + (compilation) => { + proofs.get(compilation).emitted = Object.freeze( + assetsOf(compilation), + ) + }, + ) + compiler.hooks.additionalPass.tap('EngineProof', () => { + additionalPasses++ + proofs.get(lastCompilation).additionalPass = true + }) + compiler.hooks.thisCompilation.tap( + 'EngineProof', + (compilation) => { + lastCompilation = compilation + const proof = { source: handledSource, removed: {} } + proofs.set(compilation, proof) + compilationProofs.push(proof) + served.clear() + const deleteAsset = + compilation.deleteAsset.bind(compilation) + compilation.deleteAsset = (name) => { + deleted++ + Object.assign(proof.removed, assetsOf(compilation)) + deleteAsset(name) + } + compilation.hooks.succeedModule.tap( + 'EngineProof', + (module) => { + if ( + hold && + !layersReleased && + module + .identifier() + .includes( + compiler.rspack.CssExtractRspackPlugin.loader, + ) && + pendingCss.delete(module.resource) && + !pendingCss.size + ) { + layersReleased = true + require(gateFile).release() + } + }, + ) + }, + ) + }, + }) + }) + }, + }, + ], + source: { entry: { main: hold ? './src/empty.js' : './src/entry.js' } }, + output: { + distPath: { root: 'dist' }, + minify: false, + filenameHash: false, + sourceMap: { js: 'source-map', css: true }, + }, + performance: { printFileSize: false }, + html: { template: false }, + server: { port: 0, host: '127.0.0.1', printUrls: false }, + dev: { hmr: false, liveReload: false, lazyCompilation: false }, + tools: { + rspack: { + cache: false, + // df exclusion isolates main follow-up (l), not a product fix. + watchOptions: { ignored: /[\\/](?:df|node_modules)[\\/]/ }, + optimization: { minimize: false, concatenateModules: false }, + }, + }, + }, + }) + } + if (scenario === 'errors') { + const userCause = new Error('user resolver callback failure', { + cause: new Error('nested user resolver cause'), + }) + Object.defineProperty(userCause, 'hidden', { + value: 'user resolver detail', + }) + const pairs = [] + for (const callbackFailure of [false, true]) { + const errors = [] + const invoked = [] + const bundlerDiagnostics = [] + for (const engine of ['native', 'wasm']) { + const backend = utils.loadCompiler(engine) + backend.resetStateForTesting() + const setResolver = backend.setModuleResolver + let compiler + let callbackCalls = 0 + received.length = 0 + try { + if (callbackFailure) { + backend.setModuleResolver = () => + setResolver(() => { + callbackCalls++ + throw userCause + }) + } + put( + sourcePath, + callbackFailure + ? 'import { css } from "@devup-ui/react"; import { color } from "./throws.js"; export const x = css({ color });' + : 'import { Box } from "@devup-ui/react"; export const x = ;', + ) + const rsbuild = await configure({ engine, singleCss, atomHoist: 0 }) + compiler = await rsbuild.createCompiler() + const stats = await new Promise((resolve, reject) => + compiler.run((error, stats) => + error ? reject(error) : resolve(stats), + ), + ) + saveFailure(undefined, stats) + bundlerDiagnostics.push( + (stats.stats ?? [stats]).map((child) => + child.compilation.errors.map(errorEvidence), + ), + ) + assert(stats.hasErrors()) + assert.equal(received.length, 1) + errors.push(received[0]) + invoked.push(callbackCalls) + } catch (error) { + saveFailure(error) + throw error + } finally { + try { + if (compiler) + await new Promise((resolve, reject) => + compiler.close((error) => (error ? reject(error) : resolve())), + ) + } finally { + closeBuild?.() + backend.setModuleResolver = setResolver + backend.resetStateForTesting() + } + } + } + pairs.push({ + callbackFailure, + invoked, + bundlerDiagnostics, + native: errorEvidence(errors[0]), + wasm: errorEvidence(errors[1]), + nativeThrownIdentity: errors[0] === userCause, + wasmThrownIdentity: errors[1] === userCause, + nativeCauseIdentity: errors[0]?.cause === userCause, + wasmCauseIdentity: errors[1]?.cause === userCause, + }) + put( + destination, + JSON.stringify({ pairs, userCause: errorEvidence(userCause) }, null, 2), + ) + assertErrorParity(errors[0], errors[1]) + if (callbackFailure) { + for (const count of invoked) assert(count > 0) + } + } + return { pairs, userCause: errorEvidence(userCause) } + } + let result, server + const cleanup = async () => { + try { + try { + if (server) await server.close() + } finally { + try { + if (result) await result.close() + } finally { + closeBuild?.() + } + } + } catch (error) { + saveFailure(error) + throw error + } + } + try { + const rsbuild = await configure({ ...selection, singleCss, atomHoist: 0 }) + if (scenario === 'dev' || scenario === 'watch') { + const first = nextCompile() + if (scenario === 'dev') { + server = await rsbuild.createDevServer() + ;({ port } = await server.listen()) + generations.push(await first) + const cssAssets = Object.keys(generations[0].assets).filter((name) => + name.endsWith('.css'), + ) + for (const name of cssAssets) { + const response = await fetch(`http://127.0.0.1:${port}/${name}`, { + signal: AbortSignal.timeout(15000), + }) + assert.equal(response.status, 200) + assert.equal(await response.text(), generations[0].assets[name]) + } + } else { + result = await rsbuild.build({ watch: true }) + generations.push(await first) + } + for (const color of ['rebeccapurple', 'teal']) { + const source = `import { globalCss, css } from "@devup-ui/react"; globalCss({ body: { backgroundColor: "rebeccapurple" } }); export const layer = css({ borderColor: "${color}" });` + const before = compilationProofs.length + const revision = nextCompile(source) + put(join(directory, 'src/B.css.ts'), source) + const generation = await revision + generations.push(generation) + const suppressed = compilationProofs + .slice(before) + .filter( + (proof) => proof.source === source && proof.needAdditionalPass, + ) + assert.equal(suppressed.length, scenario === 'dev' ? 1 : 0) + for (const proof of suppressed) assert(proof.additionalPass) + if (scenario === 'dev') { + generation.http = {} + for (const [name, bytes] of Object.entries(generation.emitted)) { + if (!/\.(?:js|css)$/.test(name)) continue + const response = await fetch(`http://127.0.0.1:${port}/${name}`, { + signal: AbortSignal.timeout(15000), + }) + assert.equal(response.status, 200) + generation.http[name] = await response.text() + assert.equal(generation.http[name], bytes) + } + } + } + } else { + result = await rsbuild.build() + generations.push(snapshot(result.stats)) + } + assert.equal(owner.watchMode, scenario === 'dev' || scenario === 'watch') + } catch (error) { + saveFailure(error) + throw error + } finally { + await cleanup() + } + try { + if (hold) { + assert(layersReleased) + assert(additionalPasses > 0) + assert(deleted > 0) + } + if (scenario === 'late') { + create({ engine: selection.engine === 'wasm' ? 'native' : 'wasm' }) + const before = calls.getCss + const css = await callbacks[0]({ + resourcePath: join(directory, 'df/devup-ui/devup-ui.css'), + environment: { name: 'web' }, + }) + assert.equal(css, selected.getCss(null, true)) + assert(calls.getCss > before) + } + for (const name of [ + 'registerShorthands', + 'registerTheme', + 'reserveClassSlots', + 'setModuleResolver', + 'codeExtract', + 'getCss', + ]) + assert(calls[name] > 0, name) + const local = [] + for (const code of [ + 'export const plain = 1;', + readFileSync(sourcePath, 'utf8'), + ]) { + const dependencies = [] + const output = await callbacks[1]({ + code, + resourcePath: sourcePath, + addDependency: (file) => dependencies.push(file), + }) + local.push({ ...output, dependencies }) + } + assert.equal(owner.watchMode, false) + if (failure) throw failure + return { generations, local, additionalPasses, deleted, compilationProofs } + } catch (error) { + saveFailure(error) + throw error + } +} + +export function prepareBunFixture(directory, customShorthands = true) { + for (const name of ['src', 'df', 'dist']) + rmSync(join(directory, name), { recursive: true, force: true }) + put( + join(directory, 'devup.json'), + '{"theme":{"length":{"default":{"size":"17px"}}}}', + ) + put(join(directory, 'src/tokens.ts'), 'export const color="teal";') + for (const name of ['A', 'B']) + put( + join(directory, `src/${name}.ts`), + `import {css} from '@devup-ui/react'; import {color} from './tokens.ts'; export const cls=css({color,w:'$size',${customShorthands ? "insetX:'19px'" : "left:'19px',right:'19px'"},p:${name === 'A' ? 1 : 2}});`, + ) + put( + join(directory, 'src/Unused.ts'), + 'import {css} from "@devup-ui/react"; export const unused=css({color:"magenta"});', + ) +} + +async function consumeBun(context) { + const { + directory, + scenario, + selection, + create, + selected, + outputs, + calls, + utils, + plugin, + } = context + assert(process.versions.bun, 'Bun consumers require the real Bun runtime') + const Bun = await import('bun') + const options = { ...selection, shorthands: { insetX: ['left', 'right'] } } + const entries = ['A', 'B'].map((name) => join(directory, `src/${name}.ts`)) + const callbacks = new Map() + const phases = [] + const local = [] + const assertShorthands = (classes, css, file) => { + assert(classes?.length, file) + for (const property of ['left', 'right']) + assert( + classes.some((name) => + new RegExp(`\\.${name}\\s*\\{\\s*${property}:\\s*19px;?\\s*\\}`).test( + css, + ), + ), + `${file}: ${property}`, + ) + } + const compilerState = (compiler) => ({ + css: compiler.getCss(null, false), + sheet: compiler.exportSheet(), + classes: compiler.exportClassMap(), + files: compiler.exportFileMap(), + canonical: compiler.exportCanonicalMap(), + residual: compiler.getUnreservedClassSlotCount(), + prefix: compiler.getPrefix(), + debug: compiler.isDebug(), + theme: compiler.getDefaultTheme(), + }) + const snapshot = (assets, sources) => { + const state = compilerState(selected) + assert.equal(state.residual, 0) + assert(!state.css.includes('magenta')) + for (const [name, source] of Object.entries(sources)) { + assert(typeof source === 'string' && source.length > 0, name) + assert.doesNotMatch( + source, + /Cannot run on the runtime|@devup-ui\/(?:wasm|binding)/, + ) + } + const phase = { + state, + assets, + sources, + outputs: Object.fromEntries( + [...outputs].sort(([a], [b]) => a.localeCompare(b)), + ), + disk: { + df: tree(join(directory, 'df')), + dist: + scenario === 'runtime' || scenario === 'preload' + ? {} + : tree(join(directory, 'dist')), + }, + calls: { ...calls }, + } + phases.push(phase) + return phase + } + try { + if (scenario === 'runtime' || scenario === 'preload') { + if (scenario === 'runtime') await plugin.register(options) + const sources = {} + for (const file of entries) { + const module = await import(pathToFileURL(file).href) + assert(typeof module.cls === 'string' && module.cls.length > 0) + assertShorthands( + module.cls.split(/\s+/), + selected.getCss(null, false), + file, + ) + sources[file] = module.cls + } + const phase = snapshot({}, sources) + assert.equal( + readFileSync(join(directory, 'df/devup-ui/devup-ui.css'), 'utf8'), + phase.state.css, + ) + return { phases, local } + } + for (let round = 0; round < (scenario === 'repeat' ? 2 : 1); round++) { + outputs.clear() + for (const name of Object.keys(calls)) calls[name] = 0 + const pending = new Map() + const sources = {} + const first = entries[scenario === 'opposite' ? 1 : 0] + const second = entries.find((file) => file !== first) + const genuine = create(options) + const observed = { + ...genuine, + setup(builder) { + return genuine.setup( + new Proxy(builder, { + get(target, key) { + if (key === 'onLoad') + return (filter, callback) => { + callbacks.set(filter.namespace ?? 'file', callback) + return target.onLoad(filter, async (args) => { + if (filter.namespace || !entries.includes(args.path)) + return callback(args) + await new Promise((release) => { + assert(!pending.has(args.path)) + pending.set(args.path, release) + if (pending.size === 2) pending.get(first)() + }) + try { + const result = await callback(args) + assert(result && typeof result.contents === 'string') + sources[args.path] = result.contents + return result + } finally { + if (args.path === first) pending.get(second)() + } + }) + } + const value = Reflect.get(target, key, target) + return typeof value === 'function' ? value.bind(target) : value + }, + }), + ) + }, + } + const result = await Bun.build({ + root: directory, + entrypoints: entries, + target: 'browser', + format: 'esm', + sourcemap: 'external', + external: ['react', 'react/jsx-runtime'], + outdir: join(directory, 'dist'), + plugins: [observed], + }) + assert(result.success, result.logs.map(String).join('\n')) + assert.deepEqual(result.logs, []) + assert.equal(Object.keys(sources).length, 2) + const assets = {} + for (const output of result.outputs) + assets[relative(directory, output.path)] = await output.text() + const phase = snapshot(assets, sources) + const css = Object.entries(assets) + .filter(([name]) => name.endsWith('.css')) + .map(([, text]) => text) + .join('\n') + assert(css.includes('19px') && !css.includes('magenta')) + for (const file of entries) { + const classes = /\bcls\s*=\s*["']([^"']+)["']/ + .exec(sources[file])?.[1] + .split(/\s+/) + .filter(Boolean) + assertShorthands(classes, css, file) + } + if (round) assertRawParity(phases[0], phase) + } + if (scenario === 'late' || scenario === 'errors') { + const callback = callbacks.get('file') + assert(callback) + const args = { + path: entries[0], + namespace: 'file', + loader: 'file', + defer: async () => {}, + } + if (scenario === 'late') { + const other = utils.loadCompiler( + selection.engine === 'wasm' ? 'native' : 'wasm', + ) + const before = compilerState(other) + create({ engine: selection.engine === 'wasm' ? 'native' : 'wasm' }) + await callback(args) + const deferred = [...callbacks].find( + ([namespace]) => namespace !== 'file', + )?.[1] + assert(deferred) + assert.equal( + (await deferred(args)).contents, + selected.getCss(null, false), + ) + const after = compilerState(other) + local.push({ unselected: { before, after } }) + assertRawParity(before, after) + snapshot(phases[0].assets, phases[0].sources) + } else { + const originalSource = readFileSync(args.path, 'utf8') + try { + put( + args.path, + 'import {css} from "@devup-ui/react"; export const cls=css({color:window.name});', + ) + let parseError + try { + await callback(args) + } catch (error) { + parseError = error + } + local.push({ parseError: errorEvidence(parseError) }) + assert(parseError instanceof Error) + } finally { + put(args.path, originalSource) + } + const cause = new Error('compiler callback cause') + const failure = new Error('compiler callback failure', { cause }) + const extract = selected.codeExtract + try { + selected.codeExtract = () => { + throw failure + } + await assert.rejects( + () => callback(args), + (error) => error === failure && error.cause === cause, + ) + local.push({ + error: errorEvidence(failure), + thrownIdentity: true, + causeIdentity: true, + }) + } finally { + selected.codeExtract = extract + } + } + } + return { phases, local } + } finally { + Bun.plugin.clearAll() + } +} + +async function consumeNextLoaders({ + directory, + format, + selection, + singleCss, + selected, + utils, + outputs, + calls, + observations, +}) { + const require = createRequire(join(directory, 'package.json')) + const modules = [] + for (const name of ['loader', 'css-loader']) { + const specifier = `@devup-ui/next-plugin/${name}` + const entry = + format === 'require' + ? require.resolve(specifier) + : fileURLToPath(import.meta.resolve(specifier)) + const root = dirname(dirname(realpathSync(entry))) + const manifest = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf8'), + ) + assert.equal( + realpathSync(entry), + realpathSync(join(root, manifest.exports[`./${name}`][format])), + ) + modules.push( + format === 'require' ? require(specifier) : await import(specifier), + ) + } + const [sourceModule, cssModule] = modules + const sourceLoader = sourceModule.default ?? sourceModule + const cssLoader = cssModule.default ?? cssModule + const other = utils.loadCompiler( + selection.engine === 'wasm' ? 'native' : 'wasm', + ) + other.resetStateForTesting() + const state = (compiler) => ({ + css: compiler.getCss(null, false), + sheet: compiler.exportSheet(), + classes: compiler.exportClassMap(), + files: compiler.exportFileMap(), + canonical: compiler.exportCanonicalMap(), + residual: compiler.getUnreservedClassSlotCount(), + prefix: compiler.getPrefix(), + debug: compiler.isDebug(), + theme: compiler.getDefaultTheme(), + }) + const unselectedBefore = state(other) + selected.registerShorthands({ insetX: ['left', 'right'] }) + const cssDir = join(directory, 'direct-css') + mkdirSync(cssDir, { recursive: true }) + put(join(directory, 'src/tokens.js'), "export const tone = 'teal';") + const resourcePath = join(directory, 'src/direct.jsx') + const source = + "import {css} from '@devup-ui/react'; import {tone} from './tokens.js'; export const cls=css({color:tone,insetX:'19px',w:'$size',p:'1px'});" + put(resourcePath, source) + const options = { + ...selection, + package: '@devup-ui/react', + cssDir, + singleCss, + watch: false, + sheetFile: join(cssDir, 'sheet.json'), + classMapFile: join(cssDir, 'classMap.json'), + fileMapFile: join(cssDir, 'fileMap.json'), + themeFile: join(directory, 'direct-devup.json'), + defaultSheet: JSON.parse(selected.exportSheet()), + defaultClassMap: {}, + defaultFileMap: {}, + theme: { length: { default: { size: ['17px'] } } }, + } + const dependencies = [] + const sourceResult = await new Promise((resolve, reject) => { + sourceLoader.call( + { + resourcePath, + getOptions: () => options, + addDependency: (path) => dependencies.push(path), + async: () => (error, code, map) => + error ? reject(error) : resolve({ code, map }), + }, + Buffer.from(source), + ) + // The asynchronous callback must retain the compiler selected on entry. + options.engine = selection.engine === 'wasm' ? 'native' : 'wasm' + }) + options.engine = selection.engine + assert.doesNotMatch(sourceResult.code, /Cannot run on the runtime/) + assert(sourceResult.map && JSON.parse(sourceResult.map).version === 3) + assert(dependencies.includes(join(directory, 'src/tokens.js'))) + const extraction = outputs.get('src/direct.jsx') + assert(extraction) + assert.equal(sourceResult.code, extraction.code) + assert.equal(sourceResult.map, extraction.map) + const fileNum = JSON.parse(selected.exportFileMap())['src/direct.jsx'] + // Global single-CSS extraction need not allocate a per-file bucket number. + if (!singleCss) assert(Number.isInteger(fileNum)) + assert.equal(typeof extraction.cssFile, 'string') + const cssPath = resolve(dirname(resourcePath), extraction.cssFile) + assert.equal( + cssPath, + join(cssDir, singleCss ? 'devup-ui.css' : `devup-ui-${fileNum}.css`), + ) + const requestedFileNum = utils.getFileNumByFilename(cssPath) + assert.equal(requestedFileNum, singleCss ? null : fileNum) + const liveCss = selected.getCss(requestedFileNum, true) + const mainCss = selected.getCss(null, false) + for (const rule of [ + 'color:teal', + 'left:19px', + 'right:19px', + '17px', + 'padding:1px', + ]) + assert( + `${mainCss}\n${liveCss}`.includes(rule), + `Missing actual fallback CSS ${rule}`, + ) + const cssCalls = [] + const cssMap = { version: 3, sources: [], names: [], mappings: '' } + const meta = { token: 'next-production-map-meta' } + const productionSource = Buffer.from(liveCss) + const invokeCss = (watch, callback, source = productionSource) => + cssLoader.call( + { + resourcePath: cssPath, + getOptions: () => ({ ...options, watch }), + callback, + }, + source, + cssMap, + meta, + ) + invokeCss(true, (error, css) => { + assert.equal(error, null) + assert.equal(css, liveCss) + cssCalls.push({ css }) + }) + invokeCss(false, (error, css, map, value) => { + assert.equal(error, null) + assert.equal(css, productionSource) + assert.equal(map, cssMap) + assert.equal(value, meta) + cssCalls.push({ css: css.toString(), map, meta: value }) + }) + const local = [] + const cause = new Error('Next fallback compiler cause') + const failure = new Error('Next fallback compiler failure', { cause }) + assert([undefined, 'authored-errors'].includes(process.argv[8])) + const authoredErrors = process.argv[8] === 'authored-errors' + if (authoredErrors) { + // Author the supplied fixture, never a projection of a received error. + // Ordinary realm-created Errors remain covered by the original 40 cells. + for (const error of [cause, failure]) { + for (const key of Reflect.ownKeys(error)) + assert(['message', 'cause', ...runtimeErrorKeys].includes(key)) + for (const key of runtimeErrorKeys) delete error[key] + Object.defineProperties(error, { + stack: { + value: `${error.name}: ${error.message}\n at nextLoaderFixture (next-loader-fixture:1:1)`, + configurable: true, + writable: true, + }, + code: { + value: 'NEXT_SUPPLIED_ERROR', + configurable: true, + enumerable: true, + }, + detail: { + value: { filename: 'src/direct.jsx' }, + configurable: true, + enumerable: true, + }, + }) + assert(error instanceof Error) + assert.equal(error.constructor, Error) + assert.equal(Object.getPrototypeOf(error), Error.prototype) + } + Object.defineProperty(failure, 'causeAlias', { + value: cause, + enumerable: true, + }) + } + // Materialize runtime-owned lazy stack fields before the immutable baseline. + errorEvidence(failure) + const inputError = errorEvidence(failure), + inputCause = errorEvidence(cause) + const unchangedError = (after) => assert.deepEqual(after, inputError) + const errorControls = [] + if (authoredErrors) { + const property = (node, key) => + node.properties.find((entry) => entry.key.value === key) + for (const [name, mutate] of [ + [ + 'constructor', + (node) => { + node.constructor.value += '-changed' + }, + ], + [ + 'prototype', + (node) => { + node.prototypes[0].value += '-changed' + }, + ], + [ + 'message', + (node) => { + property(node, 'message').value.value += '-changed' + }, + ], + [ + 'cause', + (node) => { + property(property(node, 'cause').value, 'message').value.value += + '-changed' + }, + ], + [ + 'unknown own property', + (node) => { + node.properties.push({ + ...structuredClone(property(node, 'code')), + key: { type: 'string', value: 'unknown' }, + }) + }, + ], + [ + 'authored descriptor', + (node) => { + property(node, 'code').writable = true + }, + ], + [ + 'cause alias', + (node) => { + property(node, 'causeAlias').value.reference = 999 + }, + ], + [ + 'authored raw stack', + (node) => { + property(node, 'stack').value.value += '-changed' + }, + ], + ]) { + const changed = structuredClone(inputError) + mutate(changed.raw) + assert.throws(() => unchangedError(changed), undefined, name) + if (name !== 'authored raw stack') + assert.throws( + () => assertRawParity(inputError, changed), + undefined, + name, + ) + errorControls.push(name) + } + } + for (const name of ['codeExtract', 'getCss']) { + const original = selected[name] + let received + try { + selected[name] = () => { + throw failure + } + if (name === 'codeExtract') { + received = await new Promise((resolve) => + sourceLoader.call( + { + resourcePath, + getOptions: () => options, + addDependency() {}, + async: () => resolve, + }, + Buffer.from(source), + ), + ) + } else + invokeCss(true, (error) => { + received = error + }) + assert.equal(received, failure) + assert.equal(received.cause, cause) + const error = errorEvidence(received) + unchangedError(error) + assert.deepEqual(errorEvidence(received.cause), inputCause) + local.push({ + method: name, + inputError, + inputCause, + error, + thrownIdentity: true, + causeIdentity: true, + }) + } finally { + selected[name] = original + } + } + let callbackCount = 0 + assert.throws( + () => + invokeCss(false, () => { + callbackCount++ + throw failure + }), + (error) => error === failure, + ) + assert.equal(callbackCount, 1) + const selectedState = state(selected) + assert.equal(selectedState.residual, 0) + assert( + selectedState.css !== unselectedBefore.css || + selectedState.sheet !== unselectedBefore.sheet, + ) + const unselectedAfter = state(other) + assert.deepEqual(unselectedAfter, unselectedBefore) + assert(calls.codeExtract > 0 && calls.getCss > 0) + return { + sourceResult, + dependencies, + mainCss, + liveCss, + cssCalls, + local, + errorFixture: authoredErrors ? 'authored' : 'ordinary', + errorControls, + callbackCount, + state: selectedState, + unselectedBefore, + unselectedAfter, + outputs: Object.fromEntries(outputs), + observations, + calls, + disk: tree(cssDir), + } +} + +let nextCompilerCapture + +export async function captureNextCoordinator({ + directory, + mode, + singleCss, + destination, +}) { + const require = createRequire(join(directory, 'package.json')) + const fromNext = createRequire( + realpathSync(require.resolve('@devup-ui/next-plugin')), + ) + const utils = fromNext('@devup-ui/plugin-utils') + const compiler = utils.loadCompiler(mode === 'default' ? undefined : mode) + const portFile = readFileSync(join(directory, 'df/coordinator.port'), 'utf8') + const [port, pid] = portFile.trim().split(/\r?\n/).map(Number) + assert(Number.isInteger(port) && port > 0 && port < 65536) + assert.equal(pid, process.pid) + assert.equal(typeof singleCss, 'boolean') + assert.equal( + nextCompilerCapture?.selected, + compiler, + 'HTTP witness must use the same physical compiler as the actual CLI capture', + ) + const resources = ['src/A.jsx', 'src/B.jsx'].map((file) => { + const record = nextCompilerCapture.outputs.findLast( + ({ input }) => input[0] === file, + ) + assert(record, `Missing actual CLI extraction for ${file}`) + assert.equal(typeof record.output.cssFile, 'string') + const resourcePath = resolve( + dirname(join(directory, file)), + record.output.cssFile, + ) + assert.equal(dirname(resourcePath), join(directory, 'df/devup-ui')) + const fileNum = utils.getFileNumByFilename(resourcePath) + if (singleCss) { + assert.equal(fileNum, null) + assert.equal(resourcePath, join(directory, 'df/devup-ui/devup-ui.css')) + } else { + assert(Number.isInteger(fileNum) && fileNum >= 0) + assert.equal( + fileNum, + JSON.parse(compiler.exportFileMap())['src/app/page.jsx'], + ) + } + return { file, cssFile: record.output.cssFile, resourcePath, fileNum } + }) + assert.equal(resources[0].resourcePath, resources[1].resourcePath) + const fileNum = resources[0].fileNum + const requests = [] + for (const [number, importMainCss] of [ + [null, false], + [fileNum, true], + ]) { + const query = new URLSearchParams({ + importMainCss: String(importMainCss), + waitForIdle: 'true', + }) + if (number !== null) query.set('fileNum', String(number)) + const url = `http://127.0.0.1:${port}/css?${query}` + const response = await fetch(url, { signal: AbortSignal.timeout(90000) }) + const body = Buffer.from(await response.arrayBuffer()) + const liveCss = compiler.getCss(number, importMainCss) + assert.equal(response.status, 200) + assert.equal(response.headers.get('content-type'), 'text/css') + assert.deepEqual(body, Buffer.from(liveCss)) + requests.push({ + fileNum: number, + importMainCss, + url, + status: response.status, + headers: Object.fromEntries(response.headers), + body: body.toString('base64'), + liveCss, + }) + } + writeFileSync( + destination, + JSON.stringify({ portFile, pid, singleCss, resources, requests }, null, 2), + { flag: 'wx' }, + ) +} + +export async function captureNextCompiler({ directory, mode, destination }) { + const require = createRequire(join(directory, 'package.json')) + if ( + !require('node:worker_threads').isMainThread || + !/[\\/]next[\\/]dist[\\/]bin[\\/]next(?:\.js)?$/.test(process.argv[1] ?? '') + ) + return + const nextEntry = realpathSync(require.resolve('@devup-ui/next-plugin')) + const nextRequire = createRequire(nextEntry) + const webpackEntry = realpathSync( + nextRequire.resolve('@devup-ui/webpack-plugin'), + ) + const webpackRequire = createRequire(webpackEntry) + const nextUtils = realpathSync(nextRequire.resolve('@devup-ui/plugin-utils')) + const webpackUtils = realpathSync( + webpackRequire.resolve('@devup-ui/plugin-utils'), + ) + const fromUtils = createRequire(nextUtils) + const anchors = { + nextEntry, + webpackEntry, + nextUtils, + webpackUtils, + bindingEntry: realpathSync(fromUtils.resolve('@devup-ui/binding')), + wasmEntry: realpathSync(fromUtils.resolve('@devup-ui/wasm')), + } + writeFileSync(`${destination}.anchors.json`, JSON.stringify(anchors, null, 2)) + assert.equal( + nextUtils, + webpackUtils, + 'Capture must wrap the delegated compiler instance', + ) + const utils = nextRequire('@devup-ui/plugin-utils') + const selected = utils.loadCompiler(mode === 'default' ? undefined : mode) + const other = utils.loadCompiler(mode === 'wasm' ? 'native' : 'wasm') + selected.resetStateForTesting() + other.resetStateForTesting() + const state = (compiler) => ({ + css: compiler.getCss(null, false), + sheet: compiler.exportSheet(), + classes: compiler.exportClassMap(), + files: compiler.exportFileMap(), + canonical: compiler.exportCanonicalMap(), + residual: compiler.getUnreservedClassSlotCount(), + prefix: compiler.getPrefix(), + debug: compiler.isDebug(), + theme: compiler.getDefaultTheme(), + }) + const unselectedBefore = state(other), + calls = {}, + outputs = [] + nextCompilerCapture = { selected, outputs } + for (const name of [ + 'registerShorthands', + 'registerTheme', + 'setModuleResolver', + 'reserveClassSlots', + 'codeExtract', + 'codeExtractWithoutSourceMap', + 'getCss', + 'exportSheet', + 'exportClassMap', + 'exportFileMap', + 'importCanonicalMap', + 'importFileRoutes', + 'setPrefix', + 'setDebug', + 'setAtomHoist', + ]) { + const original = selected[name] + calls[name] = 0 + selected[name] = (...args) => { + calls[name]++ + const result = original(...args) + if (name.startsWith('codeExtract')) + outputs.push({ + input: args.slice(0, 2), + output: { + code: result.code, + css: result.css, + cssFile: result.cssFile, + map: result.map, + dependencies: result.dependencies, + updatedBaseStyle: result.updatedBaseStyle, + }, + }) + return result + } + } + process.once('exit', (exitCode) => { + const compiler = { + anchors, + state: state(selected), + fileCss: Object.fromEntries( + Object.values(JSON.parse(selected.exportFileMap())).map((number) => [ + number, + selected.getCss(number, true), + ]), + ), + outputs, + calls, + unselectedBefore, + unselectedAfter: state(other), + pid: process.pid, + exitCode, + } + writeFileSync( + destination, + JSON.stringify( + compiler, + (_, value) => (value === undefined ? { undefined: true } : value), + 2, + ), + ) + }) +} + +export async function consume({ + directory, + scenario, + mode, + format, + singleCss, + destination, + adapter = 'webpack', +}) { + const require = createRequire(join(directory, 'package.json')) + const selection = mode === 'default' ? {} : { engine: mode } + const resolvedEngine = mode === 'wasm' ? 'wasm' : 'native' + const packageName = `@devup-ui/${adapter}-plugin${adapter === 'bun' ? '/register' : ''}` + const entry = + format === 'require' + ? require.resolve(packageName) + : fileURLToPath(import.meta.resolve(packageName)) + const physical = + adapter === 'vite' || adapter === 'bun' ? viteRealpath : realpathSync + const utilsRequire = createRequire( + adapter === 'vite' ? physical(entry) : entry, + ) + const utils = utilsRequire('@devup-ui/plugin-utils') + const utilsRoot = dirname( + dirname(utilsRequire.resolve('@devup-ui/plugin-utils')), + ) + const utilsManifest = JSON.parse( + readFileSync(join(utilsRoot, 'package.json'), 'utf8'), + ) + const fromEsm = await import( + pathToFileURL(join(utilsRoot, utilsManifest.exports['.'].import)).href + ) + const entryUtils = format === 'import' ? fromEsm : utils + const pluginRoot = physical(dirname(dirname(entry))) + const assertDist = + adapter === 'bun' + ? assertBunDist + : adapter === 'rsbuild' || adapter === 'vite' + ? assertRsbuildDist + : assertWebpackDist + assertDist(join(pluginRoot, 'dist')) + const manifest = JSON.parse( + readFileSync(join(pluginRoot, 'package.json'), 'utf8'), + ) + assert.equal( + physical(entry), + physical( + join( + pluginRoot, + manifest.exports[adapter === 'bun' ? './register' : '.'][format], + ), + ), + ) + assert( + !Object.values(manifest.dependencies).some((version) => + version.startsWith('workspace:'), + ), + ) + const load = Module._resolveFilename + const denied = new Error('unselected-backend-resolution') + let nativeFailure = false + let nextGuardWasmResolutions = 0 + Module._resolveFilename = function (request, ...args) { + if ( + nativeFailure && + adapter === 'next' && + (request === '@devup-ui/wasm' || request.startsWith('@devup-ui/wasm/')) + ) { + nextGuardWasmResolutions++ + throw denied + } + if (scenario !== 'late' && scenario !== 'errors' && adapter !== 'next') { + const blocked = + resolvedEngine === 'wasm' ? '@devup-ui/binding' : '@devup-ui/wasm' + if (request === blocked || request.startsWith(`${blocked}/`)) { + throw denied + } + } + if ( + nativeFailure && + (request === '@devup-ui/binding' || + request.startsWith('@devup-ui/binding-')) + ) { + throw denied + } + return load.call(this, request, ...args) + } + const previousCwd = process.cwd() + const now = Date.now + const originals = new Map() + let compiler + let watching + try { + process.chdir(directory) + if (adapter === 'webpack') Date.now = () => 1700000000000 + const plugin = + format === 'require' ? require(packageName) : await import(packageName) + const create = (options) => + adapter === 'next' + ? plugin.DevupUI({}, options) + : adapter === 'rsbuild' || adapter === 'vite' || adapter === 'bun' + ? plugin.DevupUI(options) + : new plugin.DevupUIWebpackPlugin(options) + if (scenario === 'errors' && adapter === 'webpack') { + const sourceModule = + format === 'import' + ? await import('@devup-ui/webpack-plugin/loader') + : require('@devup-ui/webpack-plugin/loader') + const sourceLoader = sourceModule.default ?? sourceModule + const userCause = Object.assign( + new Error('user resolver callback failure', { + cause: new Error('nested user resolver cause'), + }), + { code: 'user-resolver-code' }, + ) + Object.defineProperty(userCause, 'hidden', { + value: 'user resolver detail', + }) + const pairs = [] + const live = [] + for (const callbackFailure of [false, true]) { + const received = [] + const invoked = [] + for (const engine of ['native', 'wasm']) { + const selected = utils.loadCompiler(engine) + selected.resetStateForTesting() + const setResolver = selected.setModuleResolver + let callbackCalls = 0 + try { + if (callbackFailure) { + selected.setModuleResolver = () => + setResolver(() => { + callbackCalls++ + throw userCause + }) + } + const error = await new Promise((resolve) => { + sourceLoader.call( + { + resourcePath: join(directory, 'src/error.tsx'), + getOptions: () => ({ + engine, + package: '@devup-ui/react', + cssDir: join(directory, 'df/devup-ui'), + singleCss, + watch: false, + }), + addDependency() {}, + async: () => (error) => resolve(error), + }, + Buffer.from( + callbackFailure + ? 'import { css } from "@devup-ui/react"; import { color } from "./throws.js"; export const x = css({ color });' + : 'import { Box } from "@devup-ui/react"; export const x = ;', + ), + ) + }) + received.push(error) + invoked.push(callbackCalls) + } finally { + selected.setModuleResolver = setResolver + selected.resetStateForTesting() + } + } + pairs.push({ + callbackFailure, + invoked, + native: errorEvidence(received[0]), + wasm: errorEvidence(received[1]), + nativeThrownIdentity: received[0] === userCause, + wasmThrownIdentity: received[1] === userCause, + nativeCauseIdentity: received[0]?.cause === userCause, + wasmCauseIdentity: received[1]?.cause === userCause, + }) + live.push({ callbackFailure, invoked, received }) + } + const evidence = { pairs, userCause: errorEvidence(userCause) } + // Save all fields before a native-only property or lost-cause assertion. + put(destination, JSON.stringify(evidence, null, 2)) + for (const { callbackFailure, invoked, received } of live) { + for (const error of received) assert(error instanceof Error) + assertErrorParity(received[0], received[1]) + if (callbackFailure) { + for (const calls of invoked) assert(calls > 0) + for (const error of received) { + assert.notEqual(error, userCause) + assert.equal(Object.hasOwn(error, 'cause'), false) + assert.equal(Object.hasOwn(error, 'code'), false) + assert.equal(Object.hasOwn(error, 'hidden'), false) + assert.match(error.message, /user resolver callback failure/) + } + } + } + return evidence + } + if (scenario === 'guards' && adapter === 'next') { + nativeFailure = true + const sourceModule = + format === 'require' + ? require('@devup-ui/next-plugin/loader') + : await import('@devup-ui/next-plugin/loader') + const loader = sourceModule.default ?? sourceModule + const diagnostics = [] + for (const engine of ['invalid', undefined, 'native']) { + const error = await new Promise((resolve) => + loader.call( + { + resourcePath: join(directory, 'src/A.jsx'), + getOptions: () => ({ engine }), + async: () => resolve, + }, + Buffer.from('export const x = 1;'), + ), + ) + diagnostics.push(errorEvidence(error)) + put(destination, JSON.stringify({ diagnostics }, null, 2)) + if (engine === 'invalid') assert.equal(error.name, 'InvalidEngineError') + else { + assert.equal(error.name, 'NativeCompilerLoadError') + assert.equal(error.cause, denied) + assert.equal( + error.platformPackage, + JSON.parse( + readFileSync(join(directory, '..', 'provenance.json'), 'utf8'), + ).host.name, + ) + } + } + assert.equal( + nextGuardWasmResolutions, + 0, + 'Native failure must never attempt WASM fallback', + ) + return { guards: true, diagnostics, denied: errorEvidence(denied) } + } + if (scenario === 'guards') { + nativeFailure = true + const diagnostics = [] + const capture = (action) => { + try { + action() + } catch (error) { + diagnostics.push(errorEvidence(error)) + put(destination, JSON.stringify({ diagnostics }, null, 2)) + throw error + } + } + assert.throws( + () => + capture(() => + create({ + shorthands: { bad: ['backgroundColour'] }, + }), + ), + entryUtils.ShorthandConfigError, + ) + assert.throws( + () => + capture(() => + create({ + engine: 'invalid', + }), + ), + { name: 'InvalidEngineError' }, + ) + assert.throws( + () => + capture(() => + create({ + engine: 'native', + }), + ), + (error) => { + assert.equal(error.name, 'NativeCompilerLoadError') + assert.equal(error.cause, denied) + assert.match(error.platformPackage, /^@devup-ui\/binding-/) + assert(error.message.includes(error.platformPackage)) + assert(error.message.includes("engine: 'wasm'")) + return true + }, + ) + const instance = create({ engine: 'wasm' }) + if (adapter === 'vite') await instance[0].closeBundle() + return { guards: true, diagnostics, denied: errorEvidence(denied) } + } + if (adapter === 'vite' && scenario === 'errors') + return await viteErrors({ + directory, + create, + singleCss, + utils, + destination, + }) + const selected = utils.loadCompiler(selection.engine) + assert.equal(fromEsm.loadCompiler(selection.engine), selected) + if (adapter !== 'bun' || scenario !== 'preload') + selected.resetStateForTesting() + const calls = {} + const outputs = new Map() + const errors = [] + const observations = [] + for (const name of [ + 'registerShorthands', + 'registerTheme', + 'setModuleResolver', + 'reserveClassSlots', + 'codeExtract', + 'getCss', + 'exportSheet', + 'exportClassMap', + 'exportFileMap', + ...(adapter === 'vite' || adapter === 'next' + ? [ + 'exportCanonicalMap', + 'seedFileMap', + 'setDebug', + 'setPrefix', + 'importCanonicalMap', + 'importFileRoutes', + 'setAtomHoist', + ] + : []), + ]) { + const method = selected[name] + originals.set(name, method) + calls[name] = 0 + selected[name] = (...args) => { + calls[name]++ + const scratch = + adapter === 'vite' && name === 'reserveClassSlots' + ? selected.exportSheet() + : undefined + const result = method(...args) + if (adapter === 'vite' && name === 'reserveClassSlots') { + observations.push({ + reservation: args, + sheetBefore: scratch, + sheetAfter: selected.exportSheet(), + }) + assert.equal(selected.exportSheet(), scratch) + } + if (name === 'codeExtract') { + outputs.set(args[0], { + code: result.code, + css: result.css, + cssFile: result.cssFile, + map: result.map, + dependencies: result.dependencies, + updatedBaseStyle: result.updatedBaseStyle, + }) + if (adapter === 'vite' || adapter === 'next') + observations.push({ + input: args.slice(0, 2), + output: outputs.get(args[0]), + }) + } + return result + } + } + if (adapter === 'next') + return await consumeNextLoaders({ + directory, + format, + selection, + singleCss, + selected, + utils, + outputs, + calls, + observations, + }) + if (adapter === 'vite') + return await consumeVite({ + directory, + scenario, + selection, + singleCss, + destination, + create, + selected, + outputs, + calls, + utils, + observations, + }) + if (adapter === 'bun') + return await consumeBun({ + directory, + scenario, + selection, + create, + selected, + outputs, + calls, + utils, + plugin, + }) + rmSync(join(directory, 'df'), { recursive: true, force: true }) + rmSync(join(directory, 'dist'), { recursive: true, force: true }) + put( + join(directory, 'devup.json'), + JSON.stringify({ + theme: { colors: { default: { primary: '#17395b' } } }, + }), + ) + put(join(directory, 'src/tokens.js'), 'export const color = "maroon";') + put( + join(directory, 'src/base.css.ts'), + 'import { css } from "@devup-ui/react"; export const base = css({ color: "navy" });', + ) + const layer = (color) => + `import { globalCss, css } from '@devup-ui/react'; globalCss({ body: { backgroundColor: '${color}' } }); export const layer = css({ borderColor: '${color}' });` + put(join(directory, 'src/A.css.ts'), layer('cyan')) + put(join(directory, 'src/B.css.ts'), layer('goldenrod')) + put( + join(directory, 'src/entry.js'), + 'import { css, keyframes } from "@devup-ui/react"; import { color } from "./tokens.js"; import { base } from "./base.css.ts"; import { layer as a } from "./A.css.ts"; import { layer as b } from "./B.css.ts"; const spin = keyframes({ from: { opacity: 0 }, to: { opacity: 1 } }); export const cls = css({ color, p: [1, null, 3], animation: `${spin} 1s linear` }); console.log(cls, base, a, b);', + ) + if (adapter === 'rsbuild') { + return await consumeRsbuild({ + directory, + scenario, + selection, + singleCss, + destination, + require, + create, + selected, + outputs, + calls, + utils, + }) + } + const { webpack } = require('webpack') + const gateFile = join(directory, 'hold.cjs') + put(gateFile, initialLayerGate) + const watch = scenario === 'watch' + const holdLayers = watch || scenario === 'additional' + const instance = new plugin.DevupUIWebpackPlugin({ + ...selection, + watch, + singleCss, + atomHoist: 0, + ...(scenario === 'additional' ? { productionPrewarmFiles: [] } : {}), + }) + compiler = webpack({ + mode: 'production', + context: directory, + cache: false, + devtool: false, + entry: './src/entry.js', + output: { + path: join(directory, 'dist'), + filename: 'main.js', + clean: true, + }, + optimization: { + minimize: false, + concatenateModules: false, + usedExports: false, + }, + resolve: { extensions: ['.js', '.ts'] }, + experiments: { css: true }, + module: { rules: [{ test: /\.css$/, type: 'css' }] }, + plugins: [ + instance, + ...(holdLayers + ? [ + { + apply(owner) { + owner.options.module.rules.push({ + test: /\.css\.ts$/, + enforce: 'pre', + use: [gateFile], + }) + }, + }, + ] + : []), + ], + }) + assert(compiler) + const rules = compiler.options.module.rules + .flatMap((rule) => rule.use || []) + .filter((use) => use.options?.engine) + assert.equal(rules.length, 2) + for (const [index, subpath] of ['./loader', './css-loader'].entries()) { + assert.equal(rules[index].options.engine, resolvedEngine) + assert.equal( + realpathSync(rules[index].loader), + realpathSync(join(pluginRoot, manifest.exports[subpath].require)), + ) + const fromLoader = createRequire(rules[index].loader)( + '@devup-ui/plugin-utils', + ).loadCompiler(selection.engine) + assert.equal(fromLoader, selected) + } + if (scenario === 'wrong-loader') { + rules[0].options.engine = resolvedEngine === 'native' ? 'wasm' : 'native' + const stats = await new Promise((resolve, reject) => + compiler.run((error, stats) => + error ? reject(error) : resolve(stats), + ), + ) + const diagnostics = stats.compilation.errors.map((error) => + errorEvidence(error.error ?? error), + ) + put(destination, JSON.stringify({ diagnostics }, null, 2)) + assert(stats.hasErrors()) + assert.match( + stats.toString({ all: false, errors: true }), + /unselected-backend-resolution/, + ) + return { wrongLoaderRejected: true, diagnostics } + } + if (scenario === 'late') { + const done = compiler.hooks.done.taps.find( + ({ name }) => name === 'DevupUIWebpackPlugin', + ) + assert(done) + await new Promise((resolve, reject) => + compiler.close((error) => (error ? reject(error) : resolve())), + ) + compiler = undefined + const other = resolvedEngine === 'native' ? 'wasm' : 'native' + new plugin.DevupUIWebpackPlugin({ engine: other }) + const before = calls.registerTheme + instance.writeDataFiles() + assert.equal(calls.registerTheme, before + 1) + const cssCalls = calls.getCss + await done.fn({ compilation: {}, hasErrors: () => false }) + assert(calls.getCss > cssCalls) + assert.equal(utils.loadCompiler(selection.engine), selected) + return { late: true } + } + let additionalPasses = 0 + compiler.hooks.additionalPass.tap('EngineProof', () => { + additionalPasses++ + }) + compiler.hooks.thisCompilation.tap('EngineProof', (compilation) => { + compilation.hooks.succeedModule.tap('EngineProof', (module) => { + if (holdLayers && module.resource?.endsWith('.css')) { + require(gateFile).release() + } + }) + }) + const snapshot = (stats) => { + if (stats.hasErrors()) { + put( + destination, + JSON.stringify( + { + diagnostics: stats.compilation.errors.map((error) => + errorEvidence(error.error ?? error), + ), + }, + null, + 2, + ), + ) + } + assert(!stats.hasErrors(), stats.toString({ all: false, errors: true })) + const files = selected.exportFileMap() + const css = [null, ...new Set(Object.values(JSON.parse(files)))].map( + (number) => [number, selected.getCss(number, true)], + ) + const served = [ + ...(globalThis.__devupUiServedCss?.get(stats.compilation) ?? []), + ].sort(([a], [b]) => a.localeCompare(b)) + for (const [path, value] of served) { + assert.equal( + value, + selected.getCss(utils.getFileNumByFilename(path), true), + ) + } + assert(served.length > 0) + const assets = tree(join(directory, 'dist')) + for (const [path, content] of Object.entries(assets)) { + if (path.endsWith('.js')) { + assert.doesNotMatch( + content, + /Cannot run on the runtime|@devup-ui\/(?:binding|wasm)/, + ) + } + } + const shippedCss = Object.entries(assets) + .filter(([path]) => path.endsWith('.css')) + .map(([, content]) => content) + .join('\n') + assert.match(shippedCss, /cyan/) + assert.match(shippedCss, /goldenrod|rebeccapurple/) + return { + outputs: Object.fromEntries( + [...outputs].sort(([a], [b]) => a.localeCompare(b)), + ), + css, + served, + sheet: selected.exportSheet(), + classes: selected.exportClassMap(), + files, + canonical: selected.exportCanonicalMap(), + assets, + disk: tree(join(directory, 'df')), + residual: selected.getUnreservedClassSlotCount(), + } + } + const generations = [] + if (watch) { + await new Promise((resolve, reject) => { + let revision = 0 + watching = compiler.watch( + { + aggregateTimeout: 0, + ignored: /[\\/](?:df|dist)[\\/]/, + }, + (error, stats) => { + if (error) { + reject(error) + return + } + try { + generations.push(snapshot(stats)) + if (revision++ === 0) { + watching.suspend() + put(join(directory, 'src/B.css.ts'), layer('rebeccapurple')) + watching.invalidate() + watching.resume() + } else { + resolve() + } + } catch (error) { + reject(error) + } + }, + ) + }) + await new Promise((resolve, reject) => + watching.close((error) => (error ? reject(error) : resolve())), + ) + watching = undefined + } else { + const stats = await new Promise((resolve, reject) => + compiler.run((error, stats) => + error ? reject(error) : resolve(stats), + ), + ) + generations.push(snapshot(stats)) + } + for (const name of [ + 'registerShorthands', + 'registerTheme', + 'reserveClassSlots', + 'setModuleResolver', + 'codeExtract', + 'getCss', + 'exportSheet', + 'exportClassMap', + 'exportFileMap', + ]) { + assert(calls[name] > 0, name) + } + const sourceModule = + format === 'import' + ? await import('@devup-ui/webpack-plugin/loader') + : require('@devup-ui/webpack-plugin/loader') + const cssModule = + format === 'import' + ? await import('@devup-ui/webpack-plugin/css-loader') + : require('@devup-ui/webpack-plugin/css-loader') + const sourceLoader = sourceModule.default ?? sourceModule + const cssLoader = cssModule.default ?? cssModule + const local = [] + const sourcePath = join(directory, 'src/entry.js') + for (const source of [ + 'export const plain = 1;', + readFileSync(sourcePath, 'utf8'), + 'import { Box } from "@devup-ui/react"; export const x = ;', + ]) { + await new Promise((resolve) => + sourceLoader.call( + { + resourcePath: sourcePath, + getOptions: () => rules[0].options, + addDependency() {}, + async: () => (error, code, map) => { + if (error) { + const evidence = errorEvidence(error) + errors.push(evidence) + local.push({ error: evidence }) + resolve() + return + } + local.push({ code, map }) + resolve() + }, + }, + Buffer.from(source), + ), + ) + } + assert.equal(errors.length, 1) + const cssPath = join(directory, 'df/devup-ui/devup-ui.css') + const cssCallbacks = [] + cssLoader.call( + { + resourcePath: cssPath, + getOptions: () => rules[1].options, + cacheable(value) { + assert.equal(value, false) + }, + callback(error, css, map, meta) { + assert.equal(error, null) + cssCallbacks.push({ css, map, meta }) + }, + }, + Buffer.from(''), + { + version: 3, + sources: [], + names: [], + mappings: '', + }, + { token: 'meta' }, + ) + assert.equal(cssCallbacks[0].css, selected.getCss(null, true)) + if (scenario === 'additional') assert(additionalPasses > 0) + return { generations, local, cssCallbacks, errors, additionalPasses } + } finally { + try { + try { + if (watching) { + await new Promise((resolve, reject) => + watching.close((error) => (error ? reject(error) : resolve())), + ) + } + } finally { + if (compiler) { + await new Promise((resolve, reject) => + compiler.close((error) => (error ? reject(error) : resolve())), + ) + } + } + } finally { + Module._resolveFilename = load + Date.now = now + process.chdir(previousCwd) + if (originals.size) { + const selected = utils.loadCompiler(selection.engine) + for (const [name, method] of originals) { + selected[name] = method + } + selected.resetStateForTesting() + } + } + } +} + +if ( + process.argv[1] && + (['vite', 'bun'].includes(process.argv[7]) ? viteRealpath : realpathSync)( + resolve(process.argv[1]), + ) === + (['vite', 'bun'].includes(process.argv[7]) ? viteRealpath : realpathSync)( + fileURLToPath(import.meta.url), + ) +) { + const [scenario, mode, format, css, destination, adapter = 'webpack'] = + process.argv.slice(2) + assert( + [ + 'build', + 'additional', + 'watch', + 'late', + 'guards', + 'wrong-loader', + 'errors', + 'dev', + 'lifecycle', + 'repeat', + 'opposite', + 'runtime', + 'preload', + ].includes(scenario), + ) + assert(['default', 'native', 'wasm'].includes(mode)) + assert(['require', 'import'].includes(format)) + const result = await consume({ + directory: process.cwd(), + scenario, + mode, + format, + singleCss: css === 'single', + destination, + adapter, + }) + writeFileSync( + destination, + JSON.stringify( + result, + (_, value) => (value === undefined ? { undefined: true } : value), + 2, + ), + ) +} diff --git a/e2e/plugins/engine-dev-cuts.mjs b/e2e/plugins/engine-dev-cuts.mjs new file mode 100644 index 000000000..cbdd80962 --- /dev/null +++ b/e2e/plugins/engine-dev-cuts.mjs @@ -0,0 +1,650 @@ +import assert from 'node:assert/strict' +import { AsyncLocalStorage } from 'node:async_hooks' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { request } from 'node:http' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + diagnosticHook, + errorEvidence, + viteRealpath, +} from './engine-consumer.mjs' + +const filename = fileURLToPath(import.meta.url) +const json = (value) => + JSON.stringify( + value, + (_, item) => (item === undefined ? { undefined: true } : item), + 2, + ) +const save = (path, value) => writeFileSync(path, json(value)) +const [command, ...args] = process.argv.slice(2) +assert.equal(viteRealpath(resolve(process.argv[1])), viteRealpath(filename)) +assert.equal(process.versions.bun, undefined) +if (command === 'matrix') { + const [root, port] = args + const summary = [] + for (const defaults of [false, true]) + for (const css of ['per-file', 'single']) { + const receipts = [] + for (const [index, engine] of [ + 'native', + 'native', + 'wasm', + 'wasm', + ].entries()) { + const base = join( + root, + `cuts-${defaults ? 'defaults' : 'hmr'}-${css}-${index}`, + ) + const child = spawnSync( + process.execPath, + [filename, engine, css, String(defaults), `${base}.json`, port], + { + cwd: process.cwd(), + env: process.env, + timeout: 120000, + windowsHide: true, + }, + ) + writeFileSync(`${base}.stdout`, child.stdout ?? '') + writeFileSync(`${base}.stderr`, child.stderr ?? '') + const ok = !child.error && child.status === 0 && child.signal === null + summary.push({ + base, + ok, + error: child.error && errorEvidence(child.error), + status: child.status, + signal: child.signal, + }) + try { + receipts.push( + ok ? JSON.parse(readFileSync(`${base}.json`, 'utf8')) : undefined, + ) + } catch (error) { + receipts.push(undefined) + summary.push({ base, ok: false, error: errorEvidence(error) }) + } + } + for (const [left, right] of [ + [0, 1], + [2, 3], + [0, 2], + ]) { + try { + assert( + receipts[left] && receipts[right], + 'Missing successful scheduled endpoint', + ) + assert.deepEqual(receipts[left], receipts[right]) + summary.push({ defaults, css, left, right, ok: true }) + } catch (error) { + summary.push({ + defaults, + css, + left, + right, + ok: false, + error: errorEvidence(error), + }) + } + } + save(join(root, 'dev-cuts-summary.json'), summary) + } + assert( + summary.every((row) => row.ok), + 'Inspect dev-cuts-summary.json and raw streams', + ) +} else { + assert(['native', 'wasm'].includes(command)) + const [css, defaultsText, destination, portText] = args + const defaults = defaultsText === 'true', + singleCss = css === 'single', + port = Number(portText) + const directory = viteRealpath(process.cwd()), + generated = join(directory, 'df') + const require = createRequire(join(directory, 'package.json')) + const pluginPath = require.resolve('@devup-ui/vite-plugin') + const { DevupUI } = require(pluginPath) + const utils = createRequire(pluginPath)('@devup-ui/plugin-utils') + const compiler = utils.loadCompiler(command), + originalClock = Date.now + const context = new AsyncLocalStorage(), + ready = Promise.withResolvers(), + poison = Promise.withResolvers() + const trace = [], + operations = [], + turns = [], + fences = new Map(), + restores = [], + cuts = [] + let clock = 2000000000000, + phase = 'C0', + finding, + server, + creation, + owner, + originalCss, + originalProof + Date.now = () => context.getStore()?.fence?.clock ?? clock + const fail = (error) => { + finding ??= error instanceof Error ? error : new Error(String(error)) + trace.push({ failure: errorEvidence(finding) }) + poison.resolve(finding) + } + const check = () => { + if (finding) throw finding + } + const bounded = async (work, clean = false) => { + let timer + try { + return await Promise.race([ + work, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Vite cut operation exceeded 10 seconds')), + 10000, + ) + }), + ...(clean + ? [] + : [ + poison.promise.then((error) => { + throw error + }), + ]), + ]) + } finally { + clearTimeout(timer) + } + } + const observed = (work) => { + const task = Promise.resolve(work).then( + (value) => ({ value }), + (error) => { + fail(error) + return { error: errorEvidence(error) } + }, + ) + operations.push(task) + return task + } + const state = () => ({ + css: compiler.getCss(null, false), + sheet: compiler.exportSheet(), + classes: compiler.exportClassMap(), + files: compiler.exportFileMap(), + canonical: compiler.exportCanonicalMap(), + residual: compiler.getUnreservedClassSlotCount(), + prefix: compiler.getPrefix(), + debug: compiler.isDebug(), + theme: compiler.getDefaultTheme(), + }) + const disk = () => + Object.fromEntries( + readdirSync(generated, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => { + const path = join(entry.parentPath, entry.name) + return [path, readFileSync(path).toString('base64')] + }) + .sort(([a], [b]) => a.localeCompare(b)), + ) + const put = (path, content) => { + mkdirSync(dirname(join(directory, path)), { recursive: true }) + writeFileSync(join(directory, path), content) + } + const urls = (code) => + [...code.matchAll(/["']([^"'\n]*devup-ui(?:-\d+)?\.css[^"'\n]*)["']/g)].map( + (match) => match[1], + ) + const http = async (url, accept) => { + const receipt = await bounded( + new Promise((resolveResponse, reject) => { + const req = request( + { + host: '127.0.0.1', + port, + path: url, + headers: { Accept: accept }, + agent: false, + }, + (res) => { + const chunks = [] + res.on('data', (chunk) => chunks.push(chunk)) + res.on('error', reject) + res.on('aborted', () => + reject(new Error(`Aborted response: ${url}`)), + ) + res.on('end', () => + resolveResponse({ + url, + accept, + queryBytes: Buffer.from( + url.includes('?') ? url.slice(url.indexOf('?')) : '', + ).toString('base64'), + requestRaw: req._header, + status: res.statusCode, + statusMessage: res.statusMessage, + httpVersion: res.httpVersion, + rawHeaders: res.rawHeaders, + headers: res.headers, + allheaders: res.headersDistinct, + rawTrailers: res.rawTrailers, + body: Buffer.concat(chunks).toString('base64'), + }), + ) + }, + ) + req.on('error', reject) + req.setTimeout(10000, () => + req.destroy(new Error(`HTTP deadline: ${url}`)), + ) + req.end() + }), + ) + trace.push({ http: receipt }) + assert.equal(receipt.status, 200) + return receipt + } + const witness = (receipt) => { + const name = receipt.url.split('?')[0].split('/').at(-1), + path = join(generated, 'devup-ui', name) + const bytes = readFileSync(path), + live = compiler.getCss(utils.getFileNumByFilename(name), false) + const result = { + url: receipt.url, + path, + live, + disk: bytes.toString('base64'), + response: receipt.body, + } + assert.equal(result.response, result.disk) + assert.equal(bytes.toString(), live) + return result + } + const seal = async () => { + const count = operations.length + await bounded(Promise.all(operations)) + check() + assert.equal( + operations.length, + count, + 'Work was added during the finite cut', + ) + for (const turn of turns) turn.sealed = true + } + const publish = async (name, revision) => { + const transforms = {}, + responses = [], + coherence = [], + discovered = new Set() + for (const url of [ + '/src/A.js', + '/src/B.js', + '/src/entry.js', + '/src/tokens.js', + ]) { + transforms[`admit:${url}`] = await bounded( + server.environments.client.transformRequest(url), + ) + assert.equal(typeof transforms[`admit:${url}`]?.code, 'string') + } + for (const url of ['/', '/src/A.js', '/src/B.js']) { + const receipt = await http(url, '*/*') + responses.push(receipt) + for (const sheet of urls(Buffer.from(receipt.body, 'base64').toString())) + discovered.add(sheet) + } + for (const url of ['/src/A.js', '/src/B.js']) { + transforms[url] = await bounded( + server.environments.client.transformRequest(url), + ) + for (const sheet of urls(transforms[url].code)) discovered.add(sheet) + } + assert(discovered.size > 0) + for (const url of discovered) { + assert.match(url, /[?&]devup-revision=\d+/) + const raw = await http(url, 'text/css') + coherence.push(witness(raw)) + responses.push(raw) + if (/\/devup-ui\.css(?:\?|$)/.test(url) && !originalCss) originalCss = raw + const js = await http(url, '*/*'), + direct = await http(`${url}&direct`, '*/*') + coherence.push(witness(direct)) + responses.push(js, direct) + const embedded = /const __vite__css = ("(?:[^"\\]|\\.)*")/.exec( + Buffer.from(js.body, 'base64').toString(), + ) + assert(embedded) + assert.equal(JSON.parse(embedded[1]), coherence.at(-1).live) + assert.match(raw.headers['content-type'], /text\/css/) + assert.match(direct.headers['content-type'], /text\/css/) + assert.match(js.headers['content-type'], /javascript/) + } + for (const name of ['A', 'B']) { + const mapped = urls(transforms[`/src/${name}.js`].code), + numbered = mapped.filter((url) => /devup-ui-\d+\.css/.test(url)) + const buckets = numbered.length ? numbered : mapped + assert(buckets.length > 0) + for (const url of buckets) + assert.match( + Buffer.from( + responses.find( + (item) => item.url === url && item.accept === 'text/css', + ).body, + 'base64', + ).toString(), + revision ? /teal/ : /maroon/, + ) + } + await seal() + const cut = { + name, + transforms, + http: responses, + coherence, + original: originalProof, + disk: disk(), + state: state(), + } + assert.equal(cut.state.residual, 0) + assert.match(cut.state.css, revision === 2 ? /29px/ : /17px/) + for (const [path, body] of Object.entries(cut.disk)) + if (path.endsWith('.css')) + assert.equal( + Buffer.from(body, 'base64').toString(), + compiler.getCss(utils.getFileNumByFilename(path), false), + ) + cuts.push(cut) + save(destination, cuts) + return cut + } + try { + for (const name of ['src', 'df', '.vite-cuts']) + rmSync(join(directory, name), { recursive: true, force: true }) + put('devup.json', '{"extends":["./theme.json"]}') + put('theme.json', '{"theme":{"length":{"default":{"size":"17px"}}}}') + put('index.html', '') + put('src/tokens.js', 'export const color="maroon";') + for (const name of ['A', 'B']) + put( + `src/${name}.js`, + `import {css} from '@devup-ui/react'; import {color} from './tokens.js'; export const cls=css({color,w:'$size',p:${name === 'A' ? 1 : 2}});`, + ) + put( + 'src/entry.js', + 'import {cls as a} from "./A.js"; import {cls as b} from "./B.js"; let latestA=a,latestB=b; const render=()=>{document.body.className=latestA+" "+latestB}; render(); if(import.meta.hot) import.meta.hot.accept(["./A.js","./B.js"],([nextA,nextB])=>{if(nextA)latestA=nextA.cls;if(nextB)latestB=nextB.cls;render()});', + ) + compiler.resetStateForTesting() + const extract = compiler.codeExtract + let extractionCount = 0 + compiler.codeExtract = (...values) => { + extractionCount++ + return extract(...values) + } + restores.push(() => { + compiler.codeExtract = extract + }) + const plugins = DevupUI({ + engine: command, + singleCss, + ...(defaults ? {} : { atomHoist: 0 }), + }) + owner = plugins[0] + for (const plugin of plugins) + for (const name of [ + 'configResolved', + 'buildStart', + 'watchChange', + 'hotUpdate', + 'transform', + 'load', + 'closeBundle', + ]) { + if (typeof plugin[name] === 'function') + plugin[name] = diagnosticHook(plugin[name], (_, error) => fail(error)) + } + const { createServer, createLogger } = await import('vite') + const logger = createLogger('silent'), + logError = logger.error, + consoleError = console.error + logger.error = (...values) => { + trace.push({ logger: values.map(errorEvidence) }) + fail(values[0]) + return logError(...values) + } + console.error = (...values) => { + trace.push({ console: values.map(errorEvidence) }) + fail(values[0]) + return consoleError(...values) + } + const fatal = (error, origin) => { + trace.push({ origin }) + fail(error) + } + process.on('uncaughtExceptionMonitor', fatal) + restores.push(() => { + console.error = consoleError + process.off('uncaughtExceptionMonitor', fatal) + }) + const observer = { + name: 'devup-cut-inputs', + enforce: 'post', + configureServer(current) { + const watcher = current.watcher, + emit = watcher.emit + watcher.emit = function (event, ...values) { + if (!['add', 'change', 'unlink'].includes(event)) + return Reflect.apply(emit, this, [event, ...values]) + const file = resolve(values[0]), + fence = fences.get(file) + const turn = { + id: turns.length, + file, + fence, + event, + phase, + hooks: new Map(), + sealed: false, + } + turns.push(turn) + trace.push({ native: { id: turn.id, file, event, phase } }) + if (fence) { + if (fence.turn) fail(new Error(`Duplicate armed input: ${file}`)) + else fence.turn = turn + } + return context.run(turn, () => + Reflect.apply(emit, this, [event, ...values]), + ) + } + watcher.once('ready', ready.resolve) + watcher.on('error', fail) + restores.push(() => { + watcher.emit = emit + watcher.off('error', fail) + watcher.off('ready', ready.resolve) + }) + for (const environment of Object.values(current.environments)) { + const hot = environment.hot, + send = hot.send + hot.send = function (...values) { + trace.push({ hot: environment.name, values }) + if (values[0]?.type === 'error') fail(new Error(json(values[0]))) + return Reflect.apply(send, this, values) + } + restores.push(() => { + hot.send = send + }) + } + }, + hotUpdate: { + order: 'post', + handler({ file, type, timestamp }) { + const turn = context.getStore() + assert(turn && !turn.sealed) + assert.equal(resolve(file), turn.file) + assert.equal( + type, + { add: 'create', change: 'update', unlink: 'delete' }[turn.event], + ) + assert(!turn.hooks.has(this.environment.name)) + turn.hooks.set(this.environment.name, timestamp) + trace.push({ + post: this.environment.name, + turn: turn.id, + file, + type, + timestamp, + }) + }, + }, + } + observer.hotUpdate.handler = diagnosticHook( + observer.hotUpdate.handler, + (_, error) => fail(error), + ) + const options = { + root: directory, + configFile: false, + customLogger: logger, + cacheDir: join(directory, '.vite-cuts'), + plugins: [...plugins, observer], + optimizeDeps: { noDiscovery: true, include: [] }, + server: { + host: '127.0.0.1', + port, + strictPort: true, + headers: { Date: new Date(clock).toUTCString() }, + ...(defaults ? {} : { preTransformRequests: false }), + hotUpdateEnvironments(current, hmr) { + const turn = context.getStore() + const work = (async () => { + assert(turn && !turn.sealed) + const environments = Object.values(current.environments) + assert.deepEqual( + [...turn.hooks.keys()], + environments.map((environment) => environment.name), + ) + assert.equal(new Set(turn.hooks.values()).size, 1) + const outcomes = await Promise.all( + environments.map((environment) => observed(hmr(environment))), + ) + check() + assert(outcomes.every((result) => !result.error)) + trace.push({ completed: turn.id, outcomes }) + const fence = fences.get(turn.file) + if (fence?.turn === turn) fence.resolve() + })() + observed(work) + return work + }, + }, + } + creation = createServer(options).then(async (value) => { + server = value + if (finding) { + await bounded(value.close(), true) + throw finding + } + return value + }) + observed(creation) + await bounded(creation) + assert.equal(server.config.server.preTransformRequests, defaults) + assert.equal( + server.environments.client.config.dev.preTransformRequests, + defaults, + ) + await bounded(server.listen()) + assert.equal(server.httpServer.address().port, port) + await bounded(ready.promise) + await bounded(server.environments.client.pluginContainer.buildStart()) + await publish(defaults ? 'final' : 'C0', 0) + assert( + extractionCount > 0, + 'Installed plugin must use the observed compiler namespace', + ) + if (!defaults) + for (const [index, file, content] of [ + [1, 'src/tokens.js', 'export const color="teal";'], + [2, 'theme.json', '{"theme":{"length":{"default":{"size":"29px"}}}}'], + ]) { + phase = `C${index}` + const fence = Promise.withResolvers() + fence.clock = (clock += 10000) + (index === 2 ? 1000 : 0) + fences.set(resolve(directory, file), fence) + put(file, content) + await bounded(fence.promise) + assert(fence.turn) + if (index === 2) { + assert(originalCss) + assert.match( + Buffer.from(originalCss.body, 'base64').toString(), + /17px/, + ) + const updated = await http(originalCss.url, originalCss.accept), + coherent = witness(updated) + assert.match(coherent.live, /29px/) + assert(originalCss.headers.etag && updated.headers.etag) + assert.notEqual(updated.headers.etag, originalCss.headers.etag) + originalProof = { + before: originalCss, + after: updated, + coherence: coherent, + } + } + await publish(phase, index) + } + if (!defaults) await publish('final', 2) + } catch (error) { + fail(error) + process.exitCode = 1 + } finally { + for (const release of [ + async () => { + if (creation) { + const current = await bounded(creation, true) + await bounded(current.close(), true) + } + }, + async () => { + if (owner) await bounded(owner.closeBundle(), true) + }, + ]) { + try { + await release() + } catch (error) { + fail(error) + process.exitCode = 1 + } + } + try { + await bounded(Promise.all(operations), true) + const final = cuts.at(-1) + await seal() + assert.deepEqual(disk(), final.disk) + assert.deepEqual(state(), final.state) + assert.equal(utils.getBuildOwnership().running, 0) + check() + save(destination, cuts) + } catch (error) { + fail(error) + process.exitCode = 1 + } + save(`${destination}.trace.json`, trace) + for (const restore of restores.reverse()) restore() + context.disable() + Date.now = originalClock + compiler.resetStateForTesting() + } +} diff --git a/e2e/plugins/engine-fixtures.mjs b/e2e/plugins/engine-fixtures.mjs new file mode 100644 index 000000000..17d844076 --- /dev/null +++ b/e2e/plugins/engine-fixtures.mjs @@ -0,0 +1,476 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path' +import { fileURLToPath } from 'node:url' + +import { selectHostTarget } from '../binding/package/build-host.mjs' +import { targets } from '../binding/package/matrix.mjs' + +export function hostPackRealpath( + filename, + realpath = { native: realpathSync.native, regular: realpathSync }, +) { + try { + return realpath.native(filename) + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'EISDIR' + ) + return realpath.regular(filename) + throw error + } +} + +export class HostPackError extends Error { + constructor(category, packageName, cause) { + super( + `${packageName}: ${category}${typeof cause === 'string' ? `: ${cause}` : ''}`, + { cause }, + ) + this.name = 'HostPackError' + this.category = category + this.packageName = packageName + } +} + +export function hostPackSelection() { + const row = selectHostTarget() + if (!row) + throw new HostPackError( + 'missing-host', + '@devup-ui/binding', + 'unsupported host', + ) + return { + name: `@devup-ui/binding-${row.suffix}`, + omitted: targets + .filter((item) => item !== row) + .map((item) => `@devup-ui/binding-${item.suffix}`), + installArgs: [ + `--os=${row.os}`, + `--cpu=${row.cpu}`, + ...(row.libc ? [`--libc=${row.libc}`] : []), + ], + } +} + +export async function packHostEngines(context, run) { + const host = hostPackSelection() + const tarballs = {} + for (const name of ['@devup-ui/binding', host.name]) { + const short = name.slice('@devup-ui/'.length) + const source = join(context.repository, 'bindings', `devup-ui-${short}`) + const manifest = JSON.parse( + readFileSync(join(source, 'package.json'), 'utf8'), + ) + const entry = statSync(join(source, manifest.main)) + if (!entry.isFile() || entry.size === 0) { + throw new HostPackError( + 'byte-mismatch', + name, + 'missing existing checkout build entry', + ) + } + const destination = join(context.root, 'packs', short) + mkdirSync(destination, { recursive: true }) + await run( + context.bun, + [ + 'pm', + 'pack', + '--ignore-scripts', + '--quiet', + '--destination', + destination, + ], + { + cwd: source, + }, + ) + const files = readdirSync(destination).filter((file) => + file.endsWith('.tgz'), + ) + if (files.length !== 1) + throw new HostPackError( + 'byte-mismatch', + name, + 'expected one host tarball', + ) + tarballs[name] = + `file:${hostPackRealpath(join(destination, files[0])).split(sep).join('/')}` + } + return { host, tarballs } +} + +function archivePath(reference, base) { + if (typeof reference !== 'string' || !reference.startsWith('file:')) { + throw new TypeError('Expected a local file tarball reference') + } + return hostPackRealpath( + reference.startsWith('file://') + ? fileURLToPath(reference) + : resolve(base, reference.slice('file:'.length)), + ) +} + +function payload(directory, name, integrity) { + const files = new Map() + const pending = [{ directory, prefix: '' }] + while (pending.length) { + const current = pending.pop() + for (const entry of readdirSync(current.directory, { + withFileTypes: true, + })) { + if (entry.name === 'node_modules') continue + const path = join(current.directory, entry.name) + const key = current.prefix + entry.name + if (entry.name === '.bun-tag') { + if (key !== '.bun-tag') { + throw new HostPackError('marker-nested', name, key) + } + if (!entry.isFile()) { + throw new HostPackError('marker-kind', name, key) + } + if (!readFileSync(path).equals(Buffer.from(integrity))) { + throw new HostPackError('marker-bytes', name, key) + } + } + if (entry.isDirectory()) + pending.push({ directory: path, prefix: `${key}/` }) + else if (entry.isFile()) + files.set( + key, + createHash('sha256').update(readFileSync(path)).digest('hex'), + ) + else + throw new HostPackError( + 'byte-mismatch', + name, + `nonregular payload ${key}`, + ) + } + } + return files +} + +function packedPayload(name, reference) { + const scratch = mkdtempSync(join(tmpdir(), 'devup-host-pack-')) + try { + const file = archivePath(reference, process.cwd()) + const options = { cwd: dirname(file), encoding: 'utf8', timeout: 30000 } + const members = execFileSync('tar', ['-tf', basename(file)], options) + .split(/\r?\n/) + .filter(Boolean) + const seen = new Set() + for (const member of members) { + const parts = member.replace(/\/$/, '').split('/') + if (parts.includes('.bun-tag')) { + throw new HostPackError('archive-marker', name, member) + } + if ( + parts[0] !== 'package' || + member.includes('\\') || + parts.some( + (part) => + !part || + part === '.' || + part === '..' || + part.includes(':') || + part === 'node_modules', + ) || + seen.has(member) + ) { + throw new HostPackError( + 'byte-mismatch', + name, + `unsafe archive member ${member}`, + ) + } + seen.add(member) + } + const listing = execFileSync('tar', ['-tvf', basename(file)], options) + .split(/\r?\n/) + .filter(Boolean) + if (listing.some((line) => line[0] !== '-' && line[0] !== 'd')) { + throw new HostPackError( + 'byte-mismatch', + name, + 'archive links or special files', + ) + } + execFileSync('tar', ['-xf', basename(file), '-C', scratch], options) + const directory = join(scratch, 'package') + if ( + JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')).name !== + name + ) { + throw new HostPackError('byte-mismatch', name, 'archive package name') + } + const compressed = readFileSync(file) + return { + files: payload(directory, name), + sha256: createHash('sha256').update(compressed).digest('hex'), + integrity: `sha512-${createHash('sha512').update(compressed).digest('base64')}`, + } + } catch (cause) { + if (cause instanceof HostPackError) throw cause + throw new HostPackError('byte-mismatch', name, cause) + } finally { + rmSync(scratch, { recursive: true, force: true }) + } +} + +function installedNodes(directory) { + const pending = [join(directory, 'node_modules')] + const visited = new Set() + const packages = new Map() + while (pending.length) { + const path = pending.pop() + const physical = hostPackRealpath(path) + if (!statSync(physical).isDirectory()) continue + const parent = dirname(path) + const slot = + basename(parent) === 'node_modules' + ? basename(path) + : basename(dirname(parent)) === 'node_modules' && + basename(parent).startsWith('@') + ? `${basename(parent)}/${basename(path)}` + : undefined + const manifestFile = join(physical, 'package.json') + if (slot && existsSync(manifestFile)) { + const manifest = JSON.parse(readFileSync(manifestFile, 'utf8')) + if (slot.startsWith('@devup-ui/') && manifest.name !== slot) { + throw new HostPackError( + 'unexpected-package', + slot, + 'installed manifest name', + ) + } + if ( + typeof manifest.name === 'string' && + manifest.name.startsWith('@devup-ui/') + ) { + packages.set(physical, { name: manifest.name, path: physical }) + } + } else if (slot?.startsWith('@devup-ui/')) { + throw new HostPackError( + 'unexpected-package', + slot, + 'missing installed manifest', + ) + } + if (visited.has(physical)) continue + visited.add(physical) + for (const entry of readdirSync(physical, { withFileTypes: true })) { + // Metadata cannot contain packages; payload() still inspects its kind/bytes. + if (entry.name === '.bun-tag') continue + if (entry.isDirectory() || entry.isSymbolicLink()) + pending.push(join(physical, entry.name)) + } + } + return [...packages.values()] +} + +function verifySource(name, reference, context) { + const records = Object.entries(context.lock.packages).filter( + ([key, record]) => + key === name || + key.endsWith(`/${name}`) || + (Array.isArray(record) && + typeof record[0] === 'string' && + record[0].startsWith(`${name}@`)), + ) + try { + if (records.length === 0) + throw new TypeError('missing Bun package source record') + const expected = archivePath(reference, context.directory) + for (const [, record] of records) { + if ( + !Array.isArray(record) || + typeof record[0] !== 'string' || + !record[0].startsWith(`${name}@`) + ) { + throw new TypeError('non-file Bun package source') + } + const source = record[0].slice(name.length + 1) + if (!isAbsolute(source) || hostPackRealpath(source) !== expected) { + throw new TypeError( + "Bun package source is not this run's canonical tarball", + ) + } + } + } catch (cause) { + throw new HostPackError('source-mismatch', name, cause) + } + return records.map(([, record]) => record) +} + +export function verifyHostInstall(options) { + const host = hostPackSelection() + const directory = hostPackRealpath(options.directory) + const tarballs = new Map( + Object.entries(options.tarballs).map(([name, reference]) => [ + name.startsWith('@devup-ui/') ? name : `@devup-ui/${name}`, + reference, + ]), + ) + const installed = installedNodes(directory) + if (!installed.some((item) => item.name === host.name)) { + throw new HostPackError( + 'missing-host', + host.name, + 'host sibling not installed', + ) + } + if (!installed.some((item) => item.name === '@devup-ui/binding')) { + throw new HostPackError( + 'source-mismatch', + '@devup-ui/binding', + 'binding root not installed', + ) + } + for (const binding of installed.filter( + (item) => item.name === '@devup-ui/binding', + )) { + try { + const require = createRequire(join(binding.path, 'package.json')) + const sibling = hostPackRealpath( + dirname(require.resolve(`${host.name}/package.json`)), + ) + if ( + !installed.some( + (item) => item.name === host.name && item.path === sibling, + ) + ) { + throw new TypeError( + 'host sibling resolves outside the installed inventory', + ) + } + } catch (cause) { + throw new HostPackError('missing-host', host.name, cause) + } + } + let lock + try { + lock = JSON.parse( + execFileSync( + options.bun ?? 'bun', + [ + '--eval', + "process.stdout.write(JSON.stringify(Bun.JSONC.parse(require('node:fs').readFileSync(process.env.DEVUP_ENGINE_LOCKFILE, 'utf8'))))", + ], + { + cwd: directory, + encoding: 'utf8', + timeout: 30000, + env: { + ...process.env, + DEVUP_ENGINE_LOCKFILE: join(directory, 'bun.lock'), + }, + }, + ), + ) + if ( + !lock || + typeof lock.packages !== 'object' || + lock.packages === null || + Array.isArray(lock.packages) + ) { + throw new TypeError('missing Bun packages table') + } + } catch (cause) { + throw new HostPackError('source-mismatch', '@devup-ui/binding', cause) + } + const cache = new Map() + for (const item of installed) { + if (!tarballs.has(item.name) || host.omitted.includes(item.name)) { + throw new HostPackError( + 'unexpected-package', + item.name, + 'not in this host pack inventory', + ) + } + const location = relative(directory, item.path) + if ( + location === '..' || + location.startsWith(`..${sep}`) || + isAbsolute(location) + ) { + throw new HostPackError( + 'source-mismatch', + item.name, + 'installed link escapes consumer', + ) + } + const reference = tarballs.get(item.name) + const records = verifySource(item.name, reference, { directory, lock }) + if (!cache.has(item.name)) + cache.set(item.name, packedPayload(item.name, reference)) + const packed = cache.get(item.name) + for (const record of records) { + if (typeof record[2] !== 'string' || record[2].length === 0) { + throw new HostPackError('lock-integrity-missing', item.name) + } + if (record[2] !== packed.integrity) { + throw new HostPackError('lock-integrity-mismatch', item.name) + } + } + const expected = new Map(packed.files) + const actual = payload(item.path, item.name, packed.integrity) + if (actual.has('.bun-tag')) { + expected.set( + '.bun-tag', + createHash('sha256') + .update(Buffer.from(packed.integrity)) + .digest('hex'), + ) + } + if ( + actual.size !== expected.size || + [...actual].some(([file, hash]) => expected.get(file) !== hash) + ) { + throw new HostPackError( + 'byte-mismatch', + item.name, + 'installed payload differs from its tarball', + ) + } + } + const receipt = { + event: 'devup.host-pack-proof', + mode: 'host-only', + host: host.name, + packages: installed, + omitted: host.omitted, + tarballs: [...cache].map(([name, packed]) => ({ + name, + sha256: packed.sha256, + })), + reason: `Explicit Bun ${host.installArgs.join(' ')}; non-host Devup siblings verified absent`, + } + console.info(JSON.stringify(receipt)) + return receipt +} diff --git a/e2e/plugins/engine-parity.mjs b/e2e/plugins/engine-parity.mjs new file mode 100644 index 000000000..188db91a8 --- /dev/null +++ b/e2e/plugins/engine-parity.mjs @@ -0,0 +1,1980 @@ +import assert from 'node:assert/strict' +import { spawn, spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + renameSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { createServer as createPortLease } from 'node:net' +import { tmpdir } from 'node:os' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { deserialize, serialize } from 'node:v8' + +import { + assertBunDist, + assertRawParity, + assertRsbuildDist, + assertRsbuildParity, + assertViteParity, + assertWebpackDist, + collectViteCases, + completedReceipt, + errorEvidence, + prepareBunFixture, + viteRealpath, +} from './engine-consumer.mjs' +import { packHostEngines, verifyHostInstall } from './engine-fixtures.mjs' + +let artifactRoot +let invocation = 0 + +function run(binary, args, options) { + console.info(JSON.stringify({ binary, args, cwd: options.cwd })) + const { evidenceBase, ...execution } = options + const base = evidenceBase ?? join(artifactRoot, `command-${invocation++}`) + const result = spawnSync(binary, args, { + ...execution, + timeout: 600000, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + writeFileSync(`${base}.stdout`, result.stdout ?? Buffer.alloc(0)) + writeFileSync(`${base}.stderr`, result.stderr ?? Buffer.alloc(0)) + if (result.error) throw result.error + assert.equal(result.status, 0, `Command failed; raw streams: ${base}`) + return result.stdout.toString('utf8') +} + +async function viteCells(context) { + const { root, relocated, link, bun } = context + const lease = createPortLease() + const port = await new Promise((resolve, reject) => { + lease.once('error', reject) + lease.listen(0, '127.0.0.1', () => { + const address = lease.address() + assert(address && typeof address === 'object') + lease.close((error) => (error ? reject(error) : resolve(address.port))) + }) + }) + const summary = [] + for (const runtime of [process.execPath, bun]) { + const name = runtime === bun ? 'bun' : 'node' + const cells = [] + for (const css of ['per-file', 'single']) { + for (const format of ['require', 'import']) { + for (const mode of ['default', 'native', 'wasm']) + cells.push(['lifecycle', mode, format, css]) + cells.push(['errors', 'native', format, css]) + } + for (const mode of ['native', 'wasm']) { + for (const scenario of ['repeat', 'opposite']) + cells.push([scenario, mode, 'import', css]) + } + } + for (const format of ['require', 'import']) { + cells.push(['guards', 'wasm', format, 'per-file']) + for (const mode of ['default', 'native', 'wasm']) + cells.push(['late', mode, format, 'per-file']) + } + cells.push(['guards', 'wasm', 'require', 'alias']) + const receipts = new Map() + const completed = new Set() + const results = await collectViteCases( + cells.map((cell) => cell.join('-')), + async (id) => { + const cell = cells.find((cell) => cell.join('-') === id) + const base = join(root, `${name}-${id}`) + const destination = `${base}.json` + try { + const args = [ + join(link, 'engine-consumer.mjs'), + ...cell.slice(0, 3), + cell[3] === 'alias' ? 'per-file' : cell[3], + destination, + 'vite', + ] + run(runtime, args, { + cwd: relocated, + evidenceBase: base, + env: { ...process.env, DEVUP_ENGINE_VITE_PORT: String(port) }, + }) + } finally { + if (existsSync(destination)) + receipts.set(id, JSON.parse(readFileSync(destination, 'utf8'))) + } + assert( + receipts.has(id), + 'Every alias/cell CLI must execute and save its receipt', + ) + if (cell[0] === 'guards') assert.equal(receipts.get(id).guards, true) + completed.add(id) + return destination + }, + ) + const comparisons = cells + .filter(([scenario]) => + ['lifecycle', 'repeat', 'opposite'].includes(scenario), + ) + .map((cell) => cell.join('-')) + .filter((id) => !id.startsWith('lifecycle-default-require-')) + const compared = await collectViteCases(comparisons, async (id) => { + const [scenario, mode, , css] = cells.find( + (cell) => cell.join('-') === id, + ) + const expected = completedReceipt( + receipts, + completed, + scenario === 'lifecycle' + ? `lifecycle-default-require-${css}` + : `lifecycle-${mode}-import-${css}`, + ) + assertViteParity( + expected, + completedReceipt(receipts, completed, id), + scenario === 'opposite' && css === 'single', + ) + }) + const index = (rows, group) => + rows.map((result) => { + const diagnostic = join( + root, + `${name}-${group}-${result.name}.diagnostic.json`, + ) + if (!result.ok) + writeFileSync(diagnostic, JSON.stringify(result.error, null, 2)) + return { + name: result.name, + ok: result.ok, + receipt: result.value, + partialReceipt: + !result.ok && receipts.has(result.name) + ? join(root, `${name}-${result.name}.json`) + : undefined, + diagnostic: result.ok ? undefined : diagnostic, + } + }) + summary.push({ + runtime: name, + passed: [...results, ...compared].filter((cell) => cell.ok).length, + failed: [...results, ...compared].filter((cell) => !cell.ok).length, + results: index(results, 'execute'), + comparisons: index(compared, 'compare'), + }) + writeFileSync( + join(root, 'vite-summary.json'), + JSON.stringify(summary, null, 2), + ) + } + const cuts = join(root, 'dev-cuts') + try { + run( + process.execPath, + [join(link, 'engine-dev-cuts.mjs'), 'matrix', root, String(port)], + { + cwd: relocated, + evidenceBase: cuts, + }, + ) + summary.push({ runtime: 'node', cuts: true }) + } catch (error) { + summary.push({ runtime: 'node', cuts: false, error: errorEvidence(error) }) + } + writeFileSync( + join(root, 'vite-summary.json'), + JSON.stringify(summary, null, 2), + ) + assert( + summary.every( + (row) => + row.cuts ?? + [...row.results, ...row.comparisons].every((cell) => cell.ok), + ), + 'Vite collect-all diagnostics failed; inspect vite-summary.json and raw cell evidence before one coordinator batch decision', + ) +} + +async function bunCells({ root, relocated, link, bun }) { + const receipts = new Map() + const completed = new Set() + const cells = [] + for (const scenario of [ + 'build', + 'repeat', + 'opposite', + 'runtime', + 'preload', + 'late', + 'errors', + ]) + for (const format of ['require', 'import']) + for (const mode of ['default', 'native', 'wasm']) + cells.push([scenario, mode, format]) + for (const format of ['require', 'import']) + cells.push(['guards', 'wasm', format]) + cells.push(['preload-root', 'default', 'import']) + const results = await collectViteCases( + cells.map((cell) => cell.join('-')), + async (id) => { + const [scenario, mode, format] = cells.find( + (cell) => cell.join('-') === id, + ) + const base = join(root, id) + const destination = `${base}.json` + const rootPreload = scenario === 'preload-root' + prepareBunFixture(relocated, !rootPreload) + const args = [] + if (scenario === 'preload' || rootPreload) { + const preload = join(relocated, 'engine-preload.mjs') + const select = mode === 'default' ? {} : { engine: mode } + const load = + format === 'require' + ? 'createRequire(import.meta.url)("@devup-ui/bun-plugin/register")' + : 'await import("@devup-ui/bun-plugin/register")' + writeFileSync( + preload, + rootPreload + ? 'await import("@devup-ui/bun-plugin");' + : `import {createRequire} from 'node:module'; const {register}=${load}; await register(${JSON.stringify({ ...select, shorthands: { insetX: ['left', 'right'] } })});`, + ) + args.push('--preload', preload) + } + args.push( + join(link, 'engine-consumer.mjs'), + rootPreload ? 'preload' : scenario, + mode, + format, + 'single', + destination, + 'bun', + ) + try { + run(bun, args, { cwd: relocated, evidenceBase: base }) + } finally { + if (existsSync(destination)) + receipts.set(id, JSON.parse(readFileSync(destination, 'utf8'))) + } + assert(receipts.has(id)) + if (scenario === 'guards') assert.equal(receipts.get(id).guards, true) + completed.add(id) + return destination + }, + ) + const comparisons = await collectViteCases( + cells + .filter( + ([scenario, mode, format]) => + !['guards', 'preload-root'].includes(scenario) && + !(mode === 'default' && format === 'require'), + ) + .map((cell) => cell.join('-')), + async (id) => { + const [scenario] = cells.find((cell) => cell.join('-') === id) + assertRawParity( + completedReceipt(receipts, completed, `${scenario}-default-require`), + completedReceipt(receipts, completed, id), + ) + }, + ) + comparisons.push( + ...(await collectViteCases( + cells + .filter(([scenario]) => scenario === 'opposite') + .map((cell) => `admission-${cell.join('-')}`), + async (name) => { + const id = name.slice('admission-'.length) + const [, mode, format] = cells.find((cell) => cell.join('-') === id) + const forward = completedReceipt( + receipts, + completed, + `build-${mode}-${format}`, + ) + const reverse = completedReceipt(receipts, completed, id) + try { + assertRawParity(forward.phases[0], reverse.phases[0]) + } catch (error) { + writeFileSync( + join(root, `${name}-raw-difference.json`), + JSON.stringify(errorEvidence(error), null, 2), + ) + } + // Output::new snapshots the current sheet; Bun publishes via getCss. + const publication = (phase) => { + const copy = structuredClone(phase) + for (const output of Object.values(copy.outputs)) { + assert(Object.hasOwn(output, 'css')) + delete output.css + } + return copy + } + assertRawParity( + publication(forward.phases[0]), + publication(reverse.phases[0]), + ) + }, + )), + ) + writeFileSync( + join(root, 'bun-summary.json'), + JSON.stringify({ results, comparisons }, null, 2), + ) + assert( + [...results, ...comparisons].every((row) => row.ok), + 'Bun collect-all diagnostics failed; inspect bun-summary.json and raw evidence', + ) +} + +const nextPreviewKeys = [ + 'previewModeId', + 'previewModeSigningKey', + 'previewModeEncryptionKey', +] +const nextMetadataFiles = [ + '.next/cache/.previewinfo', + '.next/cache/.rscinfo', + '.next/prerender-manifest.json', + '.next/server/preview-props.json', +] +const nextRouteMetadata = + /^\.next\/server\/route-cache\/APP_PAGE\/[a-f0-9]{64}\/\$\/index\.meta$/ +const nextSelectorManifest = '.next/required-server-files.json' +const nextSelectorScript = '.next/required-server-files.js' +const nextSelectorPrefix = 'self.__SERVER_FILES_MANIFEST=' +const nextTurboStorage = '.next/cache/turbopack/v16.4.4-b175f52b/' +const nextStrictWebpackCache = + '.next/cache/webpack/edge-server-production/0.pack' + +function nextStoredContent(file) { + return ( + nextMetadataFiles.includes(file) || + nextRouteMetadata.test(file) || + [nextSelectorManifest, 'df/setup.bin'].includes(file) + ) +} + +function nextFileContent(record) { + assert.equal(typeof record.content, 'string') + const bytes = Buffer.from(record.content, 'base64') + assert.equal(bytes.toString('base64'), record.content) + assert.equal(record.bytes, bytes.length) + assert.equal(record.sha256, createHash('sha256').update(bytes).digest('hex')) + return bytes +} + +function nextSelectors(value, engine, kind) { + const paths = [] + const visit = (object, path = []) => { + if (!object || typeof object !== 'object') return + for (const [key, child] of Object.entries(object)) { + const at = [...path, key] + if (key === 'engine') { + assert.equal( + child, + engine, + `Wrong requested Next selector ${at.join('/')}`, + ) + paths.push(at.join('/')) + object[key] = '' + } else visit(child, at) + } + } + visit(value) + const prefix = kind === 'manifest' ? 'config/turbopack/rules/' : '' + assert.deepEqual( + paths.sort(), + kind === 'key' + ? ['engine'] + : [ + `${prefix}./df/devup-ui/*.css/0/options/engine`, + `${prefix}*.{tsx,ts,jsx,js,mjs}/loaders/0/options/engine`, + ].sort(), + ) + return value +} + +function nextSelectorText(text, engine, kind) { + nextSelectors(JSON.parse(text), engine, kind) + let count = 0 + const replacement = text.replace( + /("engine"\s*:\s*")(native|wasm)(")/g, + (_, before, value, after) => { + assert.equal(value, engine) + count++ + return `${before}${after}` + }, + ) + assert.equal(count, kind === 'key' ? 1 : 2) + assert.equal( + Buffer.byteLength(replacement), + Buffer.byteLength(text) + + count * (''.length - engine.length), + ) + return replacement +} + +function nextPublicationView(publication) { + const { receipt, turbo, mode, wallStarted, wallFinished, servedAt } = + publication + assert(['default', 'native', 'wasm'].includes(mode)) + assert(wallStarted <= wallFinished && wallFinished <= servedAt) + const engine = mode === 'wasm' ? 'wasm' : 'native' + const view = structuredClone(receipt) + const { files, compiler } = view + for (const [file, record] of Object.entries(files)) { + assert.deepEqual( + Object.keys(record).sort(), + nextStoredContent(file) + ? ['bytes', 'content', 'sha256'] + : ['bytes', 'sha256'], + ) + } + assert(Number.isSafeInteger(compiler.pid) && compiler.pid > 0) + const pid = compiler.pid + delete compiler.pid + if (!turbo) { + const groups = new Map() + for (const output of compiler.outputs) { + const input = JSON.stringify(output.input) + if (!groups.has(input)) groups.set(input, []) + groups.get(input).push(output) + } + compiler.outputs = [...groups].sort(([a], [b]) => a.localeCompare(b)) + } + const administrative = turbo + ? [ + ...Array.from( + { length: 14 }, + (_, index) => + `${nextTurboStorage}${String(index + 1).padStart(8, '0')}.sst`, + ), + ...Array.from( + { length: 4 }, + (_, index) => + `${nextTurboStorage}${String(index + 15).padStart(8, '0')}.meta`, + ), + `${nextTurboStorage}CURRENT`, + `${nextTurboStorage}LOG`, + ] + : [ + 'client-production/0.pack', + 'client-production/index.pack', + 'edge-server-production/index.pack', + 'server-production/0.pack', + 'server-production/index.pack', + ].map((file) => `.next/cache/webpack/${file}`) + assert.deepEqual( + Object.keys(files) + .filter((file) => + file.startsWith( + turbo ? '.next/cache/turbopack/' : '.next/cache/webpack/', + ), + ) + .sort(), + (turbo + ? [...administrative] + : [...administrative, nextStrictWebpackCache] + ).sort(), + ) + for (const file of [...administrative, '.next/trace', '.next/trace-build']) { + assert( + Object.hasOwn(files, file), + `Missing named Next administrative file ${file}`, + ) + assert.deepEqual(Object.keys(files[file]).sort(), ['bytes', 'sha256']) + assert(Number.isSafeInteger(files[file].bytes) && files[file].bytes > 0) + assert.match(files[file].sha256, /^[a-f0-9]{64}$/) + // Raw binary storage remains retained and explicitly unqualified. + files[file] = { administrative: true } + } + const metadata = [ + ...nextMetadataFiles, + ...Object.keys(files).filter((file) => nextRouteMetadata.test(file)), + ] + assert.equal(metadata.length, 5) + const parsed = Object.fromEntries( + metadata.map((file) => [ + file, + JSON.parse(nextFileContent(files[file]).toString('utf8')), + ]), + ) + const preview = parsed[nextMetadataFiles[0]] + for (const [index, key] of nextPreviewKeys.entries()) { + assert.match( + preview[key], + new RegExp(`^[a-f0-9]{${index === 0 ? 32 : 64}}$`), + ) + for (const copy of [ + parsed['.next/prerender-manifest.json'].preview, + parsed['.next/server/preview-props.json'], + ]) { + assert.equal(copy[key], preview[key]) + delete copy[key] + } + delete preview[key] + } + const expiry = (object, key) => { + assert(Number.isSafeInteger(object[key]) && object[key] > wallFinished) + delete object[key] + } + expiry(preview, 'expireAt') + expiry(parsed['.next/cache/.rscinfo'], 'encryption.expire_at') + assert.equal( + parsed['.next/cache/.rscinfo']['encryption.key'], + Buffer.alloc(32, 7).toString('base64'), + ) + const route = parsed[metadata[4]] + assert( + Number.isSafeInteger(route.routeCacheLastModified) && + route.routeCacheLastModified >= wallStarted && + route.routeCacheLastModified <= wallFinished, + ) + delete route.routeCacheLastModified + for (const file of metadata) { + delete files[file].sha256 + delete files[file].content + files[file].parsed = parsed[file] + } + if (turbo) { + const setupFile = files['df/setup.bin'] + const setup = deserialize(nextFileContent(setupFile)) + const numeric = '(\\d+(?:\\.\\d+)?(?:e[+-]?\\d+)?)' + const owner = new RegExp(`^${numeric}-${numeric}$`).exec(setup.owner) + const token = new RegExp(`^([1-9]\\d*)-${numeric}-${numeric}$`).exec( + setup.token, + ) + assert(owner && token, 'Missing or invalid actual Next setup ownership') + for (const text of [...owner.slice(1), ...token.slice(1)]) { + assert(Number.isFinite(Number(text)) && String(Number(text)) === text) + } + assert.equal(Number(token[1]), pid) + const origin = Number(owner[1]) + assert(origin >= wallStarted && origin <= wallFinished) + assert(Number(token[2]) >= 0 && origin + Number(token[2]) <= wallFinished) + for (const random of [Number(owner[2]), Number(token[3])]) + assert(random >= 0 && random < 1) + delete setup.owner + delete setup.token + setup.key = nextSelectorText(setup.key, engine, 'key') + nextSelectors(setup.rules, engine, 'rules') + delete setupFile.sha256 + delete setupFile.bytes + setupFile.content = serialize(setup).toString('base64') + const manifest = files[nextSelectorManifest] + const rawManifest = nextFileContent(manifest).toString('utf8') + const script = files[nextSelectorScript] + const rawScript = Buffer.from(nextSelectorPrefix + rawManifest) + assert.deepEqual(script, { + bytes: rawScript.length, + sha256: createHash('sha256').update(rawScript).digest('hex'), + }) + const text = nextSelectorText(rawManifest, engine, 'manifest') + manifest.content = text + manifest.bytes = Buffer.byteLength(text) + manifest.sha256 = createHash('sha256').update(text).digest('hex') + const projectedScript = Buffer.from(nextSelectorPrefix + text) + script.bytes = projectedScript.length + script.sha256 = createHash('sha256').update(projectedScript).digest('hex') + const coordinator = view.coordinator + assert.equal(coordinator.pid, pid) + assert.match(coordinator.portFile, /^[1-9]\d*\n[1-9]\d*$/) + const [port, ownerPid] = coordinator.portFile.split('\n').map(Number) + assert(port < 65536 && Number.isSafeInteger(port)) + assert.equal(ownerPid, pid) + // The owning compiler removes its port file on exit; the hook saved it live. + assert(!Object.hasOwn(files, 'df/coordinator.port')) + delete coordinator.pid + delete coordinator.portFile + assert.equal(coordinator.requests.length, 2) + for (const request of coordinator.requests) { + const origin = `http://127.0.0.1:${port}` + assert(request.url.startsWith(`${origin}/css?`)) + request.url = request.url.slice(origin.length) + assert.equal(request.status, 200) + assert.equal( + request.body, + Buffer.from(request.liveCss).toString('base64'), + ) + assert.equal( + request.liveCss, + request.fileNum === null + ? compiler.state.css + : compiler.fileCss[request.fileNum], + ) + } + } + const headerView = (response, staticFile = false) => { + const headers = response.headers + assert.equal(typeof headers.date, 'string') + const date = Date.parse(headers.date) + assert.equal(new Date(date).toUTCString(), headers.date) + assert(date >= wallStarted - 1000 && date <= servedAt + 1000) + delete headers.date + if (staticFile) { + const { size, mtimeMs, mtimeTime } = response.fileStat + assert(Number.isSafeInteger(size) && size > 0 && Number.isFinite(mtimeMs)) + assert(Number.isSafeInteger(mtimeTime)) + // Node fs.Stats constructs its Date by rounding fractional milliseconds. + assert.equal(mtimeTime, Math.round(mtimeMs)) + const mtime = new Date(mtimeTime) + assert.equal( + headers.etag, + `W/"${size.toString(16)}-${mtime.getTime().toString(16)}"`, + ) + assert.equal(headers['last-modified'], mtime.toUTCString()) + delete headers.etag + delete headers['last-modified'] + assert.deepEqual(Object.keys(response.fileStat).sort(), [ + 'mtimeMs', + 'mtimeTime', + 'size', + ]) + delete response.fileStat + } + } + if (turbo) + for (const request of view.coordinator.requests) headerView(request) + for (const [url, response] of Object.entries(view.browser.network)) + headerView( + response, + /\/_next\/static\/.*\.(?:js|css)$/.test(new URL(url).pathname), + ) + return view +} + +export function assertNextPublication(expected, actual) { + const before = [expected, actual].map((item) => JSON.stringify(item)) + try { + assert.deepEqual(nextPublicationView(actual), nextPublicationView(expected)) + } finally { + assert.deepEqual( + [expected, actual].map((item) => JSON.stringify(item)), + before, + ) + } +} + +export function nextComparisonControls(publication) { + const original = JSON.stringify(publication) + assertNextPublication(publication, publication) + const replaceFile = (copy, file, transform, binary = false) => { + const record = copy.receipt.files[file] + const bytes = nextFileContent(record) + const value = binary ? deserialize(bytes) : JSON.parse(bytes.toString()) + transform(value) + const changed = binary + ? serialize(value) + : Buffer.from(JSON.stringify(value)) + record.content = changed.toString('base64') + record.bytes = changed.length + record.sha256 = createHash('sha256').update(changed).digest('hex') + } + const rejected = [] + const reject = (name, mutate) => { + const copy = structuredClone(publication) + mutate(copy) + assert.throws( + () => assertNextPublication(publication, copy), + undefined, + name, + ) + rejected.push(name) + assert.equal(JSON.stringify(publication), original) + } + for (const extension of ['.css', '.js', '.map']) + reject(`product ${extension}`, (copy) => { + const file = Object.keys(copy.receipt.files).find( + (file) => file.startsWith('.next/') && file.endsWith(extension), + ) + assert(file) + copy.receipt.files[file].sha256 = '0'.repeat(64) + }) + reject('df product bytes', (copy) => { + const file = Object.keys(copy.receipt.files).find( + (file) => + file.startsWith('df/') && + !['df/setup.bin', 'df/coordinator.port'].includes(file), + ) + assert(file) + copy.receipt.files[file].sha256 = '0'.repeat(64) + }) + for (const field of [ + 'code', + 'css', + 'cssFile', + 'map', + 'dependencies', + 'updatedBaseStyle', + ]) + reject(`whole getter ${field}`, (copy) => { + copy.receipt.compiler.outputs[0].output[field] = { changed: true } + }) + for (const field of Object.keys(publication.receipt.compiler.state)) + reject(`state ${field}`, (copy) => { + copy.receipt.compiler.state[field] = { changed: true } + }) + reject('calls', (copy) => { + copy.receipt.compiler.calls.codeExtract++ + }) + reject('live file CSS', (copy) => { + const file = Object.keys(copy.receipt.compiler.fileCss)[0] + copy.receipt.compiler.fileCss[file] += '/* changed */' + }) + reject('unknown compiler field', (copy) => { + copy.receipt.compiler.unknown = true + }) + reject('unknown JSON metadata field', (copy) => + replaceFile(copy, nextMetadataFiles[0], (value) => { + value.unknown = true + }), + ) + reject('missing approved metadata leaf', (copy) => + replaceFile(copy, nextMetadataFiles[0], (value) => { + delete value.expireAt + }), + ) + reject('preview copy mismatch', (copy) => + replaceFile(copy, '.next/server/preview-props.json', (value) => { + value.previewModeId = '0'.repeat(32) + }), + ) + reject('missing named storage file', (copy) => { + const file = Object.keys(copy.receipt.files).find((file) => + /\.(?:sst|pack)$/.test(file), + ) + assert(file) + delete copy.receipt.files[file] + }) + reject('artifact path', (copy) => { + const [file] = Object.keys(copy.receipt.files) + copy.receipt.files[`${file}.changed`] = copy.receipt.files[file] + delete copy.receipt.files[file] + }) + const networkUrl = Object.keys(publication.receipt.browser.network)[0] + reject('unknown HTTP header', (copy) => { + copy.receipt.browser.network[networkUrl].headers['x-unknown'] = 'changed' + }) + reject('missing HTTP date', (copy) => { + delete copy.receipt.browser.network[networkUrl].headers.date + }) + reject('HTTP body', (copy) => { + copy.receipt.browser.network[networkUrl].body += 'YQ==' + }) + reject('computed browser style', (copy) => { + copy.receipt.browser.computed.A.width = '99px' + }) + reject('published CSS/sentinel', (copy) => { + copy.receipt.publishedCss['devup-ui.css'] += '\n/* 1 */' + }) + const staticUrl = Object.keys(publication.receipt.browser.network).find( + (url) => publication.receipt.browser.network[url].fileStat, + ) + assert(staticUrl) + reject('unbound static ETag', (copy) => { + copy.receipt.browser.network[staticUrl].headers.etag = 'W/"1-1"' + }) + reject('corrupted actual stat Date', (copy) => { + copy.receipt.browser.network[staticUrl].fileStat.mtimeTime++ + }) + if (!publication.turbo) + reject('strict unchanged sixth Webpack pack', (copy) => { + copy.receipt.files[nextStrictWebpackCache].sha256 = '0'.repeat(64) + }) + const administrative = Object.keys(publication.receipt.files).find((file) => + /\.(?:sst|pack)$/.test(file), + ) + const allowed = structuredClone(publication) + allowed.receipt.files[administrative].sha256 = '0'.repeat(64) + assertNextPublication(publication, allowed) + const allowedPreview = structuredClone(publication) + for (const file of [ + nextMetadataFiles[0], + '.next/prerender-manifest.json', + '.next/server/preview-props.json', + ]) { + const record = allowedPreview.receipt.files[file] + let text = nextFileContent(record).toString() + const value = JSON.parse(text) + const keys = + file === '.next/prerender-manifest.json' ? value.preview : value + for (const [index, key] of nextPreviewKeys.entries()) + text = text.replace( + JSON.stringify(keys[key]), + JSON.stringify('1'.repeat(index === 0 ? 32 : 64)), + ) + const bytes = Buffer.from(text) + record.content = bytes.toString('base64') + record.bytes = bytes.length + record.sha256 = createHash('sha256').update(bytes).digest('hex') + } + assertNextPublication(publication, allowedPreview) + const unknownStorage = structuredClone(publication) + unknownStorage.receipt.files[`${administrative}.unexpected`] = { + bytes: 1, + sha256: '0'.repeat(64), + } + assert.throws(() => assertNextPublication(unknownStorage, unknownStorage)) + if (publication.turbo) { + for (const mutation of [ + 'missing', + 'size', + 'mirror', + 'wrapper', + 'suffix', + 'unknown', + 'third-selector', + ]) + reject(`JS selector copy ${mutation}`, (copy) => { + if (mutation === 'missing') { + delete copy.receipt.files[nextSelectorScript] + return + } + const record = copy.receipt.files[nextSelectorManifest] + let text = nextFileContent(record).toString('utf8') + if (mutation === 'unknown' || mutation === 'third-selector') { + text = text.replace( + '{', + mutation === 'unknown' + ? '{"unknown":true,' + : '{"extra":{"engine":"native"},', + ) + const bytes = Buffer.from(text) + record.content = bytes.toString('base64') + record.bytes = bytes.length + record.sha256 = createHash('sha256').update(bytes).digest('hex') + } + const bytes = Buffer.from( + (mutation === 'wrapper' + ? 'window.__SERVER_FILES_MANIFEST=' + : nextSelectorPrefix) + + (mutation === 'mirror' ? ' ' : '') + + text + + (mutation === 'suffix' ? ';unexpected' : ''), + ) + copy.receipt.files[nextSelectorScript] = { + bytes: bytes.length + (mutation === 'size' ? 1 : 0), + sha256: createHash('sha256').update(bytes).digest('hex'), + } + }) + for (const kind of ['key', 'rules', 'manifest']) + for (const mutation of ['wrong', 'missing', 'extra']) + reject(`${kind} selector ${mutation}`, (copy) => { + const file = + kind === 'manifest' ? nextSelectorManifest : 'df/setup.bin' + replaceFile( + copy, + file, + (value) => { + const object = + kind === 'key' + ? JSON.parse(value.key) + : kind === 'rules' + ? value.rules + : value.config.turbopack.rules + const selected = + kind === 'key' + ? object + : object['./df/devup-ui/*.css'][0].options + if (mutation === 'wrong') selected.engine = 'wasm' + if (mutation === 'missing') delete selected.engine + if (mutation === 'extra') object.extra = { engine: 'native' } + if (kind === 'key') value.key = JSON.stringify(object) + }, + kind !== 'manifest', + ) + }) + reject('setup default theme', (copy) => + replaceFile( + copy, + 'df/setup.bin', + (value) => { + value.defaultTheme = 'changed' + }, + true, + ), + ) + reject('setup unknown rule', (copy) => + replaceFile( + copy, + 'df/setup.bin', + (value) => { + value.rules.unknown = {} + }, + true, + ), + ) + reject('setup ownership PID', (copy) => + replaceFile( + copy, + 'df/setup.bin', + (value) => { + value.token = `1-${value.token.split('-').slice(1).join('-')}` + }, + true, + ), + ) + reject('coordinator request target', (copy) => { + copy.receipt.coordinator.requests[0].url += '&unknown=1' + }) + reject('coordinator live CSS', (copy) => { + copy.receipt.coordinator.requests[0].liveCss += 'x' + }) + reject('coordinator PID binding', (copy) => { + copy.receipt.coordinator.pid++ + }) + reject('Turbo global extraction order', (copy) => { + copy.receipt.compiler.outputs.reverse() + }) + } + assert.equal(JSON.stringify(publication), original) + return { rejected, rawUnchanged: true, administrativeOnlyPositive: true } +} + +async function nextPublicationCells({ root, relocated, link }) { + assert( + [undefined, 'webpack', 'turbopack'].includes( + process.env.DEVUP_NEXT_BUNDLER, + ), + ) + const bundlers = process.env.DEVUP_NEXT_BUNDLER + ? [process.env.DEVUP_NEXT_BUNDLER] + : ['webpack', 'turbopack'] + const cells = [] + for (const bundler of bundlers) + for (const singleCss of [false, true]) + for (const format of ['require', 'import']) + for (const mode of ['default', 'native', 'wasm']) { + cells.push({ bundler, singleCss, format, mode, repeat: false }) + if (format === 'import' && mode !== 'default') + cells.push({ bundler, singleCss, format, mode, repeat: true }) + } + const require = createRequire(join(relocated, 'package.json')) + const { chromium } = createRequire( + join(fileURLToPath(new URL('../../', import.meta.url)), 'package.json'), + )('@playwright/test') + const portLease = createPortLease() + const port = await new Promise((resolve, reject) => { + portLease.once('error', reject) + portLease.listen(0, '127.0.0.1', () => { + const address = portLease.address() + assert(address && typeof address === 'object') + portLease.close((error) => + error ? reject(error) : resolve(address.port), + ) + }) + }) + const put = (file, text) => { + mkdirSync(join(relocated, file, '..'), { recursive: true }) + writeFileSync(join(relocated, file), text) + } + put('src/tokens.js', "export const tone = 'teal';") + put( + 'src/A.jsx', + "import {Box} from '@devup-ui/react'; import {tone} from './tokens'; export default function A(){return ;}", + ) + put( + 'src/B.jsx', + "import {Box} from '@devup-ui/react'; export default function B(){return ;}", + ) + put( + 'src/Unused.jsx', + "import {Box} from '@devup-ui/react'; export const unused=;", + ) + put( + 'src/app/page.jsx', + "import A from '../A'; import B from '../B'; export default function Page(){return <>;}", + ) + put( + 'src/app/layout.jsx', + 'export default function Layout({children}){return {children};}', + ) + put( + 'devup.json', + JSON.stringify({ theme: { length: { default: { size: ['17px'] } } } }), + ) + const next = require.resolve('next/dist/bin/next') + const receipts = [], + costs = [] + const env = { + ...process.env, + NODE_ENV: 'production', + NEXT_TELEMETRY_DISABLED: '1', + NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString('base64'), + } + delete env.TURBOPACK + const rawFiles = () => + Object.fromEntries( + ['df', '.next'] + .flatMap((folder) => + readdirSync(join(relocated, folder), { + recursive: true, + withFileTypes: true, + }) + .filter((entry) => entry.isFile()) + .map((entry) => { + const file = join(entry.parentPath, entry.name), + bytes = readFileSync(file), + name = `${folder}/${relative(join(relocated, folder), file).split(sep).join('/')}` + return [ + name, + { + bytes: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), + ...(nextStoredContent(name) + ? { content: bytes.toString('base64') } + : {}), + }, + ] + }), + ) + .sort(([a], [b]) => a.localeCompare(b)), + ) + for (const cell of cells) { + const { bundler, singleCss, format, mode, repeat } = cell + const turbo = bundler === 'turbopack' + const phase = `${bundler}-${singleCss ? 'single' : 'per-file'}-${format}-${mode}-${repeat ? 'repeat' : 'first'}` + const capture = join(root, `next-${phase}-compiler.json`) + const rootImport = + format === 'require' + ? "import {createRequire} from 'node:module'; const {DevupUI}=createRequire(import.meta.url)('@devup-ui/next-plugin');" + : "import {DevupUI} from '@devup-ui/next-plugin';" + const options = { + ...(mode === 'default' ? {} : { engine: mode }), + singleCss, + debug: false, + sourceMap: true, + shorthands: { insetX: ['left', 'right'] }, + } + put( + 'next.config.mjs', + `${rootImport} ${turbo ? `import {captureNextCoordinator} from ${JSON.stringify(pathToFileURL(join(link, 'engine-consumer.mjs')).href)};` : ''} export default DevupUI({productionBrowserSourceMaps:true,generateBuildId:async()=> 'devup-next-repeat',experimental:{webpackBuildWorker:false}${turbo ? `,compiler:{runAfterProductionCompile:async()=>{await captureNextCoordinator(${JSON.stringify({ directory: relocated, mode, singleCss, destination: `${capture}.coordinator.json` })})}}` : ''}}, ${JSON.stringify(options)});`, + ) + put( + 'next-capture.mjs', + `import {captureNextCompiler} from ${JSON.stringify(pathToFileURL(join(link, 'engine-consumer.mjs')).href)}; await captureNextCompiler(${JSON.stringify({ directory: relocated, mode, destination: capture })});`, + ) + const started = performance.now(), + wallStarted = Date.now() + run( + process.execPath, + [ + '--import', + pathToFileURL(join(relocated, 'next-capture.mjs')).href, + next, + 'build', + turbo ? '--turbopack' : '--webpack', + ], + { cwd: relocated, env, evidenceBase: join(root, `next-${phase}-build`) }, + ) + const wallFinished = Date.now() + costs.push({ + phase, + buildMs: performance.now() - started, + wallStarted, + wallFinished, + }) + const compiler = JSON.parse(readFileSync(capture, 'utf8')) + assert.equal(compiler.exitCode, 0) + assert( + compiler.outputs.length > 0, + 'Actual CLI compiler process was not captured', + ) + assert( + compiler.calls.codeExtract + compiler.calls.codeExtractWithoutSourceMap > + 0, + ) + assert.deepEqual(compiler.unselectedAfter, compiler.unselectedBefore) + assert( + compiler.calls.registerShorthands > 0 && compiler.calls.registerTheme > 0, + ) + assert.notDeepEqual(compiler.state, compiler.unselectedBefore) + assert.equal(compiler.state.residual, 0) + const unusedOutputs = compiler.outputs.filter( + ({ input }) => input[0] === 'src/Unused.jsx', + ) + assert(unusedOutputs.length > 0) + const unusedScope = singleCss ? '' : 'src/Unused.jsx' + const alphabet = 'abcdefghijklmnopqrstuvwxyz_' + const unusedFile = JSON.parse(compiler.state.files)['src/Unused.jsx'] + if (!singleCss) + assert( + Number.isInteger(unusedFile) && + unusedFile >= 0 && + unusedFile < alphabet.length, + ) + const unusedIdentity = singleCss + ? 'color-0-magenta--255' + : turbo + ? `color-0-magenta--255-${alphabet[unusedFile]}` + : `a1-l-${Buffer.from(unusedScope).toString('hex')}-636f6c6f72-0-s-6d6167656e7461-6e2d6e-255` + const unusedSlot = JSON.parse(compiler.state.classes)[unusedScope][ + unusedIdentity + ] + assert( + Number.isInteger(unusedSlot) && + unusedSlot >= 0 && + unusedSlot < alphabet.length, + ) + const unusedClass = `${singleCss ? '' : `${alphabet[unusedFile]}-`}${alphabet[unusedSlot]}` + const unusedRule = `.${unusedClass}{color:magenta}` + for (const { output } of unusedOutputs) { + assert.equal(/\bclassName="([^"]+)"/.exec(output.code)?.[1], unusedClass) + assert(output.css.includes(unusedRule)) + } + const assertUnusedCss = (css, expectedRule = unusedRule) => { + if (singleCss) { + assert( + css.includes(expectedRule), + 'Single CSS must retain the prewarmed unused atom', + ) + assert.doesNotMatch(css.replaceAll(expectedRule, ''), /magenta|#f0f/) + } else assert.doesNotMatch(css, /magenta|#f0f/) + } + assertUnusedCss(compiler.state.css) + assert.throws(() => + assertUnusedCss( + singleCss + ? compiler.state.css.replaceAll(unusedRule, '') + : compiler.state.css + unusedRule, + ), + ) + assert.throws(() => + assertUnusedCss(compiler.state.css + '.unexpected{color:magenta}'), + ) + assert.throws(() => + assertUnusedCss(compiler.state.css + '.unexpected{color:#f0f}'), + ) + const coordinator = turbo + ? JSON.parse(readFileSync(`${capture}.coordinator.json`, 'utf8')) + : undefined + if (coordinator) { + assert.equal(coordinator.requests.length, 2) + for (const request of coordinator.requests) + assert.equal( + request.liveCss, + request.fileNum === null + ? compiler.state.css + : compiler.fileCss[request.fileNum], + ) + } + const cacheDirectory = join( + relocated, + turbo ? 'df/devup-ui' : '.next/cache/devup-ui', + ) + const publishedCss = Object.fromEntries( + readdirSync(cacheDirectory) + .filter((name) => name.endsWith('.css')) + .map((name) => [ + name, + readFileSync(join(cacheDirectory, name), 'utf8'), + ]), + ) + const diskMain = publishedCss['devup-ui.css'], + liveMain = compiler.state.css + let mainDiskWitness + if (turbo && diskMain !== liveMain) { + assert(diskMain.startsWith(liveMain)) + const match = /^\n\/\* ([1-9]\d*) \*\/$/.exec( + diskMain.slice(liveMain.length), + ) + assert(match, 'Unknown Turbo main CSS suffix') + const timestamp = Number(match[1]) + assert( + Number.isSafeInteger(timestamp) && + timestamp >= wallStarted && + timestamp <= wallFinished, + ) + mainDiskWitness = { variant: 'timestamp-sentinel', timestamp } + } else { + assert.equal(diskMain, liveMain) + mainDiskWitness = { variant: 'bare' } + } + for (const [name, css] of Object.entries(publishedCss)) { + if (name === 'devup-ui.css') continue + const match = /^devup-ui-(\d+)\.css$/.exec(name) + assert(match, `Unexpected published CSS file: ${name}`) + assert.equal(css, compiler.fileCss[match[1]]) + } + assert.match( + (coordinator + ? coordinator.requests.map((request) => request.liveCss) + : Object.values(publishedCss) + ).join('\n'), + /color:teal/, + ) + assertUnusedCss(Object.values(publishedCss).join('\n')) + if (coordinator) + assertUnusedCss( + coordinator.requests.map((request) => request.liveCss).join('\n'), + ) + const server = spawn( + process.execPath, + [next, 'start', '--hostname', '127.0.0.1', '--port', String(port)], + { + cwd: relocated, + env, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + const stdout = [], + stderr = [] + server.stdout.on('data', (data) => stdout.push(data)) + server.stderr.on('data', (data) => stderr.push(data)) + let serverFailure + const serverExit = new Promise((resolve) => { + server.once('exit', (code, signal) => resolve({ code, signal })) + server.once('error', (error) => { + serverFailure = error + stderr.push(Buffer.from(String(error))) + resolve({ error: errorEvidence(error) }) + }) + }) + let browser, browserReceipt + const readinessErrors = [] + try { + const deadline = performance.now() + 60000 + let ready = false + while ( + performance.now() < deadline && + server.exitCode === null && + !server.killed && + !serverFailure + ) { + try { + const response = await fetch(`http://127.0.0.1:${port}`, { + signal: AbortSignal.timeout(1000), + }) + if (response.status === 200) { + await response.arrayBuffer() + ready = true + break + } + } catch (error) { + readinessErrors.push(errorEvidence(error)) + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + assert(ready, 'Actual Next start never served HTTP200') + browser = await chromium.launch({ headless: true }) + const page = await browser.newPage(), + pending = [], + network = {}, + networkErrors = [] + page.on('response', (response) => + pending.push( + (async () => { + const url = response.url() + if (!url.startsWith(`http://127.0.0.1:${port}/`)) return + network[url] = { + status: response.status(), + headers: await response.allHeaders(), + body: (await response.body()).toString('base64'), + } + })().catch((error) => { + networkErrors.push(error) + }), + ), + ) + await page.goto(`http://127.0.0.1:${port}`, { + waitUntil: 'load', + timeout: 60000, + }) + const computed = await page.evaluate(() => + Object.fromEntries( + ['A', 'B'].map((name) => { + const element = document.querySelector(`[data-testid="${name}"]`), + style = element && getComputedStyle(element) + if (!element || !style) throw new Error(`Missing ${name}`) + const rules = [] + const visit = (items) => { + for (const rule of items) { + if ( + 'selectorText' in rule && + element.matches(rule.selectorText) + ) + rules.push({ + selector: rule.selectorText, + left: rule.style.getPropertyValue('left'), + right: rule.style.getPropertyValue('right'), + }) + if ('cssRules' in rule) visit(rule.cssRules) + } + } + for (const sheet of document.styleSheets) visit(sheet.cssRules) + return [ + name, + { + className: element.className, + color: style.color, + background: style.backgroundColor, + width: style.width, + height: style.height, + padding: style.paddingTop, + rules, + }, + ] + }), + ), + ) + for (const name of ['A', 'B']) { + assert(computed[name].className) + assert(computed[name].rules.some((rule) => rule.left === '19px')) + assert(computed[name].rules.some((rule) => rule.right === '19px')) + } + assert.equal(computed.A.color, 'rgb(0, 128, 128)') + assert.equal(computed.B.background, 'rgb(0, 0, 128)') + assert.equal(computed.A.width, '17px') + assert.equal(computed.B.height, '17px') + assert.equal(computed.A.padding, '1px') + assert.equal(computed.B.padding, '2px') + const unusedElements = await page.locator(`.${unusedClass}`).count() + assert.equal(unusedElements, 0) + await Promise.all(pending) + if (networkErrors.length) throw networkErrors[0] + const servedCss = [] + for (const [url, response] of Object.entries(network)) { + const asset = new URL(url).pathname + if (asset.startsWith('/_next/static/') && /\.(?:js|css)$/.test(asset)) { + const file = join(relocated, '.next', asset.slice('/_next/'.length)) + const { size, mtimeMs, mtime } = statSync(file) + response.fileStat = { size, mtimeMs, mtimeTime: mtime.getTime() } + assert.deepEqual( + Buffer.from(response.body, 'base64'), + readFileSync(file), + ) + if (asset.endsWith('.css')) + servedCss.push( + Buffer.from(response.body, 'base64').toString('utf8'), + ) + } + } + assert(servedCss.length > 0) + const served = servedCss.join('\n') + const servedUnusedRule = turbo + ? `.${unusedClass}{color:#f0f}` + : unusedRule + assertUnusedCss(served, servedUnusedRule) + assert.throws(() => + assertUnusedCss( + singleCss + ? served.replaceAll(servedUnusedRule, '') + : served + servedUnusedRule, + servedUnusedRule, + ), + ) + for (const color of ['magenta', '#f0f']) + assert.throws(() => + assertUnusedCss( + served + `.unexpected{color:${color}}`, + servedUnusedRule, + ), + ) + browserReceipt = { + computed, + unused: { className: unusedClass, elements: unusedElements }, + network, + } + } finally { + try { + await browser?.close() + } finally { + server.kill() + let timer + try { + const exited = await Promise.race([ + serverExit, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Owned Next server did not exit')), + 10000, + ) + }), + ]) + writeFileSync( + join(root, `next-${phase}-server-exit.json`), + JSON.stringify(exited), + ) + } finally { + clearTimeout(timer) + writeFileSync( + join(root, `next-${phase}-readiness.json`), + JSON.stringify(readinessErrors, null, 2), + ) + writeFileSync( + join(root, `next-${phase}-server.stdout`), + Buffer.concat(stdout), + ) + writeFileSync( + join(root, `next-${phase}-server.stderr`), + Buffer.concat(stderr), + ) + } + } + } + const files = rawFiles() + assert(Object.keys(files).some((file) => file.endsWith('.map'))) + assert( + Object.keys(files).some( + (file) => + file.startsWith(turbo ? 'df/devup-ui/' : '.next/cache/devup-ui/') && + file.endsWith('.css'), + ), + ) + const receipt = { + files, + compiler, + publishedCss, + coordinator, + mainDiskWitness, + browser: browserReceipt, + } + const publication = { + id: phase, + receipt, + turbo, + mode, + wallStarted, + wallFinished, + servedAt: Date.now(), + cell, + } + receipts.push(publication) + writeFileSync( + join(root, `next-${phase}-publication.json`), + JSON.stringify(publication, null, 2), + ) + const raw = join(root, `raw-${phase}`) + mkdirSync(raw) + for (const folder of ['df', '.next']) { + const source = resolve(relocated, folder), + target = resolve(raw, folder) + assert( + source.startsWith(resolve(root) + sep) && + target.startsWith(resolve(root) + sep), + ) + assert( + realpathSync(source).startsWith(realpathSync(root) + sep) && + realpathSync(raw).startsWith(realpathSync(root) + sep), + ) + renameSync(source, target) + } + } + const byId = new Map( + receipts.map((publication) => [publication.id, publication]), + ) + const comparisons = await collectViteCases( + receipts + .filter( + ({ cell }) => + cell.repeat || cell.format !== 'require' || cell.mode !== 'default', + ) + .map(({ id }) => id), + async (id) => { + const actual = byId.get(id) + const { cell } = actual + const expected = receipts.find( + ({ cell: row }) => + !row.repeat && + row.bundler === cell.bundler && + row.singleCss === cell.singleCss && + row.format === (cell.repeat ? cell.format : 'require') && + row.mode === (cell.repeat ? cell.mode : 'default'), + ) + assert(expected) + assertNextPublication(expected, actual) + }, + ) + const controls = await collectViteCases( + receipts + .filter( + ({ cell }) => + !cell.repeat && cell.format === 'require' && cell.mode === 'default', + ) + .map(({ id }) => id), + async (id) => nextComparisonControls(byId.get(id)), + ) + assert.equal(receipts.length, bundlers.length * 16) + assert.equal(comparisons.length, bundlers.length * 14) + const result = { + cliBuilds: receipts.length, + browserPages: receipts.length, + comparisons, + controls, + costs, + binaryStorageParity: 'unqualified', + } + writeFileSync( + join(root, 'next-publication-summary.json'), + JSON.stringify(result, null, 2), + ) + assert( + [...comparisons, ...controls].every((row) => row.ok), + 'Next publication comparison failed; preserve raw evidence and review exact differences', + ) + console.info( + JSON.stringify({ + cliBuilds: receipts.length, + browserPages: receipts.length, + comparisons: comparisons.length, + binaryStorageParity: 'unqualified', + root, + }), + ) +} + +async function nextLoaderCells({ root, relocated, link, bun }) { + const receipts = new Map(), + pairs = [] + const children = [] + const cell = ( + runtime, + format, + singleCss, + mode, + repeat = false, + guard = false, + authoredErrors = false, + ) => { + const id = [ + 'next-loader', + runtime === bun ? 'bun' : 'node', + format, + singleCss ? 'single' : 'per-file', + mode, + authoredErrors + ? 'authored-errors' + : repeat + ? 'repeat' + : guard + ? 'guard' + : 'first', + ].join('-') + const destination = join(root, `${id}.json`) + run( + runtime, + [ + join(link, 'engine-consumer.mjs'), + guard ? 'guards' : 'build', + mode, + format, + singleCss ? 'single' : 'per-file', + destination, + 'next', + ...(authoredErrors ? ['authored-errors'] : []), + ], + { cwd: relocated, evidenceBase: join(root, id) }, + ) + const receipt = JSON.parse(readFileSync(destination, 'utf8')) + receipts.set(id, receipt) + children.push(id) + if (guard) assert.equal(receipt.guards, true) + return id + } + const baselines = new Map() + for (const runtime of [process.execPath, bun]) + for (const format of ['require', 'import']) { + cell(runtime, format, false, 'default', false, true) + for (const singleCss of [false, true]) { + const first = cell(runtime, format, singleCss, 'default') + baselines.set(`${runtime}-${format}-${singleCss}`, first) + for (const mode of ['native', 'wasm']) { + const actual = cell(runtime, format, singleCss, mode) + pairs.push([first, actual]) + pairs.push([actual, cell(runtime, format, singleCss, mode, true)]) + } + } + } + for (const singleCss of [false, true]) { + const first = baselines.get(`${process.execPath}-require-${singleCss}`) + pairs.push([ + first, + baselines.get(`${process.execPath}-import-${singleCss}`), + ]) + pairs.push([ + baselines.get(`${bun}-require-${singleCss}`), + baselines.get(`${bun}-import-${singleCss}`), + ]) + const authored = [] + for (const runtime of [process.execPath, bun]) + for (const format of ['require', 'import']) + authored.push( + cell(runtime, format, singleCss, 'default', false, false, true), + ) + for (const actual of authored.slice(1)) pairs.push([authored[0], actual]) + } + const comparisons = await collectViteCases( + pairs, + async ([expected, actual]) => + assertRawParity(receipts.get(expected), receipts.get(actual)), + ) + const controls = await collectViteCases( + [...receipts].filter(([, receipt]) => !receipt.guards).map(([id]) => id), + async (id) => { + const receipt = receipts.get(id) + for (const [name, mutate] of [ + [ + 'original identity', + (copy) => { + copy.local[0].thrownIdentity = false + }, + ], + [ + 'cause identity', + (copy) => { + copy.local[0].causeIdentity = false + }, + ], + [ + 'unknown local field', + (copy) => { + copy.local[0].unknown = true + }, + ], + [ + 'selected state', + (copy) => { + copy.state.css += 'changed' + }, + ], + [ + 'source map', + (copy) => { + copy.sourceResult.map += 'changed' + }, + ], + ]) { + const changed = structuredClone(receipt) + mutate(changed) + assert.throws(() => assertRawParity(receipt, changed), undefined, name) + } + return { rejected: 5 } + }, + ) + writeFileSync( + join(root, 'next-loader-summary.json'), + JSON.stringify({ children, comparisons, controls }, null, 2), + ) + assert.equal(children.length, 52) + assert.equal(comparisons.length, 42) + assert.equal(controls.length, 48) + assert( + controls.every((row) => row.ok), + 'Next loader negative controls failed', + ) + assert( + comparisons.every((row) => row.ok), + 'Next installed loader parity failed', + ) +} + +export async function webpackParity(adapter = 'webpack') { + const repository = fileURLToPath(new URL('../../', import.meta.url)) + const parent = process.env.DEVUP_ENGINE_ARTIFACT_PARENT ?? tmpdir() + const root = mkdtempSync(join(parent, `devup-${adapter}-engine-`)) + artifactRoot = root + console.info(`${adapter} engine evidence: ${root}`) + const bun = process.env.BUN_BINARY ?? 'bun' + const context = { repository, root, bun } + const { host, tarballs } = await packHostEngines(context, run) + for (const name of [ + 'wasm', + 'plugin-utils', + 'react', + ...(adapter === 'next' ? ['webpack-plugin'] : []), + `${adapter}-plugin`, + ]) { + const source = join( + repository, + name === 'wasm' ? 'bindings/devup-ui-wasm' : `packages/${name}`, + ) + const destination = join(root, 'packs', name) + mkdirSync(destination, { recursive: true }) + run( + bun, + [ + 'pm', + 'pack', + '--ignore-scripts', + '--quiet', + '--destination', + destination, + ], + { cwd: source }, + ) + const packs = readdirSync(destination).filter((file) => + file.endsWith('.tgz'), + ) + assert.equal(packs.length, 1) + tarballs[`@devup-ui/${name}`] = + `file:${(adapter === 'vite' || adapter === 'bun' ? viteRealpath : realpathSync)(join(destination, packs[0])).split(sep).join('/')}` + } + const assertDist = + adapter === 'bun' + ? assertBunDist + : adapter === 'rsbuild' || adapter === 'vite' + ? assertRsbuildDist + : assertWebpackDist + assertDist(join(repository, `packages/${adapter}-plugin/dist`)) + const bundler = + adapter === 'next' + ? 'next' + : adapter === 'bun' + ? '@devup-ui/bun-plugin' + : adapter === 'vite' + ? 'vite' + : adapter === 'rsbuild' + ? '@rsbuild/core' + : 'webpack' + const versions = {} + for (const name of [ + ...(adapter === 'bun' ? [] : [bundler]), + ...(adapter === 'next' ? ['webpack', 'react-dom'] : []), + 'react', + 'csstype-extra', + ]) { + const response = await fetch(`https://registry.npmjs.org/${name}/latest`, { + signal: AbortSignal.timeout(30000), + }) + assert.equal(response.status, 200) + const manifest = await response.json() + assert.equal(manifest.name, name) + versions[name] = manifest.version + } + const directory = join(root, 'consumer') + mkdirSync(directory) + writeFileSync( + join(directory, 'package.json'), + JSON.stringify( + { + name: `${adapter}-engine-consumer`, + private: true, + type: 'module', + dependencies: { + ...versions, + [`@devup-ui/${adapter}-plugin`]: + tarballs[`@devup-ui/${adapter}-plugin`], + '@devup-ui/react': tarballs['@devup-ui/react'], + }, + overrides: tarballs, + }, + null, + 2, + ), + ) + run( + bun, + ['install', '--ignore-scripts', '--linker=isolated', ...host.installArgs], + { cwd: directory }, + ) + const beforeRelocation = verifyHostInstall({ directory, tarballs, host, bun }) + const relocated = join(root, 'relocated consumer') + const entryLink = join(directory, 'node_modules', bundler) + assert(lstatSync(entryLink).isSymbolicLink()) + const target = readlinkSync(entryLink) + const reinstall = isAbsolute(target) + const relocation = { + intent: + 'Keep a moved installed consumer usable: relative installer links move unchanged; absolute installer links require a frozen reinstall, not a claim of portable absolute junctions.', + entryLink, + target, + branch: reinstall + ? 'absolute-link-frozen-reinstall' + : 'relative-link-plain-move', + beforeRelocation, + } + writeFileSync( + join(root, 'relocation-before.json'), + JSON.stringify(relocation, null, 2), + ) + renameSync(directory, relocated) + const lock = readFileSync(join(relocated, 'bun.lock')) + writeFileSync(join(root, 'relocation-before.lock'), lock) + if (reinstall) { + run( + bun, + [ + 'install', + '--frozen-lockfile', + '--ignore-scripts', + '--linker=isolated', + ...host.installArgs, + ], + { cwd: relocated }, + ) + } + const relocatedLock = readFileSync(join(relocated, 'bun.lock')) + writeFileSync(join(root, 'relocation-after.lock'), relocatedLock) + assert.deepEqual(relocatedLock, lock) + const afterRelocation = verifyHostInstall({ + directory: relocated, + tarballs, + host, + bun, + }) + writeFileSync( + join(root, 'relocation-after.json'), + JSON.stringify({ ...relocation, afterRelocation }, null, 2), + ) + cpSync( + new URL('./engine-consumer.mjs', import.meta.url), + join(relocated, 'engine-consumer.mjs'), + ) + const link = join(root, 'consumer link') + symlinkSync( + relocated, + link, + process.platform === 'win32' ? 'junction' : 'dir', + ) + writeFileSync( + join(root, 'provenance.json'), + JSON.stringify( + { + repository, + versions, + host, + tarballs, + ...(adapter === 'next' + ? { + tarballSHA256: Object.fromEntries( + Object.entries(tarballs).map(([name, reference]) => [ + name, + createHash('sha256') + .update(readFileSync(reference.slice('file:'.length))) + .digest('hex'), + ]), + ), + } + : {}), + bun: run(bun, ['--version'], { cwd: root }).trim(), + node: process.version, + }, + null, + 2, + ), + ) + if (adapter === 'next') { + assert( + [undefined, 'publication', 'loaders'].includes( + process.env.DEVUP_NEXT_SUITE, + ), + ) + if (process.env.DEVUP_NEXT_SUITE !== 'loaders') + await nextPublicationCells({ root, relocated, link }) + if (process.env.DEVUP_NEXT_SUITE !== 'publication') + await nextLoaderCells({ root, relocated, link, bun }) + return root + } + if (adapter === 'bun') { + await bunCells({ root, relocated, link, bun }) + return root + } + if (adapter === 'vite') { + cpSync( + new URL('./engine-dev-cuts.mjs', import.meta.url), + join(relocated, 'engine-dev-cuts.mjs'), + ) + await viteCells({ root, relocated, link, bun }) + return root + } + for (const runtime of [process.execPath, bun]) { + const runtimeName = runtime === bun ? 'bun' : 'node' + const aliasControl = join(root, `${runtimeName}-alias-entry-control`) + const aliasReceipt = `${aliasControl}.json` + run( + runtime, + [ + join(link, 'engine-consumer.mjs'), + 'guards', + 'wasm', + 'require', + 'per-file', + aliasReceipt, + adapter, + ], + { cwd: relocated, evidenceBase: aliasControl }, + ) + assert( + existsSync(aliasReceipt), + 'The alias CLI must execute and produce a receipt', + ) + assert.equal(JSON.parse(readFileSync(aliasReceipt, 'utf8')).guards, true) + const cell = (scenario, mode, format, css) => { + const base = join( + root, + `${runtimeName}-${scenario}-${mode}-${format}-${css}`, + ) + const destination = `${base}.json` + run( + runtime, + [ + join(link, 'engine-consumer.mjs'), + scenario, + mode, + format, + css, + destination, + adapter, + ], + { cwd: relocated, evidenceBase: base }, + ) + return JSON.parse(readFileSync(destination, 'utf8')) + } + for (const scenario of [ + 'build', + 'additional', + 'watch', + ...(adapter === 'rsbuild' ? ['dev'] : []), + ]) { + for (const css of ['per-file', 'single']) { + let expected + for (const format of ['require', 'import']) { + for (const mode of ['default', 'native', 'wasm']) { + const actual = cell(scenario, mode, format, css) + if (expected === undefined) expected = actual + else if (adapter === 'rsbuild') + assertRsbuildParity(expected, actual) + else assertRawParity(expected, actual) + } + } + } + } + for (const format of ['require', 'import']) { + for (const mode of ['default', 'native', 'wasm']) { + cell('late', mode, format, 'per-file') + } + cell('guards', 'wasm', format, 'per-file') + if (adapter === 'webpack') + cell('wrong-loader', 'default', format, 'per-file') + for (const css of ['per-file', 'single']) { + cell('errors', 'native', format, css) + } + } + } + console.info(`${adapter} engine evidence: ${root}`) + return root +} + +export const rsbuildParity = () => webpackParity('rsbuild') +export const viteParity = () => webpackParity('vite') +export const bunParity = () => webpackParity('bun') + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + const adapter = process.argv[2] ?? 'webpack' + assert(['webpack', 'rsbuild', 'vite', 'bun', 'next'].includes(adapter)) + await webpackParity(adapter) +} diff --git a/e2e/plugins/engine.test.mjs b/e2e/plugins/engine.test.mjs new file mode 100644 index 000000000..0e4e17d21 --- /dev/null +++ b/e2e/plugins/engine.test.mjs @@ -0,0 +1,1043 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { basename, dirname, join, sep } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' + +import { + assertErrorParity, + assertRawParity, + assertRsbuildDist, + assertRsbuildParity, + assertViteBuckets, + assertViteParity, + assertWebpackDist, + collectViteCases, + completedReceipt, + diagnosticHook, + errorEvidence, + initialLayerGate, + observeRejection, + webpackDistFiles, +} from './engine-consumer.mjs' +import { + HostPackError, + hostPackRealpath, + hostPackSelection, + verifyHostInstall, +} from './engine-fixtures.mjs' + +for (const single of [false, true]) { + for (const corrupt of ['none', 'disk', 'served']) { + test(`Vite mapped bucket proof: single=${single}, corrupt=${corrupt}`, () => { + // Given: an unrelated teal bucket cannot rescue either affected bucket. + const files = single + ? ['devup-ui.css'] + : ['devup-ui-3.css', 'devup-ui-4.css'] + const urls = files.map((file) => `/df/devup-ui/${file}?devup-revision=1`) + const phase = { + disk: { '/df/devup-ui/devup-ui-5.css': '.unrelated{color:teal}' }, + requested: { transforms: {}, http: {} }, + } + for (const [index, name] of ['A', 'B'].entries()) { + const url = urls[single ? 0 : index] + phase.requested.transforms[`/src/${name}.js`] = { + code: `import "${url}";`, + } + phase.requested.http[`${url}&direct`] = { body: '.a{color:teal}' } + phase.disk[url.split('?')[0]] = '.a{color:teal}' + } + if (!single) { + phase.disk['/df/devup-ui/devup-ui.css'] = ':root{--size:17px}' + assert.throws(() => + assert.match(phase.disk['/df/devup-ui/devup-ui.css'], /teal/), + ) + } + if (corrupt === 'disk') phase.disk[urls[0].split('?')[0]] = '' + if (corrupt === 'served') + phase.requested.http[`${urls[0]}&direct`].body = '' + // When / Then + const check = () => assertViteBuckets(phase, /teal/) + if (corrupt === 'none') assert.doesNotThrow(check) + else assert.throws(check, assert.AssertionError) + }) + } +} + +test('Vite diagnostic collection preserves a failure and still visits later cells', async () => { + // Given + const visited = [] + const failure = new Error('diagnostic control') + // When + const cases = [ + 'located', + 'resolver', + 'registration', + 'reservation', + 'later-execution', + 'later-comparison', + ] + const results = await collectViteCases(cases, async (name) => { + visited.push(name) + if (name === 'located') throw failure + return 'second receipt' + }) + // Then + assert.deepEqual(visited, cases) + assert.equal(results[0].ok, false) + assertRawParity(results[0].error, errorEvidence(failure)) + assert.deepEqual(results[1], { + name: 'resolver', + ok: true, + value: 'second receipt', + }) +}) + +test('a partial saved receipt cannot become a successful boundary comparison', () => { + // Given + const receipts = new Map([['partial', { phases: [] }]]) + // When / Then + assert.throws( + () => completedReceipt(receipts, new Set(), 'partial'), + assert.AssertionError, + ) + assert.equal( + completedReceipt(receipts, new Set(['partial']), 'partial'), + receipts.get('partial'), + ) +}) + +for (const field of ['http', 'disk', 'map', 'observations', 'residual']) { + test(`Vite raw parity rejects a changed ${field} without an intermediate exclusion`, () => { + // Given + const expected = { phases: [{ [field]: 'before' }] } + const actual = { phases: [{ [field]: 'after' }] } + // When / Then + assert.throws(() => assertRawParity(expected, actual)) + }) +} + +for (const field of [ + 'css', + 'code', + 'map', + 'assets', + 'disk', + 'state', + 'calls', +]) { + test(`Vite opposite single CSS exemption preserves ${field} custody`, () => { + const expected = { phases: [{ outputs: { file: { css: 'early' } } }] } + const actual = structuredClone(expected) + const target = ['css', 'code', 'map'].includes(field) + ? actual.phases[0].outputs.file + : actual.phases[0] + target[field] = 'different' + if (field === 'css') { + assertViteParity(expected, actual, true) + assert.throws(() => assertViteParity(expected, actual)) + } else assert.throws(() => assertViteParity(expected, actual, true)) + assert.equal( + actual.phases[0].outputs.file.css, + field === 'css' ? 'different' : 'early', + ) + delete actual.phases[0].outputs.file.css + assert.throws(() => assertViteParity(expected, actual, true)) + }) +} + +test('Vite raw parity distinguishes a present no-map value from an absent field', () => { + // Given + const expected = { output: { map: undefined } } + const actual = { output: {} } + // When / Then + assert.throws(() => assertRawParity(expected, actual)) +}) + +test('host path policy equates short, expanded and junction spellings exactly', (t) => { + const root = mkdtempSync(join(tmpdir(), 'devup host realpath ')) + t.after(() => rmSync(root, { recursive: true, force: true })) + const target = join(root, 'target') + const alias = join(root, 'alias') + mkdirSync(target) + symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + const expanded = realpathSync.native(target) + assert.equal(hostPackRealpath(target), expanded) + assert.equal(hostPackRealpath(expanded), expanded) + assert.equal(hostPackRealpath(alias), expanded) +}) + +test('host path policy retries only EISDIR with the exact same input', () => { + const calls = [] + const result = hostPackRealpath('same-input', { + native(filename) { + calls.push(['native', filename]) + throw Object.assign(new Error('native EISDIR'), { code: 'EISDIR' }) + }, + regular(filename) { + calls.push(['regular', filename]) + return 'physical-path' + }, + }) + assert.equal(result, 'physical-path') + assert.deepEqual(calls, [ + ['native', 'same-input'], + ['regular', 'same-input'], + ]) +}) + +for (const code of ['ENOENT', 'EACCES']) { + test(`host path policy preserves ${code} without fallback`, () => { + const failure = Object.assign(new Error('native failure'), { code }) + assert.throws( + () => + hostPackRealpath('same-input', { + native() { + throw failure + }, + regular() { + assert.fail('Only EISDIR may retry') + }, + }), + (error) => error === failure, + ) + }) +} + +for (const format of ['require', 'import']) { + test(`built ${format} shorthand error uses its public constructor, not the other format`, async () => { + // Given + const cjs = createRequire(import.meta.url)( + '../../packages/plugin-utils/dist/index.cjs', + ) + const esm = await import('../../packages/plugin-utils/dist/index.mjs') + const selected = format === 'require' ? cjs : esm + const other = format === 'require' ? esm : cjs + const invalid = () => + selected.normalizeShorthands({ bad: ['backgroundColour'] }) + // When / Then + assert.throws(invalid, selected.ShorthandConfigError) + assert.throws( + () => assert.throws(invalid, other.ShorthandConfigError), + assert.AssertionError, + ) + }) +} + +for (const order of ['AB', 'BA']) { + for (const releaseAt of [0, 1, 2]) { + test(`initial gate releases ${order} admissions in filename order with CSS signal at ${releaseAt}`, () => { + // Given + const module = { exports: {} } + runInNewContext(initialLayerGate, { module }, { timeout: 1000 }) + const loader = module.exports + const released = [] + // When + for (let index = 0; index <= order.length; index++) { + if (index === releaseAt) loader.release() + if (index < order.length) { + const name = order[index] + loader.call( + { + resourcePath: `C:/fixture/src/${name}.css.ts`, + async: () => (error, source) => { + assert.equal(error, null) + released.push(source) + }, + }, + name, + ) + } + // Then + assert.deepEqual( + released, + index >= releaseAt && index >= 1 ? ['A', 'B'] : [], + ) + } + assert.equal( + loader.call({ resourcePath: 'C:/fixture/src/B.css.ts' }, 'edited'), + 'edited', + ) + }) + } +} + +test('built Webpack entry and both loader formats use only the shared selector', () => { + // Given + const dist = fileURLToPath( + new URL('../../packages/webpack-plugin/dist/', import.meta.url), + ) + // When / Then + assertWebpackDist(dist) +}) + +for (const name of webpackDistFiles) { + test(`rejects a direct backend import in ${name}`, (t) => { + // Given + const root = mkdtempSync(join(tmpdir(), 'devup-dist-guard-')) + t.after(() => rmSync(root, { recursive: true, force: true })) + for (const file of webpackDistFiles) { + writeFileSync(join(root, file), 'import "@devup-ui/plugin-utils";') + } + writeFileSync( + join(root, name), + 'import "@devup-ui/plugin-utils"; require("@devup-ui/wasm");', + ) + // When / Then + assert.throws(() => assertWebpackDist(root), assert.AssertionError) + }) +} + +for (const name of ['index.cjs', 'index.mjs']) { + for (const backend of ['wasm', 'binding']) { + test(`rejects a direct ${backend} import in Rsbuild ${name}`, (t) => { + const root = mkdtempSync(join(tmpdir(), 'devup-rsbuild-dist-guard-')) + t.after(() => rmSync(root, { recursive: true, force: true })) + for (const file of ['index.cjs', 'index.mjs']) { + writeFileSync(join(root, file), 'import "@devup-ui/plugin-utils";') + } + assertRsbuildDist(root) + writeFileSync( + join(root, name), + `import "@devup-ui/plugin-utils"; require("@devup-ui/${backend}");`, + ) + assert.throws(() => assertRsbuildDist(root), assert.AssertionError) + }) + } +} + +for (const field of ['code', 'css', 'map', 'dependencies', 'sheet', 'error']) { + test(`raw parity rejects a changed ${field} without normalizing it`, () => { + // Given + const expected = { + code: 'x\r\n', + css: '/* retained */.a{}', + map: undefined, + dependencies: ['a', 'b'], + sheet: '{"x":1}', + error: { name: 'Error', message: 'file.tsx:1:2' }, + } + const actual = { + ...expected, + [field]: field === 'dependencies' ? ['b', 'a'] : 'different', + } + // When / Then + assert.throws( + () => assertRawParity(expected, actual), + assert.AssertionError, + ) + }) +} + +test('error parity rejects a difference only in a non-enumerable value', () => { + // Given + const expected = new Error('same diagnostic') + const actual = new Error('same diagnostic') + Object.defineProperty(expected, 'detail', { value: 'before' }) + Object.defineProperty(actual, 'detail', { value: 'after' }) + // When / Then + assert.throws( + () => assertErrorParity(expected, actual), + assert.AssertionError, + ) +}) + +test('only Bun permits optional original creation-field presence', () => { + const expected = new Error('same diagnostic') + const actual = new Error('same diagnostic') + for (const key of ['originalLine', 'originalColumn']) { + Object.defineProperty(expected, key, { + value: 1, + configurable: true, + enumerable: false, + writable: true, + }) + Reflect.deleteProperty(actual, key) + } + for (const compare of [ + () => assertErrorParity(expected, actual), + () => assertRawParity(errorEvidence(expected), errorEvidence(actual)), + ]) { + if (process.versions.bun) assert.doesNotThrow(compare) + else assert.throws(compare, assert.AssertionError) + } +}) + +for (const side of ['expected', 'actual']) { + test(`error parity rejects an unrelated key present only on ${side}`, () => { + const expected = new Error('same diagnostic') + const actual = new Error('same diagnostic') + Object.defineProperty(side === 'expected' ? expected : actual, 'other', { + value: 1, + }) + assert.throws( + () => assertErrorParity(expected, actual), + assert.AssertionError, + ) + assert.throws( + () => assertRawParity(errorEvidence(expected), errorEvidence(actual)), + assert.AssertionError, + ) + }) +} + +test('optional creation fields retain their descriptor contract', () => { + const expected = new Error('same diagnostic') + const actual = new Error('same diagnostic') + for (const [error, writable] of [ + [expected, true], + [actual, false], + ]) { + Reflect.ownKeys(error) + Reflect.get(error, 'stack') + Object.defineProperty(error, 'originalLine', { + value: 1, + configurable: true, + enumerable: false, + writable, + }) + } + assert.equal( + Object.getOwnPropertyDescriptor(actual, 'originalLine').writable, + false, + ) + assert.throws( + () => assertErrorParity(expected, actual), + assert.AssertionError, + ) + assert.throws( + () => assertRawParity(errorEvidence(expected), errorEvidence(actual)), + assert.AssertionError, + ) +}) + +test('error parity retains the order of every other own key', () => { + const expected = new Error('same diagnostic') + const actual = new Error('same diagnostic') + for (const key of ['a', 'b']) + Object.defineProperty(expected, key, { value: 1 }) + for (const key of ['b', 'a']) Object.defineProperty(actual, key, { value: 1 }) + assert.throws( + () => assertErrorParity(expected, actual), + assert.AssertionError, + ) + assert.throws( + () => assertRawParity(errorEvidence(expected), errorEvidence(actual)), + assert.AssertionError, + ) +}) + +test('error parity rejects a difference only in a descriptor flag', () => { + // Given + const expected = new Error('same diagnostic') + const actual = new Error('same diagnostic') + Object.defineProperty(expected, 'detail', { value: 1, writable: false }) + Object.defineProperty(actual, 'detail', { value: 1, writable: true }) + // When / Then + assert.throws( + () => assertErrorParity(expected, actual), + assert.AssertionError, + ) +}) + +for (const difference of ['value', 'writable']) { + test(`packed error receipts reject a non-enumerable ${difference} difference`, () => { + // Given + const expected = new Error('same diagnostic') + const actual = new Error('same diagnostic') + Object.defineProperty(expected, 'detail', { value: 1, writable: false }) + Object.defineProperty(actual, 'detail', { + value: difference === 'value' ? 2 : 1, + writable: difference === 'writable', + }) + const before = errorEvidence(expected) + const after = errorEvidence(actual) + // When / Then + assert.throws(() => assertRawParity(before, after), assert.AssertionError) + }) +} + +test('error parity retains native-only public fields rather than excluding them', () => { + // Given + const expected = new Error('same diagnostic') + const actual = new Error('same diagnostic') + Object.defineProperty(actual, 'code', { value: 'GenericFailure' }) + // When / Then + assert.throws( + () => assertErrorParity(expected, actual), + assert.AssertionError, + ) +}) + +test('error parity compares cause diagnostics recursively', () => { + // Given + const expected = new Error('outer', { cause: new Error('before') }) + const actual = new Error('outer', { cause: new Error('after') }) + // When / Then + assert.throws( + () => assertErrorParity(expected, actual), + assert.AssertionError, + ) +}) + +for (const kind of ['symbol', 'function', 'accessor']) { + test(`error parity rejects different live ${kind} identities with equal text`, () => { + // Given + const expected = new Error('same diagnostic') + const actual = new Error('same diagnostic') + const descriptor = () => { + switch (kind) { + case 'symbol': + return { value: Symbol('same') } + case 'function': + return { value: function same() {} } + case 'accessor': + return { + get: function same() { + return 1 + }, + } + default: + assert.fail(`Unknown identity control ${kind}`) + } + } + Object.defineProperty(expected, 'detail', descriptor()) + Object.defineProperty(actual, 'detail', descriptor()) + // When / Then + assert.throws( + () => assertErrorParity(expected, actual), + assert.AssertionError, + ) + }) +} + +test('error parity accepts shared live symbol keys and accessor identities', () => { + // Given + const key = Symbol('detail') + const get = () => 1 + const expected = new Error('same diagnostic') + const actual = new Error('same diagnostic') + Object.defineProperty(expected, key, { get }) + Object.defineProperty(actual, key, { get }) + // When / Then + assertErrorParity(expected, actual) +}) + +test('saved error evidence retains complete raw stack values', () => { + // Given + const error = new Error('raw stack') + const stack = error.stack + // When + const evidence = errorEvidence(error) + // Then + const property = evidence.raw.properties.find( + ({ key }) => key.type === 'string' && key.value === 'stack', + ) + assert.equal(property.observed.value, stack) +}) + +test('hook diagnostics retain the complete catch-turn graph before bundler mutation', async () => { + // Given + const key = Symbol('hidden') + const cause = new Error('cause') + const error = new Error('boundary', { cause }) + const nested = { value: 'before' } + Object.defineProperty(error, key, { value: nested }) + Object.defineProperty(error, 'detail', { get: () => nested }) + const expected = errorEvidence(error) + let captured + const hook = diagnosticHook( + () => { + throw error + }, + (value) => { + captured = value + }, + ) + // When: Vite keeps the original reference, adds metadata and changes nested data. + await assert.rejects(hook(), (value) => value === error) + Object.assign(error, { + plugin: 'devup-ui', + id: '/src/error.js', + loc: { line: 357, column: 12 }, + frame: 'later', + }) + nested.value = 'after' + // Then: the old deferred receipt fails while the immediate graph is unchanged. + assert.throws(() => assertErrorParity(expected, errorEvidence(error))) + assertErrorParity(expected, captured) + for (const field of ['loc', 'frame', 'message', 'other']) { + const left = errorEvidence(new Error('same')) + const right = errorEvidence( + Object.assign(new Error('same'), { [field]: 'different' }), + ) + assert.throws(() => assertErrorParity(left, right)) + } +}) + +test('a rejection stays observed behind a held gate and rethrows its exact cause on consumption', async () => { + // Given + const gate = Promise.withResolvers() + const cause = new Error('held rejection') + const consume = observeRejection(Promise.reject(cause)) + // When: cross a complete event-loop turn before releasing the unrelated work. + await new Promise(setImmediate) + gate.resolve() + await gate.promise + // Then + await assert.rejects(consume(), (error) => error === cause) +}) + +function fixture(t, archiveMarker) { + const root = mkdtempSync(join(tmpdir(), 'devup host guard ')) + t.after(() => rmSync(root, { recursive: true, force: true })) + const directory = join(root, 'consumer') + const host = hostPackSelection() + const tarballs = {} + const locations = new Map() + const lock = { lockfileVersion: 1, packages: {} } + const names = [ + '@devup-ui/binding', + host.name, + '@devup-ui/wasm', + '@devup-ui/plugin-utils', + '@devup-ui/react', + '@devup-ui/webpack-plugin', + '@devup-ui/next-plugin', + ] + mkdirSync(join(directory, 'node_modules/@devup-ui'), { recursive: true }) + mkdirSync(join(root, 'packs')) + for (const [index, name] of names.entries()) { + const source = join(root, `source-${index}`) + const payload = join(source, 'package') + mkdirSync(payload, { recursive: true }) + const manifest = { name, version: '0.1.0', main: 'index.js' } + if (name === '@devup-ui/binding') { + manifest.optionalDependencies = Object.fromEntries( + [host.name, ...host.omitted].map((item) => [item, '0.1.0']), + ) + } + writeFileSync( + join(payload, 'package.json'), + JSON.stringify(manifest, null, 2), + ) + writeFileSync( + join(payload, 'index.js'), + `/* retained ${name} */\r\nexport const value=1\r\n`, + ) + writeFileSync( + join(payload, 'index.js.map'), + JSON.stringify({ sources: [name], sourcesContent: ['original\r\n'] }), + ) + if (archiveMarker && name === '@devup-ui/react') { + const marker = join(payload, archiveMarker) + mkdirSync(dirname(marker), { recursive: true }) + writeFileSync(marker, 'archive-controlled marker') + } + const archive = join(root, 'packs', `package ${index}.tgz`) + execFileSync('tar', ['-czf', basename(archive), '-C', source, 'package'], { + cwd: dirname(archive), + timeout: 30000, + }) + const installed = join( + directory, + 'node_modules/.bun', + `slot-${index}`, + 'node_modules', + name, + ) + cpSync(payload, installed, { recursive: true }) + symlinkSync( + installed, + join(directory, 'node_modules', name), + process.platform === 'win32' ? 'junction' : 'dir', + ) + locations.set(name, installed) + tarballs[name] = `file:${archive.split(sep).join('/')}` + lock.packages[name] = [ + `${name}@${archive.split(sep).join('/')}`, + {}, + `sha512-${createHash('sha512').update(readFileSync(archive)).digest('base64')}`, + ] + } + for (const name of host.omitted) lock.packages[name] = [`${name}@0.1.0`, {}] + const saveLock = () => + writeFileSync(join(directory, 'bun.lock'), JSON.stringify(lock, null, 2)) + saveLock() + return { + root, + directory, + locations, + lock, + saveLock, + options: { + directory, + tarballs, + host, + bun: process.env.BUN_BINARY ?? 'bun', + }, + } +} + +function assertRejected(options, expected) { + assert.throws( + () => verifyHostInstall(options), + (error) => { + assert.ok(error instanceof HostPackError) + assert.equal(error.category, expected.category) + assert.equal(error.packageName, expected.packageName) + return true + }, + ) +} + +test('accepts all own tarballs with physical deduplication of isolated links', (t) => { + // Given + const fs = fixture(t) + // When + const receipt = verifyHostInstall(fs.options) + // Then + assert.equal(receipt.mode, 'host-only') + assert.deepEqual( + new Set(receipt.packages.map((item) => item.name)), + new Set(Object.keys(fs.options.tarballs)), + ) + assert.equal(receipt.packages.length, Object.keys(fs.options.tarballs).length) + assert.equal(receipt.tarballs.length, Object.keys(fs.options.tarballs).length) + assert.deepEqual(receipt.omitted, fs.options.host.omitted) +}) + +test('rejects a nested payload byte mismatch even when the root copy matches', (t) => { + // Given + const fs = fixture(t) + const packageName = '@devup-ui/react' + const nested = join( + fs.directory, + 'node_modules/tool/node_modules', + packageName, + ) + cpSync(fs.locations.get(packageName), nested, { recursive: true }) + writeFileSync(join(nested, 'index.js.map'), 'different map bytes') + // When / Then + assertRejected(fs.options, { category: 'byte-mismatch', packageName }) +}) + +test('rejects byte-identical registry provenance beside a valid file record', (t) => { + // Given + const fs = fixture(t) + const packageName = '@devup-ui/react' + cpSync( + fs.locations.get(packageName), + join(fs.directory, 'node_modules/tool/node_modules', packageName), + { recursive: true }, + ) + fs.lock.packages[`tool/${packageName}`] = [ + `${packageName}@0.1.0`, + {}, + 'https://registry.npmjs.org/byte-identical.tgz', + ] + fs.saveLock() + // When / Then + assertRejected(fs.options, { category: 'source-mismatch', packageName }) +}) + +test('rejects a byte-identical local archive from a different canonical source', (t) => { + // Given + const fs = fixture(t) + const packageName = '@devup-ui/plugin-utils' + const other = join(fs.root, 'other source.tgz') + const original = fs.options.tarballs[packageName].slice('file:'.length) + cpSync(original, other) + assert.deepEqual(readFileSync(other), readFileSync(original)) + fs.lock.packages[packageName] = [ + `${packageName}@${other.split(sep).join('/')}`, + {}, + fs.lock.packages[packageName][2], + ] + fs.saveLock() + // When / Then + assertRejected(fs.options, { category: 'source-mismatch', packageName }) +}) + +test('rejects a missing host sibling rather than treating optional omission as success', (t) => { + // Given + const fs = fixture(t) + const packageName = fs.options.host.name + rmSync(join(fs.directory, 'node_modules', packageName), { + recursive: true, + force: true, + }) + rmSync(fs.locations.get(packageName), { recursive: true, force: true }) + // When / Then + assertRejected(fs.options, { category: 'missing-host', packageName }) +}) + +test('rejects an unexpected nested Devup package outside the pack inventory', (t) => { + // Given + const fs = fixture(t) + const packageName = '@devup-ui/unexpected' + const extra = join( + fs.directory, + 'node_modules/tool/node_modules', + packageName, + ) + mkdirSync(extra, { recursive: true }) + writeFileSync( + join(extra, 'package.json'), + JSON.stringify({ name: packageName }), + ) + // When / Then + assertRejected(fs.options, { category: 'unexpected-package', packageName }) +}) + +test('rejects an orphan host cache entry that the binding cannot resolve', (t) => { + // Given + const fs = fixture(t) + const packageName = fs.options.host.name + rmSync(join(fs.directory, 'node_modules', packageName), { + recursive: true, + force: true, + }) + // When / Then + assertRejected(fs.options, { category: 'missing-host', packageName }) +}) + +test('rejects an installed nonhost sibling despite optional registry metadata being allowed', (t) => { + // Given + const fs = fixture(t) + const packageName = fs.options.host.omitted[0] + const extra = join(fs.directory, 'node_modules', packageName) + mkdirSync(extra, { recursive: true }) + writeFileSync( + join(extra, 'package.json'), + JSON.stringify({ name: packageName }), + ) + fs.lock.packages[packageName] = [`${packageName}@0.1.0`, {}] + fs.saveLock() + // When / Then + assertRejected(fs.options, { category: 'unexpected-package', packageName }) +}) + +test('accepts exact archive SRI markers on every installed Devup package', (t) => { + // Given + const fs = fixture(t) + for (const [name, directory] of fs.locations) { + writeFileSync( + join(directory, '.bun-tag'), + Buffer.from(fs.lock.packages[name][2]), + ) + } + // When + const receipt = verifyHostInstall(fs.options) + // Then + assert.deepEqual( + new Set(receipt.packages.map((item) => item.name)), + new Set(fs.locations.keys()), + ) +}) + +test('rejects marker bytes with an appended newline', (t) => { + // Given + const fs = fixture(t) + const packageName = '@devup-ui/wasm' + writeFileSync( + join(fs.locations.get(packageName), '.bun-tag'), + `${fs.lock.packages[packageName][2]}\n`, + ) + // When / Then + assertRejected(fs.options, { category: 'marker-bytes', packageName }) +}) + +test('rejects an unknown extra file beside an attested marker', (t) => { + // Given + const fs = fixture(t) + const packageName = '@devup-ui/plugin-utils' + const directory = fs.locations.get(packageName) + writeFileSync(join(directory, '.bun-tag'), fs.lock.packages[packageName][2]) + writeFileSync(join(directory, 'unknown-extra'), 'not in the archive') + // When / Then + assertRejected(fs.options, { category: 'byte-mismatch', packageName }) +}) + +test('rejects a marker symlink including a Windows directory junction', (t) => { + // Given + const fs = fixture(t) + const packageName = '@devup-ui/react' + const target = join(fs.root, 'marker-target') + mkdirSync(target) + symlinkSync( + target, + join(fs.locations.get(packageName), '.bun-tag'), + process.platform === 'win32' ? 'junction' : 'dir', + ) + // When / Then + assertRejected(fs.options, { category: 'marker-kind', packageName }) +}) + +test('rejects a marker directory', (t) => { + // Given + const fs = fixture(t) + const packageName = '@devup-ui/react' + mkdirSync(join(fs.locations.get(packageName), '.bun-tag')) + // When / Then + assertRejected(fs.options, { category: 'marker-kind', packageName }) +}) + +test('rejects a nested marker even with correct archive SRI bytes', (t) => { + // Given + const fs = fixture(t) + const packageName = '@devup-ui/wasm' + const nested = join(fs.locations.get(packageName), 'nested') + mkdirSync(nested) + writeFileSync(join(nested, '.bun-tag'), fs.lock.packages[packageName][2]) + // When / Then + assertRejected(fs.options, { category: 'marker-nested', packageName }) +}) + +for (const marker of ['.bun-tag', 'nested/.bun-tag']) { + test(`rejects an archive containing ${marker}`, (t) => { + // Given + const fs = fixture(t, marker) + const packageName = '@devup-ui/react' + // When / Then + assertRejected(fs.options, { category: 'archive-marker', packageName }) + }) +} + +for (const withMarker of [false, true]) { + test(`rejects mismatched lock integrity with marker ${withMarker}`, (t) => { + // Given + const fs = fixture(t) + const packageName = '@devup-ui/wasm' + if (withMarker) { + writeFileSync( + join(fs.locations.get(packageName), '.bun-tag'), + fs.lock.packages[packageName][2], + ) + } + fs.lock.packages[packageName][2] = + `sha512-${createHash('sha512').update('different archive').digest('base64')}` + fs.saveLock() + // When / Then + assertRejected(fs.options, { + category: 'lock-integrity-mismatch', + packageName, + }) + }) + + test(`rejects missing lock integrity with marker ${withMarker}`, (t) => { + // Given + const fs = fixture(t) + const packageName = '@devup-ui/wasm' + if (withMarker) { + writeFileSync( + join(fs.locations.get(packageName), '.bun-tag'), + fs.lock.packages[packageName][2], + ) + } + fs.lock.packages[packageName].pop() + fs.saveLock() + // When / Then + assertRejected(fs.options, { + category: 'lock-integrity-missing', + packageName, + }) + }) +} + +function rsbuildReceipt() { + return { + compilationProofs: [], + generations: [{ outputs: { file: { css: 'early' } } }], + } +} + +for (const [scope, fields] of [ + ['receipt', ['local', 'additionalPasses', 'deleted', 'errors', 'future']], + [ + 'generation', + [ + 'assets', + 'emitted', + 'http', + 'css', + 'served', + 'sheet', + 'classes', + 'files', + 'canonical', + 'disk', + 'residual', + 'source', + 'errors', + 'future', + ], + ], + [ + 'output', + ['code', 'map', 'cssFile', 'dependencies', 'updatedBaseStyle', 'future'], + ], +]) { + for (const field of fields) { + test(`Rsbuild rejects changed ${scope}.${field} outside its named exclusions`, () => { + // Given + const expected = rsbuildReceipt() + const actual = rsbuildReceipt() + for (const [receipt, value] of [ + [expected, 'before'], + [actual, 'after'], + ]) { + const targets = { + receipt, + generation: receipt.generations[0], + output: receipt.generations[0].outputs.file, + } + targets[scope][field] = value + } + // When / Then + assert.throws( + () => assertRsbuildParity(expected, actual), + assert.AssertionError, + ) + }) + } +} + +for (const [scope, field] of [ + ['receipt', 'compilationProofs'], + ['output', 'css'], +]) { + for (const missing of [false, true]) { + test(`Rsbuild ${scope}.${field} variation has missing raw field ${missing}`, () => { + // Given + const expected = rsbuildReceipt() + const actual = rsbuildReceipt() + const parent = { + receipt: actual, + output: actual.generations[0].outputs.file, + }[scope] + if (missing) delete parent[field] + else parent[field] = 'different intermediate observation' + const raw = structuredClone(actual) + // When / Then + const compare = () => assertRsbuildParity(expected, actual) + if (missing) assert.throws(compare, assert.AssertionError) + else assert.doesNotThrow(compare) + assert.deepEqual(actual, raw) + }) + } +} diff --git a/e2e/release/run.mjs b/e2e/release/run.mjs index 90b6610f9..be8a3fd39 100644 --- a/e2e/release/run.mjs +++ b/e2e/release/run.mjs @@ -12,6 +12,10 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { + packHostEngines, + verifyHostInstall, +} from '../plugins/engine-fixtures.mjs' import { prepareConsumer } from './consumer.mjs' import { CommandExitError, runGuarded } from './guarded-process.mjs' @@ -155,6 +159,16 @@ try { assert.equal(files.length, 1) tarballs[name] = `file:${join(destination, files[0]).replaceAll('\\', '/')}` } + const native = await packHostEngines({ repository, root, bun }, run) + for (const [name, reference] of Object.entries(native.tarballs)) { + tarballs[name.slice('@devup-ui/'.length)] = reference + } + const installForHost = (command, args, options) => + run( + command, + args[0] === 'install' ? [...args, ...native.host.installArgs] : args, + options, + ) const targets = ( process.env.RELEASE_TARGETS ?? 'vite,rsbuild,webpack,next-webpack,next-turbopack' @@ -162,7 +176,12 @@ try { for (const name of targets) { for (const singleCss of [false, true]) { try { - const directory = await prepareConsumer(name, { root, tarballs }, run) + const directory = await prepareConsumer( + name, + { root, tarballs }, + installForHost, + ) + verifyHostInstall({ directory, tarballs, host: native.host, bun }) for (const generated of ['df', 'dist', '.next', 'out']) await rm(join(directory, generated), { recursive: true, force: true }) await buildAndCheck(name, directory, singleCss) diff --git a/package.json b/package.json index 29d815322..410defde0 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "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": "node e2e/binding/native-coverage/run.mjs && 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:bun": "node -e \"require('node:fs').rmSync('coverage/root', { recursive: true, force: true })\" && bun test --coverage && bun run test:vite && node --test e2e/plugins/engine.test.mjs", "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", @@ -16,11 +16,11 @@ "test:release": "node e2e/release/run.mjs", "build:native:artifact": "node e2e/binding/package/provenance.mjs", "pack:native": "node e2e/binding/package/collect.mjs", - "test:native:package": "node --test e2e/binding/package/package.test.mjs e2e/binding/package/publish.test.mjs e2e/binding/package/elf.test.mjs", + "test:native:package": "node --test e2e/binding/package/package.test.mjs e2e/binding/package/publish.test.mjs e2e/binding/package/elf.test.mjs e2e/binding/package/build-host.test.mjs", "test:native:consumer": "node e2e/binding/package/consumer.mjs", "test:e2e:ui": "bunx playwright test --ui", "test:e2e:update": "bunx playwright test --update-snapshots", - "build": "bun run --filter @devup-ui/wasm --filter @devup-ui/plugin-utils build && bun run --filter @devup-ui/react --filter @devup-ui/webpack-plugin build && bun run --filter @devup-ui/eslint-plugin --filter @devup-ui/vite-plugin --filter @devup-ui/next-plugin --filter @devup-ui/rsbuild-plugin --filter @devup-ui/bun-plugin --filter @devup-ui/components --filter @devup-ui/reset-css build", + "build": "node e2e/binding/package/build-host.mjs && bun run --filter @devup-ui/wasm --filter @devup-ui/plugin-utils build && bun run --filter @devup-ui/react --filter @devup-ui/webpack-plugin build && bun run --filter @devup-ui/eslint-plugin --filter @devup-ui/vite-plugin --filter @devup-ui/next-plugin --filter @devup-ui/rsbuild-plugin --filter @devup-ui/bun-plugin --filter @devup-ui/components --filter @devup-ui/reset-css build", "dev": "bun run --filter '*' dev", "verify:dist": "bun verify-dist.ts", "benchmark": "bun benchmark.js", diff --git a/packages/bun-plugin/README.md b/packages/bun-plugin/README.md index 289863c3e..03b5d0223 100644 --- a/packages/bun-plugin/README.md +++ b/packages/bun-plugin/README.md @@ -60,6 +60,21 @@ module in the bundle. Imports of the packages Devup UI takes the place of Class names are short in `Bun.build` and readable under the runtime; pass `debug` to choose. +## Compiler Engine + +The native compiler is selected by default, including the zero-config preload. +To select WASM, use the configurable registration entry and await setup: + +```ts +import { register } from '@devup-ui/bun-plugin/register' +await register({ engine: 'wasm' }) +``` + +`DevupUI({ engine: 'wasm' })` selects WASM for `Bun.build` too. Native load +errors retain their cause and identify the missing platform package; they never +silently select WASM. Each factory retains its selected compiler for later loads +and theme reloads. Independent concurrent builds in one process are unsupported. + ## Custom Shorthands To configure custom shorthands, preload a local module instead of the diff --git a/packages/bun-plugin/package.json b/packages/bun-plugin/package.json index afe2f3fc4..9c3a13136 100644 --- a/packages/bun-plugin/package.json +++ b/packages/bun-plugin/package.json @@ -52,8 +52,5 @@ "devDependencies": { "@types/bun": "^1.4.4", "typescript": "^7.0.2" - }, - "peerDependencies": { - "@devup-ui/wasm": "*" } } diff --git a/packages/bun-plugin/src/__tests__/captured-hooks.test.ts b/packages/bun-plugin/src/__tests__/captured-hooks.test.ts index e9c149f63..f9f879f4d 100644 --- a/packages/bun-plugin/src/__tests__/captured-hooks.test.ts +++ b/packages/bun-plugin/src/__tests__/captured-hooks.test.ts @@ -24,7 +24,7 @@ it('releases a rejected setup and restores spies before subsequent registration' // Given: real plugin setup rejects after registering its real session end. const failure = new Error('initialization write rejected') hooks.write.mockRejectedValueOnce(failure) - await expect(register()).rejects.toBe(failure) + await expect(register({ engine: 'wasm' })).rejects.toBe(failure) hooks.expectedErrors.add(failure) let ended = 0 const terminals = hooks.ends.splice(0) @@ -42,7 +42,7 @@ it('releases a rejected setup and restores spies before subsequent registration' expect(Bun.plugin).toBe(realPlugin) expect(bunRegistration.clearAll).toBe(realPlugin.clearAll) hooks = captureBunHooks() - await register({ debug: false }) + await register({ engine: 'wasm', debug: false }) expect(hooks.loads.length).toBe(2) expect(wasm.isDebug()).toBe(false) }) @@ -50,7 +50,7 @@ it('releases a rejected setup and restores spies before subsequent registration' it('keeps unacknowledged setup failures as teardown errors after releasing sessions', async () => { const failure = new Error('unexpected setup failure') hooks.write.mockRejectedValueOnce(failure) - await expect(register()).rejects.toBe(failure) + await expect(register({ engine: 'wasm' })).rejects.toBe(failure) let ended = false hooks.ends.push(() => { ended = true diff --git a/packages/bun-plugin/src/__tests__/order-independent-build.helper.ts b/packages/bun-plugin/src/__tests__/order-independent-build.helper.ts index 11b5bc2e9..5c81cc5bd 100644 --- a/packages/bun-plugin/src/__tests__/order-independent-build.helper.ts +++ b/packages/bun-plugin/src/__tests__/order-independent-build.helper.ts @@ -75,7 +75,7 @@ function decorate(build: PluginBuilder): PluginBuilder { }) } -const genuine = DevupUI() +const genuine = DevupUI({ engine: 'wasm' }) const observed: BunPlugin = { ...genuine, async setup(build) { diff --git a/packages/bun-plugin/src/__tests__/plugin.test.ts b/packages/bun-plugin/src/__tests__/plugin.test.ts index 485d6b7dd..6e2ea54a2 100644 --- a/packages/bun-plugin/src/__tests__/plugin.test.ts +++ b/packages/bun-plugin/src/__tests__/plugin.test.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import * as nodePath from 'node:path' import { join } from 'node:path' +import * as pluginUtils from '@devup-ui/plugin-utils' import { ShorthandConfigError } from '@devup-ui/plugin-utils' -import * as wasm from '@devup-ui/wasm' import { afterAll, afterEach, @@ -19,13 +19,30 @@ import { bunRegistration } from '../../../../bun.setup' import { DevupUI, register } from '../plugin' import { captureBunHooks } from './captured-hooks.test-support' +const selectCompiler = pluginUtils.loadCompiler +const wasm = selectCompiler('wasm') + describe('shorthand creation boundary', () => { + it.each([undefined, 'native', 'wasm'] as const)( + 'selects engine=%s once per factory', + (engine) => { + const selection = spyOn(pluginUtils, 'loadCompiler').mockReturnValue(wasm) + try { + DevupUI(engine === undefined ? undefined : { engine }) + expect(selection.mock.calls).toEqual([[engine]]) + } finally { + selection.mockRestore() + selection.mockImplementation(selectCompiler) + } + }, + ) it('registers canonical targets when the plugin is registered', async () => { const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( undefined, ) try { await register({ + engine: 'wasm', shorthands: { bgx: ['backgroundColor', 'py', '--Gap'] }, }) expect(registration.mock.calls).toEqual([ @@ -41,6 +58,7 @@ describe('shorthand creation boundary', () => { }) it('rejects a typo before registration when runtime options bypass types', () => { + const selection = spyOn(pluginUtils, 'loadCompiler') const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( undefined, ) @@ -51,8 +69,11 @@ describe('shorthand creation boundary', () => { ]), ).toThrow(ShorthandConfigError) expect(registration).not.toHaveBeenCalled() + expect(selection).not.toHaveBeenCalled() } finally { registration.mockRestore() + selection.mockRestore() + selection.mockImplementation(selectCompiler) } }) }) @@ -97,6 +118,81 @@ async function load(path: string, namespace = 'file') { } describe('registered Bun hooks', () => { + it('compiles default runtime input into native state without mutating WASM', async () => { + const native = pluginUtils.loadCompiler() + native.resetStateForTesting() + const file = join(root, 'native-default.ts') + await Bun.write( + file, + "import {css} from '@devup-ui/react'; export const cls=css({color:'teal'});", + ) + const before = { + native: native.getCss(null, false), + wasm: wasm.getCss(null, false), + } + try { + await register({ debug: false }) + const result = await load(file) + const after = { + native: native.getCss(null, false), + wasm: wasm.getCss(null, false), + } + console.info( + JSON.stringify({ observed: 'native-default', before, after, result }), + ) + expect(before.native).not.toContain('color:teal') + expect(result && 'contents' in result && result.contents).not.toContain( + 'css(', + ) + expect(after.native).toContain('color:teal') + expect(after.wasm).toBe(before.wasm) + } finally { + native.resetStateForTesting() + } + }) + + it('keeps retained source callbacks on their factory compiler', async () => { + const native = pluginUtils.loadCompiler() + native.resetStateForTesting() + const file = join(root, 'retained-native.ts') + await Bun.write( + file, + "import {css} from '@devup-ui/react'; export const cls=css({color:'teal'});", + ) + try { + await register({ engine: 'native', debug: false }) + const retained = loads.splice(0) + await register({ engine: 'wasm', debug: false }) + const before = { + native: native.getCss(null, false), + wasm: wasm.getCss(null, false), + } + const source = retained.find(({ options }) => !options.namespace) + if (!source) throw new Error('Missing retained native source callback') + const result = await source.callback({ + path: file, + namespace: 'file', + loader: 'file', + defer: async () => {}, + }) + const after = { + native: native.getCss(null, false), + wasm: wasm.getCss(null, false), + } + console.info( + JSON.stringify({ observed: 'retained-native', before, after, result }), + ) + expect(before.native).not.toContain('color:teal') + expect(result && 'contents' in result && result.contents).not.toContain( + 'css(', + ) + expect(after.native).toContain('color:teal') + expect(after.wasm).toBe(before.wasm) + } finally { + native.resetStateForTesting() + } + }) + it('seeds readable names from physical inputs before runtime source loads', async () => { const file = join(root, 'numbered.tsx') await Bun.write( @@ -108,7 +204,7 @@ describe('registered Bun hooks', () => { paths.length === 1 && paths[0] === 'src' ? root : realResolve(...paths), ) try { - await register() + await register({ engine: 'wasm' }) expect(Object.keys(JSON.parse(wasm.exportFileMap()))).toContain(file) expect(wasm.isDebug()).toBe(true) } finally { @@ -122,7 +218,7 @@ describe('registered Bun hooks', () => { }) it('reserves short runtime names during awaited setup', async () => { - await register({ debug: false }) + await register({ engine: 'wasm', debug: false }) expect(wasm.isDebug()).toBe(false) expect(loads.length).toBe(2) }) @@ -131,7 +227,7 @@ describe('registered Bun hooks', () => { const path = join(root, 'malformed.ts') const source = 'import {' await Bun.write(path, source) - await register() + await register({ engine: 'wasm' }) expect(await load(path)).toEqual({ contents: source, loader: 'ts' }) expect(writeSync).not.toHaveBeenCalled() }) @@ -149,7 +245,7 @@ describe('registered Bun hooks', () => { target: 'bun', external: ['react/jsx-dev-runtime', 'react/jsx-runtime'], outdir: join(root, 'out'), - plugins: [DevupUI()], + plugins: [DevupUI({ engine: 'wasm' })], }) expect(result.success).toBe(true) expect(result.logs).toEqual([]) @@ -166,7 +262,7 @@ describe('registered Bun hooks', () => { }) it('creates cold directories before writing configuration and CSS', async () => { - await register() + await register({ engine: 'wasm' }) expect(mkdir).toHaveBeenCalledWith('df', { recursive: true }) expect(mkdir).toHaveBeenCalledWith(expect.stringContaining('devup-ui'), { recursive: true, @@ -184,7 +280,7 @@ describe('registered Bun hooks', () => { readConfig.mockResolvedValue( '{"theme":{"colors":{"default":{"primary":"#123456"}}}}', ) - await register() + await register({ engine: 'wasm' }) expect(wasm.getCss(null, false)).toContain('#123456') expect(mkdir).not.toHaveBeenCalled() }) @@ -192,12 +288,12 @@ describe('registered Bun hooks', () => { it('registers an empty theme when reading configuration fails', async () => { exists.mockReturnValue(true) readConfig.mockRejectedValue(new Error('unreadable fixture config')) - await register() + await register({ engine: 'wasm' }) expect(wasm.getDefaultTheme()).toBeUndefined() }) it('resolves generated stylesheets and loads their runtime placeholder', async () => { - await register() + await register({ engine: 'wasm' }) const resolve = resolves[0] if (!resolve) throw new Error('No CSS resolver registered') const result = await resolve({ @@ -232,7 +328,7 @@ describe('registered Bun hooks', () => { `source.${extension}`, ) await Bun.write(path, source) - await register() + await register({ engine: 'wasm' }) const result = await load(path) if (!result || !('contents' in result)) throw new Error('Expected transformed source contents') @@ -251,7 +347,7 @@ describe('registered Bun hooks', () => { it('returns source without style imports unchanged', async () => { const path = join(root, 'plain.ts') await Bun.write(path, 'export const value = 42') - await register() + await register({ engine: 'wasm' }) expect(await load(path)).toEqual({ contents: 'export const value = 42', loader: 'ts', @@ -260,7 +356,7 @@ describe('registered Bun hooks', () => { }) it('propagates source read errors instead of returning partial output', async () => { - await register() + await register({ engine: 'wasm' }) await expect(load(join(root, 'missing.ts'))).rejects.toThrow() }) @@ -270,7 +366,7 @@ describe('registered Bun hooks', () => { path, "import { css } from '@devup-ui/react'; export const styles = css({ color: window.name })", ) - await register() + await register({ engine: 'wasm' }) await expect(load(path)).rejects.toThrow(/invalid\.ts:\d+:\d+:/) }) }) diff --git a/packages/bun-plugin/src/__tests__/reservation-lifecycle.test.ts b/packages/bun-plugin/src/__tests__/reservation-lifecycle.test.ts index bb18ca851..5a83376c8 100644 --- a/packages/bun-plugin/src/__tests__/reservation-lifecycle.test.ts +++ b/packages/bun-plugin/src/__tests__/reservation-lifecycle.test.ts @@ -50,7 +50,7 @@ writeFileSync('src/Unused.tsx','import {Box} from "@devup-ui/react"; target.onStart(()=>{events.push('devup-onStart-enter');const result=callback();events.push('devup-onStart-exit');return result;}); @@ -85,7 +85,7 @@ writeFileSync('node_modules/@devup-ui/reservation-fixture/dist/index.js','import writeFileSync('src/Entry.tsx','export {built} from "@devup-ui/reservation-fixture";'); const outputs=[]; for(let i=0;i<2;i++){ - const result=await Bun.build({entrypoints:['./src/Entry.tsx'],plugins:[DevupUI()]}); + const result=await Bun.build({entrypoints:['./src/Entry.tsx'],plugins:[DevupUI({ engine: 'wasm' })]}); assert(result.success,result.logs.map(String).join('\n')); assert.equal(engine.getUnreservedClassSlotCount(),0); const css=(await Promise.all(result.outputs.filter(o=>o.path.endsWith('.css')).map(o=>o.text()))).join('\n'); @@ -108,7 +108,7 @@ const engine=await import(${JSON.stringify(wasmUrl)}); writeFileSync('src/A.ts','import {css} from "@devup-ui/react"; export const cls=css({color:"red"});'); writeFileSync('src/B.ts','import {css} from "@devup-ui/react"; export const cls=css({background:"blue"});'); writeFileSync('src/Unused.ts','import {css} from "@devup-ui/react"; export const cls=css({color:"magenta"});'); -await register(${mode === 'short' ? '{debug:false}' : '{}'}); +await register(${mode === 'short' ? '{engine:"wasm",debug:false}' : '{engine:"wasm"}'}); assert.equal(engine.isDebug(),${mode !== 'short'}); const b=await import(process.cwd()+'/src/B.ts'); const a=await import(process.cwd()+'/src/A.ts'); diff --git a/packages/bun-plugin/src/__tests__/reservation-roots.cjs b/packages/bun-plugin/src/__tests__/reservation-roots.cjs index c94f4ade7..ce7d770f5 100644 --- a/packages/bun-plugin/src/__tests__/reservation-roots.cjs +++ b/packages/bun-plugin/src/__tests__/reservation-roots.cjs @@ -27,7 +27,7 @@ async function main() { const result = await build({ entrypoints: ['./index.ts'], outdir: './out', - plugins: [DevupUI()], + plugins: [DevupUI({ engine: 'wasm' })], }) assert(result.success, result.logs.map(String).join('\n')) for (const output of result.outputs) { @@ -36,7 +36,10 @@ async function main() { writeFileSync(output.path, bytes) } } else { - await register(mode === 'default' ? {} : { debug: false }) + await register({ + engine: 'wasm', + ...(mode === 'default' ? {} : { debug: false }), + }) const module = await import( resolve(mode === 'outside' ? 'outside.ts' : 'src/A.ts') ) diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index b425d8469..284772623 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -10,7 +10,10 @@ import { createSharedSlotReservation, createThemeInterfaceArgs, type CustomShorthands, + type DevupCompiler, + type DevupEngine, isProjectJsx, + loadCompiler, loadDevupConfig, mergeImportAliases, normalizeShorthands, @@ -19,20 +22,6 @@ import { tailwindCssFiles, withTailwindCss, } from '@devup-ui/plugin-utils' -import { - codeExtract, - getCss, - getThemeInterface, - hasDevupUI, - registerShorthands, - registerTheme, - reserveClassSlots, - resetBuildState, - seedFileMap, - setDebug, - setModuleResolver, - setPrefix, -} from '@devup-ui/wasm' import { type BunPlugin, plugin, type PluginBuilder } from 'bun' import { cssDirName, cssNamespace, resolveCssId } from './css-id' @@ -53,6 +42,8 @@ const compiledPackages = [ ] export interface DevupUIBunPluginOptions { + /** Native by default. Select WASM explicitly without automatic fallback. */ + engine?: DevupEngine shorthands?: CustomShorthands /** * Readable class names. Defaults to `true` under the Bun runtime (tests) and @@ -63,7 +54,7 @@ export interface DevupUIBunPluginOptions { type SourceLoader = 'tsx' | 'ts' | 'jsx' | 'js' -async function writeDataFiles(): Promise { +async function writeDataFiles(compiler: DevupCompiler): Promise { let theme = {} let tailwindFiles: string[] = [] try { @@ -73,28 +64,34 @@ async function writeDataFiles(): Promise { } catch { // Error reading devup.json, use empty theme } - registerTheme(theme) + compiler.registerTheme(theme) // Generate theme interface after registration (always write, even if empty) await writeFile( join(distDir, 'theme.d.ts'), - getThemeInterface(...createThemeInterfaceArgs(libPackage)), + compiler.getThemeInterface(...createThemeInterfaceArgs(libPackage)), 'utf-8', ) if (!existsSync(cssDir)) { await mkdir(cssDir, { recursive: true }) } - await writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') + await writeFile( + join(cssDir, 'devup-ui.css'), + compiler.getCss(null, false), + 'utf-8', + ) return tailwindFiles } -const watchTailwindCss = createTailwindWatcher(writeDataFiles) - -async function initialize(shorthands: Record) { - registerShorthands(shorthands) - setPrefix(null) - setModuleResolver(createModuleResolver()) +async function initialize( + compiler: DevupCompiler, + shorthands: Record, + watchTailwindCss: ReturnType, +) { + compiler.registerShorthands(shorthands) + compiler.setPrefix(null) + compiler.setModuleResolver(createModuleResolver()) if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') await writeFile( @@ -102,7 +99,7 @@ async function initialize(shorthands: Record) { createCompatTypes(importAliases), 'utf-8', ) - watchTailwindCss(await writeDataFiles()) + watchTailwindCss(await writeDataFiles(compiler)) } const scanners = new Map() @@ -128,7 +125,11 @@ function importsCompiledPackage(contents: string, loader: SourceLoader) { } } -async function loadSourceFile(filePath: string, bundling: boolean) { +async function loadSourceFile( + compiler: DevupCompiler, + filePath: string, + bundling: boolean, +) { const loader: SourceLoader = filePath.endsWith('.tsx') ? 'tsx' : filePath.endsWith('.ts') @@ -141,9 +142,9 @@ async function loadSourceFile(filePath: string, bundling: boolean) { if ( isProjectJsx(filePath, importAliases) || importsCompiledPackage(contents, loader) || - hasDevupUI(filePath, contents, libPackage, importAliases) + compiler.hasDevupUI(filePath, contents, libPackage, importAliases) ) { - const code = codeExtract( + using code = compiler.codeExtract( filePath, contents, libPackage, @@ -157,7 +158,11 @@ async function loadSourceFile(filePath: string, bundling: boolean) { // every extracted style in the base sheet; synchronous writes keep // concurrent source loads from overwriting a newer sheet with an older one. if (!bundling) - writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') + writeFileSync( + join(cssDir, 'devup-ui.css'), + compiler.getCss(null, false), + 'utf-8', + ) return { contents: code.code, loader } } return { contents, loader } @@ -169,6 +174,10 @@ async function loadSourceFile(filePath: string, bundling: boolean) { */ function DevupUI(options: DevupUIBunPluginOptions = {}) { const shorthands = normalizeShorthands(options.shorthands ?? {}) + const compiler = loadCompiler(options.engine) + const watchTailwindCss = createTailwindWatcher( + writeDataFiles.bind(undefined, compiler), + ) return { name: 'devup-ui', @@ -177,12 +186,12 @@ function DevupUI(options: DevupUIBunPluginOptions = {}) { const bundling = build.config !== undefined // A build starts from its own options, not from what an earlier build in // this process left in the engine - const endBuild = beginBuild({ resetBuildState }) + const endBuild = beginBuild(compiler) build.onEnd?.(endBuild) - await initialize(shorthands) + await initialize(compiler, shorthands, watchTailwindCss) const debug = options.debug ?? !bundling - setDebug(debug) - const slots = createSharedSlotReservation({ reserveClassSlots }) + compiler.setDebug(debug) + const slots = createSharedSlotReservation(compiler) let reservationPending = bundling const reserveFiles = () => { slots.invalidate() @@ -217,7 +226,7 @@ function DevupUI(options: DevupUIBunPluginOptions = {}) { else { try { seedFileNumbers( - { seedFileMap }, + compiler, collectNumberedFiles({ roots: [resolve('src')], needles: compiledPackages, @@ -247,7 +256,7 @@ function DevupUI(options: DevupUIBunPluginOptions = {}) { async ({ defer }) => { if (!bundling) return { contents: '', loader: 'js' } await defer() - return { contents: getCss(null, false), loader: 'css' } + return { contents: compiler.getCss(null, false), loader: 'css' } }, ) @@ -261,7 +270,7 @@ function DevupUI(options: DevupUIBunPluginOptions = {}) { reserveFiles() reservationPending = false } - return loadSourceFile(path, bundling) + return loadSourceFile(compiler, path, bundling) }, ) }, diff --git a/packages/next-plugin/README.md b/packages/next-plugin/README.md index facbc7247..631d06541 100644 --- a/packages/next-plugin/README.md +++ b/packages/next-plugin/README.md @@ -205,6 +205,20 @@ custom `distDir`) to `include`. ``` +## Extraction engine + +The build-time compiler defaults to `native` for Webpack and Turbopack. Select +WASM explicitly with `DevupUI(nextConfig, { engine: 'wasm' })`. The chosen engine +is retained by configuration, prewarm, extraction, CSS and delayed callbacks. +Turbopack loader options carry only the engine string; the coordinator retains +the selected physical compiler. Independent concurrent builds in one process +are unsupported. + +Native load failures identify the required platform package and retain the +original cause. There is no automatic fallback to WASM. Install optional native +dependencies normally, or choose WASM explicitly for an unsupported platform. +Both engines extract CSS during the build and add no client styling runtime. + ## Turbopack build profiling Set `DEVUP_UI_PROFILE=1` for an opt-in, structured timing log during a @@ -221,6 +235,6 @@ $env:DEVUP_UI_PROFILE = '1'; bun run build Each `[devup-ui:profile]` JSON entry reports one phase. `next.graph` measures the static import-graph pre-pass, `next.prewarm` measures production extraction before Turbopack starts loaders, and `coordinator.extract` separates request, -WASM extraction, CSS/state serialization, and write time for each loaded +compiler extraction, CSS/state serialization, and write time for each loaded module. The setting is disabled by default and does not collect timings or write logs when it is absent. diff --git a/packages/next-plugin/package.json b/packages/next-plugin/package.json index 27f4e4078..fc8bd81e1 100644 --- a/packages/next-plugin/package.json +++ b/packages/next-plugin/package.json @@ -61,7 +61,6 @@ }, "peerDependencies": { "@devup-ui/webpack-plugin": "*", - "next": "*", - "@devup-ui/wasm": "*" + "next": "*" } } diff --git a/packages/next-plugin/src/__tests__/native-webpack-observer.cjs b/packages/next-plugin/src/__tests__/native-webpack-observer.cjs index 5e34da6ac..08884eb05 100644 --- a/packages/next-plugin/src/__tests__/native-webpack-observer.cjs +++ b/packages/next-plugin/src/__tests__/native-webpack-observer.cjs @@ -2,7 +2,7 @@ const assert = require('node:assert/strict') const { EventEmitter } = require('node:events') const { appendFileSync } = require('node:fs') const { join } = require('node:path') -const wasm = require('@devup-ui/wasm') +const wasm = require('@devup-ui/plugin-utils').loadCompiler() const { DevupUIWebpackPlugin } = require('@devup-ui/webpack-plugin') const { parseClasses, parseSlots } = require('./native-webpack-receipt.ts') const { assertReserved } = require('./native-webpack-checks.ts') diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 6baa9b6b0..66270541c 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from 'node:child_process' import * as fs from 'node:fs' import { join, resolve } from 'node:path' @@ -16,12 +17,17 @@ import { } from 'bun:test' import * as coordinatorModule from '../coordinator' +import cssLoader, { resetInit as resetCssInit } from '../css-loader' import { DevupUI, reloadTurboSetupModuleForTesting, resetTurboSetupCacheForTesting, } from '../plugin' -import { setWasmForTesting, setWebpackPluginForTesting } from '../wasm' +import { + loadWasm, + setWasmForTesting, + setWebpackPluginForTesting, +} from '../wasm' type CodeExtractResult = ReturnType type NextWebpackConfig = Parameters< @@ -247,7 +253,155 @@ describe('DevupUINextPlugin', () => { expect(startCoordinatorSpy).toHaveBeenCalledTimes(1) }) + it('prewarms and serves actual default Turbo extraction through native state', () => { + const child = spawnSync( + process.execPath, + [ + '-e', + ` + import assert from 'node:assert/strict'; + import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + import { DevupUI } from ${JSON.stringify(new URL('../plugin.ts', import.meta.url).href)}; + import loader from ${JSON.stringify(new URL('../loader.ts', import.meta.url).href)}; + import { loadWasm, withModuleResolver } from ${JSON.stringify(new URL('../wasm.ts', import.meta.url).href)}; + import { flushCoordinatorWrites, resetCoordinator } from ${JSON.stringify(new URL('../coordinator.ts', import.meta.url).href)}; + import { loadCompiler } from ${JSON.stringify(new URL('../../../plugin-utils/src/index.ts', import.meta.url).href)}; + const native = loadCompiler('native'), wasm = loadCompiler('wasm'); + const primedWasm = loadWasm('wasm'); // Resolve the old loader dependency at its installed location. + const cwd = process.cwd(), root = mkdtempSync(join(tmpdir(), 'devup-next-native-')); + try { + mkdirSync(join(root, 'src/app'), { recursive: true }); + writeFileSync(join(root, 'tsconfig.json'), JSON.stringify({ compilerOptions: { jsx: 'react-jsx' }, include: ['src'] })); + writeFileSync(join(root, 'src/tokens.ts'), "export const tone = 'teal';"); + const source = "import {Box} from '@devup-ui/react'; import {tone} from '../tokens'; export default function Page(){return ;}"; + const resourcePath = join(root, 'src/app/page.tsx'); + writeFileSync(resourcePath, source); + process.chdir(root); + native.resetStateForTesting(); wasm.resetStateForTesting(); + withModuleResolver(primedWasm); // Match normal fixed-cwd supplier resolution after fixture setup. + const before = { native: native.getCss(null, false), wasm: wasm.getCss(null, false) }; + const config = DevupUI({}, { singleCss: true, debug: false }); + const options = config.turbopack.rules['*.{tsx,ts,jsx,js,mjs}'].loaders[0].options; + const code = await new Promise((resolve, reject) => loader.call({ + resourcePath, getOptions: () => options, addDependency() {}, + async: () => (error, code) => error ? reject(error) : resolve(code), + }, Buffer.from(source))); + const after = { native: native.getCss(null, false), wasm: wasm.getCss(null, false) }; + console.info(JSON.stringify({ observed: 'next-default-native', before, after, code })); + assert.equal(typeof code, 'string'); assert(!code.includes(' { + resetCssInit() + const failure = new Error('consumer callback failure', { + cause: new Error('consumer cause'), + }) + let callbacks = 0 + const context = { + resourcePath: 'df/devup-ui/devup-ui.css', + getOptions: () => ({ + watch: false, + defaultFileMap: {}, + defaultClassMap: {}, + defaultSheet: {}, + }), + callback() { + callbacks++ + throw failure + }, + } + expect(() => + Reflect.apply(cssLoader, context, [Buffer.from('ready')]), + ).toThrow(failure) + expect(callbacks).toBe(1) + resetCssInit() + }) + + it('retains original CSS compiler failure and cause in one callback', () => { + resetCssInit() + const cause = new Error('compiler cause') + const failure = new Error('compiler failure', { cause }) + getCssSpy.mockImplementation(() => { + throw failure + }) + const received: (Error | null)[] = [] + Reflect.apply( + cssLoader, + { + resourcePath: 'df/devup-ui/devup-ui.css', + getOptions: () => ({ + watch: true, + sheetFile: 'sheet', + classMapFile: 'classes', + fileMapFile: 'files', + themeFile: 'theme', + }), + callback(error: Error | null) { + received.push(error) + }, + }, + [Buffer.from('ready')], + ) + expect(received).toEqual([failure]) + expect(received[0]?.cause).toBe(cause) + resetCssInit() + }) + + it('caches each selected physical compiler and rejects invalid engines', () => { + setWasmForTesting(undefined) + const native = importGraphModule.loadCompiler('native') + const explicitWasm = importGraphModule.loadCompiler('wasm') + expect(loadWasm()).toBe(native) + expect(loadWasm('native')).toBe(native) + expect(loadWasm('wasm')).toBe(explicitWasm) + expect(loadWasm('wasm')).toBe(explicitWasm) + expect(native).not.toBe(explicitWasm) + expect(() => Reflect.apply(loadWasm, undefined, ['other'])).toThrow( + importGraphModule.InvalidEngineError, + ) + expect(() => Reflect.apply(loadWasm, undefined, [null])).toThrow( + importGraphModule.InvalidEngineError, + ) + }) + describe('webpack', () => { + it.each(['native', 'wasm'] as const)( + 'retains %s in a deferred Webpack callback', + (engine) => { + delete process.env.TURBOPACK + const options = { engine } + const config = DevupUI({}, options) + options.engine = engine === 'native' ? 'wasm' : 'native' + config.webpack?.(createWebpackConfig(), createWebpackContext()) + expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ engine }), + ) + }, + ) + it.each([ ['omitted', {}], ['undefined', { atomHoist: undefined }], @@ -265,6 +419,7 @@ describe('DevupUINextPlugin', () => { // Then: explicit zero/thresholds and singleCss reach the real adapter seam. expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ ...options, + engine: 'native', singleCss: true, cssDir: resolve('.next/cache', 'devup-ui'), productionPrewarmFiles: [], @@ -279,6 +434,7 @@ describe('DevupUINextPlugin', () => { expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ cssDir: resolve('.next/cache', 'devup-ui'), + engine: 'native', productionPrewarmFiles: [], }) }) @@ -290,6 +446,7 @@ describe('DevupUINextPlugin', () => { expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ cssDir: resolve('df', 'devup-ui_tmpBuildId'), + engine: 'native', watch: true, }) }) @@ -306,6 +463,7 @@ describe('DevupUINextPlugin', () => { expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ package: 'new-package', + engine: 'native', cssDir: resolve('.next/cache', 'devup-ui'), productionPrewarmFiles: [], }) @@ -326,6 +484,7 @@ describe('DevupUINextPlugin', () => { expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ package: 'new-package', + engine: 'native', cssDir: resolve('.next/cache', 'devup-ui'), productionPrewarmFiles: [], }) @@ -520,6 +679,7 @@ describe('DevupUINextPlugin', () => { { loader: '@devup-ui/next-plugin/css-loader', options: { + engine: 'native', watch: false, coordinatorPortFile: join('df', 'coordinator.port'), sheetFile: join('df', 'sheet.json'), @@ -545,6 +705,7 @@ describe('DevupUINextPlugin', () => { { loader: '@devup-ui/next-plugin/loader', options: { + engine: 'native', package: '@devup-ui/react', cssDir: resolve('df', 'devup-ui'), coordinatorPortFile: join('df', 'coordinator.port'), @@ -606,6 +767,7 @@ describe('DevupUINextPlugin', () => { { loader: '@devup-ui/next-plugin/css-loader', options: { + engine: 'native', watch: false, coordinatorPortFile: join('df', 'coordinator.port'), sheetFile: join('df', 'sheet.json'), @@ -643,6 +805,7 @@ describe('DevupUINextPlugin', () => { { loader: '@devup-ui/next-plugin/loader', options: { + engine: 'native', package: '@devup-ui/react', cssDir: resolve('df', 'devup-ui'), coordinatorPortFile: join('df', 'coordinator.port'), @@ -700,6 +863,7 @@ describe('DevupUINextPlugin', () => { { loader: '@devup-ui/next-plugin/css-loader', options: { + engine: 'native', watch: false, coordinatorPortFile: join('df', 'coordinator.port'), sheetFile: join('df', 'sheet.json'), @@ -737,6 +901,7 @@ describe('DevupUINextPlugin', () => { { loader: '@devup-ui/next-plugin/loader', options: { + engine: 'native', package: '@devup-ui/react', cssDir: resolve('df', 'devup-ui'), coordinatorPortFile: join('df', 'coordinator.port'), diff --git a/packages/next-plugin/src/css-loader.ts b/packages/next-plugin/src/css-loader.ts index b74038ca3..5a414c8cd 100644 --- a/packages/next-plugin/src/css-loader.ts +++ b/packages/next-plugin/src/css-loader.ts @@ -1,7 +1,11 @@ import { existsSync, readFileSync } from 'node:fs' import { Agent, request } from 'node:http' -import { getFileNumByFilename } from '@devup-ui/plugin-utils' +import { + type DevupCompiler, + type DevupEngine, + getFileNumByFilename, +} from '@devup-ui/plugin-utils' import type { RawLoaderDefinitionFunction } from 'webpack' import { @@ -13,6 +17,7 @@ import { import { loadWasm } from './wasm' export interface DevupUICssLoaderOptions { + engine?: DevupEngine // turbo watch: boolean coordinatorPortFile?: string @@ -26,7 +31,7 @@ export interface DevupUICssLoaderOptions { defaultFileMap: object } -let init = false +let initializedCompilers = new WeakSet() let cachedPort: number | null = null const keepAliveAgent = new Agent({ keepAlive: true }) @@ -75,6 +80,7 @@ const devupUICssLoader: RawLoaderDefinitionFunction = function (source, map, meta) { const { watch, + engine, coordinatorPortFile, sheetFile, classMapFile, @@ -135,48 +141,53 @@ const devupUICssLoader: RawLoaderDefinitionFunction = return } - const { - getCss, - importClassMap, - importFileMap, - importSheet, - registerTheme, - } = loadWasm() - if (!init) { - init = true - if (watch) { - // restart loader issue - // loader should read files when they exist in watch mode - if (existsSync(sheetFile)) - importSheet(JSON.parse(readFileSync(sheetFile, 'utf-8'))) - if (existsSync(classMapFile)) - importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8'))) - if (existsSync(fileMapFile)) - importFileMap(JSON.parse(readFileSync(fileMapFile, 'utf-8'))) - if (existsSync(themeFile)) - registerTheme( - JSON.parse(readFileSync(themeFile, 'utf-8'))?.['theme'] ?? {}, - ) - } else { - importFileMap(defaultFileMap) - importClassMap(defaultClassMap) - importSheet(defaultSheet) - registerTheme(theme) + let content: string | typeof source + try { + const compiler = loadWasm(engine) + const { + getCss, + importClassMap, + importFileMap, + importSheet, + registerTheme, + } = compiler + if (!initializedCompilers.has(compiler)) { + if (watch) { + // restart loader issue + // loader should read files when they exist in watch mode + if (existsSync(sheetFile)) + importSheet(JSON.parse(readFileSync(sheetFile, 'utf-8'))) + if (existsSync(classMapFile)) + importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8'))) + if (existsSync(fileMapFile)) + importFileMap(JSON.parse(readFileSync(fileMapFile, 'utf-8'))) + if (existsSync(themeFile)) + registerTheme( + JSON.parse(readFileSync(themeFile, 'utf-8'))?.['theme'] ?? {}, + ) + } else { + importFileMap(defaultFileMap) + importClassMap(defaultClassMap) + importSheet(defaultSheet) + registerTheme(theme) + } + initializedCompilers.add(compiler) } - } - this.callback( - null, - !watch ? source : getCss(getFileNumByFilename(this.resourcePath), true), - map, - meta, - ) + content = !watch + ? source + : getCss(getFileNumByFilename(this.resourcePath), true) + } catch (error) { + this.callback(error instanceof Error ? error : new Error(String(error))) + return + } + this.callback(null, content, map, meta) } export default devupUICssLoader /** @internal Reset init state for testing purposes only */ export const resetInit = () => { - init = false + initializedCompilers = new WeakSet() cachedPort = null } diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index 1843997fb..917b43c87 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -5,11 +5,14 @@ import { basename, dirname, join, relative, resolve } from 'node:path' import { createStateWriter, + type DevupCompiler, + type DevupEngine, tailwindCssFiles, withTailwindCss, } from '@devup-ui/plugin-utils' import type { RawLoaderDefinitionFunction } from 'webpack' +import { takeExtractOutput } from './coordinator' import { isConnectionError, missingPortFileError, @@ -23,6 +26,7 @@ const stateWriter = createStateWriter((path, content, encoding) => ) export interface DevupUILoaderOptions { + engine?: DevupEngine package: string cssDir: string sheetFile: string @@ -39,7 +43,7 @@ export interface DevupUILoaderOptions { defaultFileMap: object importAliases?: Record } -let init = false +let initializedCompilers = new WeakSet() const cachedPorts = new Map() const keepAliveAgent = new Agent({ keepAlive: true }) @@ -142,6 +146,7 @@ const devupUILoader: RawLoaderDefinitionFunction = function (source) { const { watch, + engine, package: libPackage, cssDir, sheetFile, @@ -218,49 +223,50 @@ const devupUILoader: RawLoaderDefinitionFunction = return } - // Non-coordinator mode: local WASM extraction - const { - codeExtract, - exportClassMap, - exportFileMap, - exportSheet, - getCss, - importClassMap, - importFileMap, - importSheet, - registerTheme, - } = loadWasm() - const promises: Promise[] = [] - if (!init) { - init = true - if (watch) { - this.addDependency(sheetFile) - this.addDependency(classMapFile) - this.addDependency(fileMapFile) - this.addDependency(themeFile) - // restart loader issue - // loader should read files when they exist in watch mode - if (existsSync(sheetFile)) - importSheet(JSON.parse(readFileSync(sheetFile, 'utf-8'))) - if (existsSync(classMapFile)) - importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8'))) - if (existsSync(fileMapFile)) - importFileMap(JSON.parse(readFileSync(fileMapFile, 'utf-8'))) - if (existsSync(themeFile)) { - const config = JSON.parse(readFileSync(themeFile, 'utf-8')) ?? {} - registerTheme(withTailwindCss(config.theme ?? {}, config)) - for (const file of tailwindCssFiles(config)) this.addDependency(file) - } - } else { - importFileMap(defaultFileMap) - importClassMap(defaultClassMap) - importSheet(defaultSheet) - registerTheme(theme) - } - } - + // Non-coordinator mode retains one selected compiler through callbacks. const callback = this.async() try { + const compiler = loadWasm(engine) + const { + codeExtract, + exportClassMap, + exportFileMap, + exportSheet, + getCss, + importClassMap, + importFileMap, + importSheet, + registerTheme, + } = compiler + const promises: Promise[] = [] + if (!initializedCompilers.has(compiler)) { + if (watch) { + this.addDependency(sheetFile) + this.addDependency(classMapFile) + this.addDependency(fileMapFile) + this.addDependency(themeFile) + // restart loader issue + // loader should read files when they exist in watch mode + if (existsSync(sheetFile)) + importSheet(JSON.parse(readFileSync(sheetFile, 'utf-8'))) + if (existsSync(classMapFile)) + importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8'))) + if (existsSync(fileMapFile)) + importFileMap(JSON.parse(readFileSync(fileMapFile, 'utf-8'))) + if (existsSync(themeFile)) { + const config = JSON.parse(readFileSync(themeFile, 'utf-8')) ?? {} + registerTheme(withTailwindCss(config.theme ?? {}, config)) + for (const file of tailwindCssFiles(config)) + this.addDependency(file) + } + } else { + importFileMap(defaultFileMap) + importClassMap(defaultClassMap) + importSheet(defaultSheet) + registerTheme(theme) + } + initializedCompilers.add(compiler) + } const id = this.resourcePath let relCssDir = relative(dirname(id), cssDir).replaceAll('\\', '/') @@ -269,22 +275,25 @@ const devupUILoader: RawLoaderDefinitionFunction = const relativePath = relative(process.cwd(), id).replaceAll('\\', '/') if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` + const snapshot = takeExtractOutput( + codeExtract( + relativePath, + source.toString(), + libPackage, + relCssDir, + singleCss, + false, + true, + importAliases, + ), + ) const { code, map, cssFile, updatedBaseStyle, dependencies = [], - } = codeExtract( - relativePath, - source.toString(), - libPackage, - relCssDir, - singleCss, - false, - true, - importAliases, - ) + } = snapshot for (const dependency of dependencies) { this.addDependency(resolve(dependency)) } @@ -324,7 +333,7 @@ export default devupUILoader /** @internal Reset init state for testing purposes only */ export const resetInit = () => { - init = false + initializedCompilers = new WeakSet() cachedPorts.clear() } diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 18a83d497..4ce23d33e 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -136,6 +136,7 @@ export function DevupUI( options: DevupUINextPluginOptions = {}, ): NextConfig { const normalizedShorthands = normalizeShorthands(options.shorthands ?? {}) + const engine = options.engine === undefined ? 'native' : options.engine const pluginStartedAt = profileStart() const isTurbo = process.env.TURBOPACK === '1' || process.env.TURBOPACK === 'auto' @@ -185,6 +186,7 @@ export function DevupUI( cssDir, devupFile, distDir, + engine, hoistV: process.env.DEVUP_HOIST_V, importAliases, include, @@ -231,7 +233,7 @@ export function DevupUI( }) if (!existsSync(gitignoreFile)) writeFileSync(gitignoreFile, '*') - const wasm = loadWasm() + const wasm = loadWasm(engine) const { codeExtract, codeExtractWithoutSourceMap, @@ -557,6 +559,7 @@ export function DevupUI( loader: '@devup-ui/next-plugin/css-loader', options: { watch, + engine, coordinatorPortFile, sheetFile, classMapFile, @@ -580,6 +583,7 @@ export function DevupUI( loader: '@devup-ui/next-plugin/loader', options: { package: libPackage, + engine, cssDir, coordinatorPortFile, sheetFile, @@ -653,6 +657,7 @@ export function DevupUI( config.plugins.push( new DevupUIWebpackPlugin({ ...options, + engine, watch: _options.dev, productionPrewarmFiles: _options.dev ? undefined diff --git a/packages/next-plugin/src/wasm.ts b/packages/next-plugin/src/wasm.ts index 634340c2a..16ca38748 100644 --- a/packages/next-plugin/src/wasm.ts +++ b/packages/next-plugin/src/wasm.ts @@ -2,14 +2,19 @@ import { existsSync, realpathSync } from 'node:fs' import { createRequire } from 'node:module' import { join, relative } from 'node:path' -import { createModuleResolver } from '@devup-ui/plugin-utils' +import { + createModuleResolver, + type DevupCompiler, + type DevupEngine, + loadCompiler, +} from '@devup-ui/plugin-utils' -export type DevupWasm = typeof import('@devup-ui/wasm') +export type DevupWasm = DevupCompiler export type DevupWebpackPlugin = typeof import('@devup-ui/webpack-plugin') let wasmForTesting: DevupWasm | undefined let webpackPluginForTesting: DevupWebpackPlugin | undefined -let loadedWasm: DevupWasm | undefined +const loadedCompilers = new Map() let webpackPlugin: DevupWebpackPlugin | undefined /** @internal Resolve dependencies from the plugin's physical install location. */ @@ -46,11 +51,13 @@ export function withModuleResolver(wasm: DevupWasm): DevupWasm { } /** Load the extraction engine once for the lifetime of a Next config. */ -export function loadWasm(): DevupWasm { +export function loadWasm(engine: DevupEngine = 'native'): DevupWasm { if (wasmForTesting) return wasmForTesting - return (loadedWasm ??= withModuleResolver( - requireFromPlugin('@devup-ui/wasm'), - )) + const cached = loadedCompilers.get(engine) + if (cached) return cached + const compiler = withModuleResolver(loadCompiler(engine)) + loadedCompilers.set(engine, compiler) + return compiler } /** Keep the Webpack adapter out of Turbopack startup. */ @@ -77,6 +84,6 @@ export function setWebpackPluginForTesting( export function resetWasmForTesting(): void { wasmForTesting = undefined webpackPluginForTesting = undefined - loadedWasm = undefined + loadedCompilers.clear() webpackPlugin = undefined } diff --git a/packages/plugin-utils/package.json b/packages/plugin-utils/package.json index 1ef59ee54..7859b2745 100644 --- a/packages/plugin-utils/package.json +++ b/packages/plugin-utils/package.json @@ -18,8 +18,8 @@ "version": "1.0.17", "scripts": { "lint": "eslint", - "test:types": "bun ../../node_modules/@typescript/native/bin/tsc --ignoreConfig --noEmit --strict --skipLibCheck --moduleResolution bundler --module esnext --target esnext --types bun src/__tests__/shorthands.test.ts", - "build": "bun run test:types && bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external" + "test:types": "bun ../../node_modules/@typescript/native/bin/tsc --ignoreConfig --noEmit --strict --skipLibCheck --moduleResolution bundler --module esnext --target esnext --types bun src/__tests__/shorthands.test.ts src/__tests__/compiler-types.test.ts src/__tests__/compiler.test.ts src/__tests__/native-platform.test.ts", + "build": "bun run test:types && bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external --define import.meta.url=module.filename && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external" }, "publishConfig": { "access": "public" @@ -37,6 +37,10 @@ "files": [ "dist" ], + "dependencies": { + "@devup-ui/binding": "workspace:^", + "@devup-ui/wasm": "workspace:^" + }, "devDependencies": { "typescript": "^7.0.2" } diff --git a/packages/plugin-utils/src/__tests__/compiler-types.test.ts b/packages/plugin-utils/src/__tests__/compiler-types.test.ts new file mode 100644 index 000000000..10e4921eb --- /dev/null +++ b/packages/plugin-utils/src/__tests__/compiler-types.test.ts @@ -0,0 +1,295 @@ +import * as native from '@devup-ui/binding' +import * as wasm from '@devup-ui/wasm' +import { describe, expect, it } from 'bun:test' + +import { + compilerRealpath, + createCompilerSelector, + InvalidEngineError, + loadCompiler, +} from '../compiler' +import type { + DevupCompiler, + DevupCompilerOutput, + DevupEngine, +} from '../compiler-types' +import type { DevupUIBasePluginOptions } from '../shared' + +const nativeCompiler: DevupCompiler = native +const wasmCompiler: DevupCompiler = wasm +type Accepted = T extends DevupEngine ? true : false +type Resolver = NonNullable[0]> +const optionProof: Pick = {} +const engineProof: [Accepted<'native'>, Accepted<'wasm'>, Accepted<'other'>] = [ + true, + true, + false, +] +const constructorProof: 'Output' extends keyof DevupCompiler ? true : false = + false +const resolverProof: (( + specifier: string, + importer: string, +) => number) extends Resolver + ? true + : false = false + +describe('compiler structural types', () => { + it('keeps literal options optional and resolver results precise', () => { + // Given / When: these assignments are also checked by test:types. + const configured: Pick = { + engine: 'native', + } + // Then + expect([ + optionProof, + configured, + engineProof, + constructorProof, + resolverProof, + ]).toEqual([{}, { engine: 'native' }, [true, true, false], false, false]) + }) + + for (const [name, compiler] of [ + ['native', nativeCompiler], + ['wasm', wasmCompiler], + ] as const) { + it(`accepts the actual ${name} namespace and releases its structural output`, () => { + // Given + compiler.resetStateForTesting() + try { + // When: both concrete return types must assign without a constructor cast. + using output: DevupCompilerOutput = compiler.codeExtract( + 'compiler-type.tsx', + "import {Box} from '@devup-ui/react'; export const view=;", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + // Then + expect(output.code).toContain('className=') + expect(output.css).toContain('padding:12px') + expect(output.dependencies).toEqual([]) + expect(output.map).toBeDefined() + expect( + Object.keys(compiler).filter( + (key) => key !== 'Output' && key !== 'default', + ), + ).toHaveLength(28) + } finally { + compiler.resetStateForTesting() + } + }) + } +}) + +// These assignments check the concrete extraction types, independently of the facade. +const nativeOutput: ( + output: ReturnType, +) => DevupCompilerOutput = (output) => output +const wasmOutput: ( + output: ReturnType, +) => DevupCompilerOutput = (output) => output + +for (const [name, compiler, accept] of [ + ['native', native, nativeOutput], + ['wasm', wasm, wasmOutput], +] as const) { + it(`accepts the concrete ${name} no-map output without retaining it`, () => { + // Given + compiler.resetStateForTesting() + try { + // When + using output = compiler.codeExtractWithoutSourceMap( + 'plain.ts', + 'export const n=1', + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + // Then + expect(accept(output).map).toBeUndefined() + } finally { + compiler.resetStateForTesting() + } + }) +} + +describe('compiler physical path', () => { + it('falls back once on the same path when native realpath throws EISDIR', () => { + // Given + const filename = 'compiler.js' + const nativePaths: string[] = [] + const regularPaths: string[] = [] + // When + const resolved = compilerRealpath(filename, { + native(path) { + nativePaths.push(path) + throw Object.assign(new Error('RAM disk'), { code: 'EISDIR' }) + }, + regular(path) { + regularPaths.push(path) + return 'physical/compiler.js' + }, + }) + // Then + expect(resolved).toBe('physical/compiler.js') + expect(nativePaths).toEqual([filename]) + expect(regularPaths).toEqual([filename]) + }) + + it.each([ + Object.assign(new Error('missing path'), { code: 'ENOENT' }), + new Error('unknown failure'), + { thrown: 'value' }, + null, + undefined, + 'failure', + ])('rethrows %s unchanged without regular realpath', (failure) => { + // Given + expect.assertions(2) + let fallback = 0 + // When + try { + compilerRealpath('compiler.js', { + native() { + throw failure + }, + regular() { + fallback++ + return 'unexpected' + }, + }) + } catch (error) { + // Then + expect(error).toBe(failure) + } + expect(fallback).toBe(0) + }) +}) + +describe('compiler selection contract', () => { + it.each([undefined, 'native', 'wasm'] as const)( + 'selects the actual namespace for %s', + (engine) => { + // Given / When + const compiler = loadCompiler(engine) + // Then + expect(compiler.codeExtract).toBe( + engine === 'wasm' ? wasm.codeExtract : native.codeExtract, + ) + }, + ) + + it.each([null, '', false, 0, 'other', {}, Symbol('invalid')])( + 'rejects %s before any host or backend access', + (engine) => { + // Given + let accesses = 0 + const fail = (): never => { + accesses++ + throw new Error('unexpected access') + } + const select = createCompilerSelector({ + native: fail, + wasm: fail, + platform: fail, + }) + // When / Then + expect(() => select(engine)).toThrow(InvalidEngineError) + expect(accesses).toBe(0) + }, + ) + + it('retains the earlier namespace when the other engine is selected', () => { + // Given + const select = createCompilerSelector({ + native: () => native, + wasm: () => wasm, + platform: () => '@devup-ui/binding-test', + }) + const previous = select('wasm') + // When + const next = select() + // Then + expect(previous).toBe(wasm) + expect(next).toBe(native) + expect(select('wasm')).toBe(previous) + }) + + for (const cause of [ + new Error('corrupt image'), + Object.assign(new Error('transitive library'), { + code: 'MODULE_NOT_FOUND', + }), + { thrown: 'value' }, + ]) { + it('retains the exact native failure and escape without fallback', () => { + // Given + let fallback = 0 + const platformPackage = '@devup-ui/binding-linux-x64-gnu' + const select = createCompilerSelector({ + native() { + throw cause + }, + wasm() { + fallback++ + return wasm + }, + platform: () => platformPackage, + }) + // When / Then + try { + select('native') + throw new Error('native failure was swallowed') + } catch (error) { + if (!(error instanceof Error)) throw error + expect(error.cause).toBe(cause) + expect(error.message).toContain(platformPackage) + expect(error.message).toContain("engine: 'wasm'") + } + expect(fallback).toBe(0) + }) + } + + it('propagates host failure before native loading', () => { + // Given + const failure = new Error('unknown libc') + let loads = 0 + const select = createCompilerSelector({ + native() { + loads++ + return native + }, + wasm: () => wasm, + platform() { + throw failure + }, + }) + // When / Then + expect(() => select()).toThrow(failure) + expect(loads).toBe(0) + }) + + it('preserves a WASM load failure without probing native context', () => { + // Given + const failure = new Error('wasm unavailable') + const select = createCompilerSelector({ + native: () => native, + wasm() { + throw failure + }, + platform() { + throw new Error('unexpected host probe') + }, + }) + // When / Then + expect(() => select('wasm')).toThrow(failure) + }) +}) diff --git a/packages/plugin-utils/src/__tests__/compiler.test.ts b/packages/plugin-utils/src/__tests__/compiler.test.ts new file mode 100644 index 000000000..5daa76778 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/compiler.test.ts @@ -0,0 +1,215 @@ +import { + cpSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { expect, it } from 'bun:test' + +import { nativePlatformPackage } from '../native-platform' + +const root = fileURLToPath(new URL('../../../../', import.meta.url)) +const platformPackage = nativePlatformPackage({ + platform: process.platform, + arch: process.arch, + report: process.report.getReport, +}) + +type Layout = 'both' | 'wasm' | 'native' | 'missing' | 'corrupt' | 'transitive' + +function fixture(layout: Layout) { + const directory = mkdtempSync(join(tmpdir(), 'devup-compiler-')) + try { + const physical = join(directory, 'physical/node_modules/@devup-ui') + const caller = join(directory, 'caller') + mkdirSync(physical, { recursive: true }) + mkdirSync(join(caller, 'node_modules/@devup-ui'), { recursive: true }) + const utils = join(physical, 'plugin-utils') + cpSync(join(root, 'packages/plugin-utils/dist'), join(utils, 'dist'), { + recursive: true, + }) + cpSync( + join(root, 'packages/plugin-utils/package.json'), + join(utils, 'package.json'), + ) + symlinkSync( + utils, + join(caller, 'node_modules/@devup-ui/plugin-utils'), + process.platform === 'win32' ? 'junction' : 'dir', + ) + if (layout === 'both' || layout === 'wasm') { + cpSync( + join(root, 'bindings/devup-ui-wasm/pkg'), + join(physical, 'wasm/pkg'), + { + recursive: true, + }, + ) + cpSync( + join(root, 'bindings/devup-ui-wasm/package.json'), + join(physical, 'wasm/package.json'), + ) + } + if (layout !== 'wasm') { + const binding = join(physical, 'binding') + mkdirSync(join(binding, 'pkg'), { recursive: true }) + for (const file of [ + 'package.json', + 'index.mjs', + 'loader.cjs', + 'pkg/index.cjs', + ]) { + cpSync( + join(root, 'bindings/devup-ui-binding', file), + join(binding, file), + ) + } + const sibling = join(physical, platformPackage.slice('@devup-ui/'.length)) + if (layout === 'both' || layout === 'native') { + cpSync( + join( + root, + 'bindings', + `devup-ui-${platformPackage.slice('@devup-ui/'.length)}`, + ), + sibling, + { + recursive: true, + }, + ) + } + if (layout === 'corrupt' || layout === 'transitive') { + mkdirSync(sibling) + const entry = layout === 'corrupt' ? 'broken.node' : 'index.cjs' + writeFileSync( + join(sibling, 'package.json'), + JSON.stringify({ + name: platformPackage, + main: entry, + }), + ) + writeFileSync( + join(sibling, entry), + layout === 'corrupt' + ? 'deliberately invalid image' + : "require('devup-missing-transitive-library')", + ) + } + } + for (const name of ['binding', 'wasm']) { + const decoy = join(caller, 'node_modules/@devup-ui', name) + mkdirSync(decoy) + writeFileSync( + join(decoy, 'package.json'), + JSON.stringify({ + name: `@devup-ui/${name}`, + main: 'index.cjs', + }), + ) + writeFileSync( + join(decoy, 'index.cjs'), + "throw new Error('caller decoy must never load')", + ) + } + return { + caller, + [Symbol.dispose]() { + rmSync(directory, { recursive: true, force: true }) + }, + } + } catch (error) { + rmSync(directory, { recursive: true, force: true }) + throw error + } +} + +const child = String.raw` +const assert = require('node:assert/strict'); +const {createRequire} = require('node:module'); +const {pathToFileURL} = require('node:url'); +const {realpathSync} = require('node:fs'); +const {dirname, join} = require('node:path'); +(async () => { + const [layout, engine, sibling] = process.argv.slice(1); + const facade = join(process.cwd(), 'node_modules/@devup-ui/plugin-utils/dist/index.cjs'); + const physical = realpathSync.native(facade); + const own = createRequire(physical); + const originalReport = process.report.getReport; + if (engine === 'wasm') process.report.getReport = () => { throw new Error('unselected Linux probe'); }; + const cjs = require(facade); + const esm = await import(pathToFileURL(join(dirname(physical), 'index.mjs')).href); + assert.throws(() => cjs.loadCompiler('invalid'), TypeError); + if (['missing', 'corrupt', 'transitive'].includes(layout)) { + let raw; + try { own('@devup-ui/binding'); } catch (error) { raw = error; } + assert.ok(raw); + assert.throws(() => cjs.loadCompiler('native'), error => { + assert.ok(error.message.includes(sibling)); + assert.ok(error.message.includes("engine: 'wasm'")); + assert.ok(error.cause); + assert.equal(error.cause.message, raw.message); + if (layout === 'missing') assert.equal(error.cause.cause.code, 'MODULE_NOT_FOUND'); + if (layout === 'transitive') assert.equal(error.cause.code, 'MODULE_NOT_FOUND'); + return true; + }); + } else { + const selected = engine === 'default' ? undefined : engine; + const compiler = cjs.loadCompiler(selected); + assert.equal(compiler, esm.loadCompiler(selected)); + assert.equal(compiler, own(engine === 'wasm' ? '@devup-ui/wasm' : '@devup-ui/binding')); + if (engine !== 'wasm') { + const named = await import(pathToFileURL(join(dirname(dirname(own.resolve('@devup-ui/binding'))), 'index.mjs')).href); + for (const [name, value] of Object.entries(named)) assert.equal(value, compiler[name]); + } + compiler.resetStateForTesting(); + const output = compiler.codeExtract('child.tsx', "import {Box} from '@devup-ui/react';export const v=;", '@devup-ui/react', 'df', true, false, false, {}); + try { + assert.ok(output.code.includes('className=')); assert.ok(output.css.includes('padding:8px')); + assert.deepEqual(output.dependencies, []); assert.ok(output.map); + } finally { output.free(); compiler.resetStateForTesting(); } + } + process.report.getReport = originalReport; +})().catch(error => { console.error(error); process.exitCode = 1; }); +` + +for (const runtime of ['node', process.execPath]) { + for (const [layout, engine] of [ + ['both', 'default'], + ['both', 'native'], + ['both', 'wasm'], + ['wasm', 'wasm'], + ['native', 'native'], + ['missing', 'native'], + ['corrupt', 'native'], + ['transitive', 'native'], + ] as const) { + it(`loads physical built CJS/ESM with ${runtime}: ${layout}/${engine}`, async () => { + // Given: each child has its own relocated tree and no repository ancestor. + using fs = fixture(layout) + // When + const childProcess = Bun.spawn( + [runtime, '--eval', child, layout, engine, platformPackage], + { + cwd: fs.caller, + env: { ...Bun.env, NODE_PATH: '', NODE_OPTIONS: '', BUN_OPTIONS: '' }, + timeout: 20000, + stdout: 'pipe', + stderr: 'pipe', + }, + ) + const [status, stderr] = await Promise.all([ + childProcess.exited, + new Response(childProcess.stderr).text(), + new Response(childProcess.stdout).text(), + ]) + // Then + expect({ status, stderr }).toEqual({ status: 0, stderr: '' }) + }, 30000) + } +} diff --git a/packages/plugin-utils/src/__tests__/native-platform.test.ts b/packages/plugin-utils/src/__tests__/native-platform.test.ts new file mode 100644 index 000000000..e8aef009b --- /dev/null +++ b/packages/plugin-utils/src/__tests__/native-platform.test.ts @@ -0,0 +1,428 @@ +import { randomUUID } from 'node:crypto' +import { readFileSync } from 'node:fs' +import Module, { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'bun:test' + +import { compilerRealpath, loadCompiler } from '../compiler' +import { NativePlatformError, nativePlatformPackage } from '../native-platform' + +const matrixUrl = new URL( + '../../../../e2e/binding/package/matrix.mjs', + import.meta.url, +) +const matrixProcess = Bun.spawn( + [ + 'node', + '--input-type=module', + '--eval', + `import { targets } from ${JSON.stringify(matrixUrl.href)}; console.log(JSON.stringify(targets))`, + ], + { stdout: 'pipe', stderr: 'pipe', timeout: 10000 }, +) +const [matrixStatus, matrixText, matrixError] = await Promise.all([ + matrixProcess.exited, + new Response(matrixProcess.stdout).text(), + new Response(matrixProcess.stderr).text(), +]) +if (matrixStatus !== 0) throw new Error(matrixError) +const parsed: unknown = JSON.parse(matrixText) +if (!Array.isArray(parsed)) + throw new TypeError('Canonical target table must be an array') +const rows: readonly unknown[] = parsed +const targets = rows.map((row) => { + if ( + typeof row !== 'object' || + row === null || + !('suffix' in row) || + typeof row.suffix !== 'string' || + !('os' in row) || + typeof row.os !== 'string' || + !('cpu' in row) || + typeof row.cpu !== 'string' + ) + throw new TypeError('Invalid canonical target row') + const libc = 'libc' in row ? row.libc : undefined + if (libc !== undefined && libc !== 'glibc' && libc !== 'musl') { + throw new TypeError('Invalid canonical target libc') + } + return { suffix: row.suffix, os: row.os, cpu: row.cpu, libc } +}) + +const ownRequire = createRequire( + compilerRealpath(fileURLToPath(import.meta.url)), +) +const authoredLoader = compilerRealpath( + fileURLToPath( + new URL( + '../../../../bindings/devup-ui-binding/loader.cjs', + import.meta.url, + ), + ), +) +const hostPackage = nativePlatformPackage({ + platform: process.platform, + arch: process.arch, + report: process.report.getReport, +}) +const hostAddon: unknown = ownRequire(authoredLoader) +const hostCompiler = loadCompiler('native') +const hostFunctions = Object.entries(hostCompiler) +type LoaderTarget = Pick<(typeof targets)[number], 'os' | 'cpu' | 'libc'> +type ResolverControl = 'ordinary' | 'missing' | 'other' + +function targetReport(row: LoaderTarget) { + return { header: row.libc === 'glibc' ? { glibcVersionRuntime: '2.39' } : {} } +} + +// Keep the complete override transaction synchronous: only the authored loader is +// evicted, never its public wrapper, platform sibling or native backend. +function requireAuthoredLoader( + row: LoaderTarget, + control: ResolverControl = 'ordinary', +) { + const descriptors = (['platform', 'arch', 'report'] as const).map((key) => ({ + key, + descriptor: Object.getOwnPropertyDescriptor(process, key), + })) + const cacheDescriptor = Object.getOwnPropertyDescriptor( + ownRequire.cache, + authoredLoader, + ) + const previous = ownRequire.cache[authoredLoader] + const original: unknown = Reflect.get(Module, '_resolveFilename') + if (typeof original !== 'function') + throw new TypeError('Expected Node filename resolver') + const originalResolver = original + const manifest = + control === 'ordinary' ? undefined : `${hostPackage}/package.json` + let reports = 0 + let resolverCalls = 0 + let result: unknown + let failure: Error | undefined + let resolverFailure: Error | undefined + + if (control === 'other') { + try { + readFileSync(compilerRealpath(import.meta.dirname)) + } catch (error) { + if (!(error instanceof Error)) throw error + resolverFailure = error + } + expect(resolverFailure).toBeInstanceOf(Error) + expect( + resolverFailure && 'code' in resolverFailure + ? resolverFailure.code + : undefined, + ).toBe('EISDIR') + } + + // This is Node's concrete private resolver signature, not a namespace mock. + // Its four arguments must pass through unchanged on every non-target request. + function resolveFilename( + this: unknown, + ...args: [ + request: string, + parent: NodeJS.Module | null | undefined, + isMain?: boolean, + options?: { paths?: string[] }, + ] + ): unknown { + const [request, parent] = args + if ( + request === manifest && + typeof parent?.filename === 'string' && + compilerRealpath(parent.filename) === authoredLoader + ) { + resolverCalls++ + switch (control) { + case 'missing': + try { + return Reflect.apply(originalResolver, this, [ + `${hostPackage}-w74t-${randomUUID()}`, + ...args.slice(1), + ]) + } catch (error) { + if (!(error instanceof Error)) throw error + resolverFailure = error + throw error + } + case 'other': + throw resolverFailure + case 'ordinary': + break + default: { + const unreachable: never = control + return unreachable + } + } + } + const resolved: unknown = Reflect.apply(originalResolver, this, args) + return resolved + } + + try { + Object.defineProperty(process, 'platform', { + configurable: true, + value: row.os, + }) + Object.defineProperty(process, 'arch', { + configurable: true, + value: row.cpu, + }) + Object.defineProperty(process, 'report', { + configurable: true, + value: { + getReport() { + reports++ + return targetReport(row) + }, + }, + }) + delete ownRequire.cache[authoredLoader] + if (control !== 'ordinary') + Reflect.set(Module, '_resolveFilename', resolveFilename) + try { + result = ownRequire(authoredLoader) + } catch (error) { + if (!(error instanceof Error)) throw error + failure = error + } + } finally { + if (control !== 'ordinary') + Reflect.set(Module, '_resolveFilename', original) + delete ownRequire.cache[authoredLoader] + if (previous) ownRequire.cache[authoredLoader] = previous + for (const { key, descriptor } of descriptors) { + if (descriptor) Object.defineProperty(process, key, descriptor) + else Reflect.deleteProperty(process, key) + } + expect(Reflect.get(Module, '_resolveFilename')).toBe(original) + expect(ownRequire.cache[authoredLoader]).toBe(previous) + expect( + Object.getOwnPropertyDescriptor(ownRequire.cache, authoredLoader), + ).toEqual(cacheDescriptor) + for (const { key, descriptor } of descriptors) { + expect(Object.getOwnPropertyDescriptor(process, key)).toEqual(descriptor) + } + const restoredCompiler = loadCompiler('native') + expect(restoredCompiler).toBe(hostCompiler) + for (const [name, value] of hostFunctions) { + expect(Reflect.get(restoredCompiler, name)).toBe(value) + } + } + expect(reports).toBe(row.os === 'linux' ? 1 : 0) + return { result, failure, resolverFailure, resolverCalls } +} + +describe('authored native loader drift controls', () => { + for (const row of targets) { + it(`actually selects the helper package for ${row.suffix}`, () => { + // Given + const selected = nativePlatformPackage({ + platform: row.os, + arch: row.cpu, + report: () => targetReport(row), + }) + // When + const { result, failure } = requireAuthoredLoader(row) + // Then: linked foreign manifests reach final require, not the optional catch. + expect(selected).toBe(`@devup-ui/binding-${row.suffix}`) + if (selected === hostPackage) { + expect(failure).toBeUndefined() + expect(result).toBe(hostAddon) + } else { + expect(failure).toBeInstanceOf(Error) + expect(failure?.message).toContain(selected) + expect(failure?.message).not.toContain('Missing optional dependency') + } + }) + } + + it('actually rejects unsupported freebsd/x64 before reporting', () => { + // Given + const row = { os: 'freebsd', cpu: 'x64', libc: undefined } + // When + const { failure } = requireAuthoredLoader(row) + // Then + expect(failure?.message).toBe( + 'Unsupported @devup-ui/binding target: freebsd/x64/none', + ) + }) + + for (const control of ['missing', 'other'] as const) { + it(`preserves the genuine ${control} resolver error for the host manifest`, () => { + // Given + const row = targets.find( + (target) => `@devup-ui/binding-${target.suffix}` === hostPackage, + ) + if (!row) + throw new TypeError( + 'Host package must belong to the canonical target table', + ) + // When + const { failure, resolverFailure, resolverCalls } = requireAuthoredLoader( + row, + control, + ) + // Then + expect(resolverCalls).toBe(1) + expect(resolverFailure).toBeInstanceOf(Error) + switch (control) { + case 'missing': + expect( + resolverFailure && 'code' in resolverFailure + ? resolverFailure.code + : undefined, + ).toBe('MODULE_NOT_FOUND') + expect(failure?.message).toBe( + `Missing optional dependency ${hostPackage}. Reinstall @devup-ui/binding with optional dependencies enabled (do not use --omit=optional).`, + ) + expect(failure?.cause).toBe(resolverFailure) + break + case 'other': + expect(failure).toBe(resolverFailure) + break + default: { + const unreachable: never = control + throw new TypeError(`Unexpected resolver control: ${unreachable}`) + } + } + }) + } +}) + +describe('native diagnostic platform selection', () => { + it('supports exactly the packages in the canonical table', () => { + // Given + const supported = ['win32', 'darwin', 'linux'].flatMap((platform) => + ['x64', 'arm64'].flatMap((arch) => + (platform === 'linux' ? [false, true] : [false]).map((gnu) => + nativePlatformPackage({ + platform, + arch, + report: () => ({ + header: gnu ? { glibcVersionRuntime: '2.39' } : {}, + }), + }), + ), + ), + ) + // When / Then + expect(new Set(supported)).toEqual( + new Set(targets.map((row) => `@devup-ui/binding-${row.suffix}`)), + ) + }) + + for (const row of targets) { + it(`matches the canonical package for ${row.suffix}`, () => { + // Given + let reports = 0 + const host = { + platform: row.os, + arch: row.cpu, + report() { + reports++ + return { + header: row.libc === 'glibc' ? { glibcVersionRuntime: '2.39' } : {}, + } + }, + } + // When + const name = nativePlatformPackage(host) + // Then + expect(name).toBe(`@devup-ui/binding-${row.suffix}`) + expect(reports).toBe(row.os === 'linux' ? 1 : 0) + }) + } + + it.each([ + ['freebsd', 'x64'], + ['win32', 'ia32'], + ['linux', 'riscv64'], + ])( + 'fails explicitly before reporting for unsupported %s/%s', + (platform, arch) => { + // Given + let reports = 0 + const host = { + platform, + arch, + report() { + reports++ + return { header: {} } + }, + } + // When / Then + expect(() => nativePlatformPackage(host)).toThrow(NativePlatformError) + expect(reports).toBe(0) + }, + ) + + it('retains a report failure instead of guessing musl', () => { + // Given + const failure = new Error('report unavailable') + const host = { + platform: 'linux', + arch: 'x64', + report() { + throw failure + }, + } + // When / Then + expect(() => nativePlatformPackage(host)).toThrow( + expect.objectContaining({ + name: 'NativePlatformError', + cause: failure, + }), + ) + }) + + it('retains an inaccessible report header as the platform failure cause', () => { + // Given + const failure = new Error('header unavailable') + const host = { + platform: 'linux', + arch: 'x64', + report: () => ({ + get header() { + throw failure + }, + }), + } + // When / Then + expect(() => nativePlatformPackage(host)).toThrow( + expect.objectContaining({ + name: 'NativePlatformError', + cause: failure, + }), + ) + }) + + it.each([null, 'report text', {}, { header: null }, { header: 'bad' }])( + 'rejects malformed report %j without guessing a package', + (report) => { + // Given + const host = { platform: 'linux', arch: 'arm64', report: () => report } + // When / Then + expect(() => nativePlatformPackage(host)).toThrow(NativePlatformError) + }, + ) + + it.each([undefined, '', false, 0])( + 'matches the loader truthiness rule for %j', + (glibcVersionRuntime) => { + // Given + const host = { + platform: 'linux', + arch: 'x64', + report: () => ({ header: { glibcVersionRuntime } }), + } + // When + const name = nativePlatformPackage(host) + // Then + expect(name).toBe('@devup-ui/binding-linux-x64-musl') + }, + ) +}) diff --git a/packages/plugin-utils/src/compiler-types.ts b/packages/plugin-utils/src/compiler-types.ts new file mode 100644 index 000000000..4db11c43f --- /dev/null +++ b/packages/plugin-utils/src/compiler-types.ts @@ -0,0 +1,22 @@ +export type DevupEngine = 'wasm' | 'native' + +type NativeCompiler = typeof import('@devup-ui/binding') + +/** The public functions, not the backend-specific Output constructor. */ +export type DevupCompiler = Pick< + NativeCompiler, + Exclude +> + +/** Output ownership stays with the caller; release it after reading its getters. */ +export type DevupCompilerOutput = Pick< + import('@devup-ui/binding').Output, + | 'code' + | 'css' + | 'cssFile' + | 'map' + | 'dependencies' + | 'updatedBaseStyle' + | 'free' + | typeof Symbol.dispose +> diff --git a/packages/plugin-utils/src/compiler.ts b/packages/plugin-utils/src/compiler.ts new file mode 100644 index 000000000..3d242e053 --- /dev/null +++ b/packages/plugin-utils/src/compiler.ts @@ -0,0 +1,116 @@ +import { realpathSync } from 'node:fs' +import { createRequire } from 'node:module' +import { isAbsolute } from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { DevupCompiler, DevupEngine } from './compiler-types' +import { nativePlatformPackage } from './native-platform' + +interface CompilerLoaders { + readonly native: () => typeof import('@devup-ui/binding') + readonly wasm: () => typeof import('@devup-ui/wasm') + readonly platform: () => string +} + +export class InvalidEngineError extends TypeError { + readonly name = 'InvalidEngineError' + + constructor(readonly engine: unknown) { + super("devup-ui engine must be 'native' or 'wasm'") + } +} + +export class NativeCompilerLoadError extends Error { + readonly name = 'NativeCompilerLoadError' + + constructor( + readonly platformPackage: string, + cause: unknown, + ) { + super( + `Cannot load ${platformPackage}. Install its optional dependency and native libraries, or use engine: 'wasm'.`, + { cause }, + ) + } +} + +/** @internal Concrete load boundary for source tests; not exported by the barrel. */ +export function createCompilerSelector(loaders: CompilerLoaders) { + return (engine: unknown = undefined): DevupCompiler => { + switch (engine) { + case 'wasm': + return loaders.wasm() + case undefined: + case 'native': { + const platformPackage = loaders.platform() + try { + return loaders.native() + } catch (cause) { + throw new NativeCompilerLoadError(platformPackage, cause) + } + } + default: + throw new InvalidEngineError(engine) + } + } +} + +interface RealpathFunctions { + readonly native: (filename: string) => string + readonly regular: (filename: string) => string +} + +/** @internal Concrete path boundary for source tests; not exported by the barrel. */ +export function compilerRealpath( + filename: string, + realpath: RealpathFunctions = { + native: realpathSync.native, + regular: realpathSync, + }, +): string { + try { + return realpath.native(filename) + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'EISDIR' + ) { + return realpath.regular(filename) + } + throw error + } +} + +function physicalRequire() { + // CJS build substitutes module.filename; ESM keeps its own file URL. + const location = import.meta.url + const filename = isAbsolute(location) ? location : fileURLToPath(location) + return createRequire(compilerRealpath(filename)) +} + +const selectCompiler = createCompilerSelector({ + native() { + const compiler: typeof import('@devup-ui/binding') = + physicalRequire()('@devup-ui/binding') + return compiler + }, + wasm() { + const compiler: typeof import('@devup-ui/wasm') = + physicalRequire()('@devup-ui/wasm') + return compiler + }, + platform() { + return nativePlatformPackage({ + platform: process.platform, + arch: process.arch, + report: process.report.getReport, + }) + }, +}) + +/** Select synchronously without resolving or initializing the unselected engine. */ +export function loadCompiler(engine?: DevupEngine): DevupCompiler { + return selectCompiler(engine) +} diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 0157f5a73..bfc7b1e1b 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -3,6 +3,12 @@ export { getBuildOwnership, type ResettableEngine, } from './build-session' +export { loadCompiler } from './compiler' +export type { + DevupCompiler, + DevupCompilerOutput, + DevupEngine, +} from './compiler-types' export { type AtomHoistPlan, buildCanonicalMap, diff --git a/packages/plugin-utils/src/native-platform.ts b/packages/plugin-utils/src/native-platform.ts new file mode 100644 index 000000000..c633c5e0d --- /dev/null +++ b/packages/plugin-utils/src/native-platform.ts @@ -0,0 +1,58 @@ +export interface NativeHost { + readonly platform: string + readonly arch: string + readonly report: () => unknown +} + +export class NativePlatformError extends Error { + readonly name = 'NativePlatformError' + + constructor( + readonly host: Pick, + cause?: unknown, + ) { + super( + `Cannot select @devup-ui/binding target for ${host.platform}/${host.arch}`, + { + cause, + }, + ) + } +} + +/** Diagnostic dispatch only; the binding's private loader remains authoritative. */ +export function nativePlatformPackage(host: NativeHost): string { + if (host.arch !== 'x64' && host.arch !== 'arm64') { + throw new NativePlatformError(host) + } + switch (host.platform) { + case 'win32': + return `@devup-ui/binding-win32-${host.arch}-msvc` + case 'darwin': + return `@devup-ui/binding-darwin-${host.arch}` + case 'linux': { + try { + const report = host.report() + if ( + typeof report !== 'object' || + report === null || + !('header' in report) + ) { + throw new NativePlatformError(host) + } + const header = report.header + if (typeof header !== 'object' || header === null) { + throw new NativePlatformError(host) + } + const gnu = + 'glibcVersionRuntime' in header && header.glibcVersionRuntime + return `@devup-ui/binding-linux-${host.arch}-${gnu ? 'gnu' : 'musl'}` + } catch (cause) { + if (cause instanceof NativePlatformError) throw cause + throw new NativePlatformError(host, cause) + } + } + default: + throw new NativePlatformError(host) + } +} diff --git a/packages/plugin-utils/src/shared.ts b/packages/plugin-utils/src/shared.ts index 9d5710be4..20b7aac0e 100644 --- a/packages/plugin-utils/src/shared.ts +++ b/packages/plugin-utils/src/shared.ts @@ -1,3 +1,4 @@ +import type { DevupEngine } from './compiler-types' import type { CustomShorthands, ImportAliases } from './types' /** @@ -94,6 +95,7 @@ export function createThemeInterfaceArgs( * Common plugin options shared across all devup-ui build plugins. */ export interface DevupUIBasePluginOptions { + engine?: DevupEngine package: string cssDir: string devupFile: string diff --git a/packages/rsbuild-plugin/README.md b/packages/rsbuild-plugin/README.md index 404fc2997..c94984cf7 100644 --- a/packages/rsbuild-plugin/README.md +++ b/packages/rsbuild-plugin/README.md @@ -177,6 +177,26 @@ const box = const box = ``` +## Compiler engine + +Rsbuild uses the native compiler by default. Explicit selections are supported: + +```ts +DevupUI() // native +DevupUI({ engine: 'native' }) +DevupUI({ engine: 'wasm' }) +``` + +Both engines are installed through plugin-utils. Keep optional dependencies +enabled for the native platform package. A native load failure names that +package and preserves the original error as its cause. There is no automatic +fallback; select `engine: 'wasm'` on hosts without a working native image or +required native libraries. Malformed shorthands fail before backend selection. + +The selected compiler serves configuration, reservation, transforms and delayed +watch/build callbacks together. Neither engine supports concurrent independent +builds in the same process. + ## Custom Shorthands ```ts diff --git a/packages/rsbuild-plugin/package.json b/packages/rsbuild-plugin/package.json index f12ffa121..b297ef947 100644 --- a/packages/rsbuild-plugin/package.json +++ b/packages/rsbuild-plugin/package.json @@ -44,7 +44,6 @@ "@devup-ui/wasm": "workspace:^" }, "peerDependencies": { - "@devup-ui/wasm": "*", "@rsbuild/core": "*" }, "devDependencies": { diff --git a/packages/rsbuild-plugin/src/__tests__/audit-engine.cjs b/packages/rsbuild-plugin/src/__tests__/audit-engine.cjs index 1b04a7c0b..df2b6b0cc 100644 --- a/packages/rsbuild-plugin/src/__tests__/audit-engine.cjs +++ b/packages/rsbuild-plugin/src/__tests__/audit-engine.cjs @@ -1,6 +1,5 @@ const { basename } = require('node:path') -/** @type {typeof import('@devup-ui/wasm')} */ -const wasm = require('@devup-ui/wasm') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') /** @type {unknown[]} */ const batches = [] /** @type {{name:string,code:string,map:string|undefined,before:number,after:number}[]} */ diff --git a/packages/rsbuild-plugin/src/__tests__/audit-native.cjs b/packages/rsbuild-plugin/src/__tests__/audit-native.cjs index f8bcaab65..b31f1b87a 100644 --- a/packages/rsbuild-plugin/src/__tests__/audit-native.cjs +++ b/packages/rsbuild-plugin/src/__tests__/audit-native.cjs @@ -53,7 +53,7 @@ async function native() { plugins: mode === 'reachable-native' ? [] - : [DevupUI({ singleCss: true }), observer], + : [DevupUI({ engine: 'wasm', singleCss: true }), observer], source: { entry: { main: './src/entry.js' } }, output: { distPath: { root: 'out' }, diff --git a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts index f607852db..c85723550 100644 --- a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs' import * as fsPromises from 'node:fs/promises' import { join } from 'node:path' -import * as wasm from '@devup-ui/wasm' +import { type DevupCompilerOutput, loadCompiler } from '@devup-ui/plugin-utils' import { afterAll, beforeAll, @@ -15,7 +15,8 @@ import { import { DevupUI } from '../plugin' -type CodeExtractResult = ReturnType +const wasm = loadCompiler('wasm') +type CodeExtractResult = DevupCompilerOutput type RsbuildPlugin = ReturnType type RsbuildSetupContext = Parameters[0] @@ -45,10 +46,11 @@ beforeAll(() => { css: '', cssFile: 'devup-ui-0.css', map: undefined, + dependencies: [], updatedBaseStyle: false, free: mock(), [Symbol.dispose]: mock(), - } as unknown as CodeExtractResult) + } satisfies CodeExtractResult) }) afterAll(() => { @@ -63,6 +65,7 @@ afterAll(() => { async function extractedCssDirIn(checkout: string) { const transform = mock() const plugin = DevupUI({ + engine: 'wasm', distDir: join(checkout, 'df'), cssDir: join(checkout, 'df', 'devup-ui'), }) diff --git a/packages/rsbuild-plugin/src/__tests__/entry-revisions.cjs b/packages/rsbuild-plugin/src/__tests__/entry-revisions.cjs index 576394699..fcab3be25 100644 --- a/packages/rsbuild-plugin/src/__tests__/entry-revisions.cjs +++ b/packages/rsbuild-plugin/src/__tests__/entry-revisions.cjs @@ -43,7 +43,7 @@ async function main() { cwd: root, config: { mode: 'production', - plugins: [DevupUI({ singleCss: true }), observer], + plugins: [DevupUI({ engine: 'wasm', singleCss: true }), observer], source: { entry: { main: './src/entry.js' } }, environments: { first: { output: { distPath: { root: 'out-first' } } }, diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 1194b58c9..15afbd3ad 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -4,7 +4,6 @@ import * as fsPromises from 'node:fs/promises' import { join, resolve } from 'node:path' import * as pluginUtils from '@devup-ui/plugin-utils' -import * as wasm from '@devup-ui/wasm' import { afterAll, afterEach, @@ -16,9 +15,58 @@ import { spyOn, } from 'bun:test' -import { DevupUI } from '../plugin' +import { DevupUI as createPlugin } from '../plugin' + +const wasm = pluginUtils.loadCompiler('wasm') +const DevupUI = (options: Parameters[0] = {}) => + createPlugin({ ...options, engine: 'wasm' }) describe('shorthand creation boundary', () => { + it.each([undefined, 'native'] as const)( + 'registers real native shorthands when engine=%s', + (engine) => { + // Given + const native = pluginUtils.loadCompiler('native') + native.resetStateForTesting() + // When + createPlugin({ engine, shorthands: { insetX: ['left', 'right'] } }) + const output = native.codeExtract( + 'engine.tsx', + 'import { Box } from "@devup-ui/react"; export const box = ', + '@devup-ui/react', + './df', + true, + false, + true, + {}, + ) + try { + // Then + expect(output.css).toContain('left:19px') + expect(output.css).toContain('right:19px') + } finally { + output.free() + native.resetStateForTesting() + } + }, + ) + + it('rejects malformed shorthand before consulting the selector', () => { + // Given + const selection = spyOn(pluginUtils, 'loadCompiler') + try { + // When / Then + expect(() => + Reflect.apply(createPlugin, undefined, [ + { engine: 'invalid', shorthands: { bgx: ['backgroundColour'] } }, + ]), + ).toThrow(pluginUtils.ShorthandConfigError) + expect(selection).not.toHaveBeenCalled() + } finally { + selection.mockRestore() + } + }) + it('registers canonical targets when the plugin is created', () => { const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( undefined, @@ -54,7 +102,7 @@ describe('shorthand creation boundary', () => { }) }) -type CodeExtractResult = ReturnType +type CodeExtractResult = pluginUtils.DevupCompilerOutput type RsbuildPlugin = ReturnType type RsbuildSetupContext = Parameters[0] @@ -66,11 +114,12 @@ function createCodeExtractResult( css: '', cssFile: 'devup-ui.css', map: undefined, + dependencies: [], updatedBaseStyle: false, free: mock(), [Symbol.dispose]: mock(), ...overrides, - } as unknown as CodeExtractResult + } } function createSetupContext( @@ -473,7 +522,7 @@ const App = () => `, expect(writeFileSpy).toHaveBeenCalledWith( resolve('df', 'devup-ui', 'devup-ui.css'), - expect.stringMatching(/\/\* src\/App\.tsx \d+ \*\//), + wasm.getCss(null, false), 'utf-8', ) @@ -516,6 +565,7 @@ const App = () => `, await watchRun() expect(registerThemeSpy).not.toHaveBeenCalled() statSpy.mockResolvedValueOnce({ mtimeMs: 2 } as fs.Stats) + createPlugin({ engine: 'native' }) await watchRun() expect(registerThemeSpy).toHaveBeenCalledWith({ tailwindCss: '@theme { --color-brand: #0af; }', @@ -1048,6 +1098,7 @@ const App = () => `, const compiler = { options: {}, watchMode: false, + modifiedFiles: new Set(), rspack: { Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 } }, hooks: { run: { tap: tap('run') }, @@ -1071,9 +1122,44 @@ const App = () => `, resourcePath, environment: { name: 'web' }, }) - return { onBeforeBuild, compiler, compilation, taps, serve } + return { + onBeforeBuild, + compiler, + compilation, + taps, + serve, + transform, + } } + it.each([ + ['A', 'B'], + ['B', 'A'], + ])( + 'writes stylesheet bytes when sources run %s then %s', + async (first, second) => { + // Given + const { transform } = await setup({ singleCss: true }) + const css = '.a{color:red}.b{color:blue}' + getCssSpy.mockReturnValue(css) + writeFileSpy.mockClear() + // When + for (const name of [first, second]) { + await transform.mock.calls[1][1]({ + code: 'source', + resourcePath: resolve('src', `${name}.tsx`), + addDependency: mock(), + }) + } + // Then + expect(writeFileSpy.mock.calls.at(-1)).toEqual([ + resolve('df', 'devup-ui', 'devup-ui.css'), + css, + 'utf-8', + ]) + }, + ) + it('extracts the files the entries reach before building', async () => { const { onBeforeBuild } = await setup({ atomHoist: 0 }) codeExtractSpy.mockClear() @@ -1185,11 +1271,40 @@ const App = () => `, taps.run!() expect(taps.needAdditionalPass!()).toBe(true) - // the dev server rebuilds through the files the transforms write + // Given: watch revisions cache the previously consumed stylesheet. compiler.watchMode = true - taps.start!(compilation) - taps.finishModules!() - expect(taps.needAdditionalPass!()).toBe(false) + const { watchRun, start } = taps + assert(watchRun && start) + const source = resolve('src', 'App.tsx') + const perFile = resolve('df', 'devup-ui', 'devup-ui-1.css') + const base = resolve('df', 'devup-ui', 'devup-ui.css') + for (const css of ['watch-one', 'watch-two']) { + // When: a new watchRun changes styles without serving the cached CSS again. + watchRun() + const original = new Set([source]) + compiler.modifiedFiles = original + start(compilation) + getCssSpy.mockImplementation((fileNum: number | null) => + fileNum === null ? 'watch-one' : css, + ) + taps.finishModules?.() + compilation.deleteAsset.mockClear() + taps.processAssets?.() + // Then: each edit invalidates stale CSS while preserving the modified source. + expect(compilation.deleteAsset).toHaveBeenCalledWith('index.js') + expect(taps.needAdditionalPass?.()).toBe(true) + expect(compiler.modifiedFiles).not.toBe(original) + expect(original).toEqual(new Set([source])) + expect(compiler.modifiedFiles).toEqual( + new Set([source, perFile, ...(css === 'watch-one' ? [base] : [])]), + ) + compilation.deleteAsset.mockClear() + start(compilation) + taps.finishModules?.() + taps.processAssets?.() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass?.()).toBe(false) + } }) it('keeps the pass when every stylesheet is current', async () => { diff --git a/packages/rsbuild-plugin/src/__tests__/reservation-dev.cjs b/packages/rsbuild-plugin/src/__tests__/reservation-dev.cjs index 92ea8a51e..11e2d3cf6 100644 --- a/packages/rsbuild-plugin/src/__tests__/reservation-dev.cjs +++ b/packages/rsbuild-plugin/src/__tests__/reservation-dev.cjs @@ -10,7 +10,16 @@ async function runDev(rsbuild, state) { const first = nextCompile() rsbuild.onAfterDevCompile(({ stats }) => { if (stats.hasErrors()) complete.reject(new Error(stats.toString())) - else complete.resolve(stats) + else if ( + (stats.stats ?? [stats]).some( + ({ compilation }) => compilation.needAdditionalPass, + ) + ) { + for (const { compilation } of stats.stats ?? [stats]) { + assert(compilation.needAdditionalPass) + assert.equal(compilation.getAssets().length, 0) + } + } else complete.resolve(stats) }) let server try { diff --git a/packages/rsbuild-plugin/src/__tests__/reservation-native.cjs b/packages/rsbuild-plugin/src/__tests__/reservation-native.cjs index 5c8e2d3e0..b8d9e9a9d 100644 --- a/packages/rsbuild-plugin/src/__tests__/reservation-native.cjs +++ b/packages/rsbuild-plugin/src/__tests__/reservation-native.cjs @@ -2,7 +2,7 @@ const assert = require('node:assert/strict') const { writeFileSync } = require('node:fs') const { basename } = require('node:path') const { createRsbuild } = require('@rsbuild/core') -const wasm = require('@devup-ui/wasm') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') const { put } = require('./reservation-fixture.cjs') const { runDev } = require('./reservation-dev.cjs') const { runWatch } = require('./reservation-watch.cjs') @@ -101,7 +101,7 @@ async function main() { config: { mode: mode === 'dev' ? 'development' : 'production', plugins: [ - DevupUI({ singleCss: true, include: ['included-ui'] }), + DevupUI({ engine: 'wasm', singleCss: true, include: ['included-ui'] }), observer, ], source: { entry: { main: './src/entry.js' } }, diff --git a/packages/rsbuild-plugin/src/__tests__/reservation-scenarios.cjs b/packages/rsbuild-plugin/src/__tests__/reservation-scenarios.cjs index 3b83bc435..fced3369b 100644 --- a/packages/rsbuild-plugin/src/__tests__/reservation-scenarios.cjs +++ b/packages/rsbuild-plugin/src/__tests__/reservation-scenarios.cjs @@ -2,7 +2,7 @@ const assert = require('node:assert/strict') const { writeFileSync } = require('node:fs') const { join } = require('node:path') const { createRsbuild } = require('@rsbuild/core') -const wasm = require('@devup-ui/wasm') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') const { put } = require('./reservation-fixture.cjs') const [root, scenario, receipt] = process.argv.slice(2) @@ -41,7 +41,11 @@ wasm.setAtomHoist = (value) => { const { DevupUI } = require('../../dist/index.cjs') put(root, 'devup.json', '{}') const atom = scenario === 'atom' || scenario === 'noop' -const options = { singleCss: !atom, atomHoist: atom ? 2 : undefined } +const options = { + engine: 'wasm', + singleCss: !atom, + atomHoist: atom ? 2 : undefined, +} const config = { mode: 'production', plugins: [DevupUI(options)], diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 41223de1e..91dae66c8 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -13,8 +13,11 @@ import { createStateWriter, createThemeInterfaceArgs, type CustomShorthands, + type DevupCompiler, + type DevupUIBasePluginOptions, getFileNumByFilename, type ImportAliases, + loadCompiler, loadDevupConfig, mergeImportAliases, normalizeShorthands, @@ -23,28 +26,16 @@ import { tailwindCssFiles, withTailwindCss, } from '@devup-ui/plugin-utils' -import { - codeExtract, - getCss, - getDefaultTheme, - getThemeInterface, - importCanonicalMap, - importFileRoutes, - registerShorthands, - registerTheme, - resetBuildState, - setAtomHoist, - setDebug, - setModuleResolver, - setPrefix, -} from '@devup-ui/wasm' import type { RsbuildPlugin, Rspack } from '@rsbuild/core' import { createReservation, staticEntries } from './reservation' const PLUGIN_NAME = 'devup-ui-rsbuild-plugin' -export interface DevupUIRsbuildPluginOptions { +export interface DevupUIRsbuildPluginOptions extends Pick< + DevupUIBasePluginOptions, + 'engine' +> { package: string cssDir: string devupFile: string @@ -84,15 +75,16 @@ async function writeDataFiles( DevupUIRsbuildPluginOptions, 'extractCss' | 'debug' | 'include' >, + compiler: DevupCompiler, ): Promise { let tailwindFiles: string[] = [] try { const config = await loadDevupConfig(options.devupFile) const theme = config.theme ?? {} - registerTheme(withTailwindCss(theme, config)) + compiler.registerTheme(withTailwindCss(theme, config)) tailwindFiles = tailwindCssFiles(config) - const interfaceCode = getThemeInterface( + const interfaceCode = compiler.getThemeInterface( ...createThemeInterfaceArgs(options.package), ) @@ -105,14 +97,17 @@ async function writeDataFiles( } } catch (error) { console.error(error) - registerTheme({}) + compiler.registerTheme({}) } await Promise.all([ !existsSync(options.cssDir) ? mkdir(options.cssDir, { recursive: true }) : Promise.resolve(), !options.singleCss - ? writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) + ? writeFile( + join(options.cssDir, 'devup-ui.css'), + compiler.getCss(null, false), + ) : Promise.resolve(), ]) return tailwindFiles @@ -139,10 +134,13 @@ export const DevupUI = ({ singleCss = false, prefix, shorthands, + engine, atomHoist = 2, importAliases: userImportAliases, }: Partial = {}): RsbuildPlugin => { - registerShorthands(normalizeShorthands(shorthands ?? {})) + const normalizedShorthands = normalizeShorthands(shorthands ?? {}) + const compiler = loadCompiler(engine) + compiler.registerShorthands(normalizedShorthands) const importAliases = mergeImportAliases( userImportAliases, readJsxImportSource(), @@ -156,10 +154,10 @@ export const DevupUI = ({ async setup(api) { // A build starts from its own options, not from what an earlier build // in this process left in the engine - const endBuild = beginBuild({ resetBuildState }) + const endBuild = beginBuild(compiler) api.onCloseBuild?.(endBuild) - setDebug(debug) - setPrefix(prefix ?? null) + compiler.setDebug(debug) + compiler.setPrefix(prefix ?? null) if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') @@ -169,13 +167,16 @@ export const DevupUI = ({ 'utf-8', ) - let tailwindFiles = await writeDataFiles({ - package: libPackage, - cssDir, - devupFile, - distDir, - singleCss, - }) + let tailwindFiles = await writeDataFiles( + { + package: libPackage, + cssDir, + devupFile, + distDir, + singleCss, + }, + compiler, + ) if (!extractCss) return // The project's Tailwind CSS defines what a Tailwind class compiles to: @@ -183,7 +184,7 @@ export const DevupUI = ({ api.modifyRspackConfig((config) => { config.plugins ??= [] config.plugins.push({ - apply(compiler: { + apply(owner: { hooks: { watchRun: { tapPromise: (name: string, fn: () => Promise) => void @@ -191,7 +192,7 @@ export const DevupUI = ({ } }) { let stamp: string | undefined - compiler.hooks.watchRun.tapPromise( + owner.hooks.watchRun.tapPromise( 'DevupUIRsbuildPlugin', async () => { const current = ( @@ -205,13 +206,16 @@ export const DevupUI = ({ ) ).join() if (stamp !== undefined && stamp !== current) { - tailwindFiles = await writeDataFiles({ - package: libPackage, - cssDir, - devupFile, - distDir, - singleCss, - }) + tailwindFiles = await writeDataFiles( + { + package: libPackage, + cssDir, + devupFile, + distDir, + singleCss, + }, + compiler, + ) } stamp = current }, @@ -229,7 +233,7 @@ export const DevupUI = ({ // absolute keys (keyBy: 'absolute') and the extraction filename is // POSIX-normalized to match. const atomMode = !singleCss && Number.isFinite(atomHoist) && atomHoist > 0 - setModuleResolver( + compiler.setModuleResolver( createModuleResolver({ toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), }), @@ -245,7 +249,7 @@ export const DevupUI = ({ cwd: root, keyBy: 'absolute', }) - importCanonicalMap(canonicalMap) + compiler.importCanonicalMap(canonicalMap) const fileReach = computeFileReach({ srcDir, tsconfigPath, @@ -254,8 +258,8 @@ export const DevupUI = ({ }) const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) if (plan) { - importFileRoutes(plan.reachByBucket) - setAtomHoist(plan.threshold) + compiler.importFileRoutes(plan.reachByBucket) + compiler.setAtomHoist(plan.threshold) } else { console.info( '[devup-ui] fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', @@ -266,15 +270,18 @@ export const DevupUI = ({ } } - const reservation = createReservation({ - root: api.context.rootPath, - package: libPackage, - include, - cssDir, - singleCss, - atomMode, - importAliases, - }) + const reservation = createReservation( + { + root: api.context.rootPath, + package: libPackage, + include, + cssDir, + singleCss, + atomMode, + importAliases, + }, + compiler, + ) const entryFiles = new Set() // Extract the source files under `src` that the entries reach, in path // order, the same way the transform does, so that a stylesheet built on @@ -294,7 +301,7 @@ export const DevupUI = ({ ) if (!extractCssDir.startsWith('./')) extractCssDir = `./${extractCssDir}` - codeExtract( + compiler.codeExtract( atomMode ? file.replaceAll('\\', '/') : file, readFileSync(file, 'utf-8'), libPackage, @@ -324,7 +331,7 @@ export const DevupUI = ({ // where the entry code imports the base itself so that hoisted atoms // load once (the injected splitChunks cacheGroup, see // modifyRsbuildConfig, emits the base once) - getCss(getFileNumByFilename(basename(resourcePath)), !atomMode) + compiler.getCss(getFileNumByFilename(basename(resourcePath)), !atomMode) api.transform( { @@ -339,8 +346,8 @@ export const DevupUI = ({ // A stylesheet module is built on its first import, which can come before // the modules whose styles it holds are extracted. When one was, compile - // once more: every module is extracted by then. The dev server rebuilds it - // through the stylesheet files the transforms write instead. + // once more: every module is extracted by then. Keep the consumed CSS + // across watch revisions so cached stylesheet modules are checked too. api.modifyRspackConfig((config, { environment }) => { for (const request of staticEntries(environment.entry)) entryFiles.add(resolve(api.context.rootPath, request)) @@ -348,6 +355,8 @@ export const DevupUI = ({ config.plugins.push({ apply(compiler: Rspack.Compiler) { reservation.trackCompiler(environment.name, compiler) + const served = new Map() + servedCss.set(environment.name, served) let passes = 0 compiler.hooks.run.tap(PLUGIN_NAME, () => { passes = 0 @@ -355,17 +364,15 @@ export const DevupUI = ({ }) compiler.hooks.invalid.tap(PLUGIN_NAME, reservation.invalidate) compiler.hooks.watchRun.tap(PLUGIN_NAME, () => { + passes = 0 reservation.reserve() }) compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { - const served = new Map() - servedCss.set(environment.name, served) const basePath = join(cssDir, 'devup-ui.css') const base = stylesheet(basePath) writeChanged(basePath, base) let stale: string[] = [] compilation.hooks.finishModules.tap(PLUGIN_NAME, () => { - if (compiler.watchMode) return const changed = new Set( [...served] .filter(([path, css]) => stylesheet(path) !== css) @@ -394,6 +401,10 @@ export const DevupUI = ({ passes += 1 for (const path of stale) writeFileSync(path, stylesheet(path), 'utf-8') + compiler.modifiedFiles = new Set([ + ...(compiler.modifiedFiles ?? []), + ...stale, + ]) return true }) }) @@ -402,12 +413,13 @@ export const DevupUI = ({ }) api.modifyRsbuildConfig((config) => { - const theme = getDefaultTheme() + const theme = compiler.getDefaultTheme() if (theme) { config.source ??= {} config.source.define = { - 'process.env.DEVUP_UI_DEFAULT_THEME': - JSON.stringify(getDefaultTheme()), + 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify( + compiler.getDefaultTheme(), + ), ...config.source.define, } } @@ -482,7 +494,7 @@ export const DevupUI = ({ cssFile, updatedBaseStyle, dependencies = [], - } = codeExtract( + } = compiler.codeExtract( extractName, code, libPackage, @@ -499,7 +511,7 @@ export const DevupUI = ({ promises.push( stateWriter.write( join(cssDir, 'devup-ui.css'), - getCss(null, false), + compiler.getCss(null, false), 'utf-8', ), ) @@ -509,7 +521,7 @@ export const DevupUI = ({ promises.push( stateWriter.write( join(cssDir, basename(cssFile)), - `/* ${resourcePath} ${Date.now()} */`, + stylesheet(cssFile), 'utf-8', ), ) diff --git a/packages/rsbuild-plugin/src/reservation.ts b/packages/rsbuild-plugin/src/reservation.ts index 829d04abb..897a03f6d 100644 --- a/packages/rsbuild-plugin/src/reservation.ts +++ b/packages/rsbuild-plugin/src/reservation.ts @@ -2,9 +2,9 @@ import { dirname, relative, resolve } from 'node:path' import { createSharedSlotReservation, + type DevupCompiler, type WasmImportAliases, } from '@devup-ui/plugin-utils' -import { reserveClassSlots } from '@devup-ui/wasm' import type { Rspack } from '@rsbuild/core' import type { DevupUIRsbuildPluginOptions } from './plugin' @@ -37,8 +37,9 @@ export function createReservation( readonly atomMode: boolean readonly importAliases: WasmImportAliases }, + compiler: Pick, ) { - const slots = createSharedSlotReservation({ reserveClassSlots }) + const slots = createSharedSlotReservation(compiler) const roots = new Set( [process.cwd(), options.root].flatMap((root) => ['src', 'app'].map((name) => resolve(root, name)), diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index a943c028c..61bf1278b 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -177,6 +177,24 @@ const box = const box = ``` +## Compiler engine + +Vite uses the native compiler by default. Select either engine explicitly: + +```ts +DevupUI() +DevupUI({ engine: 'native' }) +DevupUI({ engine: 'wasm' }) +``` + +Keep optional dependencies enabled for the native platform package. Native load +failures name that package and preserve the original error as their cause; +there is no automatic fallback. Select `engine: 'wasm'` on hosts without a +working native image or its required libraries. Invalid shorthands fail before +engine selection. Configuration, reservation, transforms and delayed callbacks +use the same selected compiler. Neither engine supports concurrent independent +builds in one process; Vite environments within a build share its compiler state. + ## Custom Shorthands Custom shorthands are build-plugin options, not theme tokens. Every target diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index d49c413e7..83518c347 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -49,7 +49,6 @@ "vite": "^8.3.5" }, "peerDependencies": { - "@devup-ui/wasm": "*", "vite": "*" } } diff --git a/packages/vite-plugin/src/__tests__/atom-hoist-policy.test.ts b/packages/vite-plugin/src/__tests__/atom-hoist-policy.test.ts index 49be34851..563ce9d4f 100644 --- a/packages/vite-plugin/src/__tests__/atom-hoist-policy.test.ts +++ b/packages/vite-plugin/src/__tests__/atom-hoist-policy.test.ts @@ -1,7 +1,6 @@ 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 { @@ -10,6 +9,8 @@ import { } from '../../../plugin-utils/src/__tests__/reservation-fixture' import { createViteReservation } from '../reservation' +const wasm = utils.loadCompiler('wasm') + 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) => { @@ -35,7 +36,7 @@ it.each([undefined, 0, -1, NaN, Infinity, -Infinity, 0.5, 1, 2, 3])( expect(canonical).toHaveBeenCalledTimes(enabled ? 1 : 0) expect(threshold).toHaveBeenCalledTimes(enabled ? 1 : 0) }) - const slots = createViteReservation({ + const slots = createViteReservation(wasm, { package: '@devup-ui/react', include: [], importAliases: {}, @@ -96,7 +97,7 @@ it('folds canonical reach and skips global buckets before installing the thresho using _engine = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => { events.push('scratch') }) - const slots = createViteReservation({ + const slots = createViteReservation(wasm, { package: '@devup-ui/react', include: [], importAliases: {}, diff --git a/packages/vite-plugin/src/__tests__/cache-invalidation-metadata-native.cjs b/packages/vite-plugin/src/__tests__/cache-invalidation-metadata-native.cjs index da62ad1f2..f0c072507 100644 --- a/packages/vite-plugin/src/__tests__/cache-invalidation-metadata-native.cjs +++ b/packages/vite-plugin/src/__tests__/cache-invalidation-metadata-native.cjs @@ -69,6 +69,7 @@ async function main() { const graph = { idToModuleMap: modules, getModuleById: (/** @type {string} */ id) => modules.get(id), + getModulesByFile: () => undefined, invalidateModule(/** @type {unknown[]} */ ...args) { invalidated.push(args) }, diff --git a/packages/vite-plugin/src/__tests__/plugin-lifecycle-fixture.ts b/packages/vite-plugin/src/__tests__/plugin-lifecycle-fixture.ts index 9d25ff6b1..d6bd4fe62 100644 --- a/packages/vite-plugin/src/__tests__/plugin-lifecycle-fixture.ts +++ b/packages/vite-plugin/src/__tests__/plugin-lifecycle-fixture.ts @@ -89,6 +89,12 @@ export function graph(ids: readonly string[] = []) { return { idToModuleMap, getModuleById: (id: string) => idToModuleMap.get(id), + getModulesByFile: (file: string) => { + const nodes = [...idToModuleMap.values()].filter( + ({ id }) => id.split('?')[0] === file, + ) + return nodes.length ? new Set(nodes) : undefined + }, invalidateModule: mock(), } } @@ -109,6 +115,7 @@ export async function lifecycle(options: Parameters[0] = {}) { const project = fixture() project.write('devup.json', '{}') const plugins = DevupUI({ + engine: 'wasm', singleCss: true, debug: true, distDir: join(project.root, 'df'), diff --git a/packages/vite-plugin/src/__tests__/plugin-lifecycle.test.ts b/packages/vite-plugin/src/__tests__/plugin-lifecycle.test.ts index 46b9f52ef..cc7f4edc5 100644 --- a/packages/vite-plugin/src/__tests__/plugin-lifecycle.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin-lifecycle.test.ts @@ -1,7 +1,11 @@ import { join } from 'node:path' -import type { ReservationRow } from '@devup-ui/plugin-utils' -import * as wasm from '@devup-ui/wasm' +import { + type DevupCompiler, + loadCompiler, + type ReservationEngine, + type ReservationRow, +} from '@devup-ui/plugin-utils' import { expect, it, mock, spyOn } from 'bun:test' import { STYLE } from '../../../plugin-utils/src/__tests__/reservation-fixture' @@ -12,6 +16,8 @@ import { watcher, } from './plugin-lifecycle-fixture' +const wasm: DevupCompiler & ReservationEngine = loadCompiler('wasm') + it('reserves actual server environment union and detaches each watcher lifetime on replacement and close', async () => { // Given using reserve = spyOn(wasm, 'reserveClassSlots').mockImplementation(() => {}) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index c10009e80..cbc3643da 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -4,7 +4,6 @@ import * as os from 'node:os' import * as nodePath from 'node:path' import * as pluginUtils from '@devup-ui/plugin-utils' -import * as wasm from '@devup-ui/wasm' import { afterEach, beforeEach, @@ -18,7 +17,26 @@ import type { Plugin } from 'vite' import { DevupUI } from '../plugin' +const wasm = pluginUtils.loadCompiler('wasm') + describe('shorthand creation boundary', () => { + it.each([undefined, 'native', 'wasm'] as const)( + 'passes engine=%s to the selector', + (engine) => { + using selection = spyOn(pluginUtils, 'loadCompiler').mockReturnValue(wasm) + // When + const [plugin] = DevupUI( + engine === undefined ? undefined : { engine }, + ) as unknown as [ViteTestPlugin] + try { + // Then + expect(selection.mock.calls).toEqual([[engine]]) + } finally { + plugin.closeBundle() + } + }, + ) + it('registers canonical targets when the plugin is created', () => { const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( undefined, @@ -38,16 +56,18 @@ describe('shorthand creation boundary', () => { }) it('rejects a typo before registration when runtime options bypass types', () => { + using selection = spyOn(pluginUtils, 'loadCompiler') const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( undefined, ) try { expect(() => Reflect.apply(DevupUI, undefined, [ - { shorthands: { bgx: ['backgroundColour'] } }, + { engine: 'invalid', shorthands: { bgx: ['backgroundColour'] } }, ]), ).toThrow(pluginUtils.ShorthandConfigError) expect(registration).not.toHaveBeenCalled() + expect(selection).not.toHaveBeenCalled() } finally { registration.mockRestore() } @@ -81,7 +101,10 @@ interface ViteConfig { interface HotUpdateEnvironment { config: { consumer: 'client' | 'server' } - moduleGraph: { invalidateModule: (...args: unknown[]) => void } + moduleGraph: { + invalidateModule: (...args: unknown[]) => void + getModulesByFile: (file: string) => Set | undefined + } hot: { send: (...args: unknown[]) => void } } @@ -120,6 +143,7 @@ interface ViteTestPlugin { server: { moduleGraph: { invalidateModule: (...args: unknown[]) => void + getModulesByFile: (file: string) => Set | undefined idToModuleMap?: ReadonlyMap } ws: { send: (...args: unknown[]) => void } @@ -177,16 +201,18 @@ interface ViteTestRestorePlugin { function createCodeExtractResult( overrides: Partial = {}, ): CodeExtractResult { + const free = mock() return { css: 'css code', code: 'code', cssFile: 'devup-ui.css', map: undefined, updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), + dependencies: [], + free, + [Symbol.dispose]: free, ...overrides, - } as unknown as CodeExtractResult + } } const activePlugins: ViteTestPlugin[] = [] @@ -194,7 +220,7 @@ const activePlugins: ViteTestPlugin[] = [] function createPlugins( options?: Parameters[0], ): [ViteTestPlugin, ViteTestRestorePlugin] { - const plugins = DevupUI(options) as unknown as [ + const plugins = DevupUI({ engine: 'wasm', ...options }) as unknown as [ ViteTestPlugin, ViteTestRestorePlugin, ] @@ -253,7 +279,7 @@ beforeEach(() => { relativeSpy = spyOn(nodePath, 'relative').mockImplementation( (from: string, to: string) => originalRelative(from, to), ) - codeExtractSpy = spyOn(wasm, 'codeExtract').mockReturnValue( + codeExtractSpy = spyOn(wasm, 'codeExtract').mockImplementation(() => createCodeExtractResult(), ) getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('css code') @@ -285,6 +311,22 @@ afterEach(() => { }) describe('devupUIVitePlugin', () => { + it('releases extracted Output before publishing CSS while retaining its result', async () => { + // Given + const plugin = createPlugin() + const output = createCodeExtractResult() + codeExtractSpy.mockReturnValueOnce(output) + writeFileSpy.mockImplementationOnce(async () => { + expect(output.free).toHaveBeenCalledTimes(1) + }) + // When + const result = await plugin.transform('source', 'source.tsx') + // Then + expect(result).toEqual({ code: 'code', map: undefined }) + expect(writeFileSpy).toHaveBeenCalledTimes(1) + expect(output.free).toHaveBeenCalledTimes(1) + }) + it('should apply default options', () => { const plugin = createPlugin({}) expect(plugin).toEqual({ @@ -1475,7 +1517,7 @@ describe('devupUIVitePlugin', () => { const result = await plugin.handleHotUpdate({ file: 'devup.json', server: { - moduleGraph: { invalidateModule }, + moduleGraph: { invalidateModule, getModulesByFile: () => undefined }, ws: { send }, }, modules: [module], @@ -1506,7 +1548,7 @@ describe('devupUIVitePlugin', () => { const result = await plugin.handleHotUpdate({ file: 'other.json', server: { - moduleGraph: { invalidateModule }, + moduleGraph: { invalidateModule, getModulesByFile: () => undefined }, ws: { send }, }, modules: [], @@ -1571,6 +1613,7 @@ describe('devupUIVitePlugin', () => { ...createHotUpdateEnvironment('client'), moduleGraph: { invalidateModule: mock(), + getModulesByFile: () => undefined, idToModuleMap: new Map([ ['a', first], ['b', second], @@ -1606,6 +1649,7 @@ describe('devupUIVitePlugin', () => { server: { moduleGraph: { invalidateModule, + getModulesByFile: () => undefined, idToModuleMap: new Map([['a', module]]), }, ws: { send }, @@ -1638,7 +1682,10 @@ describe('devupUIVitePlugin', () => { function createHotUpdateEnvironment(consumer: 'client' | 'server') { return { config: { consumer }, - moduleGraph: { invalidateModule: mock() }, + moduleGraph: { + invalidateModule: mock(), + getModulesByFile: () => undefined, + }, hot: { send: mock() }, } } diff --git a/packages/vite-plugin/src/__tests__/provenance-fixture.cjs b/packages/vite-plugin/src/__tests__/provenance-fixture.cjs index c728859dc..d587a4617 100644 --- a/packages/vite-plugin/src/__tests__/provenance-fixture.cjs +++ b/packages/vite-plugin/src/__tests__/provenance-fixture.cjs @@ -4,7 +4,7 @@ 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') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('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} */ @@ -39,6 +39,7 @@ function provenanceFixture(root, options = {}) { write('src/A.tsx', source) const { DevupUI } = require('../../dist/' + 'index.cjs') const plugins = DevupUI({ + engine: 'wasm', singleCss: true, devupFile: join(root, 'devup.json'), distDir: join(root, 'df'), diff --git a/packages/vite-plugin/src/__tests__/provenance-relevance.test.ts b/packages/vite-plugin/src/__tests__/provenance-relevance.test.ts index 4ddaef8dd..1028a4325 100644 --- a/packages/vite-plugin/src/__tests__/provenance-relevance.test.ts +++ b/packages/vite-plugin/src/__tests__/provenance-relevance.test.ts @@ -1,7 +1,7 @@ import { rmSync } from 'node:fs' import { join, resolve } from 'node:path' -import { resetBuildState } from '@devup-ui/wasm' +import { loadCompiler } from '@devup-ui/plugin-utils' import { expect, it, spyOn } from 'bun:test' import { @@ -13,8 +13,11 @@ import * as htmlReservation from '../html-reservation' import { createViteReservation } from '../reservation' import { createServerWatch } from '../server-watch' +const wasm = loadCompiler('wasm') +const { resetBuildState } = wasm + function provenanceReservation(root: string) { - return createViteReservation({ + return createViteReservation(wasm, { package: '@devup-ui/react', include: [], importAliases: {}, @@ -209,7 +212,7 @@ for (const suffix of ['json', 'css', 'cjs']) { const compiled = createCompiledDependencies() const supplier = join(project.root, `outside/supplier.${suffix}`) compiled.update('rsc', 'consumer.tsx?server', [supplier]) - const reservation = createViteReservation({ + const reservation = createViteReservation(wasm, { package: '@devup-ui/react', include: [], importAliases: {}, diff --git a/packages/vite-plugin/src/__tests__/reservation-hoist.test.ts b/packages/vite-plugin/src/__tests__/reservation-hoist.test.ts index ab54accd1..9f46da08e 100644 --- a/packages/vite-plugin/src/__tests__/reservation-hoist.test.ts +++ b/packages/vite-plugin/src/__tests__/reservation-hoist.test.ts @@ -2,7 +2,6 @@ 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 { @@ -13,8 +12,10 @@ import * as htmlReservation from '../html-reservation' import { createViteReservation } from '../reservation' import type { ReservationEnvironment } from '../reservation-inputs' +const wasm = utils.loadCompiler('wasm') + function reservation(root: string) { - return createViteReservation({ + return createViteReservation(wasm, { package: '@devup-ui/react', include: [], importAliases: {}, diff --git a/packages/vite-plugin/src/__tests__/reservation-native-boundary.cjs b/packages/vite-plugin/src/__tests__/reservation-native-boundary.cjs index 3ad599b21..4dac75419 100644 --- a/packages/vite-plugin/src/__tests__/reservation-native-boundary.cjs +++ b/packages/vite-plugin/src/__tests__/reservation-native-boundary.cjs @@ -82,6 +82,7 @@ async function main() { }, }, DevupUI({ + engine: 'wasm', include: ['accepted'], singleCss: true, devupFile: join(root, 'devup.json'), diff --git a/packages/vite-plugin/src/__tests__/reservation-native-hoist-checks.cjs b/packages/vite-plugin/src/__tests__/reservation-native-hoist-checks.cjs index 5edefaa8d..f13941a71 100644 --- a/packages/vite-plugin/src/__tests__/reservation-native-hoist-checks.cjs +++ b/packages/vite-plugin/src/__tests__/reservation-native-hoist-checks.cjs @@ -1,7 +1,7 @@ const assert = require('node:assert/strict') const { readFileSync, readdirSync } = require('node:fs') const { resolve } = require('node:path') -const wasm = require('@devup-ui/wasm') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') /** @param {string} css */ function declarations(css) { diff --git a/packages/vite-plugin/src/__tests__/reservation-native-installed.cjs b/packages/vite-plugin/src/__tests__/reservation-native-installed.cjs index 4cd2a5bb0..27a3d4fdd 100644 --- a/packages/vite-plugin/src/__tests__/reservation-native-installed.cjs +++ b/packages/vite-plugin/src/__tests__/reservation-native-installed.cjs @@ -39,6 +39,7 @@ async function main() { oxc: { jsx: { runtime: 'classic' } }, plugins: [ DevupUI({ + engine: 'wasm', distDir: resolve(root, 'df'), cssDir: resolve(root, 'df/devup-ui'), devupFile: resolve(root, 'devup.json'), diff --git a/packages/vite-plugin/src/__tests__/reservation-native-matrix.cjs b/packages/vite-plugin/src/__tests__/reservation-native-matrix.cjs index d5aeeaa6f..8732d65e2 100644 --- a/packages/vite-plugin/src/__tests__/reservation-native-matrix.cjs +++ b/packages/vite-plugin/src/__tests__/reservation-native-matrix.cjs @@ -107,6 +107,7 @@ async function main() { plugins: [ observer, DevupUI({ + engine: 'wasm', distDir: resolve(root, 'df'), cssDir: resolve(root, 'df/devup-ui'), devupFile: resolve(root, 'devup.json'), diff --git a/packages/vite-plugin/src/__tests__/reservation-native-observer.cjs b/packages/vite-plugin/src/__tests__/reservation-native-observer.cjs index 1cfbcdbb4..da152fb07 100644 --- a/packages/vite-plugin/src/__tests__/reservation-native-observer.cjs +++ b/packages/vite-plugin/src/__tests__/reservation-native-observer.cjs @@ -1,5 +1,5 @@ const assert = require('node:assert/strict') -const wasm = require('@devup-ui/wasm') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') /** @typedef {readonly [string, string, string]} Row */ /** @typedef {Readonly>} Aliases */ diff --git a/packages/vite-plugin/src/__tests__/reservation-native-physical.cjs b/packages/vite-plugin/src/__tests__/reservation-native-physical.cjs index 58cc510fd..1a1517ddf 100644 --- a/packages/vite-plugin/src/__tests__/reservation-native-physical.cjs +++ b/packages/vite-plugin/src/__tests__/reservation-native-physical.cjs @@ -72,6 +72,7 @@ async function main() { }, }, DevupUI({ + engine: 'wasm', distDir: resolve(root, 'df'), cssDir: scenario === 'classic' ? resolve(root, 'df/css') : root, devupFile: resolve(root, 'devup.json'), diff --git a/packages/vite-plugin/src/__tests__/reservation-native.cjs b/packages/vite-plugin/src/__tests__/reservation-native.cjs index c318c37a2..5c6bf23be 100644 --- a/packages/vite-plugin/src/__tests__/reservation-native.cjs +++ b/packages/vite-plugin/src/__tests__/reservation-native.cjs @@ -1,7 +1,7 @@ const assert = require('node:assert/strict') const { writeFileSync } = require('node:fs') const { basename, resolve } = require('node:path') -const wasm = require('@devup-ui/wasm') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') const { observe } = require('./reservation-native-observer.cjs') const { text } = require('./reservation-native-receipt.cjs') @@ -18,7 +18,7 @@ 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}} */ +/** @type {{DevupUI:typeof import('../plugin').DevupUI}} */ const { DevupUI } = require('../../dist/' + 'index.cjs') async function main() { @@ -39,6 +39,7 @@ async function main() { plugins: [ observer, DevupUI({ + engine: 'wasm', distDir: resolve(root, 'df'), cssDir: resolve(root, 'df/devup-ui'), devupFile: resolve(root, 'devup.json'), diff --git a/packages/vite-plugin/src/__tests__/reservation-physical.test.ts b/packages/vite-plugin/src/__tests__/reservation-physical.test.ts index 4b603a44a..2bf98333e 100644 --- a/packages/vite-plugin/src/__tests__/reservation-physical.test.ts +++ b/packages/vite-plugin/src/__tests__/reservation-physical.test.ts @@ -1,7 +1,11 @@ import { join } from 'node:path' -import type { ReservationRow } from '@devup-ui/plugin-utils' -import * as wasm from '@devup-ui/wasm' +import { + type DevupCompiler, + loadCompiler, + type ReservationEngine, + type ReservationRow, +} from '@devup-ui/plugin-utils' import { expect, it, spyOn } from 'bun:test' import { @@ -10,8 +14,10 @@ import { } from '../../../plugin-utils/src/__tests__/reservation-fixture' import { createViteReservation } from '../reservation' +const wasm: DevupCompiler & ReservationEngine = loadCompiler('wasm') + function reservation(root: string, location: 'root' | 'nested') { - return createViteReservation({ + return createViteReservation(wasm, { package: '@devup-ui/react', include: [], importAliases: {}, diff --git a/packages/vite-plugin/src/__tests__/reservation-recorder.test.ts b/packages/vite-plugin/src/__tests__/reservation-recorder.test.ts index 963e50417..05bfb6354 100644 --- a/packages/vite-plugin/src/__tests__/reservation-recorder.test.ts +++ b/packages/vite-plugin/src/__tests__/reservation-recorder.test.ts @@ -2,9 +2,11 @@ import { join } from 'node:path' import { createSharedSlotReservation, + type DevupCompiler, getBuildOwnership, + loadCompiler, + type ReservationEngine, } from '@devup-ui/plugin-utils' -import * as wasm from '@devup-ui/wasm' import { expect, it, spyOn } from 'bun:test' import { @@ -15,6 +17,8 @@ import { } from '../../../plugin-utils/src/__tests__/reservation-fixture' import { createViteReservation } from '../reservation' +const wasm: DevupCompiler & ReservationEngine = loadCompiler('wasm') + it('forwards the actual Vite reservation batch without publishing scratch CSS', async () => { expect(getBuildOwnership().running).toBe(0) wasm.resetBuildState() @@ -29,7 +33,7 @@ it('forwards the actual Vite reservation batch without publishing scratch CSS', reserve(...args) }, ) - const reservation = createViteReservation({ + const reservation = createViteReservation(wasm, { package: '@devup-ui/react', include: [], importAliases: {}, diff --git a/packages/vite-plugin/src/__tests__/reservation-watch-boundary.test.ts b/packages/vite-plugin/src/__tests__/reservation-watch-boundary.test.ts index 6406b3ace..df818680a 100644 --- a/packages/vite-plugin/src/__tests__/reservation-watch-boundary.test.ts +++ b/packages/vite-plugin/src/__tests__/reservation-watch-boundary.test.ts @@ -1,7 +1,7 @@ import { rmSync, symlinkSync } from 'node:fs' import { dirname, join } from 'node:path' -import * as wasm from '@devup-ui/wasm' +import { loadCompiler } from '@devup-ui/plugin-utils' import { expect, it, spyOn } from 'bun:test' import { @@ -10,8 +10,10 @@ import { } from '../../../plugin-utils/src/__tests__/reservation-fixture' import { createViteReservation } from '../reservation' +const wasm = loadCompiler('wasm') + function reservation(root: string) { - const slots = createViteReservation({ + const slots = createViteReservation(wasm, { package: '@devup-ui/react', include: ['accepted'], importAliases: {}, diff --git a/packages/vite-plugin/src/__tests__/reservation.test.ts b/packages/vite-plugin/src/__tests__/reservation.test.ts index ac49d7f89..e8529f572 100644 --- a/packages/vite-plugin/src/__tests__/reservation.test.ts +++ b/packages/vite-plugin/src/__tests__/reservation.test.ts @@ -2,7 +2,6 @@ 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 { @@ -12,8 +11,11 @@ import { import * as htmlReservation from '../html-reservation' import { createViteReservation } from '../reservation' +const wasm: utils.DevupCompiler & utils.ReservationEngine = + utils.loadCompiler('wasm') + function reservation(root: string) { - return createViteReservation({ + return createViteReservation(wasm, { package: '@devup-ui/react', include: [], importAliases: {}, diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-admission-native.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-admission-native.cjs index 4d7d6cfa9..24885abf3 100644 --- a/packages/vite-plugin/src/__tests__/serve-coherence-admission-native.cjs +++ b/packages/vite-plugin/src/__tests__/serve-coherence-admission-native.cjs @@ -1,6 +1,6 @@ const assert = require('node:assert/strict') const { join } = require('node:path') -const wasm = require('@devup-ui/wasm') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') const { project, gate } = require('./serve-coherence-async-fixture.cjs') const mode = process.argv[2] assert(mode === 'config' || mode === 'tailwind' || mode === 'tailwind-import') diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-admission.test.ts b/packages/vite-plugin/src/__tests__/serve-coherence-admission.test.ts index 5769b2fac..d0b8e1ae8 100644 --- a/packages/vite-plugin/src/__tests__/serve-coherence-admission.test.ts +++ b/packages/vite-plugin/src/__tests__/serve-coherence-admission.test.ts @@ -1,11 +1,13 @@ import * as fs from 'node:fs/promises' import { join } from 'node:path' -import { getCss } from '@devup-ui/wasm' +import { loadCompiler } from '@devup-ui/plugin-utils' import { expect, it, spyOn } from 'bun:test' import { environment, lifecycle, watcher } from './plugin-lifecycle-fixture' +const { getCss } = loadCompiler('wasm') + for (const mode of ['config', 'tailwind', 'tailwind-import'] as const) { it(`admits discovered ${mode} events while declaration output is pending`, async () => { // Given diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-async-fixture.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-async-fixture.cjs index 895e9f077..ae1f582b2 100644 --- a/packages/vite-plugin/src/__tests__/serve-coherence-async-fixture.cjs +++ b/packages/vite-plugin/src/__tests__/serve-coherence-async-fixture.cjs @@ -40,6 +40,7 @@ function project() { /** @type {{DevupUI:typeof import('../plugin').DevupUI}} */ const { DevupUI } = require('../../dist/' + 'index.cjs') const plugins = DevupUI({ + engine: 'wasm', devupFile: join(root, 'devup.json'), distDir: join(root, 'df'), cssDir: join(root, 'df/css'), diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-async-lifetime.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-async-lifetime.cjs index ee3e8c630..79168055c 100644 --- a/packages/vite-plugin/src/__tests__/serve-coherence-async-lifetime.cjs +++ b/packages/vite-plugin/src/__tests__/serve-coherence-async-lifetime.cjs @@ -1,6 +1,6 @@ const assert = require('node:assert/strict') const { join } = require('node:path') -const wasm = require('@devup-ui/wasm') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') /** @type {typeof import('../../../plugin-utils/src/index')} */ const utils = require('@devup-ui/plugin-utils') const { project, gate } = require('./serve-coherence-async-fixture.cjs') diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-async-refresh.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-async-refresh.cjs index e3fac874a..251d1b3bb 100644 --- a/packages/vite-plugin/src/__tests__/serve-coherence-async-refresh.cjs +++ b/packages/vite-plugin/src/__tests__/serve-coherence-async-refresh.cjs @@ -1,6 +1,6 @@ const assert = require('node:assert/strict') const { join } = require('node:path') -const wasm = require('@devup-ui/wasm') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') const { project, gate } = require('./serve-coherence-async-fixture.cjs') const stage = process.argv[2] assert(stage === 'load' || stage === 'write') 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 index e83d4cc40..0b36c4979 100644 --- a/packages/vite-plugin/src/__tests__/serve-coherence-build-lease-native.cjs +++ b/packages/vite-plugin/src/__tests__/serve-coherence-build-lease-native.cjs @@ -1,6 +1,6 @@ const assert = require('node:assert/strict') const { join } = require('node:path') -const wasm = require('@devup-ui/wasm') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') /** @type {typeof import('../../../plugin-utils/src/index')} */ const utils = require('@devup-ui/plugin-utils') const { observe } = require('./reservation-native-observer.cjs') diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-cache-fixture.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-cache-fixture.cjs index 31fa1eb26..c41fdb6b3 100644 --- a/packages/vite-plugin/src/__tests__/serve-coherence-cache-fixture.cjs +++ b/packages/vite-plugin/src/__tests__/serve-coherence-cache-fixture.cjs @@ -3,7 +3,7 @@ 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') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') /** @typedef {import('./serve-coherence-async-fixture.cjs').Hooks & {load(id:string):string|undefined}} CacheHooks */ /** @param {unknown} value @returns {value is CacheHooks} */ @@ -21,6 +21,7 @@ function fixture(root, options = {}) { write('src/A.tsx', source) const { DevupUI } = require('../../dist/' + 'index.cjs') const plugins = DevupUI({ + engine: 'wasm', singleCss: true, devupFile: join(root, 'devup.json'), distDir: join(root, 'df'), 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 index 0816fba5e..37e76d8c1 100644 --- a/packages/vite-plugin/src/__tests__/serve-coherence-config-refresh.test.ts +++ b/packages/vite-plugin/src/__tests__/serve-coherence-config-refresh.test.ts @@ -1,7 +1,7 @@ -import { rmSync } from 'node:fs' +import { readFileSync, rmSync } from 'node:fs' import { join } from 'node:path' -import { getCss } from '@devup-ui/wasm' +import { loadCompiler } from '@devup-ui/plugin-utils' import { expect, it, mock } from 'bun:test' import { @@ -10,6 +10,67 @@ import { lifecycle, watcher, } from './plugin-lifecycle-fixture' +import { cacheFixture, cssLoader, writeGate } from './source-cache-fixture' + +const { getCss } = loadCompiler('wasm') + +for (const hook of ['watchChange', 'hotUpdate', 'handleHotUpdate'] as const) { + it(`publishes refreshed single CSS and invalidates its cached variants through ${hook}`, async () => { + // Given: a real 17px disk publication and query-qualified cached CSS nodes. + using state = await cacheFixture() + const theme = (size: string) => + JSON.stringify({ theme: { length: { default: { size } } } }) + state.project.write('devup.json', '{"extends":["./theme.json"]}') + state.project.write('theme.json', theme('17px')) + const file = join(state.project.root, 'theme.json') + await state.plugin.watchChange(join(state.project.root, 'devup.json')) + await state.transform() + expect(readFileSync(state.cssFile, 'utf8')).toContain('--size:17px') + const css = state.cssFile.replaceAll('\\', '/') + const env = environment('client', [ + `${css}?devup-revision=1`, + `${css}?direct`, + '/unrelated.js', + ]) + const cached = [...env.moduleGraph.idToModuleMap.values()].slice(0, 2) + expect(cssLoader(state.plugin)(cached[0].id)).toContain('--size:17px') + using writes = writeGate(state.cssFile) + writes.release() + state.project.write('theme.json', theme('29px')) + const event = { file, modules: [], timestamp: 7 } + // When + switch (hook) { + case 'watchChange': + await state.plugin.watchChange(file) + break + case 'hotUpdate': + await state.plugin.hotUpdate.call({ environment: env }, event) + break + case 'handleHotUpdate': + await state.plugin.handleHotUpdate({ + ...event, + server: { moduleGraph: env.moduleGraph, ws: env.hot }, + }) + break + default: + throw new TypeError(`Unexpected hook ${hook satisfies never}`) + } + // Then: current disk bytes, targeted invalidation, no redundant publication. + expect(readFileSync(state.cssFile, 'utf8')).toBe(getCss(null, false)) + expect(readFileSync(state.cssFile, 'utf8')).toContain('--size:29px') + if (hook !== 'watchChange') { + expect( + env.moduleGraph.invalidateModule.mock.calls.map(([node]) => node), + ).toEqual(cached) + } + const count = () => + writes.spy.mock.calls.filter(([path]) => String(path) === state.cssFile) + .length + const published = count() + await state.plugin.watchChange(file) + expect(count()).toBe(published) + }) +} for (const hook of ['watchChange', 'hotUpdate', 'handleHotUpdate'] as const) { for (const action of ['edit', 'delete', 'recreate', 'root-delete'] as const) { diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-prereq-native.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-prereq-native.cjs index 3a54f9211..acce9e82b 100644 --- a/packages/vite-plugin/src/__tests__/serve-coherence-prereq-native.cjs +++ b/packages/vite-plugin/src/__tests__/serve-coherence-prereq-native.cjs @@ -4,7 +4,7 @@ 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 wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') const key = (/** @type {string} */ path) => resolve(path).replaceAll('\\', '/') const mode = process.argv[2] assert(['theme', 'alias'].includes(mode)) @@ -85,6 +85,7 @@ async function main() { }, }, DevupUI({ + engine: 'wasm', singleCss: true, devupFile: join(root, 'devup.json'), distDir: join(root, 'df'), diff --git a/packages/vite-plugin/src/__tests__/serve-coherence-prereq-ownership.cjs b/packages/vite-plugin/src/__tests__/serve-coherence-prereq-ownership.cjs index 9d0d8dbce..cb0c88111 100644 --- a/packages/vite-plugin/src/__tests__/serve-coherence-prereq-ownership.cjs +++ b/packages/vite-plugin/src/__tests__/serve-coherence-prereq-ownership.cjs @@ -4,7 +4,7 @@ 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 wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') const root = process.argv[4] assert(root) const telemetry = observe() @@ -27,6 +27,7 @@ async function main() { const owner = await load(first) const { createServer } = await import('vite') const options = { + engine: /** @type {const} */ ('wasm'), singleCss: true, devupFile: join(root, 'devup.json'), distDir: join(root, 'df'), 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 index bb143a031..25b7acf8e 100644 --- a/packages/vite-plugin/src/__tests__/serve-coherence-resolver-refresh.test.ts +++ b/packages/vite-plugin/src/__tests__/serve-coherence-resolver-refresh.test.ts @@ -1,11 +1,13 @@ import { join } from 'node:path' -import * as wasm from '@devup-ui/wasm' +import { loadCompiler } from '@devup-ui/plugin-utils' import { expect, it, spyOn } from 'bun:test' import { fixture } from '../../../plugin-utils/src/__tests__/reservation-fixture' import { createViteReservation } from '../reservation' +const wasm = loadCompiler('wasm') + it('refreshes live alias resolution before dirty reservation without refreshing clean preparation', async () => { // Given using project = fixture() @@ -22,7 +24,7 @@ it('refreshes live alias resolution before dirty reservation without refreshing 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({ + const slots = createViteReservation(wasm, { package: '@devup-ui/react', include: [], importAliases: {}, diff --git a/packages/vite-plugin/src/__tests__/server-watch.test.ts b/packages/vite-plugin/src/__tests__/server-watch.test.ts index 771a9be9f..ff56da0fa 100644 --- a/packages/vite-plugin/src/__tests__/server-watch.test.ts +++ b/packages/vite-plugin/src/__tests__/server-watch.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import { join } from 'node:path' -import * as wasm from '@devup-ui/wasm' +import { loadCompiler } from '@devup-ui/plugin-utils' import { expect, it, spyOn } from 'bun:test' import { @@ -12,8 +12,10 @@ import { createCompiledDependencies } from '../compiled-dependencies' import { createViteReservation } from '../reservation' import { createServerWatch } from '../server-watch' +const wasm = loadCompiler('wasm') + function setup(root: string) { - const reservation = createViteReservation({ + const reservation = createViteReservation(wasm, { package: '@devup-ui/react', include: [], importAliases: {}, diff --git a/packages/vite-plugin/src/__tests__/source-cache-dependencies.test.ts b/packages/vite-plugin/src/__tests__/source-cache-dependencies.test.ts index bc4d4a9da..0306bb159 100644 --- a/packages/vite-plugin/src/__tests__/source-cache-dependencies.test.ts +++ b/packages/vite-plugin/src/__tests__/source-cache-dependencies.test.ts @@ -1,6 +1,6 @@ import { unlinkSync } from 'node:fs' -import * as wasm from '@devup-ui/wasm' +import { loadCompiler } from '@devup-ui/plugin-utils' import { expect, it, mock } from 'bun:test' import { environment, watcher } from './plugin-lifecycle-fixture' @@ -11,6 +11,7 @@ import { SOURCE, writeGate, } from './source-cache-fixture' +const wasm = loadCompiler('wasm') 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() diff --git a/packages/vite-plugin/src/__tests__/source-cache-fixture.ts b/packages/vite-plugin/src/__tests__/source-cache-fixture.ts index c5cfd6942..b49ffdc2c 100644 --- a/packages/vite-plugin/src/__tests__/source-cache-fixture.ts +++ b/packages/vite-plugin/src/__tests__/source-cache-fixture.ts @@ -3,11 +3,12 @@ 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' +const wasm = utils.loadCompiler('wasm') + export const SOURCE = 'import {Box} from "@devup-ui/react"; import {tone} from "./tone"; export const A=()=> ' diff --git a/packages/vite-plugin/src/__tests__/source-cache-flight.test.ts b/packages/vite-plugin/src/__tests__/source-cache-flight.test.ts index d1ea54b14..7e0f40a8c 100644 --- a/packages/vite-plugin/src/__tests__/source-cache-flight.test.ts +++ b/packages/vite-plugin/src/__tests__/source-cache-flight.test.ts @@ -43,6 +43,7 @@ it('shares one real extraction when identical callers overlap a real write', asy 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() + rmSync(state.cssFile) mkdirSync(state.cssFile) using extracts = extractionObserver() using write = writeGate(state.cssFile) @@ -69,6 +70,7 @@ it('propagates a real filesystem write rejection and extracts again on retry', a 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() + rmSync(state.cssFile) mkdirSync(state.cssFile) using extracts = extractionObserver() using write = writeGate(state.cssFile) diff --git a/packages/vite-plugin/src/__tests__/source-cache-input.test.ts b/packages/vite-plugin/src/__tests__/source-cache-input.test.ts index 4c0832aba..4775490d9 100644 --- a/packages/vite-plugin/src/__tests__/source-cache-input.test.ts +++ b/packages/vite-plugin/src/__tests__/source-cache-input.test.ts @@ -1,4 +1,4 @@ -import * as wasm from '@devup-ui/wasm' +import { loadCompiler } from '@devup-ui/plugin-utils' import { expect, it, spyOn } from 'bun:test' import * as htmlReservation from '../html-reservation' @@ -7,6 +7,7 @@ import { extractionObserver, transformedCode, } from './source-cache-fixture' +const wasm = loadCompiler('wasm') 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. diff --git a/packages/vite-plugin/src/__tests__/source-cache-sheet.test.ts b/packages/vite-plugin/src/__tests__/source-cache-sheet.test.ts index d9c7001dc..62e9767ae 100644 --- a/packages/vite-plugin/src/__tests__/source-cache-sheet.test.ts +++ b/packages/vite-plugin/src/__tests__/source-cache-sheet.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs' -import * as wasm from '@devup-ui/wasm' +import { loadCompiler } from '@devup-ui/plugin-utils' import { expect, it } from 'bun:test' import { @@ -9,6 +9,7 @@ import { extractionObserver, writeGate, } from './source-cache-fixture' +const wasm = loadCompiler('wasm') 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() diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index c8fda6e2a..f9ce736fc 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -11,10 +11,13 @@ import { createStateWriter, createThemeInterfaceArgs, type CustomShorthands, + type DevupCompiler, + type DevupUIBasePluginOptions, extractedNeedles, getBuildOwnership, getFileNumByFilename, type ImportAliases, + loadCompiler, loadDevupConfig, mergeImportAliases, normalizeShorthands, @@ -23,19 +26,6 @@ import { tailwindCssFiles, withTailwindCss, } from '@devup-ui/plugin-utils' -import { - codeExtract, - getCss, - getDefaultTheme, - getThemeInterface, - registerShorthands, - registerTheme, - resetBuildState, - seedFileMap, - setDebug, - setModuleResolver, - setPrefix, -} from '@devup-ui/wasm' import type { EnvironmentModuleNode, ModuleNode, @@ -254,7 +244,10 @@ function createCssChunkBuildOptions( : { rollupOptions: { output } } } -export interface DevupUIPluginOptions { +export interface DevupUIPluginOptions extends Pick< + DevupUIBasePluginOptions, + 'engine' +> { package: string cssDir: string devupFile: string @@ -279,6 +272,7 @@ export interface DevupUIPluginOptions { } async function writeDataFiles( + compiler: DevupCompiler, options: Omit, inputs: Set, publishTailwind: (files: string[]) => void, @@ -287,9 +281,9 @@ async function writeDataFiles( const config = await loadDevupConfig(options.devupFile, inputs) const theme = config.theme ?? {} - registerTheme(withTailwindCss(theme, config)) + compiler.registerTheme(withTailwindCss(theme, config)) publishTailwind(tailwindCssFiles(config)) - const interfaceCode = getThemeInterface( + const interfaceCode = compiler.getThemeInterface( ...createThemeInterfaceArgs(options.package), ) @@ -302,7 +296,7 @@ async function writeDataFiles( } } catch (error) { console.error(error) - registerTheme({}) + compiler.registerTheme({}) } // Sequential: writing into cssDir concurrently with its own mkdir loses the // race on a cold start (no `df/`) and fails the build with ENOENT. @@ -310,7 +304,10 @@ async function writeDataFiles( await mkdir(options.cssDir, { recursive: true }) } if (!options.singleCss) { - await writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) + await writeFile( + join(options.cssDir, 'devup-ui.css'), + compiler.getCss(null, false), + ) } } @@ -325,15 +322,17 @@ export function DevupUI({ singleCss = false, prefix, shorthands, + engine, atomHoist = 2, importAliases: userImportAliases, }: Partial = {}): PluginOption { const normalizedShorthands = normalizeShorthands(shorthands ?? {}) + const compiler = loadCompiler(engine) // 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 ownsEngine = getBuildOwnership().running === 0 - let endBuild: (() => void) | undefined = beginBuild({ resetBuildState }) + let endBuild: (() => void) | undefined = beginBuild(compiler) let closing = false let ownedWork = 0 let drained: PromiseWithResolvers | undefined @@ -364,16 +363,16 @@ export function DevupUI({ } } function configureEngine() { - registerShorthands(normalizedShorthands) - setDebug(debug) - setPrefix(prefix ?? null) + compiler.registerShorthands(normalizedShorthands) + compiler.setDebug(debug) + compiler.setPrefix(prefix ?? null) } if (ownsEngine) configureEngine() const importAliases = mergeImportAliases( userImportAliases, readJsxImportSource(), ) - const reservation = createViteReservation({ + const reservation = createViteReservation(compiler, { package: libPackage, include, importAliases, @@ -449,6 +448,7 @@ export function DevupUI({ await previous activeConfigFiles = inputs await writeDataFiles( + compiler, { package: libPackage, cssDir, @@ -462,6 +462,8 @@ export function DevupUI({ watchConfigInputs?.() }, ) + if (singleCss) + await writeCssFile('devup-ui.css', compiler.getCss(null, false)) } finally { configFiles = inputs tailwindFiles = activeTailwindFiles @@ -485,7 +487,7 @@ export function DevupUI({ } const plugin = { name: 'devup-ui', - // The WASM sheet and transform state are intentionally shared. Vite + // The compiler 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, @@ -496,7 +498,7 @@ export function DevupUI({ isServe = config?.command === 'serve' const projectRoot = config?.root ?? process.cwd() // Vite ids are POSIX absolute paths - setModuleResolver( + compiler.setModuleResolver( createModuleResolver({ cwd: projectRoot, toId: (path) => path.replaceAll('\\', '/'), @@ -518,7 +520,7 @@ export function DevupUI({ // Files numbered before keep their numbers, so a later pass in the // dev server only numbers new files after the existing ones. seedFileNumbers( - { seedFileMap }, + compiler, collectNumberedFiles({ roots: sourceDirs, include, @@ -580,7 +582,7 @@ export function DevupUI({ this.addWatchFile(dependency) }, config(this: { meta?: ConfigHookMeta } | void, userConfig: UserConfig) { - const theme = getDefaultTheme() + const theme = compiler.getDefaultTheme() const define: Record = {} if (theme) { define['process.env.DEVUP_UI_DEFAULT_THEME'] = JSON.stringify(theme) @@ -659,7 +661,12 @@ export function DevupUI({ // every module is compiled again for (const mod of isTailwind ? environment.moduleGraph.idToModuleMap.values() - : modules) { + : new Set([ + ...modules, + ...(environment.moduleGraph.getModulesByFile( + resolve(cssDir, 'devup-ui.css').replaceAll('\\', '/'), + ) ?? []), + ])) { environment.moduleGraph.invalidateModule( mod, invalidatedModules, @@ -694,7 +701,12 @@ export function DevupUI({ const invalidatedModules = new Set() for (const mod of isTailwind ? server.moduleGraph.idToModuleMap.values() - : modules) { + : new Set([ + ...modules, + ...(server.moduleGraph.getModulesByFile( + resolve(cssDir, 'devup-ui.css').replaceAll('\\', '/'), + ) ?? []), + ])) { server.moduleGraph.invalidateModule( mod, invalidatedModules, @@ -724,7 +736,7 @@ export function DevupUI({ const fileName = basename(id).split('?')[0] if (DEVUP_CSS_FILE_RE.test(fileName)) { const fileNum = getFileNumByFilename(fileName) - const css = getCss(fileNum, false) + const css = compiler.getCss(fileNum, false) cssMap.set(fileNum, css) return css } @@ -780,16 +792,26 @@ export function DevupUI({ updatedBaseStyle, dependencies = [], // import main css in code - } = codeExtract( - fileName, - code, - libPackage, - rel, - singleCss, - true, - false, - importAliases, - ) + } = (() => { + using output = compiler.codeExtract( + fileName, + code, + libPackage, + rel, + singleCss, + true, + false, + importAliases, + ) + return { + code: output.code, + css: output.css, + map: output.map, + cssFile: output.cssFile, + updatedBaseStyle: output.updatedBaseStyle, + dependencies: output.dependencies, + } + })() compiledDependencies.update( this.environment?.name ?? 'client', id, @@ -800,7 +822,9 @@ export function DevupUI({ if (updatedBaseStyle) { // update base style - promises.push(writeCssFile('devup-ui.css', getCss(null, false))) + promises.push( + writeCssFile('devup-ui.css', compiler.getCss(null, false)), + ) } if (cssFile) { @@ -850,7 +874,7 @@ export function DevupUI({ const cssName = getDevupCssChunkName(asset.name) if (!cssName) continue if (!('source' in asset)) continue - const source = getCss(getFileNumByFilename(cssName), false) + const source = compiler.getCss(getFileNumByFilename(cssName), false) asset.source = source cssFiles.add(file) } diff --git a/packages/vite-plugin/src/reservation.ts b/packages/vite-plugin/src/reservation.ts index f9538714e..c767e8fc4 100644 --- a/packages/vite-plugin/src/reservation.ts +++ b/packages/vite-plugin/src/reservation.ts @@ -7,17 +7,11 @@ import { computeReachableFiles, createModuleResolver, createSharedSlotReservation, + type DevupCompiler, 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' @@ -28,17 +22,20 @@ import { 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 }) +export function createViteReservation( + compiler: DevupCompiler, + 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(compiler) const environments = new Map< string, { config: ReservationConfig; environment: ReservationEnvironment } @@ -104,7 +101,7 @@ export function createViteReservation(options: { } // An event during the parser await supersedes this entire candidate batch. if (current !== revision) continue - setModuleResolver( + compiler.setModuleResolver( createModuleResolver({ cwd: cwd ?? process.cwd(), toId: (path) => path.replaceAll('\\', '/'), @@ -153,10 +150,10 @@ export function createViteReservation(options: { } const reach = computeFileReach({ ...graphOptions, entries }) const plan = planAtomHoist(canonical, reach, threshold) - importCanonicalMap(canonical) + compiler.importCanonicalMap(canonical) if (plan) { - importFileRoutes(plan.reachByBucket) - setAtomHoist(plan.threshold) + compiler.importFileRoutes(plan.reachByBucket) + compiler.setAtomHoist(plan.threshold) } } // Scratch may freeze even when the engine later throws. Never reinstall. diff --git a/packages/webpack-plugin/README.md b/packages/webpack-plugin/README.md index 343ead564..74927fa6e 100644 --- a/packages/webpack-plugin/README.md +++ b/packages/webpack-plugin/README.md @@ -177,6 +177,29 @@ const box = const box = ``` +## Compiler engine + +Webpack uses the native compiler by default. Select either engine explicitly +when needed: + +```ts +new DevupUIWebpackPlugin() // native +new DevupUIWebpackPlugin({ engine: 'native' }) +new DevupUIWebpackPlugin({ engine: 'wasm' }) +``` + +Both engines are installed through plugin-utils. The native binding selects +its optional platform package; keep optional dependencies enabled when +installing. A native load failure names the required platform package and +preserves its original cause. There is no automatic fallback: use +`engine: 'wasm'` explicitly on hosts without a working native image or native +libraries. Invalid engine selections fail before loading a backend; malformed +custom shorthands are reported before attempting to load either engine. + +Selection applies to the plugin, source loader, CSS loader and delayed build +callbacks as one unit. Native state, like WASM state, does not support +concurrent independent builds in the same process. + ## Custom Shorthands ```ts diff --git a/packages/webpack-plugin/package.json b/packages/webpack-plugin/package.json index 2661a4c77..98925e7d5 100644 --- a/packages/webpack-plugin/package.json +++ b/packages/webpack-plugin/package.json @@ -52,9 +52,6 @@ "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^" }, - "peerDependencies": { - "@devup-ui/wasm": "*" - }, "devDependencies": { "@types/webpack": "^5.28.5", "typescript": "^7.0.2" diff --git a/packages/webpack-plugin/src/__tests__/config-errors.test.ts b/packages/webpack-plugin/src/__tests__/config-errors.test.ts index 266a4dbc5..fa10a0678 100644 --- a/packages/webpack-plugin/src/__tests__/config-errors.test.ts +++ b/packages/webpack-plugin/src/__tests__/config-errors.test.ts @@ -2,11 +2,13 @@ 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 { loadCompiler } from '@devup-ui/plugin-utils' import { expect, it, spyOn } from 'bun:test' import { DevupUIWebpackPlugin } from '../plugin' +const wasm = loadCompiler('wasm') + 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') @@ -18,6 +20,7 @@ it('clears the theme and creates the CSS directory when configuration cannot be const logged = spyOn(console, 'error').mockReturnValue(undefined) try { const plugin = new DevupUIWebpackPlugin({ + engine: 'wasm', devupFile: config, distDir: join(root, 'dist'), cssDir, diff --git a/packages/webpack-plugin/src/__tests__/css-loader.test.ts b/packages/webpack-plugin/src/__tests__/css-loader.test.ts index cff741693..74fc93e46 100644 --- a/packages/webpack-plugin/src/__tests__/css-loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/css-loader.test.ts @@ -1,6 +1,6 @@ import * as nodePath from 'node:path' -import * as wasm from '@devup-ui/wasm' +import { type DevupCompilerOutput, loadCompiler } from '@devup-ui/plugin-utils' import { afterEach, beforeEach, @@ -14,7 +14,9 @@ import { import devupUICssLoader from '../css-loader' import { servedCss } from '../served-css' -type CodeExtractResult = ReturnType +const wasm = loadCompiler('wasm') + +type CodeExtractResult = DevupCompilerOutput type CssLoaderThis = ThisParameterType function createCodeExtractResult( @@ -26,10 +28,11 @@ function createCodeExtractResult( cssFile: '', map: undefined, updatedBaseStyle: false, + dependencies: [], free: mock(), [Symbol.dispose]: mock(), ...overrides, - } as unknown as CodeExtractResult + } } function createCssLoaderContext( @@ -42,7 +45,10 @@ function createCssLoaderContext( cacheable: mock(), addContextDependency: mock(), resourcePath, - getOptions: () => ({ watch: resourcePath.includes('devup-ui') }), + getOptions: () => ({ + engine: 'wasm', + watch: resourcePath.includes('devup-ui'), + }), _compilation: compilation, } as unknown as CssLoaderThis } diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/loader-identity.mjs b/packages/webpack-plugin/src/__tests__/extends-theme/loader-identity.mjs index baea23368..a33f8e979 100644 --- a/packages/webpack-plugin/src/__tests__/extends-theme/loader-identity.mjs +++ b/packages/webpack-plugin/src/__tests__/extends-theme/loader-identity.mjs @@ -1,11 +1,14 @@ import assert from 'node:assert/strict' import { readFile, realpath, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' const require = createRequire(import.meta.url) const format = process.argv[2] +const mode = process.argv[3] ?? 'default' +assert(['default', 'native', 'wasm'].includes(mode)) +const options = mode === 'default' ? {} : { engine: mode } let plugin let entry switch (format) { @@ -35,12 +38,42 @@ const expectedLoaders = await Promise.all( realpath(join(packageRoot, manifest.exports[name].require)), ), ) +const utilsPath = createRequire(entry).resolve('@devup-ui/plugin-utils') +const utils = createRequire(entry)('@devup-ui/plugin-utils') +const selected = utils.loadCompiler(options.engine) +const utilsRoot = dirname(dirname(utilsPath)) +const utilsManifest = JSON.parse( + await readFile(join(utilsRoot, 'package.json'), 'utf8'), +) +const esmUtils = await import( + pathToFileURL(join(utilsRoot, utilsManifest.exports['.'].import)).href +) +assert.equal(esmUtils.loadCompiler(options.engine).getCss, selected.getCss) +const methods = [ + 'registerShorthands', + 'reserveClassSlots', + 'codeExtract', + 'getCss', + 'exportSheet', + 'exportClassMap', + 'exportFileMap', +] +const original = Object.fromEntries( + methods.map((name) => [name, selected[name]]), +) +const calls = Object.fromEntries(methods.map((name) => [name, 0])) +for (const name of methods) { + selected[name] = (...args) => { + calls[name]++ + return original[name](...args) + } +} const { webpack } = require('next/dist/compiled/webpack/webpack') const compiler = webpack({ mode: 'production', optimization: { minimize: false }, entry: './src/main.jsx', - plugins: [new plugin.DevupUIWebpackPlugin()], + plugins: [new plugin.DevupUIWebpackPlugin(options)], }) try { const loaders = compiler.options.module.rules @@ -50,18 +83,79 @@ try { const selectedLoaders = await Promise.all( loaders.map((path) => realpath(path)), ) - const entryWasmPath = - format === 'import' - ? fileURLToPath( - import.meta.resolve('@devup-ui/wasm', pathToFileURL(entry).href), - ) - : createRequire(entry).resolve('@devup-ui/wasm') - const wasmPaths = await Promise.all( + const utilsPaths = await Promise.all( [ - entryWasmPath, - ...loaders.map((path) => createRequire(path).resolve('@devup-ui/wasm')), + utilsPath, + ...loaders.map((path) => + createRequire(path).resolve('@devup-ui/plugin-utils'), + ), ].map((path) => realpath(path)), ) + assert.deepEqual(utilsPaths, [utilsPaths[0], utilsPaths[0], utilsPaths[0]]) + for (const path of loaders) { + const fromLoader = createRequire(path)( + '@devup-ui/plugin-utils', + ).loadCompiler(options.engine) + for (const name of methods) { + assert.equal(fromLoader[name], selected[name]) + } + } + const uses = compiler.options.module.rules.flatMap((rule) => rule.use || []) + for (const use of uses) { + assert.equal(use.options.engine, options.engine ?? 'native') + } + const reservation = compiler.hooks.run.taps.find( + ({ name }) => name === 'DevupUIWebpackPluginReservation', + ) + assert(reservation) + reservation.fn(compiler) + const sourceModule = + format === 'import' + ? (await import('@devup-ui/webpack-plugin/loader')).default + : require('@devup-ui/webpack-plugin/loader') + const cssModule = + format === 'import' + ? (await import('@devup-ui/webpack-plugin/css-loader')).default + : require('@devup-ui/webpack-plugin/css-loader') + const sourceLoader = sourceModule.default ?? sourceModule + const cssLoader = cssModule.default ?? cssModule + const sourcePath = join(process.cwd(), 'src/Fixture.jsx') + const source = await readFile(sourcePath) + await new Promise((resolve, reject) => { + sourceLoader.call( + { + resourcePath: sourcePath, + getOptions: () => ({ ...uses[0].options, watch: true }), + addDependency() {}, + async: () => (error, code) => { + if (error) { + reject(error) + } else { + assert.equal(typeof code, 'string') + resolve() + } + }, + }, + source, + ) + }) + cssLoader.call( + { + resourcePath: join(uses[0].options.cssDir, 'devup-ui.css'), + getOptions: () => uses[1].options, + cacheable(value) { + assert.equal(value, false) + }, + callback(error, css) { + assert.equal(error, null) + assert.equal(typeof css, 'string') + }, + }, + Buffer.from(''), + ) + for (const name of methods) { + assert(calls[name] > 0, `selected namespace must observe ${name}`) + } const evidence = { format, entry: await realpath(entry), @@ -69,7 +163,9 @@ try { packageRoot, selectedLoaders, expectedLoaders, - wasmPaths, + mode, + utilsPaths, + calls, } await writeFile( join(import.meta.dirname, `identity-${format}.json`), @@ -82,25 +178,10 @@ try { expectedLoaders, 'both loaders must belong to the installed plugin', ) - const wasmRoot = await realpath( - join(import.meta.dirname, 'node_modules/@devup-ui/wasm'), - ) - const wasmManifest = JSON.parse( - await readFile(join(wasmRoot, 'package.json'), 'utf8'), - ) - const consumerWasm = await realpath( - join(wasmRoot, wasmManifest.exports['.'][format]), - ) - assert.deepEqual( - wasmPaths, - [consumerWasm, consumerWasm, consumerWasm], - 'entry and both loaders must share the consumer WASM file', - ) - const entryWasm = await import(pathToFileURL(entryWasmPath).href) - for (const path of loaders) { - assert.equal(createRequire(path)('@devup-ui/wasm').getCss, entryWasm.getCss) - } } finally { + for (const name of methods) { + selected[name] = original[name] + } await new Promise((resolve, reject) => compiler.close((error) => (error ? reject(error) : resolve())), ) diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/packed-consumer.mjs b/packages/webpack-plugin/src/__tests__/extends-theme/packed-consumer.mjs index 417ac132b..4a3774f6a 100644 --- a/packages/webpack-plugin/src/__tests__/extends-theme/packed-consumer.mjs +++ b/packages/webpack-plugin/src/__tests__/extends-theme/packed-consumer.mjs @@ -13,6 +13,11 @@ import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { + packHostEngines, + verifyHostInstall, +} from '../../../../../e2e/plugins/engine-fixtures.mjs' + const repository = fileURLToPath(new URL('../../../../../', import.meta.url)) const fixtures = fileURLToPath(new URL('./fixtures/', import.meta.url)) const packages = [ @@ -119,6 +124,8 @@ export async function prepareRun(root) { .digest('hex'), }) } + const native = await packHostEngines({ repository, root, bun }, command) + Object.assign(tarballs, native.tarballs) const evidence = { node: process.version, bunVersion: execFileSync(bun, ['--version'], { encoding: 'utf8' }).trim(), @@ -126,6 +133,7 @@ export async function prepareRun(root) { tarballs, hashes, repository, + host: native.host, } await writeFile( join(root, 'resolution.json'), @@ -188,7 +196,9 @@ export async function prepareConsumer(context, target, singleCss) { default: throw new Error(`Unknown target ${target}`) } - command(context.bun, ['install'], { cwd: directory }) + command(context.bun, ['install', ...context.host.installArgs], { + cwd: directory, + }) const require = createRequire(join(directory, 'package.json')) for (const [name, expected] of Object.entries(dependencies)) { if (name.startsWith('@devup-ui/')) continue @@ -201,6 +211,7 @@ export async function prepareConsumer(context, target, singleCss) { if (actual.version !== expected) throw new Error(`Resolution mismatch for ${name}`) } + verifyHostInstall({ ...context, directory }) const args = target === 'next-webpack' ? [require.resolve('next/dist/bin/next'), 'build', '--webpack'] diff --git a/packages/webpack-plugin/src/__tests__/loader.test.ts b/packages/webpack-plugin/src/__tests__/loader.test.ts index 532dc9c55..4da6cbe60 100644 --- a/packages/webpack-plugin/src/__tests__/loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/loader.test.ts @@ -2,7 +2,7 @@ import * as fsPromises from 'node:fs/promises' import * as nodePath from 'node:path' import { join } from 'node:path' -import * as wasm from '@devup-ui/wasm' +import { type DevupCompilerOutput, loadCompiler } from '@devup-ui/plugin-utils' import { afterEach, beforeEach, @@ -16,7 +16,9 @@ import { import type { DevupUILoaderOptions } from '../loader' import devupUILoader from '../loader' -type CodeExtractResult = ReturnType +const wasm = loadCompiler('wasm') + +type CodeExtractResult = DevupCompilerOutput type LoaderThis = ThisParameterType type LoaderCallback = ReturnType interface TestLoaderContext extends Pick< @@ -36,10 +38,11 @@ function createCodeExtractResult( cssFile: undefined, updatedBaseStyle: false, map: undefined, + dependencies: [], free: () => {}, [Symbol.dispose]: () => {}, ...overrides, - } as unknown as CodeExtractResult + } } function createLoaderContext( @@ -49,7 +52,7 @@ function createLoaderContext( overrides: Partial = {}, ): LoaderThis { return { - getOptions: () => options, + getOptions: () => ({ engine: 'wasm', ...options }), async: mock().mockReturnValue(callback), resourcePath, addDependency: mock(), @@ -125,6 +128,50 @@ const waitFor = async (fn: () => void, timeout = 1000) => { } describe('devupUILoader', () => { + it.each(['snapshot', 'map', 'success'] as const)( + 'releases output exactly once when %s completes', + async (stage) => { + // Given + let released = false + const free = mock(() => { + released = true + }) + const failure = new Error('getter failed') + codeExtractSpy.mockReturnValue({ + ...createCodeExtractResult(), + get code() { + expect(released).toBe(false) + if (stage === 'snapshot') throw failure + return 'detached code' + }, + map: stage === 'map' ? '{' : undefined, + free, + }) + const callback = mock() + const context = createLoaderContext( + { package: 'package', cssDir: 'df', singleCss: true }, + callback, + ) + // When + devupUILoader.call(context, Buffer.from('source'), undefined) + await Promise.resolve() + // Then + expect(free).toHaveBeenCalledTimes(1) + expect(released).toBe(true) + switch (stage) { + case 'snapshot': + expect(callback).toHaveBeenCalledWith(failure) + break + case 'map': + expect(callback).toHaveBeenCalledWith(expect.any(SyntaxError)) + break + case 'success': + expect(callback).toHaveBeenCalledWith(null, 'detached code', null) + break + } + }, + ) + it('resolves imports to cwd-relative ids and depends on the modules read', async () => { const setModuleResolverSpy = spyOn( wasm, @@ -236,7 +283,7 @@ describe('devupUILoader', () => { }) expect(writeFileSpy).toHaveBeenCalledWith( join('cssFile', 'cssFile'), - '/* index.tsx 0 */', + '/* cssFile 0 */', ) expect(writeFileSpy).toHaveBeenCalledWith('sheetFile', 'sheet') expect(writeFileSpy).toHaveBeenCalledWith('classMapFile', 'classMap') @@ -407,6 +454,47 @@ export const a =
` expect(asyncCallback).not.toHaveBeenCalledWith(null, 'code', {}) }) + it.each([ + ['src/A.css.ts', 'src/B.css.ts'], + ['src/B.css.ts', 'src/A.css.ts'], + ])( + 'keeps the collapsed bucket sentinel identical for %s then %s', + async (first, second) => { + codeExtractSpy.mockReturnValue( + createCodeExtractResult({ + code: 'code', + css: '.shared{color:red}', + cssFile: '../devup-ui-23.css', + }), + ) + const cssDir = 'sentinel-css' + for (const resourcePath of [first, second]) { + const callback = mock() + const context = createLoaderContext( + { + package: 'package', + cssDir, + watch: true, + singleCss: false, + sheetFile: 'sheet.json', + classMapFile: 'class-map.json', + fileMapFile: 'file-map.json', + }, + callback, + resourcePath, + ) + devupUILoader.call(context, Buffer.from('same final source'), undefined) + await waitFor(() => + expect(callback).toHaveBeenCalledWith(null, 'code', null), + ) + } + const writes = writeFileSpy.mock.calls.filter( + ([path]) => path === join(cssDir, 'devup-ui-23.css'), + ) + expect(writes.at(-1)?.[1]).toBe('/* devup-ui-23.css 0 */') + }, + ) + it('should load with date now on watch', async () => { const asyncCallback = mock() const t = createLoaderContext( diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 9f22c0be2..f22c489e3 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -5,7 +5,6 @@ import * as fsPromises from 'node:fs/promises' import { join, relative, resolve } from 'node:path' import * as pluginUtils from '@devup-ui/plugin-utils' -import * as wasm from '@devup-ui/wasm' import { afterEach, beforeEach, @@ -20,13 +19,64 @@ import type { Compiler } from 'webpack' import { DevupUIWebpackPlugin } from '../plugin' import { servedCss } from '../served-css' +const native = pluginUtils.loadCompiler('native') +const wasm = pluginUtils.loadCompiler('wasm') + describe('shorthand creation boundary', () => { + it.each([undefined, 'native'] as const)( + 'registers real native shorthands when engine=%s', + (engine) => { + // Given + native.resetStateForTesting() + // When + new DevupUIWebpackPlugin({ + engine, + shorthands: { insetX: ['left', 'right'] }, + }) + const output = native.codeExtract( + 'engine.tsx', + 'import { Box } from "@devup-ui/react"; export const box = ', + '@devup-ui/react', + './df', + true, + false, + true, + {}, + ) + try { + // Then + expect(output.css).toContain('left:19px') + expect(output.css).toContain('right:19px') + } finally { + output.free() + native.resetStateForTesting() + } + }, + ) + + it('rejects malformed shorthand before consulting the selector', () => { + // Given + const selection = spyOn(pluginUtils, 'loadCompiler') + try { + // When / Then + expect(() => + Reflect.construct(DevupUIWebpackPlugin, [ + { engine: 'invalid', shorthands: { bgx: ['backgroundColour'] } }, + ]), + ).toThrow(pluginUtils.ShorthandConfigError) + expect(selection).not.toHaveBeenCalled() + } finally { + selection.mockRestore() + } + }) + it('registers canonical targets when the plugin is constructed', () => { const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( undefined, ) try { new DevupUIWebpackPlugin({ + engine: 'wasm', shorthands: { bgx: ['backgroundColor', 'py', '--Gap'] }, }) expect(registration.mock.calls).toEqual([ @@ -65,6 +115,7 @@ describe('shorthand creation boundary', () => { try { // Given const plugin = new DevupUIWebpackPlugin({ + engine: 'wasm', shorthands: { bgx: ['backgroundColor', 'py', '--Gap'] }, }) registration.mockClear() @@ -86,7 +137,7 @@ describe('shorthand creation boundary', () => { }) }) -type CodeExtractResult = ReturnType +type CodeExtractResult = pluginUtils.DevupCompilerOutput interface MockCompiler { options: { module: { rules: unknown[] } @@ -120,10 +171,11 @@ function createCodeExtractResult( cssFile: '', map: undefined, updatedBaseStyle: false, + dependencies: [], free: mock(), [Symbol.dispose]: mock(), ...overrides, - } as unknown as CodeExtractResult + } } function createStats(mtimeMs: number): Stats { @@ -254,6 +306,76 @@ function createCompiler(): MockCompiler { } describe('devupUIWebpackPlugin', () => { + it.each([ + ['complete', 'empty', false], + ['empty', 'complete', false], + ['complete', 'empty', true], + ['empty', 'complete', true], + ] as const)( + 'finalizes served production buckets after %s then %s with failed=%s', + async (first, second, failed) => { + // Given + const compiler = createCompiler() + const compilation = {} + const plugin = new DevupUIWebpackPlugin({ engine: 'wasm' }) + plugin.apply(asCompiler(compiler)) + const bucket = join(plugin.options.cssDir, 'devup-ui-23.css') + const other = join(plugin.options.cssDir, 'devup-ui-41.css') + const unserved = join(plugin.options.cssDir, 'devup-ui-99.css') + const complete = '.shared{color:red}' + const otherCss = '.other{color:blue}' + const disk = new Map([ + [other, otherCss], + [unserved, 'untouched'], + ]) + for (const value of [first, second]) + disk.set(bucket, value === 'complete' ? complete : '') + const before = [...disk] + servedCss(compilation).set(bucket, complete) + servedCss(compilation).set(other, otherCss) + servedCss(compilation).set( + join(plugin.options.cssDir, 'devup-ui.css'), + 'base', + ) + getCssSpy.mockImplementation((fileNumber: number | null) => + fileNumber === 23 ? complete : fileNumber === 41 ? otherCss : 'base', + ) + writeFileSpy.mockImplementation( + ( + path: Parameters[0], + content: unknown, + ) => { + assert(typeof content === 'string') + disk.set(String(path), content) + return Promise.resolve() + }, + ) + writeFileSpy.mockClear() + try { + // When + await compiler.hooks.done.tapPromise.mock.calls[0][1]({ + compilation, + hasErrors: () => failed, + }) + // Then + if (failed) { + expect([...disk]).toEqual(before) + expect(writeFileSpy).not.toHaveBeenCalled() + } else { + expect(Object.fromEntries(disk)).toEqual({ + [bucket]: complete, + [other]: otherCss, + [unserved]: 'untouched', + [join(plugin.options.cssDir, 'devup-ui.css')]: 'base', + }) + } + } finally { + for (const [, endBuild] of compiler.hooks.shutdown.tap.mock.calls) + endBuild() + } + }, + ) + describe('deterministic file numbering', () => { it('numbers the files the scan finds and ends the build at shutdown', () => { const collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') @@ -265,9 +387,11 @@ describe('devupUIWebpackPlugin', () => { return ['src/a.tsx', 'src/b.tsx'] }) const compiler = createCompiler() - new DevupUIWebpackPlugin({ include: ['@acme/ui'], debug: true }).apply( - asCompiler(compiler), - ) + new DevupUIWebpackPlugin({ + engine: 'wasm', + include: ['@acme/ui'], + debug: true, + }).apply(asCompiler(compiler)) expect(seedSpy).toHaveBeenCalledWith(['src/a.tsx', 'src/b.tsx']) expect(collectSpy.mock.calls[0][0]).toMatchObject({ roots: [resolve('src')], @@ -278,7 +402,7 @@ describe('devupUIWebpackPlugin', () => { collectSpy.mockImplementation(() => { throw new Error('scan boom') }) - new DevupUIWebpackPlugin({ debug: true }).apply( + new DevupUIWebpackPlugin({ engine: 'wasm', debug: true }).apply( asCompiler(createCompiler()), ) } finally { @@ -303,6 +427,7 @@ describe('devupUIWebpackPlugin', () => { watch: false, debug: false, singleCss: false, + engine: 'native', atomHoist: 2, }) }, @@ -315,7 +440,7 @@ describe('devupUIWebpackPlugin', () => { } reservationSpy.mockReturnValue(manager) const compiler = createCompiler() - new DevupUIWebpackPlugin({}).apply(asCompiler(compiler)) + new DevupUIWebpackPlugin({ engine: 'wasm' }).apply(asCompiler(compiler)) await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]() expect(manager.reserve).toHaveBeenCalledTimes(1) }) @@ -333,8 +458,11 @@ describe('devupUIWebpackPlugin', () => { }), )('options', (options) => { it('should apply options', () => { - expect(new DevupUIWebpackPlugin(options).options).toEqual({ + expect( + new DevupUIWebpackPlugin({ ...options, engine: 'wasm' }).options, + ).toEqual({ ...options, + engine: 'wasm', atomHoist: 2, }) }) @@ -355,7 +483,7 @@ describe('devupUIWebpackPlugin', () => { getCssSpy.mockReturnValue(_options.getCss) writeFileSyncSpy.mockReturnValue(undefined) - const plugin = new DevupUIWebpackPlugin(options) + const plugin = new DevupUIWebpackPlugin({ ...options, engine: 'wasm' }) await plugin.writeDataFiles() expect(loadDevupConfigSyncSpy).toHaveBeenCalledWith(options.devupFile) @@ -417,6 +545,7 @@ describe('devupUIWebpackPlugin', () => { }), )('should set include', async (options) => { const plugin = new DevupUIWebpackPlugin({ + engine: 'wasm', include: options.include.input, }) existsSyncSpy.mockReturnValue(false) @@ -439,7 +568,7 @@ describe('devupUIWebpackPlugin', () => { debug: [true, false], }), )('should set debug', async (options) => { - const plugin = new DevupUIWebpackPlugin(options) + const plugin = new DevupUIWebpackPlugin({ ...options, engine: 'wasm' }) const compiler = createCompiler() @@ -449,6 +578,7 @@ describe('devupUIWebpackPlugin', () => { it('should reset data files when load error', async () => { const plugin = new DevupUIWebpackPlugin({ + engine: 'wasm', watch: true, }) const compiler = createCompiler() @@ -481,7 +611,7 @@ describe('devupUIWebpackPlugin', () => { it('registers the text of the Tailwind CSS and depends on its files', () => { mockTailwindCss() - const plugin = new DevupUIWebpackPlugin({}) + const plugin = new DevupUIWebpackPlugin({ engine: 'wasm' }) const compiler = createCompiler() plugin.apply(asCompiler(compiler)) @@ -499,7 +629,7 @@ describe('devupUIWebpackPlugin', () => { it('reads it again when a file changes', async () => { mockTailwindCss() - const plugin = new DevupUIWebpackPlugin({ watch: true }) + const plugin = new DevupUIWebpackPlugin({ engine: 'wasm', watch: true }) const compiler = createCompiler() statSpy.mockResolvedValueOnce(createStats(1)) statSpy.mockResolvedValueOnce(createStats(1)) @@ -525,6 +655,7 @@ describe('devupUIWebpackPlugin', () => { path === tailwindFile ? '@import "tailwindcss";' : '{}', ) const plugin = new DevupUIWebpackPlugin({ + engine: 'wasm', watch: true, devupFile: 'missing.json', }) @@ -549,6 +680,7 @@ describe('devupUIWebpackPlugin', () => { }), )('should apply', async (options) => { const plugin = new DevupUIWebpackPlugin({ + engine: 'wasm', watch: options.watch, }) const compiler = createCompiler() @@ -663,6 +795,7 @@ describe('devupUIWebpackPlugin', () => { ) await compiler.hooks.done.tapPromise.mock.calls[0][1]({ + compilation: {}, hasErrors: () => false, }) expect(writeFileSpy).toHaveBeenCalledWith( @@ -676,7 +809,10 @@ describe('devupUIWebpackPlugin', () => { }) it('should call setPrefix when prefix option is provided', async () => { - const plugin = new DevupUIWebpackPlugin({ prefix: 'my-prefix' }) + const plugin = new DevupUIWebpackPlugin({ + engine: 'wasm', + prefix: 'my-prefix', + }) const compiler = createCompiler() existsSyncSpy.mockReturnValue(false) plugin.apply(asCompiler(compiler)) @@ -729,7 +865,7 @@ describe('devupUIWebpackPlugin', () => { buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - const plugin = new DevupUIWebpackPlugin({ atomHoist: 0 }) + const plugin = new DevupUIWebpackPlugin({ engine: 'wasm', atomHoist: 0 }) plugin.apply(asCompiler(createCompiler())) expect(buildCanonicalMapSpy).toHaveBeenCalledWith( expect.objectContaining({ keyBy: 'cwd-relative' }), @@ -757,7 +893,7 @@ describe('devupUIWebpackPlugin', () => { 'matches explicit 2 on two routes when atomHoist is %s', (_label, options) => { // Given: explicit 2 installs a known two-route plan. - new DevupUIWebpackPlugin({ atomHoist: 2 }).apply( + new DevupUIWebpackPlugin({ engine: 'wasm', atomHoist: 2 }).apply( asCompiler(createCompiler()), ) const explicitReach = [...importFileRoutesSpy.mock.calls] @@ -774,7 +910,9 @@ describe('devupUIWebpackPlugin', () => { importFileRoutesSpy.mockClear() setAtomHoistSpy.mockClear() // When: the public option is defaulted. - new DevupUIWebpackPlugin(options).apply(asCompiler(createCompiler())) + new DevupUIWebpackPlugin({ ...options, engine: 'wasm' }).apply( + asCompiler(createCompiler()), + ) // Then: the same plan and threshold are installed. expect(importFileRoutesSpy.mock.calls).toEqual(explicitReach) expect(setAtomHoistSpy.mock.calls).toEqual([[2]]) @@ -786,7 +924,7 @@ describe('devupUIWebpackPlugin', () => { (atomHoist) => { // Given: two routes, not the single-route fallback. // When: a disabled numeric option is supplied. - new DevupUIWebpackPlugin({ atomHoist }).apply( + new DevupUIWebpackPlugin({ engine: 'wasm', atomHoist }).apply( asCompiler(createCompiler()), ) // Then: collapse still runs but reach and threshold are untouched. @@ -800,7 +938,7 @@ describe('devupUIWebpackPlugin', () => { it('preserves a custom threshold above the minimum', () => { // Given: two routes and a custom threshold. // When: the plugin applies. - new DevupUIWebpackPlugin({ atomHoist: 3 }).apply( + new DevupUIWebpackPlugin({ engine: 'wasm', atomHoist: 3 }).apply( asCompiler(createCompiler()), ) // Then: the threshold is not replaced by 2. @@ -827,9 +965,11 @@ describe('devupUIWebpackPlugin', () => { a: { import: ['./src/a.tsx'] }, b: { import: ['./src/b.tsx'] }, } - new DevupUIWebpackPlugin({ ...policy, singleCss: true }).apply( - asCompiler(compiler), - ) + new DevupUIWebpackPlugin({ + ...policy, + engine: 'wasm', + singleCss: true, + }).apply(asCompiler(compiler)) compiler.hooks.run.tap.mock.calls[0][1]() return compiler.options.module.rules } @@ -878,6 +1018,7 @@ describe('devupUIWebpackPlugin', () => { ).mockReturnValue(undefined) try { const plugin = new DevupUIWebpackPlugin({ + engine: 'wasm', package: '@devup-ui/react', singleCss: true, }) @@ -922,6 +1063,10 @@ describe('devupUIWebpackPlugin', () => { } expect(resolvedModules[1]).toEqual(resolvedModules[0]) expect(codeExtractSpy).toHaveBeenCalledTimes(2) + for (const result of codeExtractSpy.mock.results) { + if (result.type === 'return') + expect(result.value.free).toHaveBeenCalledTimes(1) + } expect(codeExtractSpy).toHaveBeenCalledWith( 'src/parent.tsx', 'source', @@ -954,6 +1099,7 @@ describe('devupUIWebpackPlugin', () => { undefined, ) const plugin = new DevupUIWebpackPlugin({ + engine: 'wasm', productionPrewarmFiles: [ 'src/z.tsx', resolve('node_modules/ui/a.tsx'), @@ -993,6 +1139,7 @@ describe('devupUIWebpackPlugin', () => { // Given a reachable generic entry which an explicit set supersedes. computeReachableFilesSpy.mockReturnValue([resolve('src/parent.tsx')]) const plugin = new DevupUIWebpackPlugin({ + engine: 'wasm', watch, productionPrewarmFiles: watch ? ['src/parent.tsx'] : [], }) @@ -1024,6 +1171,7 @@ describe('devupUIWebpackPlugin', () => { if (stage === 'read') readFileSyncSpy.mockImplementation(fail) if (stage === 'extract') codeExtractSpy.mockImplementation(fail) const plugin = new DevupUIWebpackPlugin({ + engine: 'wasm', debug: stage === 'numbering', productionPrewarmFiles: ['src/parent.tsx'], }) @@ -1052,7 +1200,7 @@ describe('devupUIWebpackPlugin', () => { buildCanonicalMapSpy.mockReturnValue({}) const compiler = createCompiler() compiler.options.entry = () => ({}) - new DevupUIWebpackPlugin({}).apply(asCompiler(compiler)) + new DevupUIWebpackPlugin({ engine: 'wasm' }).apply(asCompiler(compiler)) compiler.hooks.run.tap.mock.calls[0][1]() expect(computeReachableFilesSpy).toHaveBeenCalledWith( expect.objectContaining({ entries: [] }), @@ -1067,7 +1215,7 @@ describe('devupUIWebpackPlugin', () => { resolve(process.cwd(), 'src', 'parent.tsx'), ]) readFileSyncSpy.mockReturnValue('source') - const plugin = new DevupUIWebpackPlugin({ watch: true }) + const plugin = new DevupUIWebpackPlugin({ engine: 'wasm', watch: true }) plugin.apply(asCompiler(createCompiler())) expect(codeExtractSpy).not.toHaveBeenCalled() }) @@ -1083,7 +1231,7 @@ describe('devupUIWebpackPlugin', () => { codeExtractSpy.mockImplementation(() => { throw new Error('extract boom') }) - const plugin = new DevupUIWebpackPlugin({}) + const plugin = new DevupUIWebpackPlugin({ engine: 'wasm' }) // apply must still complete without throwing const compiler = createCompiler() plugin.apply(asCompiler(compiler)) @@ -1102,7 +1250,7 @@ describe('devupUIWebpackPlugin', () => { 'src/glob.tsx': [0, 1], 'src/r1.tsx': [1], }) - const plugin = new DevupUIWebpackPlugin({ atomHoist: 2 }) + const plugin = new DevupUIWebpackPlugin({ engine: 'wasm', atomHoist: 2 }) plugin.apply(asCompiler(createCompiler())) // collapse runs with cwd-relative keys (webpack loader passes relative path) expect(buildCanonicalMapSpy).toHaveBeenCalledWith( @@ -1122,14 +1270,14 @@ describe('devupUIWebpackPlugin', () => { 'src/a.tsx': [0], 'src/b.tsx': [1], }) - const plugin = new DevupUIWebpackPlugin({ atomHoist: 1 }) + const plugin = new DevupUIWebpackPlugin({ engine: 'wasm', atomHoist: 1 }) plugin.apply(asCompiler(createCompiler())) expect(setAtomHoistSpy).toHaveBeenCalledWith(2) }) it('stays off when fewer than two routes are reachable', () => { computeFileReachSpy.mockReturnValue({ 'src/a.tsx': [0] }) - const plugin = new DevupUIWebpackPlugin({ atomHoist: 2 }) + const plugin = new DevupUIWebpackPlugin({ engine: 'wasm', atomHoist: 2 }) plugin.apply(asCompiler(createCompiler())) expect(setAtomHoistSpy).not.toHaveBeenCalled() expect(importFileRoutesSpy).not.toHaveBeenCalled() @@ -1139,7 +1287,7 @@ describe('devupUIWebpackPlugin', () => { buildCanonicalMapSpy.mockImplementation(() => { throw new Error('boom') }) - const plugin = new DevupUIWebpackPlugin({ atomHoist: 2 }) + const plugin = new DevupUIWebpackPlugin({ engine: 'wasm', atomHoist: 2 }) // apply must still complete without throwing plugin.apply(asCompiler(createCompiler())) expect(setAtomHoistSpy).not.toHaveBeenCalled() @@ -1155,7 +1303,7 @@ describe('devupUIWebpackPlugin', () => { setAtomHoistSpy.mockImplementation(() => { rulesLenAtSetAtomHoist = compiler.options.module.rules.length }) - const plugin = new DevupUIWebpackPlugin({ atomHoist: 2 }) + const plugin = new DevupUIWebpackPlugin({ engine: 'wasm', atomHoist: 2 }) plugin.apply(asCompiler(compiler)) // pre-pass must run before module rules are pushed (single WASM instance) expect(rulesLenAtSetAtomHoist).toBe(0) @@ -1166,7 +1314,7 @@ describe('devupUIWebpackPlugin', () => { describe('another pass for stylesheets built too early', () => { function compile(watch = false) { const compiler = createCompiler() - const plugin = new DevupUIWebpackPlugin({ watch }) + const plugin = new DevupUIWebpackPlugin({ engine: 'wasm', watch }) plugin.apply(asCompiler(compiler)) const taps: Record unknown> = {} const tap = @@ -1195,7 +1343,7 @@ describe('devupUIWebpackPlugin', () => { return { compiler, plugin, compilation, taps, run, start } } - it('uses same-compilation reconciliation instead of another pass in watch mode', () => { + it('uses same-compilation reconciliation instead of another pass in watch mode', async () => { const { compiler } = compile(true) expect(compiler.hooks.run.tap.mock.calls.map(([name]) => name)).toEqual([ 'DevupUIWebpackPluginReservation', @@ -1203,6 +1351,35 @@ describe('devupUIWebpackPlugin', () => { expect( compiler.hooks.thisCompilation.tap.mock.calls.map(([name]) => name), ).toEqual(['DevupUIWebpackPluginWatchCss']) + type Module = { readonly resource: string } + type Finish = (modules: Iterable) => Promise + const completions: Finish[] = [] + const rebuild = mock((_module: Module, done: () => void) => done()) + const compilation = { + hooks: { + finishModules: { + tapPromise( + _options: { readonly name: string; readonly stage: number }, + callback: Finish, + ) { + completions.push(callback) + }, + }, + }, + rebuildModule: rebuild, + } + const setup = compiler.hooks.thisCompilation.tap.mock.calls[0]?.[1] + assert(typeof setup === 'function') + setup(compilation) + const [finish] = completions + assert(finish, 'watch setup must install reconciliation completion') + const module = { resource: '/df/devup-ui/devup-ui-1.css' } + servedCss(compilation).set(module.resource, 'before') + getCssSpy.mockClear() + getCssSpy.mockReturnValue('after') + await finish([module]) + expect(getCssSpy).toHaveBeenCalledWith(1, true) + expect(rebuild).toHaveBeenCalledWith(module, expect.any(Function)) }) it('writes the shared base to disk when it does not hold it yet', () => { diff --git a/packages/webpack-plugin/src/__tests__/production-prewarm.mjs b/packages/webpack-plugin/src/__tests__/production-prewarm.mjs index f45e6f86a..11a745c14 100644 --- a/packages/webpack-plugin/src/__tests__/production-prewarm.mjs +++ b/packages/webpack-plugin/src/__tests__/production-prewarm.mjs @@ -35,7 +35,7 @@ function apply(options = {}, entry = 'next-app-loader?query!') { context: root, entry, optimization: { minimizer: [] }, - plugins: [new DevupUIWebpackPlugin(options)], + plugins: [new DevupUIWebpackPlugin({ ...options, engine: 'wasm' })], }) compilers.add(compiler) return compiler @@ -59,7 +59,7 @@ async function close(compiler) { } } function extract(file, singleCss) { - return codeExtract( + const output = codeExtract( file, readFileSync(join(root, file), 'utf8'), '@devup-ui/react', @@ -68,7 +68,12 @@ function extract(file, singleCss) { false, true, {}, - ).code + ) + try { + return output.code + } finally { + output.free() + } } function sheet() { return [ @@ -213,7 +218,7 @@ try { await prepare(compiler) assert.match(sheet(), /maroon/) assert.doesNotMatch(sheet(), /chartreuse|failed|left:19px/) - const cleanCode = codeExtract( + const cleanOutput = codeExtract( 'clean.tsx', 'import { Box } from "@devup-ui/react"; export const c = ', '@devup-ui/react', @@ -222,8 +227,12 @@ try { false, true, {}, - ).code - assert.doesNotMatch(cleanCode, /failed/) + ) + try { + assert.doesNotMatch(cleanOutput.code, /failed/) + } finally { + cleanOutput.free() + } assert.match(sheet(), /inset-x:23px/) assert.doesNotMatch(sheet(), /left:23px/) await close(compiler) diff --git a/packages/webpack-plugin/src/__tests__/reservation-build.cjs b/packages/webpack-plugin/src/__tests__/reservation-build.cjs index d7f992269..48af96af0 100644 --- a/packages/webpack-plugin/src/__tests__/reservation-build.cjs +++ b/packages/webpack-plugin/src/__tests__/reservation-build.cjs @@ -4,7 +4,7 @@ const { join } = require('node:path') const { webpack, } = require('../../../next-plugin/node_modules/next/dist/compiled/webpack/webpack') -const wasm = require('@devup-ui/wasm') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') const { state } = require('./reservation-loaders.cjs') const fixtureSources = require('./reservation-fixture.cjs') const snapshot = require('./reservation-output.cjs') @@ -66,6 +66,7 @@ let entryCalls = 0 let setups = 0 let compilations = 0 const plugin = new DevupUIWebpackPlugin({ + engine: 'wasm', watch, singleCss, include: ['@slot/ui'], diff --git a/packages/webpack-plugin/src/__tests__/reservation-output.cjs b/packages/webpack-plugin/src/__tests__/reservation-output.cjs index 98099d9e3..b26e03756 100644 --- a/packages/webpack-plugin/src/__tests__/reservation-output.cjs +++ b/packages/webpack-plugin/src/__tests__/reservation-output.cjs @@ -1,7 +1,7 @@ const assert = require('node:assert/strict') const { readFileSync } = require('node:fs') const { join } = require('node:path') -const wasm = require('@devup-ui/wasm') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') const { state } = require('./reservation-loaders.cjs') module.exports = function snapshot(stats, fixture, extracts) { diff --git a/packages/webpack-plugin/src/__tests__/reservation-roots.cjs b/packages/webpack-plugin/src/__tests__/reservation-roots.cjs index 53312855b..f22ede6e8 100644 --- a/packages/webpack-plugin/src/__tests__/reservation-roots.cjs +++ b/packages/webpack-plugin/src/__tests__/reservation-roots.cjs @@ -3,7 +3,7 @@ const { join } = require('node:path') const { webpack, } = require('../../../next-plugin/node_modules/next/dist/compiled/webpack/webpack') -const wasm = require('@devup-ui/wasm') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') const { state } = require('./reservation-loaders.cjs') const snapshot = require('./reservation-output.cjs') @@ -71,7 +71,7 @@ const compiler = webpack({ }, experiments: { css: true }, module: { rules: [{ test: /\.css$/, type: 'css' }] }, - plugins: [new DevupUIWebpackPlugin({ atomHoist: 0 })], + plugins: [new DevupUIWebpackPlugin({ engine: 'wasm', atomHoist: 0 })], }) assert(compiler) compiler.hooks.thisCompilation.tap('root-custody', () => { diff --git a/packages/webpack-plugin/src/__tests__/reservation.test.ts b/packages/webpack-plugin/src/__tests__/reservation.test.ts index b43564b36..d117e2d10 100644 --- a/packages/webpack-plugin/src/__tests__/reservation.test.ts +++ b/packages/webpack-plugin/src/__tests__/reservation.test.ts @@ -1,12 +1,13 @@ import { dirname, relative, resolve } from 'node:path' import * as utils from '@devup-ui/plugin-utils' -import * as wasm from '@devup-ui/wasm' import { expect, it, mock, spyOn } from 'bun:test' import { DevupUIWebpackPlugin } from '../plugin' import { createWebpackReservation } from '../reservation' +const wasm = utils.loadCompiler('wasm') + it('maps the physical inventory to the exact loader IDs, CSS directory and flags', () => { const input = { physicalPath: resolve('physical', 'A.js'), @@ -42,19 +43,19 @@ it('maps the physical inventory to the exact loader IDs, CSS directory and flags ) try { const plugin = new DevupUIWebpackPlugin({ + engine: 'wasm', include: ['@slot/ui'], singleCss: true, }) - const barrier = createWebpackReservation(plugin.options, {}) + const selected = utils.loadCompiler('wasm') + const barrier = createWebpackReservation(plugin.options, {}, selected) barrier({ context: resolve('context'), entry: () => { throw new Error('entry must stay opaque') }, }) - expect(create).toHaveBeenCalledWith({ - reserveClassSlots: wasm.reserveClassSlots, - }) + expect(create).toHaveBeenCalledWith(selected) expect(manager.invalidate).toHaveBeenCalledTimes(1) expect(reserve).toHaveBeenCalledTimes(1) expect(setResolver).toHaveBeenCalledTimes(1) diff --git a/packages/webpack-plugin/src/__tests__/watch-css.test.ts b/packages/webpack-plugin/src/__tests__/watch-css.test.ts index f011b5659..e0c42d094 100644 --- a/packages/webpack-plugin/src/__tests__/watch-css.test.ts +++ b/packages/webpack-plugin/src/__tests__/watch-css.test.ts @@ -1,11 +1,13 @@ import assert from 'node:assert/strict' -import * as wasm from '@devup-ui/wasm' +import { loadCompiler } from '@devup-ui/plugin-utils' import { afterEach, expect, it, mock, spyOn } from 'bun:test' import { servedCss } from '../served-css' import { reconcileWatchCss } from '../watch-css' +const wasm = loadCompiler('wasm') + type CssModule = { readonly resource?: unknown } type Finish = (modules: Iterable) => Promise @@ -30,7 +32,7 @@ function compilation(error: Error | null = null) { callback(error) }, } - reconcileWatchCss(owner) + reconcileWatchCss(owner, loadCompiler('wasm')) assert(finish) return { owner, finish, rebuilt } } @@ -74,3 +76,16 @@ it('rejects module completion when native CSS rebuilding fails', async () => { // Then: the original failure propagates instead of publishing stale success. await expect(completion).rejects.toBe(error) }) + +it('reads the retained namespace method when a delayed completion runs', async () => { + // Given + const selected = loadCompiler('wasm') + const { owner, finish, rebuilt } = compilation() + const method = spyOn(selected, 'getCss') + servedCss(owner).set('/devup-ui.css', 'stale') + // When + await finish([{ resource: '/devup-ui.css' }]) + // Then + expect(method).toHaveBeenCalledTimes(1) + expect(rebuilt).toEqual([{ resource: '/devup-ui.css' }]) +}) diff --git a/packages/webpack-plugin/src/__tests__/watch-generation-observer.cjs b/packages/webpack-plugin/src/__tests__/watch-generation-observer.cjs index 80ad16e09..5fe669f4e 100644 --- a/packages/webpack-plugin/src/__tests__/watch-generation-observer.cjs +++ b/packages/webpack-plugin/src/__tests__/watch-generation-observer.cjs @@ -2,7 +2,7 @@ const assert = require('node:assert/strict') const fs = require('node:fs') const Module = require('node:module') const { basename, join } = require('node:path') -const wasm = require('@devup-ui/wasm') +const wasm = require('@devup-ui/plugin-utils').loadCompiler('wasm') const { getFileNumByFilename } = require('@devup-ui/plugin-utils') let generation = 0 diff --git a/packages/webpack-plugin/src/css-loader.ts b/packages/webpack-plugin/src/css-loader.ts index 450d9e671..3bd0b393e 100644 --- a/packages/webpack-plugin/src/css-loader.ts +++ b/packages/webpack-plugin/src/css-loader.ts @@ -1,15 +1,25 @@ -import { getFileNumByFilename } from '@devup-ui/plugin-utils' -import { getCss } from '@devup-ui/wasm' +import { + type DevupEngine, + getFileNumByFilename, + loadCompiler, +} from '@devup-ui/plugin-utils' import type { RawLoaderDefinitionFunction } from 'webpack' import { servedCss } from './served-css' -const devupUICssLoader: RawLoaderDefinitionFunction = function (_, map, meta) { - // The stylesheet comes from every extracted module, not from this file - this.cacheable(false) - const css = getCss(getFileNumByFilename(this.resourcePath), true) - if (this._compilation) - servedCss(this._compilation).set(this.resourcePath, css) - this.callback(null, css, map, meta) +export interface DevupUICssLoaderOptions { + readonly engine?: DevupEngine + readonly watch?: boolean } + +const devupUICssLoader: RawLoaderDefinitionFunction = + function (_, map, meta) { + // The stylesheet comes from every extracted module, not from this file + this.cacheable(false) + const compiler = loadCompiler(this.getOptions().engine) + const css = compiler.getCss(getFileNumByFilename(this.resourcePath), true) + if (this._compilation) + servedCss(this._compilation).set(this.resourcePath, css) + this.callback(null, css, map, meta) + } export default devupUICssLoader diff --git a/packages/webpack-plugin/src/loader.ts b/packages/webpack-plugin/src/loader.ts index 9463fd081..ee4db747f 100644 --- a/packages/webpack-plugin/src/loader.ts +++ b/packages/webpack-plugin/src/loader.ts @@ -1,18 +1,18 @@ import { writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' -import { createModuleResolver, createStateWriter } from '@devup-ui/plugin-utils' import { - codeExtract, - exportClassMap, - exportFileMap, - exportSheet, - getCss, - setModuleResolver, -} from '@devup-ui/wasm' + createModuleResolver, + createStateWriter, + type DevupCompiler, + type DevupCompilerOutput, + type DevupEngine, + loadCompiler, +} from '@devup-ui/plugin-utils' import type { LoaderContext, RawLoaderDefinitionFunction } from 'webpack' export interface DevupUILoaderOptions { + readonly engine?: DevupEngine package: string cssDir: string sheetFile: string @@ -44,11 +44,13 @@ const stateWriter = createStateWriter((path, content, encoding) => let moduleResolver: ReturnType | undefined /** Resolve imports to the cwd-relative ids this loader extracts files under */ -function setCwdModuleResolver(): void { +function setCwdModuleResolver( + compiler: Pick, +): void { moduleResolver ??= createModuleResolver({ toId: (path) => relative(process.cwd(), path).replaceAll('\\', '/'), }) - setModuleResolver(moduleResolver) + compiler.setModuleResolver(moduleResolver) } const devupUILoader: RawLoaderDefinitionFunction = @@ -61,6 +63,7 @@ const devupUILoader: RawLoaderDefinitionFunction = classMapFile, fileMapFile, singleCss, + engine, importAliases = {}, } = this.getOptions() const callback = this.async() @@ -73,6 +76,7 @@ const devupUILoader: RawLoaderDefinitionFunction = } try { + const compiler = loadCompiler(engine) let relCssDir = relative(dirname(id), cssDir).replaceAll('\\', '/') // POSIX-normalize so the engine's bucket key matches the canonical map / @@ -82,15 +86,8 @@ const devupUILoader: RawLoaderDefinitionFunction = const relativePath = relative(process.cwd(), id).replaceAll('\\', '/') if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` - setCwdModuleResolver() - const { - code, - css = '', - map, - cssFile, - updatedBaseStyle, - dependencies = [], - } = codeExtract( + setCwdModuleResolver(compiler) + const output: DevupCompilerOutput = compiler.codeExtract( relativePath, source.toString(), libPackage, @@ -100,6 +97,30 @@ const devupUILoader: RawLoaderDefinitionFunction = true, importAliases, ) + let snapshot: Pick< + DevupCompilerOutput, + 'code' | 'css' | 'map' | 'cssFile' | 'updatedBaseStyle' | 'dependencies' + > + try { + snapshot = { + code: output.code, + css: output.css, + map: output.map, + cssFile: output.cssFile, + updatedBaseStyle: output.updatedBaseStyle, + dependencies: output.dependencies, + } + } finally { + output.free() + } + const { + code, + css = '', + map, + cssFile, + updatedBaseStyle, + dependencies, + } = snapshot for (const dependency of dependencies) { this.addDependency(resolve(dependency)) } @@ -110,13 +131,13 @@ const devupUILoader: RawLoaderDefinitionFunction = promises.push( stateWriter.write( join(cssDir, 'devup-ui.css'), - getCss(null, false), + compiler.getCss(null, false), 'utf-8', ), ) } if (cssFile) { - const content = `${this.resourcePath} ${Date.now()}` + const content = `${basename(cssFile)} ${Date.now()}` // should be reset css promises.push( stateWriter.write( @@ -126,9 +147,9 @@ const devupUILoader: RawLoaderDefinitionFunction = ) if (watch) { promises.push( - stateWriter.write(sheetFile, exportSheet()), - stateWriter.write(classMapFile, exportClassMap()), - stateWriter.write(fileMapFile, exportFileMap()), + stateWriter.write(sheetFile, compiler.exportSheet()), + stateWriter.write(classMapFile, compiler.exportClassMap()), + stateWriter.write(fileMapFile, compiler.exportFileMap()), ) } } diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 14e9cfd85..2c5bd2ed0 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -14,9 +14,12 @@ import { createNodeModulesExcludeRegex, createThemeInterfaceArgs, type CustomShorthands, + type DevupCompiler, + type DevupUIBasePluginOptions, extractedNeedles, getFileNumByFilename, type ImportAliases, + loadCompiler, loadDevupConfigSync, mergeImportAliases, normalizeShorthands, @@ -27,32 +30,16 @@ import { type WasmImportAliases, withTailwindCss, } from '@devup-ui/plugin-utils' -import { - codeExtract, - getCss, - getDefaultTheme, - getThemeInterface, - importCanonicalMap, - importClassMap, - importFileMap, - importFileRoutes, - importSheet, - registerShorthands, - registerTheme, - resetBuildState, - seedFileMap, - setAtomHoist, - setDebug, - setModuleResolver, - setPrefix, -} from '@devup-ui/wasm' import { type Compiler } from 'webpack' import { createWebpackReservation } from './reservation' import { servedCss } from './served-css' import { reconcileWatchCss } from './watch-css' -export interface DevupUIWebpackPluginOptions { +export interface DevupUIWebpackPluginOptions extends Pick< + DevupUIBasePluginOptions, + 'engine' +> { package: string cssDir: string devupFile: string @@ -92,6 +79,7 @@ export class DevupUIWebpackPlugin { fileMapFile: string private importAliases: WasmImportAliases private readonly normalizedShorthands: Record + private readonly compiler: DevupCompiler constructor({ package: libPackage = '@devup-ui/react', @@ -102,6 +90,7 @@ export class DevupUIWebpackPlugin { debug = false, include = [], singleCss = false, + engine = 'native', prefix, shorthands, atomHoist = 2, @@ -109,7 +98,8 @@ export class DevupUIWebpackPlugin { importAliases: userImportAliases, }: Partial = {}) { this.normalizedShorthands = normalizeShorthands(shorthands ?? {}) - registerShorthands(this.normalizedShorthands) + this.compiler = loadCompiler(engine) + this.compiler.registerShorthands(this.normalizedShorthands) this.importAliases = mergeImportAliases( userImportAliases, readJsxImportSource(), @@ -124,6 +114,7 @@ export class DevupUIWebpackPlugin { debug, include, singleCss, + engine, prefix, shorthands, atomHoist, @@ -155,9 +146,9 @@ export class DevupUIWebpackPlugin { const config = loadDevupConfigSync(this.options.devupFile) const theme = config.theme ?? {} - registerTheme(withTailwindCss(theme, config)) + this.compiler.registerTheme(withTailwindCss(theme, config)) this.tailwindFiles = tailwindCssFiles(config) - const interfaceCode = getThemeInterface( + const interfaceCode = this.compiler.getThemeInterface( ...createThemeInterfaceArgs(this.options.package), ) @@ -168,20 +159,20 @@ export class DevupUIWebpackPlugin { } } catch (error) { console.error(error) - registerTheme({}) + this.compiler.registerTheme({}) } if (!existsSync(this.options.cssDir)) mkdirSync(this.options.cssDir, { recursive: true }) if (this.options.watch) writeFileSync( join(this.options.cssDir, 'devup-ui.css'), - getCss(null, false), + this.compiler.getCss(null, false), ) } /** * Extract the source files under `src` that `entries` reach into the shared - * WASM sheet, in path order, so that a stylesheet built on its first import + * compiler sheet, in path order, so that a stylesheet built on its first import * holds the styles of every one (all collapsed members of a bucket, and the * shared base), not just those of the modules webpack happened to build * first. Mirrors the loader's `codeExtract` call (same filename keying + @@ -194,7 +185,7 @@ export class DevupUIWebpackPlugin { const cwd = process.cwd() // The same resolver as the loader's, so imported constants and // stylesheets extract to the classes the loader emits - setModuleResolver( + this.compiler.setModuleResolver( createModuleResolver({ toId: (path) => relative(cwd, path).replaceAll('\\', '/'), }), @@ -209,9 +200,7 @@ export class DevupUIWebpackPlugin { entries, }) if (this.options.productionPrewarmFiles !== undefined) { - seedFileNumbers({ seedFileMap }, [ - ...this.options.productionPrewarmFiles, - ]) + seedFileNumbers(this.compiler, [...this.options.productionPrewarmFiles]) } for (const file of files) { const relativePath = relative(cwd, file).replaceAll('\\', '/') @@ -220,7 +209,7 @@ export class DevupUIWebpackPlugin { '/', ) if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` - codeExtract( + const output = this.compiler.codeExtract( relativePath, readFileSync(file, 'utf-8'), this.options.package, @@ -230,6 +219,7 @@ export class DevupUIWebpackPlugin { true, this.importAliases, ) + output.free() } } catch (error) { if (this.options.productionPrewarmFiles !== undefined) throw error @@ -241,7 +231,7 @@ export class DevupUIWebpackPlugin { apply(compiler: Compiler) { // A build starts from its own options, not from what an earlier build in // this process left in the engine - const endBuild = beginBuild({ resetBuildState }) + const endBuild = beginBuild(this.compiler) try { compiler.hooks.shutdown?.tap('DevupUIWebpackPlugin', endBuild) this.initialize(compiler) @@ -253,9 +243,9 @@ export class DevupUIWebpackPlugin { } private initialize(compiler: Compiler) { - setDebug(this.options.debug) - setPrefix(this.options.prefix ?? null) - registerShorthands(this.normalizedShorthands) + this.compiler.setDebug(this.options.debug) + this.compiler.setPrefix(this.options.prefix ?? null) + this.compiler.registerShorthands(this.normalizedShorthands) const existsDevup = existsSync(this.options.devupFile) // read devup.json if (!existsSync(this.options.distDir)) @@ -271,23 +261,29 @@ export class DevupUIWebpackPlugin { try { // load sheet if (existsSync(this.sheetFile)) - importSheet(JSON.parse(readFileSync(this.sheetFile, 'utf-8'))) + this.compiler.importSheet( + JSON.parse(readFileSync(this.sheetFile, 'utf-8')), + ) if (existsSync(this.classMapFile)) - importClassMap(JSON.parse(readFileSync(this.classMapFile, 'utf-8'))) + this.compiler.importClassMap( + JSON.parse(readFileSync(this.classMapFile, 'utf-8')), + ) if (existsSync(this.fileMapFile)) - importFileMap(JSON.parse(readFileSync(this.fileMapFile, 'utf-8'))) + this.compiler.importFileMap( + JSON.parse(readFileSync(this.fileMapFile, 'utf-8')), + ) } catch (error) { console.error(error) - importSheet({}) - importClassMap({}) - importFileMap({}) + this.compiler.importSheet({}) + this.compiler.importClassMap({}) + this.compiler.importFileMap({}) } } this.writeDataFiles() // Per-file atom hoisting defaults to 2; 0 disables it, singleCss ignores it. // Configured before loaders run codeExtract (apply() is synchronous), so - // eligible atoms receive global short counter names. The WASM + // eligible atoms receive global short counter names. The selected compiler // instance is shared in-process with the loaders. Composes with // single-importer collapse: both keyed by the canonical bucket. The webpack // loader passes relative(process.cwd(), id) as the extraction filename, so @@ -312,7 +308,7 @@ export class DevupUIWebpackPlugin { cwd, keyBy: 'cwd-relative', }) - importCanonicalMap(canonicalMap) + this.compiler.importCanonicalMap(canonicalMap) if (atomMode) { const fileReach = computeFileReach({ @@ -323,8 +319,8 @@ export class DevupUIWebpackPlugin { }) const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) if (plan) { - importFileRoutes(plan.reachByBucket) - setAtomHoist(plan.threshold) + this.compiler.importFileRoutes(plan.reachByBucket) + this.compiler.setAtomHoist(plan.threshold) } else { console.info( '[devup-ui] fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', @@ -348,7 +344,7 @@ export class DevupUIWebpackPlugin { try { const cwd = process.cwd() seedFileNumbers( - { seedFileMap }, + this.compiler, collectNumberedFiles({ roots: [resolve(cwd, 'src')], include: this.options.include, @@ -372,10 +368,14 @@ export class DevupUIWebpackPlugin { // base (global styles of every file) and, under collapse, a bucket's // devup-ui-N.css shared by several source files. Files extracted after that // would be missing, so the files the entries reach are extracted up front - // (single shared WASM instance). Re-extraction by the per-file loader is + // (single shared compiler instance). Re-extraction by the per-file loader is // then idempotent (set-based atom dedup). Watch mode rebuilds stylesheets // through the files the loaders write instead. - const reserve = createWebpackReservation(this.options, this.importAliases) + const reserve = createWebpackReservation( + this.options, + this.importAliases, + this.compiler, + ) const reserveBeforeCompile = () => { reserve(compiler.options) if (this.options.watch) return @@ -403,7 +403,7 @@ export class DevupUIWebpackPlugin { if (this.options.watch) { compiler.hooks.thisCompilation.tap( 'DevupUIWebpackPluginWatchCss', - reconcileWatchCss, + (compilation) => reconcileWatchCss(compilation, this.compiler), ) let lastModifiedTime: string | null = null compiler.hooks.watchRun.tapPromise('DevupUIWebpackPlugin', async () => { @@ -437,7 +437,9 @@ export class DevupUIWebpackPlugin { }) compiler.options.plugins.push( new compiler.webpack.DefinePlugin({ - 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify(getDefaultTheme()), + 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify( + this.compiler.getDefaultTheme(), + ), }), ) if (!this.options.watch) { @@ -453,7 +455,7 @@ export class DevupUIWebpackPlugin { 'DevupUIWebpackPlugin', (compilation) => { const basePath = join(this.options.cssDir, 'devup-ui.css') - const base = getCss(null, true) + const base = this.compiler.getCss(null, true) // A file's stylesheet `@import`s the shared base, which the CSS // loaders read from disk without passing through this plugin, so it // must hold every style extracted so far @@ -466,11 +468,12 @@ export class DevupUIWebpackPlugin { [...servedCss(compilation)] .filter( ([path, css]) => - getCss(getFileNumByFilename(path), true) !== css, + this.compiler.getCss(getFileNumByFilename(path), true) !== + css, ) .map(([path]) => path), ) - if (getCss(null, true) !== base) changed.add(basePath) + if (this.compiler.getCss(null, true) !== base) changed.add(basePath) stale = [...changed] }) // The next pass writes the build; this one writes none of its files @@ -493,7 +496,7 @@ export class DevupUIWebpackPlugin { for (const path of stale) writeFileSync( path, - getCss(getFileNumByFilename(path), true), + this.compiler.getCss(getFileNumByFilename(path), true), 'utf-8', ) return true @@ -503,12 +506,26 @@ export class DevupUIWebpackPlugin { ) compiler.hooks.done.tapPromise('DevupUIWebpackPlugin', async (stats) => { if (!stats.hasErrors()) { - // write css file - await writeFile( - join(this.options.cssDir, 'devup-ui.css'), - getCss(null, false), - 'utf-8', - ) + const writes = [ + writeFile( + join(this.options.cssDir, 'devup-ui.css'), + this.compiler.getCss(null, false), + 'utf-8', + ), + ] + // Prewarmed calls can omit CSS; completion must persist the final buckets. + for (const path of servedCss(stats.compilation).keys()) { + const fileNumber = getFileNumByFilename(path) + if (fileNumber !== null) + writes.push( + writeFile( + path, + this.compiler.getCss(fileNumber, true), + 'utf-8', + ), + ) + } + await Promise.all(writes) } }) } @@ -528,6 +545,7 @@ export class DevupUIWebpackPlugin { ), options: { package: this.options.package, + engine: this.options.engine, cssDir: this.options.cssDir, sheetFile: this.sheetFile, classMapFile: this.classMapFile, @@ -549,6 +567,7 @@ export class DevupUIWebpackPlugin { ), options: { watch: this.options.watch, + engine: this.options.engine, }, }, ], diff --git a/packages/webpack-plugin/src/reservation.ts b/packages/webpack-plugin/src/reservation.ts index 8bc10b403..7caebdc1b 100644 --- a/packages/webpack-plugin/src/reservation.ts +++ b/packages/webpack-plugin/src/reservation.ts @@ -3,9 +3,9 @@ import { dirname, relative, resolve } from 'node:path' import { createModuleResolver, createSharedSlotReservation, + type DevupCompiler, type WasmImportAliases, } from '@devup-ui/plugin-utils' -import { reserveClassSlots, setModuleResolver } from '@devup-ui/wasm' import type { Compiler } from 'webpack' import type { DevupUIWebpackPluginOptions } from './plugin' @@ -13,8 +13,9 @@ import type { DevupUIWebpackPluginOptions } from './plugin' export function createWebpackReservation( options: Omit, importAliases: WasmImportAliases, + compiler: Pick, ) { - const slots = createSharedSlotReservation({ reserveClassSlots }) + const slots = createSharedSlotReservation(compiler) return ({ context, entry, @@ -22,7 +23,7 @@ export function createWebpackReservation( const cwd = process.cwd() const root = resolve(context ?? cwd) const toId = (path: string) => relative(cwd, path).replaceAll('\\', '/') - setModuleResolver(createModuleResolver({ toId })) + compiler.setModuleResolver(createModuleResolver({ toId })) slots.invalidate() slots.reserve( { diff --git a/packages/webpack-plugin/src/watch-css.ts b/packages/webpack-plugin/src/watch-css.ts index c3b6b4d7e..4ee0bb058 100644 --- a/packages/webpack-plugin/src/watch-css.ts +++ b/packages/webpack-plugin/src/watch-css.ts @@ -1,5 +1,7 @@ -import { getFileNumByFilename } from '@devup-ui/plugin-utils' -import { getCss } from '@devup-ui/wasm' +import { + type DevupCompiler, + getFileNumByFilename, +} from '@devup-ui/plugin-utils' import { servedCss } from './served-css' @@ -17,6 +19,7 @@ interface WatchCssCompilation { export function reconcileWatchCss( compilation: WatchCssCompilation, + compiler: Pick, ): void { compilation.hooks.finishModules.tapPromise( { name: 'DevupUIWebpackPluginWatchCss', stage: Number.MAX_SAFE_INTEGER }, @@ -29,7 +32,7 @@ export function reconcileWatchCss( const css = served.get(module.resource) if ( css === undefined || - css === getCss(getFileNumByFilename(module.resource), true) + css === compiler.getCss(getFileNumByFilename(module.resource), true) ) return await new Promise((resolve, reject) => {