mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-12 10:11:42 -04:00
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:
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@jest/globals": "catalog:",
|
||||
"@pnpm/exe": "workspace:*",
|
||||
"@pnpm/jest-config": "workspace:*",
|
||||
"@zkochan/cmd-shim": "catalog:",
|
||||
"execa": "catalog:"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
@@ -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` })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user