Files
pnpm/engine/runtime/system-node-version/test/getSystemNodeVersion.test.ts
Zoltan Kochan 3ddde2b975 fix(pacquet): align GVS slot layout with pnpm (#11689)
## Summary

Adds three end-to-end **GVS parity tests** under `pacquet/crates/cli/tests/pnpm_compatibility.rs` that run `pnpm install` and `pacquet install --frozen-lockfile` against the same workspace + lockfile with `enableGlobalVirtualStore: true`, then diff the resulting `<store>/v11/links/` slot trees. The tests surfaced three independent divergences, each fixed in its own commit set:

1. **`<store>/v11/links` prefix.** `getStorePath` appends `STORE_VERSION` (`v11`) to the configured `storeDir` before `extendInstallOptions.ts:352` joins `'links'` onto it, so pnpm's GVS lives at `<store>/v11/links/` — pacquet's `StoreDir::links()` was one level shallower, joining onto `self.root`. Same gap on `projects()`. Anchored both under `self.v11()` so the on-disk paths agree.

2. **GVS engine-name resolution.** `ENGINE_NAME` was computed from `process.version`, which is wrong in two cases:
   - **`@pnpm/exe` SEA bundle.** The bundle has its own embedded Node, not the `node` on PATH that runs lifecycle scripts. Two pnpm installs on the same machine (one SEA, one npm-package) therefore disagreed on the cache key, partitioning the side-effects cache and the global virtual store.
   - **`engines.runtime` / `devEngines.runtime` pin.** When a project pins a Node version, pnpm downloads that Node into `node_modules/node/` and uses it to run lifecycle scripts. But the hash still anchored to whichever Node ran pnpm itself, not to the pinned Node.

   `@pnpm/engine.runtime.system-node-version` now exports `engineName(nodeVersion?)` and `findRuntimeNodeVersion(snapshotKeys)`. The override has priority; otherwise the helper falls through to `getSystemNodeVersion()` — which already prefers shell `node --version` over `process.version` in SEA contexts — and finally to `process.version` as a last resort. `@pnpm/deps.graph-hasher`'s `calcDepState`, `calcGraphNodeHash`, and `iterateHashedGraphNodes` accept an optional `nodeVersion`. Every install-side caller (`deps.graph-builder`, `installing.deps-resolver`, `installing.deps-restorer`, `installing.deps-installer/install/link`, `building.during-install`, `building.after-install`) derives the project's pinned runtime via `findRuntimeNodeVersion` once per invocation and forwards it. The legacy `ENGINE_NAME` constant in `@pnpm/constants` is unchanged so external consumers and existing tests keep working.

   Pacquet mirrors this with `find_runtime_node_major` in `install_frozen_lockfile.rs` — it scans the lockfile's `snapshots:` map for a `node@runtime:<version>` entry and uses that major outright, only falling back to the host probe when no pin is present.

3. **Slot bin-shim layout.** Pacquet was emitting `.cmd` / `.ps1` shims on every host platform, even though pnpm only writes them on Windows ([`@zkochan/cmd-shim` `createCmdFile: isWindows`](https://github.com/pnpm/cmd-shim/blob/0d79ca9534/src/index.ts#L32) + `bins/linker`'s [`POWER_SHELL_IS_SUPPORTED = IS_WINDOWS`](https://github.com/pnpm/pnpm/blob/29a42efc3b/bins/linker/src/index.ts#L28) gate). Pacquet also excluded the slot's own package from the slot-local `node_modules/.bin/` based on a stale assumption ("which pnpm doesn't"), but pnpm's [`linkBinsOfDependencies`](https://github.com/pnpm/pnpm/blob/29a42efc3b/building/during-install/src/index.ts#L272-L298) appends `depNode` to the bin-source list unconditionally, so a leaf package like `hello-world-js-bin` writes a self-shim at `<slot>/node_modules/<pkg>/node_modules/.bin/<pkg>`. Both behaviors now match pnpm.

## Test plan

- [x] `cargo nextest run -p pacquet-cli --test pnpm_compatibility` — 5 active tests pass, 1 ignored (see below)
- [x] `cargo nextest run -p pacquet-store-dir -p pacquet-config -p pacquet-cmd-shim -p pacquet-package-manager` — 600+ tests pass after the prefix / bin-shim updates
- [x] `same_global_virtual_store_layout_pure_js` — pacquet & pnpm produce byte-identical `<store>/v11/links/` trees for `@pnpm.e2e/hello-world-js-bin-parent`
- [x] `same_global_virtual_store_layout_diamond` — same for `pkg-with-1-dep` + `parent-of-pkg-with-1-dep`, verifying `calc_dep_graph_hash` memoization parity
- [x] Three new TS unit tests in `engine/runtime/system-node-version/test/` cover the `engineName(version)` override branch and `findRuntimeNodeVersion`'s extraction rule (with and without peer suffix)
- [ ] `same_global_virtual_store_layout_with_approved_postinstall` is currently `#[ignore]`d. It requires pnpm and pacquet to agree on the `<platform>;<arch>;node<major>` triple in the engine-included hash branch. The `pnpm/setup` action on CI installs an `@pnpm/exe` SEA bundle whose embedded Node (node26) differs from the runner's PATH `node` (node24), so the digests don't line up. The pnpm-side fix in this PR resolves `engineName()` via `getSystemNodeVersion()` which prefers the shell `node`, so once a published pnpm version with the fix reaches `pnpm/setup` the test will pass without modification — re-enable it then. The other two GVS parity tests are unaffected since they exercise the engine-agnostic branch.

## Notes

- Two pacquet integration tests in `package-manager/src/install/tests.rs` had hard-coded `<store_dir>/projects/` assertions; updated to `<store_dir>/v11/projects/` to follow the prefix fix.
- The `link_bins_rewrites_when_only_sh_flavor_exists` cmd-shim test is now `#[cfg(windows)]` — the upgrade-recovery scenario it exercises is meaningless on Unix where `.cmd`/`.ps1` are no longer written in the first place.
- Review feedback addressed: (a) test YAML helper now guarantees a trailing newline before appending GVS keys; (b) `findRuntimeNodeVersion` calls in `installing/deps-restorer/` switched from `Object.keys(graph)` (install-dir-keyed in that module) to extracting `depPath` per node, with the computation lifted out of the recursion; (c) `dlx.e2e.ts`'s `jest.unstable_mockModule` against `@pnpm/engine.runtime.system-node-version` now forwards every exported symbol so transitive importers of `engineName` don't break.
- Known caveat: pacquet's non-lockfile install path (`run_with_readdir`) still excludes the slot's own bin via `link_bins_excluding`. That path runs only for the legacy flat layout where GVS parity isn't a constraint, so it's deliberately out of scope here.
- Known caveat tracked in #11690: when a dependency's own manifest declares `engines.runtime`, the resolver desugars it into a regular `dependencies.node: 'runtime:<v>'` entry on that package, so the **deps** portion of the hash captures it on both sides. The **engine** portion is still install-wide rather than per-snapshot, so cached side-effects for dep-pinned runtimes can be reused under the wrong host Node. pnpm has this same gap today; closing it on both sides requires per-snapshot engine resolution and is outside this PR's scope.
2026-05-16 23:58:53 +02:00

78 lines
3.0 KiB
TypeScript

import { expect, jest, test } from '@jest/globals'
let isSea = false
jest.unstable_mockModule('@pnpm/cli.meta', () => ({
detectIfCurrentPkgIsExecutable: jest.fn(() => isSea),
}))
jest.unstable_mockModule('execa', () => ({
sync: jest.fn(() => ({
stdout: 'v10.0.0',
})),
}))
const { getSystemNodeVersionNonCached, engineName, findRuntimeNodeVersion } = await import('../lib/index.js')
const execa = await import('execa')
test('getSystemNodeVersion() executed from an executable pnpm CLI', () => {
isSea = true
expect(getSystemNodeVersionNonCached()).toBe('v10.0.0')
expect(execa.sync).toHaveBeenCalledWith('node', ['--version'])
})
test('getSystemNodeVersion() from a non-executable pnpm CLI', () => {
isSea = false
expect(getSystemNodeVersionNonCached()).toBe(process.version)
})
test('getSystemNodeVersion() returns undefined if execa.sync throws an error', () => {
// Mock execa.sync to throw an error
jest.mocked(execa.sync).mockImplementationOnce(() => {
throw new Error('not found: node')
})
isSea = true
expect(getSystemNodeVersionNonCached()).toBeUndefined()
expect(execa.sync).toHaveBeenCalledWith('node', ['--version'])
})
test('engineName() honours an explicit nodeVersion over the host probe', () => {
// The pinned-runtime override path: when a project's
// `engines.runtime` / `devEngines.runtime` resolves to a specific
// Node version, the caller forwards it to `engineName(version)`
// and the result reflects that pinned Node — not whatever pnpm
// itself is running on. Format-stable across `v`-prefixed and
// bare versions.
const major22 = `${process.platform};${process.arch};node22`
expect(engineName('22.11.0')).toBe(major22)
expect(engineName('v22.11.0')).toBe(major22)
})
test('engineName() falls back to the host Node when no override is provided', () => {
// No-arg call mirrors the pre-runtime-pin behaviour: anchor to
// `getSystemNodeVersion()` (which itself prefers shell `node` over
// `process.version` only when running as a SEA bundle — covered
// by the tests above). Non-SEA test environment, so the system
// version equals `process.version`.
isSea = false
const major = process.version.replace(/^v/, '').split('.')[0]
expect(engineName()).toBe(`${process.platform};${process.arch};node${major}`)
})
test('findRuntimeNodeVersion() pulls the pinned major from a node@runtime: snapshot key', () => {
// Mirrors pacquet's `find_runtime_node_major` helper; both must
// agree on the version-extraction rule or the two tools would
// hash GVS slots under different engine majors for the same
// project. The peer-suffixed form must reduce to the same bare
// version as the form without a peer suffix.
expect(
findRuntimeNodeVersion(['leftpad@1.3.0', 'node@runtime:22.11.0'])
).toBe('22.11.0')
expect(
findRuntimeNodeVersion(['node@runtime:22.11.0(node@22.11.0)'])
).toBe('22.11.0')
expect(
findRuntimeNodeVersion(['leftpad@1.3.0', 'is-positive@3.1.0'])
).toBeUndefined()
})