mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-22 21:53:02 -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.
* fix(exe): route pn/pnpx/pnx through .exe hardlinks on Windows (#11486)
The @pnpm/exe install rewrote bin to point pn/pnpx/pnx at .cmd files,
which cmd-shim wraps as `exec cmd /C ...target.cmd "$@"` in its Bash
shim. MSYS2 / Git Bash mangles the lone `/C` into a Windows path
before cmd.exe sees it, so cmd.exe finds no /C or /K and falls into
interactive mode — the user sees its banner instead of `pnpm dlx`.
Hardlink pn.exe / pnpx.exe / pnx.exe to the SEA pnpm.exe (in setup.js
preinstall and in self-update's linkExePlatformBinary) and rewrite
those bin entries to the .exe names. cmd-shim emits a direct exec for
.exe sources, taking cmd.exe out of the chain entirely. The SEA reads
process.execPath's basename and prepends `dlx` when launched as
pnpx / pnx.
* test(exe): make Windows alias tests robust to local-dev environments
Two follow-ups from Copilot review on #11501:
* Use `'junction'` instead of `'dir'` for the detect-libc symlink on
Windows. Non-junction directory symlinks need Developer Mode or
admin, which the existing failure-path tests already skip on Windows
for; junctions don't.
* Probe \`bash --version\` before running the Git Bash / MSYS2 alias
test, and skip cleanly if it isn't on PATH (local Windows dev
machines often lack it; CI windows-latest ships it). Fold the status
check into the assertion so a non-zero exit surfaces in the diff.
* test(exe): wire @pnpm/exe into the recursive test runner
The setup.test.ts in this package wasn't running in CI — `@pnpm/exe`
had no `.test` script, so `pn -r .test` (what `test-pkgs-all` runs)
silently skipped it. The existing tests there have apparently been
dead since they were added; the Windows alias repro added in 1e93a1d
inherited the same gap.
Add `.test` (jest invocation, matching every other workspace
package's shape) and a `test` alias so it's picked up by the
recursive runner. meta-updater's @pnpm/exe / artifacts branch
short-circuits before adding test scripts; preserve that behavior by
hand-writing them rather than restructuring the rule.
1006 lines
39 KiB
TypeScript
1006 lines
39 KiB
TypeScript
import fs from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import path from 'node:path'
|
|
|
|
import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'
|
|
import { STORE_VERSION } from '@pnpm/constants'
|
|
import { prepare as prepareWithPkg, tempDir } from '@pnpm/prepare'
|
|
import { prependDirsToPath } from '@pnpm/shell.path'
|
|
import { getRegisteredProjects } from '@pnpm/store.controller'
|
|
import { getMockAgent, setupMockAgent, teardownMockAgent } from '@pnpm/testing.mock-agent'
|
|
import spawn from 'cross-spawn'
|
|
import { familySync } from 'detect-libc'
|
|
|
|
const require = createRequire(import.meta.dirname)
|
|
const pnpmTarballPath = require.resolve('@pnpm/tgz-fixtures/tgz/pnpm-9.1.0.tgz')
|
|
|
|
const actualModule = await import('@pnpm/cli.meta')
|
|
jest.unstable_mockModule('@pnpm/cli.meta', () => {
|
|
return {
|
|
...actualModule,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '9.0.0',
|
|
},
|
|
}
|
|
})
|
|
const { selfUpdate, installPnpm, linkExePlatformBinary, exePlatformPkgDirName, exePlatformPkgDirNameNext } = await import('@pnpm/engine.pm.commands')
|
|
|
|
beforeEach(async () => {
|
|
await setupMockAgent()
|
|
getMockAgent().enableNetConnect()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await teardownMockAgent()
|
|
})
|
|
|
|
function prepare (manifest: object = {}) {
|
|
const dir = tempDir(false)
|
|
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(manifest), 'utf8')
|
|
return prepareOptions(dir)
|
|
}
|
|
|
|
function prepareOptions (dir: string) {
|
|
return {
|
|
argv: {
|
|
original: [],
|
|
},
|
|
cliOptions: {},
|
|
excludeLinksFromLockfile: false,
|
|
linkWorkspacePackages: true,
|
|
bail: true,
|
|
globalPkgDir: path.join(dir, 'global', 'v11'),
|
|
pnpmHomeDir: dir,
|
|
preferWorkspacePackages: true,
|
|
registries: {
|
|
default: 'https://registry.npmjs.org/',
|
|
},
|
|
sort: false,
|
|
rootProjectManifestDir: dir,
|
|
bin: path.join(dir, 'bin'),
|
|
workspaceConcurrency: 1,
|
|
extraEnv: {},
|
|
pnpmfile: '',
|
|
configByUri: {},
|
|
cacheDir: path.join(dir, '.cache'),
|
|
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
|
|
dir,
|
|
}
|
|
}
|
|
|
|
function createMetadata (latest: string, registry: string, otherVersions: string[] = []) {
|
|
const versions = [...otherVersions, latest]
|
|
return {
|
|
name: 'pnpm',
|
|
'dist-tags': { latest },
|
|
versions: Object.fromEntries(versions.map((version) => [
|
|
version,
|
|
{
|
|
name: 'pnpm',
|
|
version,
|
|
dist: {
|
|
shasum: '217063ce3fcbf44f3051666f38b810f1ddefee4a',
|
|
tarball: `${registry}pnpm/-/pnpm-${version}.tgz`,
|
|
fileCount: 880,
|
|
integrity: 'sha512-Z/WHmRapKT5c8FnCOFPVcb6vT3U8cH9AyyK+1fsVeMaq07bEEHzLO6CzW+AD62IaFkcayDbIe+tT+dVLtGEnJA==',
|
|
},
|
|
},
|
|
])),
|
|
}
|
|
}
|
|
|
|
function createExeMetadata (version: string, registry: string) {
|
|
return {
|
|
name: '@pnpm/exe',
|
|
'dist-tags': { latest: version },
|
|
versions: {
|
|
[version]: {
|
|
name: '@pnpm/exe',
|
|
version,
|
|
dist: {
|
|
shasum: 'abcdef1234567890',
|
|
tarball: `${registry}@pnpm/exe/-/exe-${version}.tgz`,
|
|
integrity: 'sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==',
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mock @pnpm/exe metadata for tests that call resolvePackageManagerIntegrities.
|
|
* This prevents install() from making real HTTP requests for @pnpm/exe.
|
|
*/
|
|
function mockExeMetadata (registry: string, version: string) {
|
|
getMockAgent().get(registry.replace(/\/$/, ''))
|
|
.intercept({ path: '/@pnpm%2Fexe', method: 'GET' }) // cspell:disable-line
|
|
.reply(200, createExeMetadata(version, registry))
|
|
}
|
|
|
|
/**
|
|
* Mock all registry requests needed for a full self-update flow.
|
|
* This includes: initial resolution, resolvePackageManagerIntegrities, and handleGlobalAdd.
|
|
*/
|
|
function mockRegistryForUpdate (registry: string, version: string, metadata: object) {
|
|
// Use persist for metadata since multiple components request it
|
|
getMockAgent().get(registry.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, metadata).persist()
|
|
mockExeMetadata(registry, version)
|
|
const tgzData = fs.readFileSync(pnpmTarballPath)
|
|
getMockAgent().get(registry.replace(/\/$/, ''))
|
|
.intercept({ path: `/pnpm/-/pnpm-${version}.tgz`, method: 'GET' })
|
|
.reply(200, tgzData)
|
|
}
|
|
|
|
test('self-update', async () => {
|
|
const opts = prepare()
|
|
mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default))
|
|
|
|
await selfUpdate.handler(opts, [])
|
|
|
|
// Verify the package was installed in the global dir.
|
|
// The globalDir contains both the real install dir (a directory) and a
|
|
// hash symlink pointing to it. Use lstatSync to pick the real dir.
|
|
const globalDir = path.join(opts.pnpmHomeDir, 'global', 'v11')
|
|
const entries = fs.readdirSync(globalDir)
|
|
const installDirName = entries.find((e) => fs.lstatSync(path.join(globalDir, e)).isDirectory())
|
|
expect(installDirName).toBeDefined()
|
|
const installDir = path.join(globalDir, installDirName!)
|
|
const pnpmPkgJson = JSON.parse(fs.readFileSync(path.join(installDir, 'node_modules/pnpm/package.json'), 'utf8'))
|
|
expect(pnpmPkgJson.version).toBe('9.1.0')
|
|
|
|
// Verify the install dir was registered in the store's project registry.
|
|
// Without this, `pnpm store prune` would remove the install's packages
|
|
// from the global virtual store.
|
|
const storeDir = path.join(opts.pnpmHomeDir, 'store', STORE_VERSION)
|
|
const registeredProjects = await getRegisteredProjects(storeDir)
|
|
expect(registeredProjects).toContain(installDir)
|
|
|
|
const pnpmEnv = prependDirsToPath([path.join(opts.pnpmHomeDir, 'bin')])
|
|
const { status, stdout } = spawn.sync('pnpm', ['-v'], {
|
|
env: {
|
|
...process.env,
|
|
[pnpmEnv.name]: pnpmEnv.value,
|
|
},
|
|
})
|
|
expect(status).toBe(0)
|
|
expect(stdout.toString().trim()).toBe('9.1.0')
|
|
})
|
|
|
|
test('self-update refreshes legacy v10 bootstrap shim at pnpmHomeDir', async () => {
|
|
// pnpm v10 setup added pnpmHomeDir (not pnpmHomeDir/bin) to PATH and wrote
|
|
// a `pnpm` bootstrap shim there. After upgrading to v11, that shim still
|
|
// points into the old `.tools/<version>` install, so PATH continues to
|
|
// resolve to the pre-update pnpm. Self-update on v11 must refresh the
|
|
// legacy shim so the upgrade actually takes effect for users still on the
|
|
// v10 PATH layout. See pnpm/pnpm#11464.
|
|
const opts = prepare()
|
|
// Simulate a leftover v10 bootstrap shim. Content is irrelevant — the
|
|
// detector only cares about file presence, and linkBins will overwrite it.
|
|
fs.writeFileSync(path.join(opts.pnpmHomeDir, 'pnpm'), '#!/bin/sh\necho stale\n', { mode: 0o755 })
|
|
if (process.platform === 'win32') {
|
|
fs.writeFileSync(path.join(opts.pnpmHomeDir, 'pnpm.cmd'), '@echo stale\n')
|
|
}
|
|
mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default))
|
|
|
|
await selfUpdate.handler(opts, [])
|
|
|
|
// Invoking pnpm via pnpmHomeDir (the v10 PATH layout, NOT pnpmHomeDir/bin)
|
|
// must now resolve to the freshly installed version.
|
|
const pnpmEnv = prependDirsToPath([opts.pnpmHomeDir])
|
|
const { status, stdout } = spawn.sync('pnpm', ['-v'], {
|
|
env: {
|
|
...process.env,
|
|
[pnpmEnv.name]: pnpmEnv.value,
|
|
},
|
|
})
|
|
expect(status).toBe(0)
|
|
expect(stdout.toString().trim()).toBe('9.1.0')
|
|
})
|
|
|
|
test('self-update does not write shims to pnpmHomeDir on a clean v11 layout', async () => {
|
|
// Mirror image of the previous test: when there is no v10-style shim at
|
|
// pnpmHomeDir, self-update must NOT start writing bins there. Otherwise we
|
|
// would clutter pnpmHomeDir on every fresh-v11 self-update.
|
|
const opts = prepare()
|
|
mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default))
|
|
|
|
await selfUpdate.handler(opts, [])
|
|
|
|
expect(fs.existsSync(path.join(opts.pnpmHomeDir, 'pnpm'))).toBe(false)
|
|
expect(fs.existsSync(path.join(opts.pnpmHomeDir, 'pnpm.cmd'))).toBe(false)
|
|
})
|
|
|
|
test('self-update by exact version', async () => {
|
|
const opts = prepare()
|
|
const metadata = createMetadata('9.2.0', opts.registries.default, ['9.1.0'])
|
|
const registry = opts.registries.default.replace(/\/$/, '')
|
|
getMockAgent().get(registry)
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, metadata).persist()
|
|
mockExeMetadata(opts.registries.default, '9.1.0')
|
|
const tgzData = fs.readFileSync(pnpmTarballPath)
|
|
getMockAgent().get(registry)
|
|
.intercept({ path: '/pnpm/-/pnpm-9.1.0.tgz', method: 'GET' })
|
|
.reply(200, tgzData)
|
|
|
|
await selfUpdate.handler(opts, ['9.1.0'])
|
|
|
|
// Verify the package was installed in the global dir
|
|
const globalDir = path.join(opts.pnpmHomeDir, 'global', 'v11')
|
|
const entries = fs.readdirSync(globalDir)
|
|
const installDirName = entries.find((e) => fs.statSync(path.join(globalDir, e)).isDirectory())
|
|
expect(installDirName).toBeDefined()
|
|
const pnpmPkgJson = JSON.parse(fs.readFileSync(path.join(globalDir, installDirName!, 'node_modules/pnpm/package.json'), 'utf8'))
|
|
expect(pnpmPkgJson.version).toBe('9.1.0')
|
|
|
|
const pnpmEnv = prependDirsToPath([path.join(opts.pnpmHomeDir, 'bin')])
|
|
const { status, stdout } = spawn.sync('pnpm', ['-v'], {
|
|
env: {
|
|
...process.env,
|
|
[pnpmEnv.name]: pnpmEnv.value,
|
|
},
|
|
})
|
|
expect(status).toBe(0)
|
|
expect(stdout.toString().trim()).toBe('9.1.0')
|
|
})
|
|
|
|
test('self-update does nothing when pnpm is up to date', async () => {
|
|
const opts = prepare()
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.0.0', opts.registries.default))
|
|
|
|
const output = await selfUpdate.handler(opts, [])
|
|
|
|
expect(output).toBe('The currently active pnpm v9.0.0 is already "latest" and doesn\'t need an update')
|
|
})
|
|
|
|
test('self-update refuses to downgrade when latest is older than current', async () => {
|
|
const opts = prepare()
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('8.15.0', opts.registries.default))
|
|
|
|
const output = await selfUpdate.handler(opts, [])
|
|
|
|
expect(output).toBe('The currently active pnpm v9.0.0 is newer than the "latest" version on the registry (v8.15.0). No update performed. Run "pnpm self-update latest" to downgrade.')
|
|
// No global install dir should have been created.
|
|
const globalDir = path.join(opts.pnpmHomeDir, 'global', 'v11')
|
|
expect(fs.existsSync(globalDir)).toBe(false)
|
|
})
|
|
|
|
test('self-update latest forces the downgrade even when latest is older', async () => {
|
|
const opts = prepare()
|
|
// Mocked current pnpm is v9.0.0; mocking `latest` as v8.15.0 makes this an
|
|
// actual downgrade so the test exercises the explicit-`latest` bypass of
|
|
// the no-downgrade guard. The fixture tarball is still 9.1.0, but this test
|
|
// only checks that the install path was reached — not the resulting pinned
|
|
// version.
|
|
mockRegistryForUpdate(opts.registries.default, '8.15.0', createMetadata('8.15.0', opts.registries.default))
|
|
|
|
const output = await selfUpdate.handler(opts, ['latest'])
|
|
|
|
expect(output).not.toMatch(/No update performed/)
|
|
const globalDir = path.join(opts.pnpmHomeDir, 'global', 'v11')
|
|
expect(fs.existsSync(globalDir)).toBe(true)
|
|
})
|
|
|
|
test('self-update by exact older version skips the no-downgrade guard', async () => {
|
|
const opts = prepare()
|
|
// The fixture tarball's actual contents are still 9.1.0; only the registry
|
|
// metadata claims 8.15.0. That is fine here — this test only verifies that
|
|
// an explicit version argument bypasses the implicit-latest guard, not the
|
|
// resulting pinned version.
|
|
mockRegistryForUpdate(opts.registries.default, '8.15.0', createMetadata('8.15.0', opts.registries.default))
|
|
|
|
const output = await selfUpdate.handler(opts, ['8.15.0'])
|
|
|
|
expect(output).not.toMatch(/No update performed/)
|
|
const globalDir = path.join(opts.pnpmHomeDir, 'global', 'v11')
|
|
expect(fs.existsSync(globalDir)).toBe(true)
|
|
})
|
|
|
|
test('self-update refuses to downgrade the project pin when latest is older', async () => {
|
|
const opts = prepare({
|
|
packageManager: 'pnpm@10.0.0',
|
|
})
|
|
const pkgJsonPath = path.join(opts.dir, 'package.json')
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.5.0', opts.registries.default))
|
|
|
|
const output = await selfUpdate.handler({
|
|
...opts,
|
|
wantedPackageManager: {
|
|
name: 'pnpm',
|
|
version: '10.0.0',
|
|
},
|
|
}, [])
|
|
|
|
expect(output).toBe('The current project is set to use pnpm v10.0.0, which is newer than the "latest" version on the registry (v9.5.0). No update performed. Run "pnpm self-update latest" to downgrade.')
|
|
expect(JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')).packageManager).toBe('pnpm@10.0.0')
|
|
})
|
|
|
|
test('self-update refuses to downgrade the project pin when the lockfile is pinned above the range', async () => {
|
|
// Range spec like ">=8.0.0" understates the installed version when the
|
|
// env lockfile has pinned a higher exact version. The guard must consult
|
|
// the lockfile, not just the spec's lower bound.
|
|
const opts = prepare({
|
|
devEngines: {
|
|
packageManager: { name: 'pnpm', version: '>=8.0.0' },
|
|
},
|
|
})
|
|
fs.writeFileSync(path.join(opts.dir, 'pnpm-lock.yaml'), [
|
|
'---',
|
|
"lockfileVersion: '9.0'",
|
|
'',
|
|
'importers:',
|
|
'',
|
|
' .:',
|
|
' configDependencies: {}',
|
|
' packageManagerDependencies:',
|
|
' pnpm:',
|
|
" specifier: '>=8.0.0'",
|
|
' version: 10.5.0',
|
|
'',
|
|
'packages: {}',
|
|
'snapshots: {}',
|
|
'---',
|
|
'',
|
|
].join('\n'), 'utf8')
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.5.0', opts.registries.default))
|
|
|
|
const output = await selfUpdate.handler({
|
|
...opts,
|
|
wantedPackageManager: {
|
|
name: 'pnpm',
|
|
version: '>=8.0.0',
|
|
},
|
|
}, [])
|
|
|
|
expect(output).toBe('The current project is set to use pnpm v10.5.0, which is newer than the "latest" version on the registry (v9.5.0). No update performed. Run "pnpm self-update latest" to downgrade.')
|
|
})
|
|
|
|
test('should update packageManager field when a newer pnpm version is available', async () => {
|
|
const opts = prepare()
|
|
const pkgJsonPath = path.join(opts.dir, 'package.json')
|
|
fs.writeFileSync(pkgJsonPath, JSON.stringify({
|
|
packageManager: 'pnpm@8.0.0',
|
|
}), 'utf8')
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.0.0', opts.registries.default))
|
|
|
|
const output = await selfUpdate.handler({
|
|
...opts,
|
|
wantedPackageManager: {
|
|
name: 'pnpm',
|
|
version: '8.0.0',
|
|
},
|
|
}, [])
|
|
|
|
expect(output).toBe('The current project has been updated to use pnpm v9.0.0')
|
|
expect(JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')).packageManager).toBe('pnpm@9.0.0')
|
|
})
|
|
|
|
test('should not update packageManager field when current version matches latest', async () => {
|
|
const opts = prepare()
|
|
const pkgJsonPath = path.join(opts.dir, 'package.json')
|
|
fs.writeFileSync(pkgJsonPath, JSON.stringify({
|
|
packageManager: 'pnpm@9.0.0',
|
|
}), 'utf8')
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.0.0', opts.registries.default))
|
|
|
|
const output = await selfUpdate.handler({
|
|
...opts,
|
|
wantedPackageManager: {
|
|
name: 'pnpm',
|
|
version: '9.0.0',
|
|
},
|
|
}, [])
|
|
|
|
expect(output).toBe('The current project is already set to use pnpm v9.0.0')
|
|
expect(JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')).packageManager).toBe('pnpm@9.0.0')
|
|
})
|
|
|
|
test('should update devEngines.packageManager version when a newer pnpm version is available', async () => {
|
|
const opts = prepare({
|
|
devEngines: {
|
|
packageManager: { name: 'pnpm', version: '8.0.0' },
|
|
},
|
|
})
|
|
const pkgJsonPath = path.join(opts.dir, 'package.json')
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.0.0', opts.registries.default)).persist()
|
|
mockExeMetadata(opts.registries.default, '9.0.0')
|
|
|
|
const output = await selfUpdate.handler({
|
|
...opts,
|
|
wantedPackageManager: {
|
|
name: 'pnpm',
|
|
version: '8.0.0',
|
|
},
|
|
}, [])
|
|
|
|
expect(output).toBe('The current project has been updated to use pnpm v9.0.0')
|
|
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
|
|
expect(pkgJson.devEngines.packageManager.version).toBe('9.0.0')
|
|
expect(pkgJson.packageManager).toBeUndefined()
|
|
})
|
|
|
|
test('should update pnpm entry in devEngines.packageManager array', async () => {
|
|
const opts = prepare({
|
|
devEngines: {
|
|
packageManager: [
|
|
{ name: 'npm', version: '10.0.0' },
|
|
{ name: 'pnpm', version: '8.0.0' },
|
|
],
|
|
},
|
|
})
|
|
const pkgJsonPath = path.join(opts.dir, 'package.json')
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.0.0', opts.registries.default)).persist()
|
|
mockExeMetadata(opts.registries.default, '9.0.0')
|
|
|
|
const output = await selfUpdate.handler({
|
|
...opts,
|
|
wantedPackageManager: {
|
|
name: 'pnpm',
|
|
version: '8.0.0',
|
|
},
|
|
}, [])
|
|
|
|
expect(output).toBe('The current project has been updated to use pnpm v9.0.0')
|
|
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
|
|
expect(pkgJson.devEngines.packageManager[1].version).toBe('9.0.0')
|
|
expect(pkgJson.devEngines.packageManager[0].version).toBe('10.0.0')
|
|
expect(pkgJson.packageManager).toBeUndefined()
|
|
})
|
|
|
|
test('should not modify devEngines.packageManager range when resolved version still satisfies it', async () => {
|
|
const opts = prepare({
|
|
devEngines: {
|
|
packageManager: { name: 'pnpm', version: '>=8.0.0' },
|
|
},
|
|
})
|
|
const pkgJsonPath = path.join(opts.dir, 'package.json')
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.0.0', opts.registries.default)).persist()
|
|
mockExeMetadata(opts.registries.default, '9.0.0')
|
|
|
|
const output = await selfUpdate.handler({
|
|
...opts,
|
|
wantedPackageManager: {
|
|
name: 'pnpm',
|
|
version: '>=8.0.0',
|
|
},
|
|
}, [])
|
|
|
|
expect(output).toBe('The current project has been updated to use pnpm v9.0.0')
|
|
// The range should remain unchanged — the exact version is pinned in the lockfile
|
|
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
|
|
expect(pkgJson.devEngines.packageManager.version).toBe('>=8.0.0')
|
|
// The lockfile should be written with the resolved exact version
|
|
const lockfile = fs.readFileSync(path.join(opts.dir, 'pnpm-lock.yaml'), 'utf8')
|
|
expect(lockfile).toContain('9.0.0')
|
|
})
|
|
|
|
test('should fall back to ^version when complex range cannot accommodate the new version', async () => {
|
|
const opts = prepare({
|
|
devEngines: {
|
|
packageManager: { name: 'pnpm', version: '>=8.0.0 <9.0.0' },
|
|
},
|
|
})
|
|
const pkgJsonPath = path.join(opts.dir, 'package.json')
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.0.0', opts.registries.default)).persist()
|
|
mockExeMetadata(opts.registries.default, '9.0.0')
|
|
|
|
await selfUpdate.handler({
|
|
...opts,
|
|
wantedPackageManager: {
|
|
name: 'pnpm',
|
|
version: '>=8.0.0 <9.0.0',
|
|
},
|
|
}, [])
|
|
|
|
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
|
|
expect(pkgJson.devEngines.packageManager.version).toBe('^9.0.0')
|
|
})
|
|
|
|
test('should update both packageManager and devEngines.packageManager when both pin the same exact version', async () => {
|
|
const opts = prepare({
|
|
packageManager: 'pnpm@8.0.0',
|
|
devEngines: {
|
|
packageManager: { name: 'pnpm', version: '8.0.0' },
|
|
},
|
|
})
|
|
const pkgJsonPath = path.join(opts.dir, 'package.json')
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.0.0', opts.registries.default)).persist()
|
|
mockExeMetadata(opts.registries.default, '9.0.0')
|
|
|
|
const output = await selfUpdate.handler({
|
|
...opts,
|
|
wantedPackageManager: {
|
|
name: 'pnpm',
|
|
version: '8.0.0',
|
|
},
|
|
}, [])
|
|
|
|
expect(output).toBe('The current project has been updated to use pnpm v9.0.0')
|
|
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
|
|
expect(pkgJson.packageManager).toBe('pnpm@9.0.0')
|
|
expect(pkgJson.devEngines.packageManager.version).toBe('9.0.0')
|
|
})
|
|
|
|
test('should update both packageManager (with integrity hash) and devEngines.packageManager when versions agree', async () => {
|
|
const opts = prepare({
|
|
packageManager: 'pnpm@8.0.0+sha512.0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
|
|
devEngines: {
|
|
packageManager: { name: 'pnpm', version: '8.0.0' },
|
|
},
|
|
})
|
|
const pkgJsonPath = path.join(opts.dir, 'package.json')
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.0.0', opts.registries.default)).persist()
|
|
mockExeMetadata(opts.registries.default, '9.0.0')
|
|
|
|
await selfUpdate.handler({
|
|
...opts,
|
|
wantedPackageManager: {
|
|
name: 'pnpm',
|
|
version: '8.0.0',
|
|
},
|
|
}, [])
|
|
|
|
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
|
|
expect(pkgJson.packageManager).toBe('pnpm@9.0.0')
|
|
expect(pkgJson.devEngines.packageManager.version).toBe('9.0.0')
|
|
})
|
|
|
|
test('should sync both fields to the new exact version when their current versions disagree', async () => {
|
|
const opts = prepare({
|
|
packageManager: 'pnpm@7.0.0',
|
|
devEngines: {
|
|
packageManager: { name: 'pnpm', version: '8.0.0' },
|
|
},
|
|
})
|
|
const pkgJsonPath = path.join(opts.dir, 'package.json')
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.0.0', opts.registries.default)).persist()
|
|
mockExeMetadata(opts.registries.default, '9.0.0')
|
|
|
|
await selfUpdate.handler({
|
|
...opts,
|
|
wantedPackageManager: {
|
|
name: 'pnpm',
|
|
version: '8.0.0',
|
|
},
|
|
}, [])
|
|
|
|
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
|
|
expect(pkgJson.packageManager).toBe('pnpm@9.0.0')
|
|
expect(pkgJson.devEngines.packageManager.version).toBe('9.0.0')
|
|
})
|
|
|
|
test('should pin devEngines.packageManager to an exact version when packageManager also pins pnpm', async () => {
|
|
const opts = prepare({
|
|
packageManager: 'pnpm@8.0.0',
|
|
devEngines: {
|
|
packageManager: { name: 'pnpm', version: '^8.0.0' },
|
|
},
|
|
})
|
|
const pkgJsonPath = path.join(opts.dir, 'package.json')
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.0.0', opts.registries.default)).persist()
|
|
mockExeMetadata(opts.registries.default, '9.0.0')
|
|
|
|
await selfUpdate.handler({
|
|
...opts,
|
|
wantedPackageManager: {
|
|
name: 'pnpm',
|
|
version: '^8.0.0',
|
|
},
|
|
}, [])
|
|
|
|
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
|
|
expect(pkgJson.packageManager).toBe('pnpm@9.0.0')
|
|
expect(pkgJson.devEngines.packageManager.version).toBe('9.0.0')
|
|
})
|
|
|
|
test('should leave packageManager alone when it pins a different package manager', async () => {
|
|
const opts = prepare({
|
|
packageManager: 'yarn@4.0.0',
|
|
devEngines: {
|
|
packageManager: { name: 'pnpm', version: '^8.0.0' },
|
|
},
|
|
})
|
|
const pkgJsonPath = path.join(opts.dir, 'package.json')
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.0.0', opts.registries.default)).persist()
|
|
mockExeMetadata(opts.registries.default, '9.0.0')
|
|
|
|
await selfUpdate.handler({
|
|
...opts,
|
|
wantedPackageManager: {
|
|
name: 'pnpm',
|
|
version: '^8.0.0',
|
|
},
|
|
}, [])
|
|
|
|
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
|
|
expect(pkgJson.packageManager).toBe('yarn@4.0.0')
|
|
expect(pkgJson.devEngines.packageManager.version).toBe('^9.0.0')
|
|
})
|
|
|
|
test('should update devEngines.packageManager range when resolved version no longer satisfies it', async () => {
|
|
const opts = prepare({
|
|
devEngines: {
|
|
packageManager: { name: 'pnpm', version: '^8' },
|
|
},
|
|
})
|
|
const pkgJsonPath = path.join(opts.dir, 'package.json')
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.0.0', opts.registries.default)).persist()
|
|
mockExeMetadata(opts.registries.default, '9.0.0')
|
|
|
|
const output = await selfUpdate.handler({
|
|
...opts,
|
|
wantedPackageManager: {
|
|
name: 'pnpm',
|
|
version: '^8',
|
|
},
|
|
}, [])
|
|
|
|
expect(output).toBe('The current project has been updated to use pnpm v9.0.0')
|
|
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
|
|
// Range operator preserved, version updated
|
|
expect(pkgJson.devEngines.packageManager.version).toBe('^9.0.0')
|
|
})
|
|
|
|
test('self-update finds pnpm that is already in the global dir', async () => {
|
|
const opts = prepare()
|
|
const globalDir = opts.globalPkgDir
|
|
|
|
// Pre-create a pnpm package in the global dir with a hash symlink
|
|
const installDir = path.join(globalDir, 'test-install')
|
|
const pkgDir = path.join(installDir, 'node_modules', 'pnpm')
|
|
fs.mkdirSync(pkgDir, { recursive: true })
|
|
fs.writeFileSync(path.join(installDir, 'package.json'), JSON.stringify({ dependencies: { pnpm: '9.2.0' } }), 'utf8')
|
|
fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: 'pnpm', version: '9.2.0', bin: { pnpm: 'bin.js' } }), 'utf8')
|
|
fs.writeFileSync(path.join(pkgDir, 'bin.js'), `#!/usr/bin/env node
|
|
console.log('9.2.0')`, 'utf8')
|
|
// Create a hash symlink pointing to the install dir (like handleGlobalAdd does)
|
|
fs.symlinkSync(installDir, path.join(globalDir, 'fake-hash'))
|
|
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.2.0', opts.registries.default)).persist()
|
|
mockExeMetadata(opts.registries.default, '9.2.0')
|
|
|
|
const output = await selfUpdate.handler(opts, [])
|
|
|
|
expect(output).toBe(`The latest version, v9.2.0, is already present on the system. It was activated by linking it from ${installDir}.`)
|
|
|
|
const pnpmEnv = prependDirsToPath([path.join(opts.pnpmHomeDir, 'bin')])
|
|
const { status, stdout } = spawn.sync('pnpm', ['-v'], {
|
|
env: {
|
|
...process.env,
|
|
[pnpmEnv.name]: pnpmEnv.value,
|
|
},
|
|
})
|
|
expect(status).toBe(0)
|
|
expect(stdout.toString().trim()).toBe('9.2.0')
|
|
})
|
|
|
|
test('self-update works globally without package.json', async () => {
|
|
const dir = tempDir(false)
|
|
// No package.json in this directory
|
|
const pnpmHomeDir = path.join(dir, 'pnpm-home')
|
|
fs.mkdirSync(pnpmHomeDir, { recursive: true })
|
|
const opts = {
|
|
...prepareOptions(dir),
|
|
globalPkgDir: path.join(pnpmHomeDir, 'global', 'v11'),
|
|
pnpmHomeDir,
|
|
bin: path.join(pnpmHomeDir, 'bin'),
|
|
}
|
|
mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default))
|
|
|
|
await selfUpdate.handler(opts, [])
|
|
|
|
// Verify no package.json was created
|
|
expect(fs.existsSync(path.join(dir, 'package.json'))).toBe(false)
|
|
|
|
// Verify pnpm-lock.yaml was written to pnpmHomeDir
|
|
expect(fs.existsSync(path.join(pnpmHomeDir, 'pnpm-lock.yaml'))).toBe(true)
|
|
|
|
// Verify the package was installed in the global dir
|
|
const globalDir = path.join(pnpmHomeDir, 'global', 'v11')
|
|
const globalEntries = fs.readdirSync(globalDir)
|
|
const globalInstallDir = globalEntries.find((e) => fs.statSync(path.join(globalDir, e)).isDirectory())
|
|
expect(globalInstallDir).toBeDefined()
|
|
expect(fs.existsSync(path.join(globalDir, globalInstallDir!, 'node_modules', 'pnpm', 'package.json'))).toBe(true)
|
|
|
|
const pnpmEnv = prependDirsToPath([path.join(pnpmHomeDir, 'bin')])
|
|
const { status, stdout } = spawn.sync('pnpm', ['-v'], {
|
|
env: {
|
|
...process.env,
|
|
[pnpmEnv.name]: pnpmEnv.value,
|
|
},
|
|
})
|
|
expect(status).toBe(0)
|
|
expect(stdout.toString().trim()).toBe('9.1.0')
|
|
})
|
|
|
|
test('self-update updates the packageManager field in package.json', async () => {
|
|
prepareWithPkg({
|
|
packageManager: 'pnpm@9.0.0',
|
|
})
|
|
const opts = {
|
|
...prepareOptions(process.cwd()),
|
|
wantedPackageManager: {
|
|
name: 'pnpm',
|
|
version: '9.0.0',
|
|
},
|
|
}
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.1.0', opts.registries.default))
|
|
|
|
const output = await selfUpdate.handler(opts, [])
|
|
|
|
expect(output).toBe('The current project has been updated to use pnpm v9.1.0')
|
|
|
|
const pkgJson = JSON.parse(fs.readFileSync(path.resolve('package.json'), 'utf8'))
|
|
expect(pkgJson.packageManager).toBe('pnpm@9.1.0')
|
|
})
|
|
|
|
test('installPnpm without env lockfile uses resolution path', async () => {
|
|
const opts = prepare()
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm', method: 'GET' })
|
|
.reply(200, createMetadata('9.1.0', opts.registries.default)).persist()
|
|
const tgzData = fs.readFileSync(pnpmTarballPath)
|
|
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
|
|
.intercept({ path: '/pnpm/-/pnpm-9.1.0.tgz', method: 'GET' })
|
|
.reply(200, tgzData)
|
|
|
|
const result = await installPnpm('9.1.0', opts)
|
|
|
|
expect(result.alreadyExisted).toBe(false)
|
|
const pnpmPkgJson = JSON.parse(fs.readFileSync(path.join(result.baseDir, 'node_modules/pnpm/package.json'), 'utf8'))
|
|
expect(pnpmPkgJson.version).toBe('9.1.0')
|
|
expect(fs.existsSync(result.binDir)).toBe(true)
|
|
})
|
|
|
|
describe('linkExePlatformBinary', () => {
|
|
const platform = process.platform
|
|
const arch = platform === 'win32' && process.arch === 'ia32' ? 'x86' : process.arch
|
|
const executable = platform === 'win32' ? 'pnpm.exe' : 'pnpm'
|
|
// Match the libc family linkExePlatformBinary detects at runtime so the
|
|
// fixture directory matches what the implementation looks up, including on
|
|
// musl hosts (Alpine CI).
|
|
const libcFamily = familySync()
|
|
const platformPkgName = exePlatformPkgDirName(platform, arch, libcFamily)
|
|
|
|
test('links platform binary in pnpm symlinked node_modules layout', () => {
|
|
const dir = tempDir(false)
|
|
|
|
// Create a virtual store layout like pnpm produces:
|
|
// .pnpm/@pnpm+exe@1.0.0/node_modules/@pnpm/exe/ (the real @pnpm/exe dir)
|
|
// .pnpm/@pnpm+exe@1.0.0/node_modules/@pnpm/<platform>-<arch>/ (platform binary)
|
|
// node_modules/@pnpm/exe -> symlink to the virtual store entry
|
|
const vsExeDir = path.join(dir, 'node_modules', '.pnpm', '@pnpm+exe@1.0.0', 'node_modules', '@pnpm', 'exe')
|
|
const vsPlatformDir = path.join(dir, 'node_modules', '.pnpm', '@pnpm+exe@1.0.0', 'node_modules', '@pnpm', platformPkgName)
|
|
const topLevelExeDir = path.join(dir, 'node_modules', '@pnpm', 'exe')
|
|
|
|
// Create the virtual store directories
|
|
fs.mkdirSync(vsExeDir, { recursive: true })
|
|
fs.mkdirSync(vsPlatformDir, { recursive: true })
|
|
|
|
// Write the placeholder file (as published in the @pnpm/exe tarball)
|
|
fs.writeFileSync(path.join(vsExeDir, executable), 'This file intentionally left blank')
|
|
// Write a package.json (needed on Windows where bin.pnpm is rewritten to pnpm.exe)
|
|
fs.writeFileSync(path.join(vsExeDir, 'package.json'), JSON.stringify({ bin: { pnpm: 'pnpm' } }))
|
|
|
|
// Write a fake platform binary
|
|
const fakeBinaryContent = '#!/bin/sh\necho "fake pnpm binary"'
|
|
fs.writeFileSync(path.join(vsPlatformDir, executable), fakeBinaryContent)
|
|
|
|
// Create the top-level symlink: node_modules/@pnpm/exe -> virtual store
|
|
fs.mkdirSync(path.join(dir, 'node_modules', '@pnpm'), { recursive: true })
|
|
fs.symlinkSync(vsExeDir, topLevelExeDir)
|
|
|
|
// Run the function
|
|
linkExePlatformBinary(dir)
|
|
|
|
// The placeholder should be replaced with the platform binary content
|
|
const result = fs.readFileSync(path.join(topLevelExeDir, executable), 'utf8')
|
|
expect(result).toBe(fakeBinaryContent)
|
|
|
|
// pn is a shell script in the tarball (not created by linkExePlatformBinary)
|
|
})
|
|
|
|
test('also works with flat node_modules layout', () => {
|
|
const dir = tempDir(false)
|
|
|
|
// In a flat layout (no symlinks), both packages are at the top level
|
|
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')
|
|
// Write a package.json (needed on Windows where bin.pnpm is rewritten to pnpm.exe)
|
|
fs.writeFileSync(path.join(exeDir, 'package.json'), JSON.stringify({ bin: { pnpm: 'pnpm' } }))
|
|
|
|
const fakeBinaryContent = '#!/bin/sh\necho "fake pnpm binary"'
|
|
fs.writeFileSync(path.join(platformDir, executable), fakeBinaryContent)
|
|
|
|
linkExePlatformBinary(dir)
|
|
|
|
const result = fs.readFileSync(path.join(exeDir, executable), 'utf8')
|
|
expect(result).toBe(fakeBinaryContent)
|
|
})
|
|
|
|
test('does nothing when @pnpm/exe is not installed', () => {
|
|
const dir = tempDir(false)
|
|
fs.mkdirSync(path.join(dir, 'node_modules'), { recursive: true })
|
|
|
|
// Should not throw
|
|
linkExePlatformBinary(dir)
|
|
})
|
|
|
|
test('does nothing when platform binary is not available', () => {
|
|
const dir = tempDir(false)
|
|
const exeDir = path.join(dir, 'node_modules', '@pnpm', 'exe')
|
|
fs.mkdirSync(exeDir, { recursive: true })
|
|
|
|
const placeholder = 'This file intentionally left blank'
|
|
fs.writeFileSync(path.join(exeDir, executable), placeholder)
|
|
|
|
linkExePlatformBinary(dir)
|
|
|
|
// Placeholder should remain unchanged
|
|
const result = fs.readFileSync(path.join(exeDir, executable), 'utf8')
|
|
expect(result).toBe(placeholder)
|
|
})
|
|
|
|
test('falls back to future exe.<platform>-<arch> naming scheme', () => {
|
|
const dir = tempDir(false)
|
|
|
|
// Simulate a future release where only the new-scheme platform package
|
|
// directory exists under the virtual store — the legacy name is absent.
|
|
const nextPkgName = exePlatformPkgDirNameNext(platform, arch, libcFamily)
|
|
const exeDir = path.join(dir, 'node_modules', '@pnpm', 'exe')
|
|
const platformDir = path.join(dir, 'node_modules', '@pnpm', nextPkgName)
|
|
|
|
fs.mkdirSync(exeDir, { recursive: true })
|
|
fs.mkdirSync(platformDir, { recursive: true })
|
|
|
|
fs.writeFileSync(path.join(exeDir, executable), 'This file intentionally left blank')
|
|
fs.writeFileSync(path.join(exeDir, 'package.json'), JSON.stringify({ bin: { pnpm: 'pnpm' } }))
|
|
|
|
const fakeBinaryContent = '#!/bin/sh\necho "fake pnpm binary"'
|
|
fs.writeFileSync(path.join(platformDir, executable), fakeBinaryContent)
|
|
|
|
linkExePlatformBinary(dir)
|
|
|
|
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', () => {
|
|
test('uses linuxstatic- prefix for linux + musl libc family', () => {
|
|
expect(exePlatformPkgDirName('linux', 'x64', 'musl')).toBe('linuxstatic-x64')
|
|
expect(exePlatformPkgDirName('linux', 'arm64', 'musl')).toBe('linuxstatic-arm64')
|
|
})
|
|
|
|
test('uses linux- prefix when libc is glibc or unknown', () => {
|
|
expect(exePlatformPkgDirName('linux', 'x64', 'glibc')).toBe('linux-x64')
|
|
expect(exePlatformPkgDirName('linux', 'arm64', null)).toBe('linux-arm64')
|
|
})
|
|
|
|
test('libc is irrelevant on non-linux platforms', () => {
|
|
expect(exePlatformPkgDirName('darwin', 'arm64', 'musl')).toBe('macos-arm64')
|
|
expect(exePlatformPkgDirName('darwin', 'x64', null)).toBe('macos-x64')
|
|
expect(exePlatformPkgDirName('win32', 'x64', 'musl')).toBe('win-x64')
|
|
})
|
|
|
|
test('normalizes ia32 to x86 on win32 only', () => {
|
|
expect(exePlatformPkgDirName('win32', 'ia32', null)).toBe('win-x86')
|
|
expect(exePlatformPkgDirName('linux', 'ia32', null)).toBe('linux-ia32')
|
|
})
|
|
})
|
|
|
|
describe('exePlatformPkgDirNameNext', () => {
|
|
test('appends -musl for linux + musl libc family', () => {
|
|
expect(exePlatformPkgDirNameNext('linux', 'x64', 'musl')).toBe('exe.linux-x64-musl')
|
|
expect(exePlatformPkgDirNameNext('linux', 'arm64', 'musl')).toBe('exe.linux-arm64-musl')
|
|
})
|
|
|
|
test('does not append -musl when libc is glibc or unknown', () => {
|
|
expect(exePlatformPkgDirNameNext('linux', 'x64', 'glibc')).toBe('exe.linux-x64')
|
|
expect(exePlatformPkgDirNameNext('linux', 'arm64', null)).toBe('exe.linux-arm64')
|
|
})
|
|
|
|
test('libc is irrelevant on non-linux platforms', () => {
|
|
expect(exePlatformPkgDirNameNext('darwin', 'arm64', 'musl')).toBe('exe.darwin-arm64')
|
|
expect(exePlatformPkgDirNameNext('darwin', 'x64', null)).toBe('exe.darwin-x64')
|
|
expect(exePlatformPkgDirNameNext('win32', 'x64', 'musl')).toBe('exe.win32-x64')
|
|
})
|
|
|
|
test('normalizes ia32 to x86 on win32 only', () => {
|
|
expect(exePlatformPkgDirNameNext('win32', 'ia32', null)).toBe('exe.win32-x86')
|
|
expect(exePlatformPkgDirNameNext('linux', 'ia32', null)).toBe('exe.linux-ia32')
|
|
})
|
|
})
|