From 4bcfdf91aa5f615679e57067df14bc78202b22be Mon Sep 17 00:00:00 2001 From: Ross Brandes Date: Tue, 6 Oct 2026 14:58:05 -0400 Subject: [PATCH 1/2] Add a pnpm example that replaces a third-party ES module td.replaceEsm('some-package') resolves the package through quibble, which under pnpm can't see packages that only the app depends on unless pnpm hoisting happens to make them visible (testdouble/quibble#84). The new examples/node-esm-pnpm project is a pnpm workspace with hoisting off whose packages/app test replaces is-number, a dependency of that package only. It has to be a workspace: in a single pnpm project the app's own node_modules is still within reach of quibble's. It fails with quibble 0.11.x (the real is-number runs, because quibble replaces a different copy) and with 0.12.0 on Node 24+ (the generated stub for a CommonJS module is a SyntaxError), so it's expected to fail until quibble is bumped to 0.12.1. script/run-examples installs examples that have a pnpm-workspace.yaml with pnpm, and CI installs pnpm (8 on Node 16, 9 on Node 18, latest otherwise). The example sets hoisting off in both pnpm-workspace.yaml (pnpm 10+) and .npmrc (pnpm 8/9), and minimumReleaseAge: 0 so pnpm doesn't add exclusions to pnpm-workspace.yaml for just-released dependencies. Its lockfile isn't committed, so run-examples installs with --no-frozen-lockfile. --- .github/workflows/test.yml | 8 ++++++++ examples/node-esm-pnpm/.gitignore | 2 ++ examples/node-esm-pnpm/.npmrc | 1 + examples/node-esm-pnpm/package.json | 7 +++++++ .../packages/app/lib/numbers-only.mjs | 8 ++++++++ .../node-esm-pnpm/packages/app/package.json | 11 ++++++++++ .../packages/app/test/numbers-only-test.mjs | 20 +++++++++++++++++++ examples/node-esm-pnpm/pnpm-workspace.yaml | 11 ++++++++++ package.json | 3 ++- script/run-examples | 11 +++++++++- 10 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 examples/node-esm-pnpm/.gitignore create mode 100644 examples/node-esm-pnpm/.npmrc create mode 100644 examples/node-esm-pnpm/package.json create mode 100644 examples/node-esm-pnpm/packages/app/lib/numbers-only.mjs create mode 100644 examples/node-esm-pnpm/packages/app/package.json create mode 100644 examples/node-esm-pnpm/packages/app/test/numbers-only-test.mjs create mode 100644 examples/node-esm-pnpm/pnpm-workspace.yaml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ebd493b6..198fa3f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,5 +26,13 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: 'npm' + - name: Install pnpm (for the pnpm example project) + shell: bash + run: | + case "$(node -p 'process.versions.node.split(".")[0]')" in + 16) npm install --global pnpm@8 ;; + 18) npm install --global pnpm@9 ;; + *) npm install --global pnpm ;; + esac - run: npm ci - run: npm test diff --git a/examples/node-esm-pnpm/.gitignore b/examples/node-esm-pnpm/.gitignore new file mode 100644 index 00000000..c07ea0f3 --- /dev/null +++ b/examples/node-esm-pnpm/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +pnpm-lock.yaml diff --git a/examples/node-esm-pnpm/.npmrc b/examples/node-esm-pnpm/.npmrc new file mode 100644 index 00000000..84ff0791 --- /dev/null +++ b/examples/node-esm-pnpm/.npmrc @@ -0,0 +1 @@ +hoist=false diff --git a/examples/node-esm-pnpm/package.json b/examples/node-esm-pnpm/package.json new file mode 100644 index 00000000..66c61087 --- /dev/null +++ b/examples/node-esm-pnpm/package.json @@ -0,0 +1,7 @@ +{ + "name": "testdouble-node-esm-pnpm-example", + "private": true, + "scripts": { + "test": "pnpm --recursive test" + } +} diff --git a/examples/node-esm-pnpm/packages/app/lib/numbers-only.mjs b/examples/node-esm-pnpm/packages/app/lib/numbers-only.mjs new file mode 100644 index 00000000..e75cdce7 --- /dev/null +++ b/examples/node-esm-pnpm/packages/app/lib/numbers-only.mjs @@ -0,0 +1,8 @@ +import isNumber from 'is-number' + +export default function (thing) { + if (!isNumber(thing)) { + throw new Error('numbers only!') + } + return true +} diff --git a/examples/node-esm-pnpm/packages/app/package.json b/examples/node-esm-pnpm/packages/app/package.json new file mode 100644 index 00000000..175559b0 --- /dev/null +++ b/examples/node-esm-pnpm/packages/app/package.json @@ -0,0 +1,11 @@ +{ + "name": "testdouble-node-esm-pnpm-example-app", + "private": true, + "scripts": { + "test": "node --loader=testdouble --test ./test/numbers-only-test.mjs" + }, + "devDependencies": { + "is-number": "^7.0.0", + "testdouble": "file:../../../.." + } +} diff --git a/examples/node-esm-pnpm/packages/app/test/numbers-only-test.mjs b/examples/node-esm-pnpm/packages/app/test/numbers-only-test.mjs new file mode 100644 index 00000000..ead31fe7 --- /dev/null +++ b/examples/node-esm-pnpm/packages/app/test/numbers-only-test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import { describe, it, afterEach } from 'node:test' +import * as td from 'testdouble' + +// is-number is a dependency of this package only, so pnpm installs it where +// this file can see it but testdouble (in the workspace root's node_modules) +// can't. td.replaceEsm() has to resolve it the way this file would. +describe('numbers-only', function () { + afterEach(function () { + td.reset() + }) + + it('replaces a third-party module only this package depends on', async function () { + const isNumber = (await td.replaceEsm('is-number')).default + const numbersOnly = (await import('../lib/numbers-only.mjs')).default + td.when(isNumber('seven')).thenReturn(true) + + assert.strictEqual(numbersOnly('seven'), true) + }) +}) diff --git a/examples/node-esm-pnpm/pnpm-workspace.yaml b/examples/node-esm-pnpm/pnpm-workspace.yaml new file mode 100644 index 00000000..384c668a --- /dev/null +++ b/examples/node-esm-pnpm/pnpm-workspace.yaml @@ -0,0 +1,11 @@ +packages: + - packages/* + +# Keep each package's dependencies out of reach of the packages installed in +# the workspace root, so td.replaceEsm() has to resolve bare specifiers from +# the test file (pnpm 10+ reads this here, pnpm 8 and 9 from .npmrc) +hoist: false + +# Install testdouble's dependencies even when they were just released, +# instead of pnpm adding exclusions to this file for them +minimumReleaseAge: 0 diff --git a/package.json b/package.json index d3fade82..d3c39530 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "test:esm-auto-loader": "node test/support/supports-auto-load.js not || cross-env TS_NODE_IGNORE=\"node_modules,notypescript\" ./test/safe-esm/teenytest-proxy.js --helper test/helper.js test/safe-esm/replace.test.js", "test:no-loader-esm": "node test/support/supports-auto-load.js || cross-env TS_NODE_IGNORE=\"node_modules,notypescript\" teenytest test/helper.js './test/safe-esm/*.no-loader-test.{mjs,js}'", "test:ci": "npm run compile && run-p style test && echo \"All done!\"", - "test:example": "run-s test:example:babel test:example:jest test:example:jest-broken test:example:node test:example:node-ava test:example:node-esm test:example-esm-auto-loader", + "test:example": "run-s test:example:babel test:example:jest test:example:jest-broken test:example:node test:example:node-ava test:example:node-esm test:example:node-esm-pnpm test:example-esm-auto-loader", "test:example-esm-auto-loader": "node test/support/supports-auto-load.js not || npm run test:example:node-esm-auto-loader", "test:example:babel": "bash ./script/run-examples babel", "test:example:jest": "bash ./script/run-examples jest", @@ -55,6 +55,7 @@ "test:example:node": "bash ./script/run-examples node", "test:example:node-ava": "bash ./script/run-examples node-ava", "test:example:node-esm": "bash ./script/run-examples node-esm", + "test:example:node-esm-pnpm": "bash ./script/run-examples node-esm-pnpm", "test:example:node-esm-auto-loader": "bash ./script/run-examples node-esm-auto-loader", "test:example:webpack": "bash ./script/run-examples webpack", "version:write": "echo \"export default '$npm_package_version'\" > src/version.js", diff --git a/script/run-examples b/script/run-examples index 3e785f2e..39e43781 100755 --- a/script/run-examples +++ b/script/run-examples @@ -9,7 +9,16 @@ cd examples for D in $projects; do echo "<------ Running tests for example project '${D}'" cd "${D}" - npm install + if [ -f pnpm-workspace.yaml ]; then + if ! command -v pnpm > /dev/null; then + echo "pnpm is required to run the example project '${D}' (npm install --global pnpm)" + exit 1 + fi + # Its lockfile isn't committed, which pnpm refuses by default on CI + pnpm install --no-frozen-lockfile + else + npm install + fi npm test cd .. done From 2f5bb319ad86206ec1a5fe9873a06e09d2e35d3f Mon Sep 17 00:00:00 2001 From: Ross Brandes Date: Tue, 6 Oct 2026 14:58:11 -0400 Subject: [PATCH 2/2] Bump quibble to 0.12.1 Picks up two fixes for replacing ES modules: - 0.12.0 resolves bare specifiers from the caller instead of from quibble's own directory, so td.replaceEsm('some-package') replaces the copy the test imports under pnpm, and in monorepos where packages need different versions of a dependency (testdouble/quibble#84) - 0.12.1 generates valid stubs for export names that aren't identifiers, so td.replaceEsm() of a CommonJS package no longer fails on Node 24+ with "SyntaxError: Unexpected token '.'" (Node gives CommonJS modules a 'module.exports' named export) With these, the node-esm-pnpm example passes. --- examples/babel/package-lock.json | 4 ++-- examples/jest-broken/package-lock.json | 4 ++-- examples/jest/package-lock.json | 4 ++-- examples/node-ava/package-lock.json | 4 ++-- examples/node-esm-auto-loader/package-lock.json | 4 ++-- examples/node-esm/package-lock.json | 2 +- examples/node/package-lock.json | 4 ++-- package-lock.json | 14 +++++++------- package.json | 2 +- 9 files changed, 21 insertions(+), 21 deletions(-) diff --git a/examples/babel/package-lock.json b/examples/babel/package-lock.json index 1a3d4135..a9344b46 100644 --- a/examples/babel/package-lock.json +++ b/examples/babel/package-lock.json @@ -20,7 +20,7 @@ "license": "MIT", "dependencies": { "lodash": "^4.17.21", - "quibble": "^0.11.0", + "quibble": "^0.12.1", "stringify-object-es5": "^2.5.0", "theredoc": "^1.0.0" }, @@ -4329,7 +4329,7 @@ "mkdirp": "^3.0.1", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", - "quibble": "^0.11.0", + "quibble": "^0.12.1", "rimraf": "^5.0.5", "standard": "^17.1.0", "stringify-object-es5": "^2.5.0", diff --git a/examples/jest-broken/package-lock.json b/examples/jest-broken/package-lock.json index 21a471cc..bd76699a 100644 --- a/examples/jest-broken/package-lock.json +++ b/examples/jest-broken/package-lock.json @@ -21,7 +21,7 @@ "license": "MIT", "dependencies": { "lodash": "^4.17.21", - "quibble": "^0.11.0", + "quibble": "^0.12.1", "stringify-object-es5": "^2.5.0", "theredoc": "^1.0.0" }, @@ -5837,7 +5837,7 @@ "mkdirp": "^3.0.1", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", - "quibble": "^0.11.0", + "quibble": "^0.12.1", "rimraf": "^5.0.5", "standard": "^17.1.0", "stringify-object-es5": "^2.5.0", diff --git a/examples/jest/package-lock.json b/examples/jest/package-lock.json index 4fa5c49e..042d101e 100644 --- a/examples/jest/package-lock.json +++ b/examples/jest/package-lock.json @@ -21,7 +21,7 @@ "license": "MIT", "dependencies": { "lodash": "^4.17.21", - "quibble": "^0.11.0", + "quibble": "^0.12.1", "stringify-object-es5": "^2.5.0", "theredoc": "^1.0.0" }, @@ -5837,7 +5837,7 @@ "mkdirp": "^3.0.1", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", - "quibble": "^0.11.0", + "quibble": "^0.12.1", "rimraf": "^5.0.5", "standard": "^17.1.0", "stringify-object-es5": "^2.5.0", diff --git a/examples/node-ava/package-lock.json b/examples/node-ava/package-lock.json index 23856c8d..8e9bc43f 100644 --- a/examples/node-ava/package-lock.json +++ b/examples/node-ava/package-lock.json @@ -19,7 +19,7 @@ "license": "MIT", "dependencies": { "lodash": "^4.17.21", - "quibble": "^0.11.0", + "quibble": "^0.12.1", "stringify-object-es5": "^2.5.0", "theredoc": "^1.0.0" }, @@ -8647,7 +8647,7 @@ "mkdirp": "^3.0.1", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", - "quibble": "^0.11.0", + "quibble": "^0.12.1", "rimraf": "^5.0.5", "standard": "^17.1.0", "stringify-object-es5": "^2.5.0", diff --git a/examples/node-esm-auto-loader/package-lock.json b/examples/node-esm-auto-loader/package-lock.json index 33205436..3180409e 100644 --- a/examples/node-esm-auto-loader/package-lock.json +++ b/examples/node-esm-auto-loader/package-lock.json @@ -18,7 +18,7 @@ "license": "MIT", "dependencies": { "lodash": "^4.17.21", - "quibble": "^0.11.0", + "quibble": "^0.12.1", "stringify-object-es5": "^2.5.0", "theredoc": "^1.0.0" }, @@ -6175,7 +6175,7 @@ "mkdirp": "^3.0.1", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", - "quibble": "^0.11.0", + "quibble": "^0.12.1", "rimraf": "^5.0.5", "standard": "^17.1.0", "stringify-object-es5": "^2.5.0", diff --git a/examples/node-esm/package-lock.json b/examples/node-esm/package-lock.json index 0cb3ecdd..f2b26a64 100644 --- a/examples/node-esm/package-lock.json +++ b/examples/node-esm/package-lock.json @@ -18,7 +18,7 @@ "license": "MIT", "dependencies": { "lodash": "^4.17.21", - "quibble": "^0.11.0", + "quibble": "^0.12.1", "stringify-object-es5": "^2.5.0", "theredoc": "^1.0.0" }, diff --git a/examples/node/package-lock.json b/examples/node/package-lock.json index 9e883ddc..0b26ff53 100644 --- a/examples/node/package-lock.json +++ b/examples/node/package-lock.json @@ -20,7 +20,7 @@ "license": "MIT", "dependencies": { "lodash": "^4.17.21", - "quibble": "^0.11.0", + "quibble": "^0.12.1", "stringify-object-es5": "^2.5.0", "theredoc": "^1.0.0" }, @@ -6464,7 +6464,7 @@ "mkdirp": "^3.0.1", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", - "quibble": "^0.11.0", + "quibble": "^0.12.1", "rimraf": "^5.0.5", "standard": "^17.1.0", "stringify-object-es5": "^2.5.0", diff --git a/package-lock.json b/package-lock.json index 19af6944..20648272 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "lodash": "^4.17.21", - "quibble": "^0.11.0", + "quibble": "^0.12.1", "stringify-object-es5": "^2.5.0", "theredoc": "^1.0.0" }, @@ -6010,9 +6010,9 @@ ] }, "node_modules/quibble": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/quibble/-/quibble-0.11.0.tgz", - "integrity": "sha512-DNoMAchxhKyKAbJhVVlA+oOgdicluabD/CRuBQrH3c3Cf9Veie2Zl1TRWVuaqMmq+DcXqZIx02uCUzz0+CCtQg==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/quibble/-/quibble-0.12.1.tgz", + "integrity": "sha512-XyqltQYTTG+U5zLuvQusmc/F3Yes9DoqvgkrcY0pB1YmMaScqW0mdW00GYderJ+u8+geUv88Xapx+8FMp2Ro3Q==", "license": "MIT", "dependencies": { "lodash": "^4.18.1", @@ -12415,9 +12415,9 @@ "dev": true }, "quibble": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/quibble/-/quibble-0.11.0.tgz", - "integrity": "sha512-DNoMAchxhKyKAbJhVVlA+oOgdicluabD/CRuBQrH3c3Cf9Veie2Zl1TRWVuaqMmq+DcXqZIx02uCUzz0+CCtQg==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/quibble/-/quibble-0.12.1.tgz", + "integrity": "sha512-XyqltQYTTG+U5zLuvQusmc/F3Yes9DoqvgkrcY0pB1YmMaScqW0mdW00GYderJ+u8+geUv88Xapx+8FMp2Ro3Q==", "requires": { "lodash": "^4.18.1", "resolve": "^1.22.8" diff --git a/package.json b/package.json index d3c39530..139dff54 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ }, "dependencies": { "lodash": "^4.17.21", - "quibble": "^0.11.0", + "quibble": "^0.12.1", "stringify-object-es5": "^2.5.0", "theredoc": "^1.0.0" },