Files
pnpm/pnpm11/patching/commands/test/patchRemove.test.ts
Zoltan Kochan fc2f33912e refactor: move the TypeScript pnpm CLI into a pnpm11/ directory (#12537)
The TypeScript pnpm CLI freezes at v11; pnpm 12 will be the Rust pacquet
port. To make that split legible, all TypeScript source, test, and build
directories move under a new top-level pnpm11/ directory. The name states
the version boundary rather than implying a behavioral fork, since the two
stacks are meant to behave identically.

Scope is source-only: the shared workspace root stays at the repo root.
pnpm-workspace.yaml, package.json, pnpm-lock.yaml, .pnpmfile.cjs,
.meta-updater, __patches__, .changeset, .husky, and the lint/spell configs
remain in place, so one pnpm workspace and one Cargo workspace still span
all three products. pnpr/client and pacquet/tasks/registry-mock stay as
cross-product workspace members.

Rewiring the move required:
- pnpm-workspace.yaml globs prefixed with pnpm11/
- root package.json script paths, eslint.config.mjs, tsconfig.lint.json,
  .gitignore, and CODEOWNERS updated
- .meta-updater/src/index.ts literals repointed (pnpm11/pnpm/package.json,
  pnpm11/__utils__, pnpm11/__typings__, and the main package directory)
- regenerated every moved package's repository/homepage URL via meta-updater
- pnpm11/pnpm/bundle-deps.ts and __utils__/scripts/src/typecheck-only.ts
  climb one more level to reach the repo root

.meta-updater stays at the repo root because @pnpm/meta-updater resolves
its config at <cwd>/.meta-updater/main.mjs.

TS CI (.github/workflows/ci.yml) now only runs when pnpm11/-relevant paths
change, via a dorny/paths-filter changes job plus a TS CI / Success
aggregate gate; branch protection should require only that gate.
2026-06-20 14:36:25 +02:00

168 lines
6.4 KiB
TypeScript

import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, expect, jest, test } from '@jest/globals'
import type { PatchRemoveCommandOptions } from '../src/patchRemove.js'
jest.unstable_mockModule('@pnpm/installing.commands', () => ({
install: {
handler: jest.fn(),
},
}))
jest.unstable_mockModule('../src/updatePatchedDependencies.js', () => ({
updatePatchedDependencies: jest.fn(),
}))
const { install } = await import('@pnpm/installing.commands')
const patchRemove = await import('../src/patchRemove.js')
const { updatePatchedDependencies } = await import('../src/updatePatchedDependencies.js')
const installHandler = jest.mocked(install.handler)
const updatePatchedDependenciesMock = jest.mocked(updatePatchedDependencies)
const testOnNonWindows = process.platform === 'win32' ? test.skip : test
let tempRoot: string
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-patch-remove-'))
installHandler.mockResolvedValue(undefined)
updatePatchedDependenciesMock.mockResolvedValue(undefined)
})
afterEach(() => {
installHandler.mockReset()
updatePatchedDependenciesMock.mockReset()
fs.rmSync(tempRoot, { force: true, recursive: true })
})
test('patch-remove rejects traversal outside the patches directory before deleting any patch', async () => {
const projectDir = path.join(tempRoot, 'project')
const outsideFile = path.join(tempRoot, 'outside.patch')
const goodPatch = path.join(projectDir, 'patches/good.patch')
fs.mkdirSync(path.dirname(goodPatch), { recursive: true })
fs.writeFileSync(goodPatch, 'good patch', 'utf8')
fs.writeFileSync(outsideFile, 'outside patch', 'utf8')
await expect(patchRemove.handler(createOptions(projectDir, {
good: 'patches/good.patch',
bad: '../outside.patch',
}), ['good', 'bad'])).rejects.toMatchObject({
code: 'ERR_PNPM_PATCH_FILE_OUTSIDE_PATCHES_DIR',
})
expect(fs.existsSync(goodPatch)).toBe(true)
expect(fs.existsSync(outsideFile)).toBe(true)
expect(updatePatchedDependenciesMock).not.toHaveBeenCalled()
expect(installHandler).not.toHaveBeenCalled()
})
test('patch-remove rejects directory entries before deleting any patch', async () => {
const projectDir = path.join(tempRoot, 'project')
const goodPatch = path.join(projectDir, 'patches/good.patch')
const patchDir = path.join(projectDir, 'patches/not-a-file.patch')
fs.mkdirSync(patchDir, { recursive: true })
fs.writeFileSync(goodPatch, 'good patch', 'utf8')
await expect(patchRemove.handler(createOptions(projectDir, {
good: 'patches/good.patch',
bad: 'patches/not-a-file.patch',
}), ['good', 'bad'])).rejects.toMatchObject({
code: 'ERR_PNPM_PATCH_FILE_IS_DIRECTORY',
})
expect(fs.existsSync(goodPatch)).toBe(true)
expect(updatePatchedDependenciesMock).not.toHaveBeenCalled()
expect(installHandler).not.toHaveBeenCalled()
})
testOnNonWindows('patch-remove rejects a nested parent symlink outside the patches directory before unlinking a dangling target', async () => {
const projectDir = path.join(tempRoot, 'project')
const patchesDir = path.join(projectDir, 'patches')
const outsideDir = path.join(tempRoot, 'outside')
const outsideDanglingLink = path.join(outsideDir, 'dangling.patch')
fs.mkdirSync(patchesDir, { recursive: true })
fs.mkdirSync(outsideDir, { recursive: true })
fs.symlinkSync(outsideDir, path.join(patchesDir, 'linked-dir'), 'dir')
fs.symlinkSync(path.join(tempRoot, 'missing-target.patch'), outsideDanglingLink)
await expect(patchRemove.handler(createOptions(projectDir, {
bad: 'patches/linked-dir/dangling.patch',
}), ['bad'])).rejects.toMatchObject({
code: 'ERR_PNPM_PATCH_FILE_OUTSIDE_PATCHES_DIR',
})
expect(fs.lstatSync(outsideDanglingLink).isSymbolicLink()).toBe(true)
expect(updatePatchedDependenciesMock).not.toHaveBeenCalled()
expect(installHandler).not.toHaveBeenCalled()
})
testOnNonWindows('patch-remove unlinks a final symlink inside the patches directory without touching its target', async () => {
const projectDir = path.join(tempRoot, 'project')
const patchesDir = path.join(projectDir, 'patches')
const outsideTarget = path.join(tempRoot, 'outside-target.patch')
const patchLink = path.join(patchesDir, 'linked.patch')
fs.mkdirSync(patchesDir, { recursive: true })
fs.writeFileSync(outsideTarget, 'outside target', 'utf8')
fs.symlinkSync(outsideTarget, patchLink)
await patchRemove.handler(createOptions(projectDir, {
pkg: 'patches/linked.patch',
}), ['pkg'])
expect(fs.existsSync(patchLink)).toBe(false)
expect(fs.readFileSync(outsideTarget, 'utf8')).toBe('outside target')
expect(updatePatchedDependenciesMock).toHaveBeenCalledWith({}, expect.any(Object))
expect(installHandler).toHaveBeenCalledWith(expect.objectContaining({
patchedDependencies: {},
}))
})
test('patch-remove allows a symlinked patches directory that resolves inside the project', async () => {
const projectDir = path.join(tempRoot, 'project')
const realPatchesDir = path.join(projectDir, 'real-patches')
const patchFile = path.join(realPatchesDir, 'pkg.patch')
fs.mkdirSync(realPatchesDir, { recursive: true })
fs.symlinkSync(realPatchesDir, path.join(projectDir, 'patches'), process.platform === 'win32' ? 'junction' : 'dir')
fs.writeFileSync(patchFile, 'patch', 'utf8')
await patchRemove.handler(createOptions(projectDir, {
pkg: 'patches/pkg.patch',
}), ['pkg'])
expect(fs.existsSync(patchFile)).toBe(false)
expect(updatePatchedDependenciesMock).toHaveBeenCalledWith({}, expect.any(Object))
expect(installHandler).toHaveBeenCalledWith(expect.objectContaining({
patchedDependencies: {},
}))
})
test('patch-remove keeps missing patch files as no-ops', async () => {
const projectDir = path.join(tempRoot, 'project')
fs.mkdirSync(path.join(projectDir, 'patches'), { recursive: true })
await patchRemove.handler(createOptions(projectDir, {
pkg: 'patches/missing.patch',
}), ['pkg'])
expect(updatePatchedDependenciesMock).toHaveBeenCalledWith({}, expect.any(Object))
expect(installHandler).toHaveBeenCalledWith(expect.objectContaining({
patchedDependencies: {},
}))
})
function createOptions (
projectDir: string,
patchedDependencies: Record<string, string>
): PatchRemoveCommandOptions {
return {
dir: projectDir,
lockfileDir: projectDir,
patchedDependencies,
rootProjectManifest: {},
rootProjectManifestDir: projectDir,
} as PatchRemoveCommandOptions
}