Files
pnpm/installing/commands/test/update/interactive.ts
Zoltan Kochan ae2175829a feat(registry-access): extract dist-tag + adduser helpers, dogfood from tests (#11926)
* feat(registry-access): extract setDistTag and dogfood from tests

Add `@pnpm/registry-access.commands#setDistTag` — the low-level PUT to
`/-/package/:pkg/dist-tags/:tag`. The CLI `dist-tag add` handler now
calls it instead of issuing the fetch inline.

Tests in this monorepo now use a thin new package
`@pnpm/testing.registry-mock` (REGISTRY_MOCK_PORT + REGISTRY_MOCK_CREDENTIALS
baked in) that delegates to `setDistTag`, replacing `addDistTag` from
`@pnpm/registry-mock`. That dropped helper relied on
`anonymous-npm-registry-client` and a verdaccio-era
fetch-then-DELETE-then-PUT dance that is no longer needed against
pnpm-registry.

39 test files swapped from `@pnpm/registry-mock` to
`@pnpm/testing.registry-mock`.

* fix: move setDistTag to its own package to break tsconfig project-reference cycle

testing/registry-mock → registry-access.commands → releasing/commands
→ installing/commands → installing/deps-installer → testing/registry-mock.

Extract setDistTag into @pnpm/registry-access.set-dist-tag (only depends
on @pnpm/error, @pnpm/network.fetch, @pnpm/npm-package-arg). Both
@pnpm/registry-access.commands and @pnpm/testing.registry-mock import
from it. Cycle gone.

* feat(registry-access): extract addUser helper, dogfood from login + tests

Add @pnpm/registry-access.add-user — a small helper that PUTs to
/-/user/org.couchdb.user:<name> and returns { token }. The CLI's
classicLogin (pnpm login fallback path) now calls it, and tests
use it via @pnpm/testing.registry-mock instead of the legacy
addUser from @pnpm/registry-mock.

Swapped 3 call sites: globalSetup.js, installing/deps-installer's
auth.ts, and pnpm/test/dlx.ts. AddUserHttpError exposes status +
text + parsed-json-if-applicable + headers so the CLI can still
do its OTP detection. One webauth-OTP login test mock had to be
adjusted to provide its body via `text` (JSON-stringified) rather
than `json` only, since the helper consumes the body via `text()`.

* refactor: consolidate set-dist-tag + add-user helpers into one @pnpm/registry-access.client package

One shared package is better than splitting per endpoint. Future endpoints
(publish, deprecate, etc.) can land here without another wrapper.

No behavioral change — same setDistTag and addUser exports as before,
just under one roof. Callers updated: registry-access.commands,
auth.commands, testing.registry-mock.

* fix(registry-access): sort imports
2026-05-25 14:01:00 +02:00

367 lines
9.9 KiB
TypeScript
Raw 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 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 { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
import { addDistTag } 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('enquirer', () => ({ default: { prompt: jest.fn() } }))
const { default: enquirer } = await import('enquirer')
const { add, install, update } = await import('@pnpm/installing.commands')
const prompt = jest.mocked(enquirer.prompt)
const REGISTRY_URL = `http://localhost:${REGISTRY_MOCK_PORT}`
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: {},
registries: {
default: REGISTRY_URL,
},
rootProjectManifestDir: '',
sort: true,
userConfig: {},
workspaceConcurrency: 1,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
}
test('interactively update', 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')
const headerChoice = {
name: 'Package Current Target URL ',
disabled: true,
hint: '',
value: '',
}
await Promise.all([
addDistTag({ package: 'is-negative', version: '2.1.0', distTag: 'latest' }),
addDistTag({ package: 'micromatch', version: '4.0.0', distTag: 'latest' }),
])
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']
)
prompt.mockResolvedValue({
updateDependencies: [
{
value: 'is-negative',
name: `is-negative 1.0.0 1.0.${chalk.greenBright.bold('1')} https://pnpm.io/ `,
},
],
})
prompt.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
expect((prompt.mock.calls[0][0] as any).choices).toStrictEqual([
{
choices: [
headerChoice,
{
message: `is-negative 1.0.0 1.0.${chalk.greenBright.bold('1')} `,
value: 'is-negative',
name: 'is-negative',
},
{
message: `micromatch 3.0.0 3.${chalk.yellowBright.bold('1.10')} `,
value: 'micromatch',
name: 'micromatch',
},
],
name: '[dependencies]',
message: 'dependencies',
},
])
expect(prompt).toHaveBeenCalledWith(
expect.objectContaining({
footer: '\nEnter to start updating. Ctrl-c to cancel.',
message:
'Choose which packages to update ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)`,
name: 'updateDependencies',
type: 'multiselect',
})
)
{
const lockfile = project.readLockfile()
expect(lockfile.packages['micromatch@3.0.0']).toBeTruthy()
expect(lockfile.packages['is-negative@1.0.1']).toBeTruthy()
expect(lockfile.packages['is-positive@2.0.0']).toBeTruthy()
}
// Update to latest versions
prompt.mockClear()
await update.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
interactive: true,
latest: true,
linkWorkspacePackages: true,
storeDir,
})
// eslint-disable-next-line
expect((prompt.mock.calls[0][0] as any).choices).toStrictEqual([
{
choices: [
headerChoice,
{
message: `is-negative 1.0.1 ${chalk.redBright.bold('2.1.0')} `,
value: 'is-negative',
name: 'is-negative',
},
{
message: `is-positive 2.0.0 ${chalk.redBright.bold('3.1.0')} `,
value: 'is-positive',
name: 'is-positive',
},
{
message: `micromatch 3.0.0 ${chalk.redBright.bold('4.0.0')} `,
value: 'micromatch',
name: 'micromatch',
},
],
name: '[dependencies]',
message: 'dependencies',
},
])
expect(prompt).toHaveBeenCalledWith(
expect.objectContaining({
footer: '\nEnter to start updating. Ctrl-c to cancel.',
message:
'Choose which packages to update ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)`,
name: 'updateDependencies',
type: 'multiselect',
})
)
{
const lockfile = project.readLockfile()
expect(lockfile.packages['micromatch@3.0.0']).toBeTruthy()
expect(lockfile.packages['is-negative@2.1.0']).toBeTruthy()
expect(lockfile.packages['is-positive@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')
prompt.mockResolvedValue({
updateDependencies: [
{
value: 'is-negative',
name: `is-negative 1.0.0 1.0.${chalk.greenBright.bold('1')} https://pnpm.io/ `,
},
],
})
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']
)
prompt.mockResolvedValue({
updateDependencies: [{ value: 'micromatch', name: 'anything' }],
})
prompt.mockClear()
await update.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
interactive: true,
linkWorkspacePackages: true,
storeDir,
updateConfig: {
ignoreDependencies: ['is-negative'],
},
})
// eslint-disable-next-line
expect((prompt.mock.calls[0][0] as any).choices as any).toStrictEqual(
[
{
choices: [
{
disabled: true,
hint: '',
name: 'Package Current Target URL ',
value: '',
},
{
message: `micromatch 3.0.0 3.${chalk.yellowBright.bold('1.10')} `,
value: 'micromatch',
name: 'micromatch',
},
],
name: '[dependencies]',
message: 'dependencies',
},
]
)
expect(prompt).toHaveBeenCalledWith(
expect.objectContaining({
footer: '\nEnter to start updating. Ctrl-c to cancel.',
message:
'Choose which packages to update ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)`,
name: 'updateDependencies',
type: 'multiselect',
})
)
{
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()
}
})