From ce5bf75fc8dbd875ade06d21f5fcfdd008870e66 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Thu, 30 Jul 2026 11:43:08 -0700 Subject: [PATCH] ci: verify the packed package with publint, attw and size-limit Replaces the bespoke 700-line release gate with off-the-shelf tooling, keeping only the one check nothing off-the-shelf covers. Two new jobs run against the packed tarball the publish job uploads verbatim, so they check the exact bytes that ship: Verify package publint --strict, attw, size-limit Verify package loads entry-load.mjs on React 18 and 19 publint and attw cannot see the #759 class: their output is identical across 4.2.3 through 4.2.6, because that regression was a require call inlined into the body of a structurally valid ESM file. Loading the package is the only thing that catches it, which is why entry-load.mjs survives. They do catch two live consumer-facing bugs the bespoke gate never did. Published 4.2.6 gives TypeScript CJS consumers under node16 resolution no type declarations at all, and the package declared no sideEffects. Both are fixed here: types conditions on both exports branches, a duplicated .d.cts, and sideEffects false. Also repoints the size-limit config, which named TSDX-era filenames the vite build stopped emitting, so npm run size was checking nothing. publint runs with --strict because it exits 0 on warnings, and the missing types condition is a warning. attw ignores internal-resolution-error via .attw.json: the emitted .d.ts use extensionless relative imports, which is a real pre-existing bug with its own fix, and ignoring the one rule keeps every other resolution check enforcing. Addresses #765 items 1 to 5. Item 6 is #749 and lands separately. --- .attw.json | 4 + .eslintrc.json | 16 +++ .github/workflows/test.yaml | 64 +++++++++- package-lock.json | 242 ++++++++++++++++++++++++++++++++++++ package.json | 32 +++-- scripts/entry-load.mjs | 220 ++++++++++++++++++++++++++++++++ test/entry-load.test.mjs | 121 ++++++++++++++++++ 7 files changed, 689 insertions(+), 10 deletions(-) create mode 100644 .attw.json create mode 100644 scripts/entry-load.mjs create mode 100644 test/entry-load.test.mjs 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); + }); +});