Files
pnpm/pnpm11/installing/commands/test/update/interactive.ts
Zoltan Kochan 430d892741 feat(pnpr)!: serve every ecosystem under its own prefix (#17087)
pnpr served npm at the server root when npm was the only configured
ecosystem and moved it under /npm/ as soon as a second ecosystem was
added. Adding an ecosystem therefore broke every .npmrc registry URL,
every tarball URL already written into a lockfile, named-registry URLs,
and OIDC workload publish paths, and /npm meant the package `npm` on one
server and the ecosystem prefix on another. This settles section A1 of
pnpm/tasks#131: every ecosystem is always prefixed.

- Ecosystem::base_path is now independent of the configuration. The
  per-configuration Registries::base_path and is_only_ecosystem are gone,
  and the routes, the resolver's tarball URLs, OIDC workload paths and
  pnpr's own-origin route classification all use it.
- The default image registry still answers at /v2/ on the host root; an
  image client cannot be pointed at a prefix. The registry directory keeps
  its `prefixed` and `namedPrefixed` flags for the web UI.
- The read-only token allowlist matched the npm audit endpoints at the
  root only, so read-only tokens were already refused for audits on
  multi-ecosystem servers. It now matches them under the npm prefix.
- /metrics no longer switches between Prometheus metrics and the npm
  package `metrics` on the Accept header; the package is /npm/metrics.
- The pnpm v11 and v12 test harnesses, the registry-mock crate and the
  integrated benchmark address the fixture registry at /npm/ and keep the
  server origin for the pnpr protocol endpoints. The benchmark asks each
  revision mock's registry directory whether npm is prefixed, so it can
  still compare against a pnpr revision from before this change.
2026-10-18 16:55:55 +02:00

591 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { writeFile } from 'node:fs/promises'
import path from 'node:path'
import { expect, jest, test } from '@jest/globals'
import type { LockfileObject } from '@pnpm/lockfile.types'
import { prepare, preparePackages } from '@pnpm/prepare'
import { addDistTag, REGISTRY_MOCK_PORT } from '@pnpm/testing.registry-mock'
import { filterProjectsBySelectorObjectsFromDir } from '@pnpm/workspace.projects-filter'
import chalk from 'chalk'
import { readYamlFileSync } from 'read-yaml-file'
jest.unstable_mockModule('@inquirer/prompts', () => {
class Separator {
separator: string
readonly type = 'separator' as const
constructor (separator: string) {
this.separator = separator
}
}
return {
Separator,
checkbox: jest.fn(),
confirm: jest.fn(),
input: jest.fn(),
password: jest.fn(),
select: jest.fn(),
}
})
const { checkbox, Separator } = await import('@inquirer/prompts')
const { add, install, update } = await import('@pnpm/installing.commands')
const mockCheckbox = jest.mocked(checkbox)
const REGISTRY_URL = `http://localhost:${REGISTRY_MOCK_PORT}/npm/`
const DEFAULT_OPTIONS = {
argv: {
original: [],
},
bail: false,
bin: 'node_modules/.bin',
excludeLinksFromLockfile: false,
extraEnv: {},
cliOptions: {},
deployAllFiles: false,
include: {
dependencies: true,
devDependencies: true,
optionalDependencies: true,
},
lock: true,
pnpmfile: ['.pnpmfile.cjs'],
pnpmHomeDir: '',
preferWorkspacePackages: true,
configByUri: {},
registriesByScope: {
default: REGISTRY_URL,
},
rootProjectManifestDir: '',
sort: true,
userConfig: {},
workspaceConcurrency: 1,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
}
test('global interactive update handles an empty global directory', async () => {
const globalDir = path.resolve('empty-global')
await expect(update.handler({
...DEFAULT_OPTIONS,
bin: path.resolve('bin'),
dir: process.cwd(),
global: true,
globalPkgDir: globalDir,
interactive: true,
} as any)).resolves.toBe('No global packages found') // eslint-disable-line @typescript-eslint/no-explicit-any -- the test passes only the options these handlers read, not a full Config
expect(mockCheckbox).not.toHaveBeenCalled()
})
test('global interactive update reads current versions from the global virtual store', async () => {
prepare()
const globalDir = path.resolve('global')
const storeDir = path.resolve('pnpm-store')
const options = {
...DEFAULT_OPTIONS,
allowBuilds: {},
bin: path.resolve('bin'),
cacheDir: path.resolve('cache'),
dir: process.cwd(),
enableGlobalVirtualStore: true,
global: true,
globalPkgDir: globalDir,
pnpmHomeDir: '',
storeDir,
}
await add.handler(options as any, ['@pnpm.e2e/multi-version-a@1.0.0']) // eslint-disable-line @typescript-eslint/no-explicit-any -- the test passes only the options these handlers read, not a full Config
await addDistTag({ package: '@pnpm.e2e/multi-version-a', version: '2.1.0', distTag: 'latest' })
mockCheckbox.mockResolvedValue([])
await update.handler({
...options,
interactive: true,
latest: true,
} as any) // eslint-disable-line @typescript-eslint/no-explicit-any -- the test passes only the options these handlers read, not a full Config
expect(mockCheckbox).toHaveBeenCalledWith(expect.objectContaining({
choices: [{
name: '@pnpm.e2e/multi-version-a 1.0.0 → 2.1.0',
value: expect.any(String),
}],
}))
})
test('global interactive update offers a matching group even when the requested package is current', async () => {
prepare()
const options = globalOptions()
await addDistTag({ package: '@pnpm.e2e/multi-version-b', version: '3.1.0', distTag: 'latest' })
// One comma-joined param installs both packages into a single group.
await add.handler(options as any, ['@pnpm.e2e/multi-version-a@1.0.0,@pnpm.e2e/multi-version-b@3.1.0']) // eslint-disable-line @typescript-eslint/no-explicit-any -- the test passes only the options these handlers read, not a full Config
await addDistTag({ package: '@pnpm.e2e/multi-version-a', version: '2.1.0', distTag: 'latest' })
mockCheckbox.mockClear()
mockCheckbox.mockResolvedValue([])
await update.handler({
...options,
interactive: true,
latest: true,
} as any, ['@pnpm.e2e/multi-version-b']) // eslint-disable-line @typescript-eslint/no-explicit-any -- the test passes only the options these handlers read, not a full Config
expect(mockCheckbox).toHaveBeenCalledWith(expect.objectContaining({
choices: [{
name: '@pnpm.e2e/multi-version-a 1.0.0 → 2.1.0',
value: expect.any(String),
}],
}))
})
test('global interactive update reports when no group has the requested package', async () => {
prepare()
const options = globalOptions()
await add.handler(options as any, ['@pnpm.e2e/multi-version-a@1.0.0']) // eslint-disable-line @typescript-eslint/no-explicit-any -- the test passes only the options these handlers read, not a full Config
mockCheckbox.mockClear()
await expect(update.handler({
...options,
interactive: true,
latest: true,
} as any, ['@pnpm.e2e/multi-version-c'])).resolves.toBe('No matching global packages found') // eslint-disable-line @typescript-eslint/no-explicit-any -- the test passes only the options these handlers read, not a full Config
expect(mockCheckbox).not.toHaveBeenCalled()
})
// pacquet reads only the wanted lockfile, which its global install writes
// whatever `lockfile` is set to; here the current lockfile carries the
// versions instead. Both stacks must keep reporting them.
test('global interactive update reads current versions when the lockfile setting is off', async () => {
prepare()
const options = { ...globalOptions(), useLockfile: false }
await add.handler(options as any, ['@pnpm.e2e/multi-version-a@1.0.0']) // eslint-disable-line @typescript-eslint/no-explicit-any -- the test passes only the options these handlers read, not a full Config
await addDistTag({ package: '@pnpm.e2e/multi-version-a', version: '2.1.0', distTag: 'latest' })
mockCheckbox.mockClear()
mockCheckbox.mockResolvedValue([])
await update.handler({
...options,
interactive: true,
latest: true,
} as any) // eslint-disable-line @typescript-eslint/no-explicit-any -- the test passes only the options these handlers read, not a full Config
expect(mockCheckbox).toHaveBeenCalledWith(expect.objectContaining({
choices: [{
name: '@pnpm.e2e/multi-version-a 1.0.0 → 2.1.0',
value: expect.any(String),
}],
}))
})
test('global interactive update does not match a group on an inherited Object key', async () => {
prepare()
const options = globalOptions()
await add.handler(options as any, ['@pnpm.e2e/multi-version-a@1.0.0']) // eslint-disable-line @typescript-eslint/no-explicit-any -- the test passes only the options these handlers read, not a full Config
mockCheckbox.mockClear()
await expect(update.handler({
...options,
interactive: true,
latest: true,
} as any, ['constructor'])).resolves.toBe('No matching global packages found') // eslint-disable-line @typescript-eslint/no-explicit-any -- the test passes only the options these handlers read, not a full Config
expect(mockCheckbox).not.toHaveBeenCalled()
})
test('global interactive update leaves without an error when the prompt is canceled', async () => {
prepare()
const options = globalOptions()
await add.handler(options as any, ['@pnpm.e2e/multi-version-a@1.0.0']) // eslint-disable-line @typescript-eslint/no-explicit-any -- the test passes only the options these handlers read, not a full Config
await addDistTag({ package: '@pnpm.e2e/multi-version-a', version: '2.1.0', distTag: 'latest' })
const canceled = new Error('User force closed the prompt')
canceled.name = 'ExitPromptError'
mockCheckbox.mockClear()
mockCheckbox.mockRejectedValue(canceled)
// `process.exit()` never returns in production, so the spy throws to stop
// the handler where the real call would.
const exited = new Error('process.exit')
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
throw exited
})
try {
await expect(update.handler({
...options,
interactive: true,
latest: true,
} as any)).rejects.toBe(exited) // eslint-disable-line @typescript-eslint/no-explicit-any -- the test passes only the options these handlers read, not a full Config
expect(exitSpy).toHaveBeenCalledWith(0)
} finally {
exitSpy.mockRestore()
mockCheckbox.mockReset()
}
})
function globalOptions (): Record<string, unknown> {
return {
...DEFAULT_OPTIONS,
allowBuilds: {},
bin: path.resolve('bin'),
cacheDir: path.resolve('cache'),
dir: process.cwd(),
global: true,
globalPkgDir: path.resolve('global'),
pnpmHomeDir: '',
storeDir: path.resolve('pnpm-store'),
}
}
test('interactively update', async () => {
const project = prepare({
dependencies: {
// has 1.0.0 and 1.0.1 that satisfy this range
'@pnpm.e2e/multi-version-a': '^1.0.0',
// only 2.0.0 satisfies this range
'@pnpm.e2e/multi-version-b': '^2.0.0',
// has several versions that satisfy ^3.0.0
'@pnpm.e2e/multi-version-c': '^3.0.0',
},
})
const storeDir = path.resolve('pnpm-store')
await Promise.all([
addDistTag({ package: '@pnpm.e2e/multi-version-a', version: '2.1.0', distTag: 'latest' }),
addDistTag({ package: '@pnpm.e2e/multi-version-c', version: '4.0.0', distTag: 'latest' }),
])
await add.handler(
{
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
linkWorkspacePackages: true,
save: false,
storeDir,
},
['@pnpm.e2e/multi-version-a@1.0.0', '@pnpm.e2e/multi-version-b@2.0.0', '@pnpm.e2e/multi-version-c@3.0.0']
)
mockCheckbox.mockResolvedValue(['@pnpm.e2e/multi-version-a'])
mockCheckbox.mockClear()
// Update to compatible versions
await update.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
interactive: true,
linkWorkspacePackages: true,
storeDir,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the prompt theme types highlight as a partial style object, but the test calls it
const callArgs = mockCheckbox.mock.calls[0][0] as any
const flatChoices = callArgs.choices
expect(flatChoices).toStrictEqual([
new Separator(chalk.bold('── dependencies ──')),
new Separator(' Package Current Target URL '),
{
name: `@pnpm.e2e/multi-version-a 1.0.0 ❯ 1.0.${chalk.greenBright.bold('1')} `,
value: '@pnpm.e2e/multi-version-a',
short: '@pnpm.e2e/multi-version-a',
},
{
name: `@pnpm.e2e/multi-version-c 3.0.0 ❯ 3.${chalk.yellowBright.bold('1.10')} `,
value: '@pnpm.e2e/multi-version-c',
short: '@pnpm.e2e/multi-version-c',
},
])
expect(mockCheckbox).toHaveBeenCalledWith(
expect.objectContaining({
message:
'Choose which dependencies to update ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)\n\nEnter to start updating. Ctrl-c to cancel.`,
pageSize: process.stdout.rows == null ? 7 : Math.max(7, process.stdout.rows - 6),
})
)
expect(callArgs.theme.style.highlight('focused row')).toBe('focused row')
{
const lockfile = project.readLockfile()
expect(lockfile.packages['@pnpm.e2e/multi-version-c@3.0.0']).toBeTruthy()
expect(lockfile.packages['@pnpm.e2e/multi-version-a@1.0.1']).toBeTruthy()
expect(lockfile.packages['@pnpm.e2e/multi-version-b@2.0.0']).toBeTruthy()
}
// Update to latest versions
mockCheckbox.mockClear()
mockCheckbox.mockResolvedValue(['@pnpm.e2e/multi-version-a'])
await update.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
interactive: true,
latest: true,
linkWorkspacePackages: true,
storeDir,
})
const callArgs2 = mockCheckbox.mock.calls[0][0]
const flatChoices2 = callArgs2.choices
expect(flatChoices2).toStrictEqual([
new Separator(chalk.bold('── dependencies ──')),
new Separator(' Package Current Target URL '),
{
name: `@pnpm.e2e/multi-version-a 1.0.1 ❯ ${chalk.redBright.bold('2.1.0')} `,
value: '@pnpm.e2e/multi-version-a',
short: '@pnpm.e2e/multi-version-a',
},
{
name: `@pnpm.e2e/multi-version-b 2.0.0 ❯ ${chalk.redBright.bold('3.1.0')} `,
value: '@pnpm.e2e/multi-version-b',
short: '@pnpm.e2e/multi-version-b',
},
{
name: `@pnpm.e2e/multi-version-c 3.0.0 ❯ ${chalk.redBright.bold('4.0.0')} `,
value: '@pnpm.e2e/multi-version-c',
short: '@pnpm.e2e/multi-version-c',
},
])
expect(mockCheckbox).toHaveBeenCalledWith(
expect.objectContaining({
message:
'Choose which dependencies to update ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)\n\nEnter to start updating. Ctrl-c to cancel.`,
})
)
{
const lockfile = project.readLockfile()
expect(lockfile.packages['@pnpm.e2e/multi-version-c@3.0.0']).toBeTruthy()
expect(lockfile.packages['@pnpm.e2e/multi-version-a@2.1.0']).toBeTruthy()
expect(lockfile.packages['@pnpm.e2e/multi-version-b@2.0.0']).toBeTruthy()
}
})
test('interactive update of dev dependencies only', async () => {
preparePackages([
{
name: 'project1',
dependencies: {
'is-negative': '^1.0.1',
},
},
{
name: 'project2',
devDependencies: {
'is-negative': '^1.0.0',
},
},
])
const storeDir = path.resolve('store')
mockCheckbox.mockResolvedValue(['is-negative'])
const { allProjects, selectedProjectsGraph } = await filterProjectsBySelectorObjectsFromDir(
process.cwd(),
[]
)
await install.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
allProjects,
dir: process.cwd(),
linkWorkspacePackages: true,
lockfileDir: process.cwd(),
recursive: true,
selectedProjectsGraph,
storeDir,
workspaceDir: process.cwd(),
})
await update.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
allProjects,
cliOptions: {
dev: true,
optional: false,
production: false,
},
dir: process.cwd(),
interactive: true,
latest: true,
linkWorkspacePackages: true,
lockfileDir: process.cwd(),
recursive: true,
selectedProjectsGraph,
storeDir,
workspaceDir: process.cwd(),
})
const lockfile = readYamlFileSync<LockfileObject>('pnpm-lock.yaml')
expect(Object.keys(lockfile.packages ?? {})).toStrictEqual([
'is-negative@1.0.1',
'is-negative@2.1.0',
])
})
test('interactively update should ignore dependencies from the ignoreDependencies field', async () => {
const project = prepare({
dependencies: {
// has 1.0.0 and 1.0.1 that satisfy this range
'is-negative': '^1.0.0',
// only 2.0.0 satisfies this range
'is-positive': '^2.0.0',
// has many versions that satisfy ^3.0.0
micromatch: '^3.0.0',
},
})
const storeDir = path.resolve('pnpm-store')
await add.handler(
{
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
linkWorkspacePackages: true,
save: false,
storeDir,
},
['is-negative@1.0.0', 'is-positive@2.0.0', 'micromatch@3.0.0']
)
mockCheckbox.mockResolvedValue(['micromatch'])
mockCheckbox.mockClear()
await update.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
interactive: true,
linkWorkspacePackages: true,
storeDir,
updateConfig: {
ignoreDependencies: ['is-negative'],
},
})
const callArgs3 = mockCheckbox.mock.calls[0][0]
const flatChoices3 = callArgs3.choices
expect(flatChoices3).toStrictEqual(
[
new Separator(chalk.bold('── dependencies ──')),
new Separator(' Package Current Target URL '),
{
name: `micromatch 3.0.0 ❯ 3.${chalk.yellowBright.bold('1.10')} `,
value: 'micromatch',
short: 'micromatch',
},
]
)
expect(mockCheckbox).toHaveBeenCalledWith(
expect.objectContaining({
message:
'Choose which dependencies to update ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)\n\nEnter to start updating. Ctrl-c to cancel.`,
})
)
{
const lockfile = project.readLockfile()
expect(lockfile.packages['micromatch@3.1.10']).toBeTruthy()
expect(lockfile.packages['is-negative@1.0.0']).toBeTruthy()
expect(lockfile.packages['is-positive@2.0.0']).toBeTruthy()
}
})
test('interactive recursive workspace update allows updating external dependencies', async () => {
preparePackages([
{
name: 'project1',
version: '1.0.0',
dependencies: {
'is-positive': '1.0.0',
},
},
{
name: 'project2',
version: '1.0.0',
dependencies: {
project1: 'workspace:*',
'is-negative': '1.0.0',
},
},
])
const storeDir = path.resolve('store')
const { allProjects, selectedProjectsGraph } = await filterProjectsBySelectorObjectsFromDir(
process.cwd(),
[]
)
await install.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
allProjects,
dir: process.cwd(),
linkWorkspacePackages: true,
lockfileDir: process.cwd(),
recursive: true,
selectedProjectsGraph,
storeDir,
workspaceDir: process.cwd(),
})
await writeFile(
path.resolve('project2/package.json'),
JSON.stringify({
name: 'project2',
version: '1.0.0',
dependencies: {
project1: 'workspace:*',
'is-negative': '^1.0.0',
},
}, null, 2)
)
mockCheckbox.mockResolvedValue(['is-negative'])
const updatedProjects = await filterProjectsBySelectorObjectsFromDir(
process.cwd(),
[]
)
await update.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
allProjects: updatedProjects.allProjects,
dir: process.cwd(),
interactive: true,
linkWorkspacePackages: true,
lockfileDir: process.cwd(),
recursive: true,
selectedProjectsGraph: updatedProjects.selectedProjectsGraph,
storeDir,
workspace: true,
workspaceDir: process.cwd(),
})
const lockfile = readYamlFileSync<LockfileObject>('pnpm-lock.yaml')
expect(lockfile.packages).toHaveProperty(['is-negative@1.0.1'])
})