test(exe): add Windows-only repro for #11486 (pn/pnpx/pnx aliases)

Captures the user-reported failure on a fresh Windows CI: when the
@pnpm/exe install rewrites bin entries to point at .cmd files,
@zkochan/cmd-shim's Bash shim does `exec cmd /C ...target.cmd`, MSYS2
mangles the lone `/C` into a Windows path, and cmd.exe falls into
interactive mode (printing its banner instead of running the alias).

These tests will fail on `windows-latest` until the follow-up commit
points the bin entries at .exe hardlinks of the SEA binary.
This commit is contained in:
Zoltan Kochan
2026-05-06 21:31:03 +02:00
parent d98ac7e4bb
commit 1e93a1d8c1
4 changed files with 153 additions and 0 deletions

View File

@@ -908,6 +908,54 @@ describe('linkExePlatformBinary', () => {
const result = fs.readFileSync(path.join(exeDir, executable), 'utf8')
expect(result).toBe(fakeBinaryContent)
})
// Regression coverage for https://github.com/pnpm/pnpm/issues/11486 — the
// `pn` / `pnpx` / `pnx` aliases were broken in MSYS2 / Git Bash on Windows.
// Root cause: linkExePlatformBinary pointed those bin entries at .cmd files,
// and @zkochan/cmd-shim's Bash shim for a .cmd source bounces through
// `exec cmd /C "...target.cmd" "$@"`. MSYS2's argument-conversion runtime
// mangles the lone `/C` switch into a Windows path before cmd.exe sees it,
// so cmd.exe finds no /C or /K and falls into interactive mode (printing its
// banner instead of running the alias). Routing the aliases through .exe
// hardlinks of the SEA binary takes cmd.exe out of the chain entirely.
const winOnlyTest = platform === 'win32' ? test : test.skip
winOnlyTest('rewrites bin to .exe entries and hardlinks pn/pnpx/pnx aliases to pnpm.exe (issue #11486)', () => {
const dir = tempDir(false)
const exeDir = path.join(dir, 'node_modules', '@pnpm', 'exe')
const platformDir = path.join(dir, 'node_modules', '@pnpm', platformPkgName)
fs.mkdirSync(exeDir, { recursive: true })
fs.mkdirSync(platformDir, { recursive: true })
fs.writeFileSync(path.join(exeDir, executable), 'This file intentionally left blank')
// Match the published bin field from pnpm/artifacts/exe/package.json
fs.writeFileSync(path.join(exeDir, 'package.json'), JSON.stringify({
bin: { pnpm: 'pnpm', pn: 'pn', pnpx: 'pnpx', pnx: 'pnx' },
}))
// The platform binary needs to be a real file so fs.linkSync can hardlink
// it. Content doesn't matter.
fs.writeFileSync(path.join(platformDir, executable), 'fake-pnpm-exe')
linkExePlatformBinary(dir)
const rewritten = JSON.parse(fs.readFileSync(path.join(exeDir, 'package.json'), 'utf8'))
expect(rewritten.bin).toEqual({
pnpm: 'pnpm.exe',
pn: 'pn.exe',
pnpx: 'pnpx.exe',
pnx: 'pnx.exe',
})
const pnpmIno = fs.statSync(path.join(exeDir, 'pnpm.exe')).ino
for (const name of ['pn', 'pnpx', 'pnx']) {
const aliasPath = path.join(exeDir, `${name}.exe`)
expect(fs.existsSync(aliasPath)).toBe(true)
// Hardlinked to pnpm.exe, so the SEA's argv[0] basename detection can
// tell `pnpx` apart from `pnpm` and inject `dlx` accordingly.
expect(fs.statSync(aliasPath).ino).toBe(pnpmIno)
}
})
})
describe('exePlatformPkgDirName', () => {

3
pnpm-lock.yaml generated
View File

@@ -7909,6 +7909,9 @@ importers:
'@pnpm/jest-config':
specifier: workspace:*
version: link:../../../__utils__/jest-config
'@zkochan/cmd-shim':
specifier: 'catalog:'
version: 9.0.2
execa:
specifier: 'catalog:'
version: safe-execa@0.3.0

View File

@@ -49,6 +49,7 @@
"@jest/globals": "catalog:",
"@pnpm/exe": "workspace:*",
"@pnpm/jest-config": "workspace:*",
"@zkochan/cmd-shim": "catalog:",
"execa": "catalog:"
},
"pnpm": {

View File

@@ -4,6 +4,7 @@ import os from 'node:os'
import path from 'node:path'
import { describe, expect, test } from '@jest/globals'
import { cmdShim } from '@zkochan/cmd-shim'
import { familySync } from 'detect-libc'
// @ts-expect-error — JS helper without type declarations
@@ -144,3 +145,103 @@ failurePathTest('setup.js exits 1 with the missing platform package name when ru
// assert on that.
expect(result.stderr).toContain(expectedPkgName === '@pnpm/macos-x64' ? '11423' : expectedPkgName)
})
// Build a sandboxed @pnpm/exe install with a real .exe playing the part of
// pnpm.exe (we use the running node binary — setup.js only hardlinks it) and
// run setup.js. Returns the sandbox directory.
function buildWinSetupSandbox (): string {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-exe-fix11486-'))
fs.copyFileSync(path.join(exeDir, 'setup.js'), path.join(sandbox, 'setup.js'))
fs.copyFileSync(path.join(exeDir, 'prepare.js'), path.join(sandbox, 'prepare.js'))
fs.copyFileSync(path.join(exeDir, 'platform-pkg-name.js'), path.join(sandbox, 'platform-pkg-name.js'))
fs.writeFileSync(path.join(sandbox, 'package.json'), JSON.stringify({
name: '@pnpm/exe',
type: 'module',
bin: { pnpm: 'pnpm', pn: 'pn', pnpx: 'pnpx', pnx: 'pnx' },
}))
const platformPkgName = exePlatformPkgName(platform, process.arch, familySync())
const platformDir = path.join(sandbox, 'node_modules', platformPkgName)
fs.mkdirSync(platformDir, { recursive: true })
fs.writeFileSync(path.join(platformDir, 'package.json'), JSON.stringify({
name: platformPkgName, version: '0.0.0',
}))
// Hardlink the test's own node.exe as the platform binary. setup.js then
// hardlinks it again into the sandbox @pnpm/exe dir; downstream tests can
// invoke the resulting `pnpx.exe` (etc.) and assert the alias actually ran.
fs.linkSync(process.execPath, path.join(platformDir, 'pnpm.exe'))
// platform-pkg-name.js calls into detect-libc on Linux; symlink the real
// package so the resolver finds it from the sandbox.
fs.symlinkSync(
path.join(exeDir, 'node_modules', 'detect-libc'),
path.join(sandbox, 'node_modules', 'detect-libc'),
'dir'
)
execFileSync(process.execPath, [path.join(sandbox, 'prepare.js')], { cwd: sandbox })
execFileSync(process.execPath, [path.join(sandbox, 'setup.js')], { cwd: sandbox })
return sandbox
}
const winSetupTest = isWindows ? test : test.skip
// Regression coverage for https://github.com/pnpm/pnpm/issues/11486.
// See the matching describe block in
// engine/pm/commands/test/self-updater/selfUpdate.test.ts for the full
// rationale; this one covers the @pnpm/exe preinstall path that handles
// fresh `npm install -g @pnpm/exe`.
winSetupTest('setup.js (Windows) rewrites bin to .exe entries and hardlinks pn/pnpx/pnx aliases (issue #11486)', () => {
const sandbox = buildWinSetupSandbox()
const pkg = JSON.parse(fs.readFileSync(path.join(sandbox, 'package.json'), 'utf8'))
expect(pkg.bin).toEqual({
pnpm: 'pnpm.exe',
pn: 'pn.exe',
pnpx: 'pnpx.exe',
pnx: 'pnx.exe',
})
const pnpmIno = fs.statSync(path.join(sandbox, 'pnpm.exe')).ino
for (const name of ['pn', 'pnpx', 'pnx']) {
const aliasPath = path.join(sandbox, `${name}.exe`)
expect(fs.existsSync(aliasPath)).toBe(true)
expect(fs.statSync(aliasPath).ino).toBe(pnpmIno)
}
})
winSetupTest('aliases run from Bash (Git Bash / MSYS2) without dropping into interactive cmd.exe (issue #11486)', async () => {
const sandbox = buildWinSetupSandbox()
const pkg = JSON.parse(fs.readFileSync(path.join(sandbox, 'package.json'), 'utf8'))
// Mirror what `pnpm self-update` does in the global bin: feed each bin
// entry into @zkochan/cmd-shim and let it write the Bash / cmd / pwsh
// shims. Using cmd-shim here (the same lib pnpm's bin linker uses) is what
// lets this repro the real-world chain rather than just asserting the
// package.json shape.
const binDir = path.join(sandbox, 'global-bin')
await Promise.all(Object.entries(pkg.bin).map(([name, target]) =>
cmdShim(path.join(sandbox, target as string), path.join(binDir, name), { createPwshFile: true })
))
for (const alias of ['pn', 'pnpx', 'pnx']) {
const shim = path.join(binDir, alias).replace(/\\/g, '/')
// The shim's target is hardlinked to node.exe in this test (it's the
// SEA pnpm.exe in production), so `-e "..."` lets us assert the alias
// really ran our snippet — a successful assertion implies the cmd.exe
// hop got bypassed.
const result = spawnSync('bash', ['-c', `'${shim}' -e "process.stdout.write('${alias}_OK')"`], {
encoding: 'utf8',
timeout: 30_000,
})
// Pre-fix symptom: cmd-shim's Bash shim for a .cmd target does
// `exec cmd /C ...`. MSYS2 mangles `/C` into a Windows path before
// cmd.exe sees it; cmd.exe finds no /C or /K and falls into interactive
// mode, printing its banner instead of running the alias.
expect({ alias, banner: /Microsoft Windows/.test(result.stdout + result.stderr) })
.toEqual({ alias, banner: false })
expect({ alias, stdout: result.stdout })
.toEqual({ alias, stdout: `${alias}_OK` })
}
})