diff --git a/.attw.json b/.attw.json new file mode 100644 index 00000000..1c02597e --- /dev/null +++ b/.attw.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://cdn.jsdelivr.net/npm/@arethetypeswrong/cli@0.18.5/config-schema.json", + "ignoreRules": ["internal-resolution-error"] +} diff --git a/.eslintrc.json b/.eslintrc.json index ed012a26..a5c1082b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -23,6 +23,22 @@ "@typescript-eslint", "no-only-tests" ], + "overrides": [ + { + "files": ["**/*.mjs"], + "env": { + "browser": false, + "node": true + }, + "globals": { + "describe": "readonly", + "it": "readonly", + "expect": "readonly", + "beforeEach": "readonly", + "afterEach": "readonly" + } + } + ], "rules": { "no-only-tests/no-only-tests": "error", "@typescript-eslint/no-explicit-any": "warn", diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d4158b35..b5d0aac4 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -46,6 +46,68 @@ jobs: publish.sh unpack.sh retention-days: 1 + verify-package: + runs-on: ubuntu-latest + needs: build + name: Verify package + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - name: Setup node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' + cache: 'npm' + - name: Install deps + run: npm ci + - name: 'Download Artifacts' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + # Unpacking makes the repo root the shipped package, which is what the + # next two steps need: publint takes a directory, and size-limit reads + # dist/, which is gitignored and so absent from a fresh checkout. + - name: Expand Artifact + run: | + chmod +x reactfire-${{ github.run_id }}/unpack.sh + ./reactfire-${{ github.run_id }}/unpack.sh + # --strict because publint exits 0 on warnings, and the manifest problems + # worth gating on (a missing types condition, for one) are warnings. + - name: Lint package + run: npm run lint:package + # Points at the tarball rather than re-packing, so it checks the exact + # bytes that ship. .attw.json ignores internal-resolution-error, because + # the emitted .d.ts use extensionless relative imports, which node16 + # resolution rejects. That is a real pre-existing bug with its own fix; + # ignoring the one rule keeps every other resolution check enforcing. + - name: Lint types + run: npm run lint:types -- reactfire-${{ github.run_id }}/reactfire.tgz + - name: Bundle size + run: npm run size + verify-loads: + runs-on: ubuntu-latest + needs: build + name: Verify package loads (React ${{ matrix.react }}) + strategy: + matrix: + react: ['18', '19'] + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - name: Setup node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' + - name: 'Download Artifacts' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + # Installs the packed tarball into a throwaway project with real react and + # firebase, then actually loads both entry points. No npm ci: the script + # builds its own dependency tree, which is why it is a separate job. + - name: Entry-point load test + run: node scripts/entry-load.mjs reactfire-${{ github.run_id }}/reactfire.tgz --react ${{ matrix.react }} test: runs-on: ubuntu-latest needs: build @@ -116,7 +178,7 @@ jobs: publish: runs-on: ubuntu-latest name: Publish (NPM) - needs: test + needs: [test, verify-package, verify-loads] if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'release' }} steps: - name: Setup node diff --git a/package-lock.json b/package-lock.json index 845fbc18..bc680dfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "use-sync-external-store": "^1.2.0" }, "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", "@rollup/plugin-typescript": "^12.3.0", "@size-limit/preset-small-lib": "^8.2.6", "@testing-library/jest-dom": "^5.16.5", @@ -38,6 +39,7 @@ "jsdom": "^22.1.0", "markdown-toc": "^1.2.0", "prettier": "^3.8.4", + "publint": "^0.3.22", "react": "^18.2.0", "react-dom": "^18.2.0", "react-test-renderer": "^18.2.0", @@ -64,6 +66,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@andrewbranch/untar.js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@andrewbranch/untar.js/-/untar.js-1.0.3.tgz", + "integrity": "sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==", + "dev": true + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", @@ -83,6 +91,138 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@arethetypeswrong/cli": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@arethetypeswrong/cli/-/cli-0.18.5.tgz", + "integrity": "sha512-gM+8vRsQOD/Uc7EnBedUhkG5OCsDWE4uoak5QvomGpMpaky0Eh41p04nIMgrWb8EOmqZUJGc6zz9hsP6E56R7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@arethetypeswrong/core": "0.18.5", + "chalk": "^4.1.2", + "cli-table3": "^0.6.3", + "commander": "^10.0.1", + "marked": "^9.1.2", + "marked-terminal": "^7.1.0", + "semver": "^7.5.4" + }, + "bin": { + "attw": "dist/index.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@arethetypeswrong/cli/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@arethetypeswrong/cli/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@arethetypeswrong/cli/node_modules/marked": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", + "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/@arethetypeswrong/cli/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@arethetypeswrong/core": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@arethetypeswrong/core/-/core-0.18.5.tgz", + "integrity": "sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@andrewbranch/untar.js": "^1.0.3", + "@loaderkit/resolve": "^1.0.2", + "cjs-module-lexer": "^1.2.3", + "fflate": "^0.8.3", + "lru-cache": "^11.0.1", + "semver": "^7.5.4", + "typescript": "5.6.1-rc", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@arethetypeswrong/core/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@arethetypeswrong/core/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@arethetypeswrong/core/node_modules/typescript": { + "version": "5.6.1-rc", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.1-rc.tgz", + "integrity": "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@babel/code-frame": { "version": "7.22.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz", @@ -201,6 +341,13 @@ "node": ">=6.9.0" } }, + "node_modules/@braidai/lang": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@braidai/lang/-/lang-1.1.2.tgz", + "integrity": "sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==", + "dev": true, + "license": "ISC" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -2017,6 +2164,16 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "dev": true }, + "node_modules/@loaderkit/resolve": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@loaderkit/resolve/-/resolve-1.0.6.tgz", + "integrity": "sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@braidai/lang": "^1.0.0" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -2668,6 +2825,22 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, + "node_modules/@publint/pack": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.6.tgz", + "integrity": "sha512-3uVNyGcVplhPZSLVyeIpL7+cIRn1YCSNHLG/rUIlBQMVH8YuN9++YF+5+UDIIO9RW98dujiUoTltO7RDB5bFJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyexec": "^1.2.4" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -5395,6 +5568,13 @@ "node": ">=8" } }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, "node_modules/cjson": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/cjson/-/cjson-0.3.3.tgz", @@ -11643,6 +11823,16 @@ "dev": true, "license": "MIT" }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -12372,6 +12562,13 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "dev": true, + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -12984,6 +13181,28 @@ "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", "dev": true }, + "node_modules/publint": { + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.22.tgz", + "integrity": "sha512-6Z/scsr5CA7APdwyF35EY88CqgDj1textWuY788DVTJYPCWVv/Wn9G6KmLnrVRnStgYcahqN4wCDLZGSbQJ69w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@publint/pack": "^0.1.6", + "package-manager-detector": "^1.7.0", + "picocolors": "^1.1.1", + "sade": "^1.8.1" + }, + "bin": { + "publint": "src/cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -13750,6 +13969,19 @@ "tslib": "^2.1.0" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -15628,6 +15860,16 @@ "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==", "dev": true }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 6b6b707f..c7d8d183 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,14 @@ "main": "dist/index.umd.cjs", "exports": { ".": { - "import": "./dist/index.js", - "require": "./dist/index.umd.cjs" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.umd.cjs" + } } }, "typings": "dist/index.d.ts", @@ -19,7 +25,8 @@ }, "scripts": { "start": "firebase emulators:exec --project=rxfire-525a3 \"vitest --ui\"", - "build": "tsc && vite build && npm run docs", + "build": "tsc && vite build && npm run build:types-cjs && npm run docs", + "build:types-cjs": "node -e \"require('fs').copyFileSync('dist/index.d.ts','dist/index.d.cts')\"", "test": "firebase emulators:exec --project=rxfire-525a3 \"vitest\"", "test:firestore": "firebase emulators:exec --only firestore --ui --project=rxfire-525a3 \"vitest firestore\"", "test:database": "firebase emulators:exec --only database --project=rxfire-525a3 \"vitest database\"", @@ -28,10 +35,14 @@ "test:storage": "firebase emulators:exec --only storage --project=rxfire-525a3 \"vitest storage\"", "test:useObservable": "vitest useObservable", "test:firebaseApp": "vitest firebaseApp", - "format": "prettier src test vite.config.ts -w", - "lint": "eslint src/* test/* vite.config.ts", + "test:loads": "vitest entry-load", + "format": "prettier src test scripts vite.config.ts -w", + "lint": "eslint src/* test/* scripts/* vite.config.ts", "size": "size-limit", "analyze": "size-limit --why", + "loads": "node scripts/entry-load.mjs", + "lint:package": "publint --strict", + "lint:types": "attw", "docs": "typedoc --options typedoc.json && markdown-toc -i docs/use.md", "docs:fork": "typedoc --options typedoc.json --gitRemote upstream && markdown-toc -i docs/use.md" }, @@ -60,17 +71,19 @@ }, "homepage": "https://firebaseopensource.com/projects/firebaseextended/reactfire/", "module": "./dist/index.js", + "sideEffects": false, "size-limit": [ { - "path": "dist/reactfire.cjs.production.min.js", - "limit": "10 KB" + "path": "dist/index.js", + "limit": "18 KB" }, { - "path": "dist/reactfire.esm.js", - "limit": "10 KB" + "path": "dist/index.umd.cjs", + "limit": "16 KB" } ], "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", "@rollup/plugin-typescript": "^12.3.0", "@size-limit/preset-small-lib": "^8.2.6", "@testing-library/jest-dom": "^5.16.5", @@ -95,6 +108,7 @@ "jsdom": "^22.1.0", "markdown-toc": "^1.2.0", "prettier": "^3.8.4", + "publint": "^0.3.22", "react": "^18.2.0", "react-dom": "^18.2.0", "react-test-renderer": "^18.2.0", diff --git a/scripts/entry-load.mjs b/scripts/entry-load.mjs new file mode 100644 index 00000000..24b23745 --- /dev/null +++ b/scripts/entry-load.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +/** + * Entry-point load test (#765, item 3). + * + * Installs the packed tarball into a throwaway project alongside real `react` + * and `firebase`, then actually loads both entry points: + * + * import('reactfire') the ESM entry + * require('reactfire') the CJS entry + * + * Usage: + * node scripts/entry-load.mjs [--react 18,19] [--keep] + * + * Why this earns its place alongside `publint` and `attw`: those analyse the + * package's structure, and #759 was a `require` inlined into the body of a + * structurally valid ESM file. Their output is byte-identical across 4.2.3, + * 4.2.4, 4.2.5 and 4.2.6. Actually loading the package is the only thing that + * catches it, because it observes the failure rather than pattern-matching for + * it. Verified against the published tarballs: 4.2.5 throws "Calling `require` + * for \"react\" in an environment that doesn't expose the `require` function" + * on the ESM entry while its CJS entry loads fine, and 4.2.6 loads on both. + * + * It runs as its own script and its own CI job because it has to install a real + * dependency tree, which the other package checks do not. + */ + +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Mirrors the versions CI already type-checks against, so a break that is +// specific to one React major is visible here too. +export const DEFAULT_REACT_VERSIONS = ['18', '19']; + +// Held to the major the repo develops against rather than "latest", so the +// fixture is deterministic and stays inside the declared peer range +// (^9 || ^10 || ^11 || ^12). +export const FIREBASE_RANGE = '^11.10.0'; + +// A load that resolves but exports nothing is still a broken package, and an +// empty or partial build would otherwise pass. These are load-bearing entry +// points from different submodules, so a missing re-export shows up here. +export const EXPECTED_EXPORTS = ['useFirestoreDocData', 'useUser', 'FirebaseAppProvider', 'useObservable']; + +export const PACKAGE_NAME = 'reactfire'; + +/** package.json for the throwaway consumer project. */ +export function fixtureManifest({ react, firebase = FIREBASE_RANGE } = {}) { + return { + name: 'reactfire-entry-load-fixture', + version: '1.0.0', + private: true, + // No "type" field: the fixture must be able to hold both an .mjs and a .cjs + // probe, and each probe carries its own extension instead. + dependencies: { + react: `^${react}`, + 'react-dom': `^${react}`, + firebase, + }, + }; +} + +/** + * Probe sources. Each prints a single JSON line so the parent does not have to + * scrape human-readable output, and each reports a missing export as a failure + * rather than only reporting a thrown error. + */ +export function probeSource(kind, { name = PACKAGE_NAME, expected = EXPECTED_EXPORTS } = {}) { + const check = ` + const missing = ${JSON.stringify(expected)}.filter((k) => typeof mod[k] === 'undefined'); + if (missing.length > 0) { + console.log(JSON.stringify({ ok: false, reason: 'missing-exports', missing })); + } else { + console.log(JSON.stringify({ ok: true, exports: Object.keys(mod).length })); + }`; + + if (kind === 'esm') { + return ` +import(${JSON.stringify(name)}) + .then((mod) => {${check} + }) + .catch((error) => { + console.log(JSON.stringify({ ok: false, reason: 'threw', message: String(error && error.message || error) })); + }); +`; + } + return ` +try { + const mod = require(${JSON.stringify(name)});${check} +} catch (error) { + console.log(JSON.stringify({ ok: false, reason: 'threw', message: String(error && error.message || error) })); +} +`; +} + +/** Read the single JSON line a probe prints; anything else is itself a failure. */ +export function parseProbeOutput(stdout) { + const line = String(stdout ?? '') + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .at(-1); + if (!line) return { ok: false, reason: 'no-output' }; + try { + return JSON.parse(line); + } catch { + return { ok: false, reason: 'unparseable-output', message: line.slice(0, 200) }; + } +} + +/** One-line summary of a probe result, for the CI log. */ +export function describeResult(result) { + if (result.ok) return `loaded, ${result.exports} export(s)`; + if (result.reason === 'missing-exports') return `loaded but missing export(s): ${result.missing.join(', ')}`; + if (result.reason === 'threw') return `threw: ${result.message}`; + if (result.reason === 'no-output') return 'probe produced no output'; + if (result.reason === 'crashed') return `probe exited ${result.code}: ${result.message}`; + return `${result.reason}: ${result.message ?? ''}`; +} + +/** + * Build the fixture and load both entry points for one React major. + * + * `run` is injectable so the orchestration is testable without installing + * anything; the real work is two npm installs and two node processes. + */ +export function loadWithReact(tarball, react, { run = execFileSync, root, expected = EXPECTED_EXPORTS, name = PACKAGE_NAME } = {}) { + const dir = root ?? fs.mkdtempSync(path.join(os.tmpdir(), `reactfire-load-${react}-`)); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), `${JSON.stringify(fixtureManifest({ react }), null, 2)}\n`); + fs.writeFileSync(path.join(dir, 'probe.mjs'), probeSource('esm', { name, expected })); + fs.writeFileSync(path.join(dir, 'probe.cjs'), probeSource('cjs', { name, expected })); + + const npm = (args) => run('npm', args, { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + + // Peers and the package under test go in together: installing the tarball + // first would resolve its peers against an empty tree. + npm(['install', '--no-audit', '--no-fund', '--loglevel', 'error']); + npm(['install', '--no-audit', '--no-fund', '--loglevel', 'error', path.resolve(tarball)]); + + const probe = (file) => { + try { + return parseProbeOutput(run(process.execPath, [path.join(dir, file)], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' })); + } catch (error) { + // A probe that dies without printing (segfault, OOM, an error thrown at + // top level in a way that escapes the handler) is still a failed load. + return { ok: false, reason: 'crashed', code: error.status ?? null, message: String(error.stderr ?? error.message ?? '').slice(0, 300) }; + } + }; + + return { dir, esm: probe('probe.mjs'), cjs: probe('probe.cjs') }; +} + +/** Collapse per-React results into the list of failures. */ +export function collectFailures(results) { + const failures = []; + for (const { react, esm, cjs } of results) { + if (!esm.ok) failures.push({ react, entry: 'esm', detail: describeResult(esm) }); + if (!cjs.ok) failures.push({ react, entry: 'cjs', detail: describeResult(cjs) }); + } + return failures; +} + +// --------------------------------------------------------------------------- + +function main() { + const args = process.argv.slice(2); + const keep = args.includes('--keep'); + const reactArg = args.includes('--react') ? args[args.indexOf('--react') + 1] : null; + const tarball = args.find((a) => !a.startsWith('--') && a !== reactArg); + + if (!tarball) { + console.error('usage: node scripts/entry-load.mjs [--react 18,19] [--keep]'); + return 2; + } + if (!fs.existsSync(tarball)) { + console.error(`tarball not found: ${tarball}`); + return 2; + } + + const versions = reactArg ? reactArg.split(',').filter(Boolean) : DEFAULT_REACT_VERSIONS; + console.log(`entry load test: ${path.basename(tarball)}\nreact: ${versions.join(', ')} firebase: ${FIREBASE_RANGE}\n`); + + const results = []; + const dirs = []; + try { + for (const react of versions) { + const { dir, esm, cjs } = loadWithReact(tarball, react); + dirs.push(dir); + results.push({ react, esm, cjs }); + console.log(` react ${react} import('${PACKAGE_NAME}') ${describeResult(esm)}`); + console.log(` react ${react} require('${PACKAGE_NAME}') ${describeResult(cjs)}`); + } + + const failures = collectFailures(results); + if (failures.length === 0) { + console.log('\nboth entry points load on every React version tested'); + return 0; + } + + console.error(`\n${failures.length} entry-point load failure(s):\n`); + for (const { react, entry, detail } of failures) { + console.error(` [${entry}] react ${react}: ${detail}`); + } + console.error('\n The packed package does not load. This is the #759 class:'); + console.error(' the files are all present, they just do not run. Check dist/index.js'); + console.error(' for an inlined CJS module, and check that the externals in'); + console.error(' vite.config.ts still cover use-sync-external-store/shim.'); + return 1; + } finally { + if (!keep) for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true }); + else console.log(`\nfixtures kept: ${dirs.join(', ')}`); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exit(main()); +} diff --git a/test/entry-load.test.mjs b/test/entry-load.test.mjs new file mode 100644 index 00000000..2020c93a --- /dev/null +++ b/test/entry-load.test.mjs @@ -0,0 +1,121 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { collectFailures, describeResult, loadWithReact, parseProbeOutput } from '../scripts/entry-load.mjs'; + +/** + * These tests pin the failure detection, not the fixture's shape. `publint` and + * `attw` can tell you the package is structurally valid; only loading it tells + * you it runs. Verified against the published tarballs: 4.2.4 and 4.2.5 throw on + * the ESM entry while their CJS entry loads, and 4.2.6 loads on both. + * + * Kept deliberately small. Each test here fails when the detection breaks, which + * is the only property worth the lines: a check that quietly degrades into + * always passing is worse than no check. + */ + +describe('parseProbeOutput', () => { + it('treats unparseable output as a failure rather than throwing', () => { + const result = parseProbeOutput('Segmentation fault'); + expect(result.ok).toBe(false); + expect(result.reason).toBe('unparseable-output'); + }); +}); + +describe('collectFailures', () => { + it('is empty when every entry loaded', () => { + expect(collectFailures([{ react: '18', esm: { ok: true }, cjs: { ok: true } }])).toEqual([]); + }); + + // The 4.2.5 shape: the ESM entry throws while the CJS entry is fine. A check + // that only looked at one of them would have missed the shipped regression. + it('reports an esm-only failure', () => { + const failures = collectFailures([{ react: '18', esm: { ok: false, reason: 'threw', message: 'require' }, cjs: { ok: true } }]); + expect(failures).toHaveLength(1); + expect(failures[0].entry).toBe('esm'); + }); + + // The mirror of the case above. Without this, dropping the CJS branch + // entirely left every test green: the check would silently have become + // ESM-only, and a UMD-side break would ship. + it('reports a cjs-only failure', () => { + const failures = collectFailures([{ react: '18', esm: { ok: true }, cjs: { ok: false, reason: 'threw', message: 'bad' } }]); + expect(failures).toHaveLength(1); + expect(failures[0].entry).toBe('cjs'); + }); +}); + +describe('loadWithReact', () => { + let dir; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'entry-load-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + /** Stand in for npm and node without installing or running anything. */ + const runner = ({ esm = '{"ok":true,"exports":74}', cjs = '{"ok":true,"exports":74}', onNode } = {}) => { + const calls = []; + const run = (cmd, args) => { + calls.push({ cmd, args }); + if (cmd === 'npm') return ''; + if (onNode) return onNode(args); + return String(args[0]).endsWith('.mjs') ? esm : cjs; + }; + return { run, calls }; + }; + + // Installing the tarball first would resolve its peers against an empty tree. + it('installs the peers before the package under test', () => { + const { run, calls } = runner(); + loadWithReact('/tmp/reactfire.tgz', '18', { run, root: dir }); + const installs = calls.filter((c) => c.cmd === 'npm'); + expect(installs).toHaveLength(2); + expect(installs[0].args.some((a) => a.endsWith('.tgz'))).toBe(false); + expect(installs[1].args.some((a) => a.endsWith('.tgz'))).toBe(true); + }); + + it('surfaces a throwing ESM entry, the 4.2.5 shape', () => { + const { run } = runner({ esm: '{"ok":false,"reason":"threw","message":"Calling `require` for \\"react\\""}' }); + const result = loadWithReact('/tmp/reactfire.tgz', '18', { run, root: dir }); + expect(result.esm.ok).toBe(false); + expect(result.cjs.ok).toBe(true); + expect(describeResult(result.esm)).toContain('require'); + }); + + // The mirror of the case above, and the one test that keeps the CJS probe + // wired up. Without it, replacing `cjs: probe('probe.cjs')` with a hardcoded + // pass leaves the whole suite green: the check silently becomes ESM-only and + // a UMD-side break ships. Verified by mutation. + it('surfaces a throwing CJS entry', () => { + const { run } = runner({ cjs: '{"ok":false,"reason":"threw","message":"boom"}' }); + const result = loadWithReact('/tmp/reactfire.tgz', '18', { run, root: dir }); + expect(result.cjs.ok).toBe(false); + expect(result.esm.ok).toBe(true); + expect(collectFailures([{ react: '18', ...result }])[0].entry).toBe('cjs'); + }); + + // A probe that dies without printing must not read as a pass. + it('treats a probe that exits without output as a failure', () => { + const onNode = () => { + const error = new Error('killed'); + error.status = 139; + error.stderr = 'Segmentation fault'; + throw error; + }; + const { run } = runner({ onNode }); + const result = loadWithReact('/tmp/reactfire.tgz', '18', { run, root: dir }); + expect(result.esm.ok).toBe(false); + expect(result.esm.reason).toBe('crashed'); + expect(result.esm.code).toBe(139); + }); + + it('fails a package that loads but is missing exports', () => { + const { run } = runner({ esm: '{"ok":false,"reason":"missing-exports","missing":["useUser"]}' }); + const result = loadWithReact('/tmp/reactfire.tgz', '18', { run, root: dir }); + expect(collectFailures([{ react: '18', ...result }])).toHaveLength(1); + }); +});