test: type the install option helpers so a stale key cannot pass (#13951)

A test's options object is the entire input to the subject under test, so a
key nothing reads does not fail: it quietly runs the default instead of the
case the test is named for. Both install helpers accepted anything — the
headless one took `opts?: any`, and the install one takes `opts?: T & {...}`,
where an unknown key is absorbed into the inferred `T`. That is how the
`registries` -> `registriesByScope` rename left seven suites pointed at the
wrong registry with a green type-check, and only CI to say so.

The headless helper now takes a `Partial<HeadlessOptions>`. Everything that
surfaced was real:

- Seven fields `HeadlessOptions` requires and reads — `configByUri`,
  `globalVirtualStoreDir`, `pruneStore`, `sideEffectsCacheRead`/`Write`,
  `userAgent`, `virtualStoreDirMaxLength` — were never set, so every headless
  test ran with them `undefined`. They now carry the values the install
  behaved as.
- `verifyStoreIntegrity` is a package-store option, not a headless one. The
  three tests that "disable" it were setting a key nobody read; it is now
  forwarded to the store.
- `hoistPattern: '*'` and `publicHoistPattern: '*'` are `string[]`. A
  one-character string iterates like a one-element array, which is the only
  reason they worked.
- `development`, `optional`, `production` predate `include`, which the same
  calls already set, and `sideEffectsCache` predates the read/write split.

Constraining the install helper's `T` is a bigger job — 162 errors, mostly
unrelated to options keys — so it keeps its generic and gains a narrow guard
instead: the three renamed keys are declared with a literal type that names
the replacement. It caught one more live case on the way in.
This commit is contained in:
Zoltan Kochan authored and GitHub committed 2026-08-17 02:19:30 +02:00
1 parent cf57dcbe1a
commit 719f62f102
6 files changed
+71 -24

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@pnpm/testing.temp-store": patch
---
`createTempStore`'s `storeOptions` are typed as a partial, which is how they are used: they are spread over the store's own defaults, so a caller that overrides one knob no longer has to restate the other five.
@@ -466,7 +466,7 @@ test('scoped module from different registry', async () => {
'@zkochan': `http://localhost:${REGISTRY_MOCK_PORT}`,
'@foo': `http://localhost:${REGISTRY_MOCK_PORT}`,
}
await addDependenciesToPackage({}, ['@zkochan/foo', '@foo/has-dep-from-same-scope', 'is-positive'], testDefaults({ registries }, { registries }))
await addDependenciesToPackage({}, ['@zkochan/foo', '@foo/has-dep-from-same-scope', 'is-positive'], testDefaults({ registriesByScope: registries }, { registriesByScope: registries }))
project.has('@zkochan/foo')
@@ -19,6 +19,21 @@ export function testDefaults<T> (
minimumReleaseAge?: number
minimumReleaseAgeStrict?: boolean
minimumReleaseAgeExclude?: string[]
/**
* Renamed to `registriesByScope`, and kept here so an options object that
* still carries the old key fails to compile: `T` is inferred from the
* argument, so an unknown key is otherwise absorbed into it and the test
* silently exercises the default registry instead.
*
* Typed as the replacement's name rather than `never` so the compiler
* prints the fix — `not assignable to type '… & "renamed: use
* registriesByScope"'`.
*/
registries?: 'renamed: use registriesByScope'
/** Renamed to `registriesByPrefix`. See `registries` above. */
namedRegistries?: 'renamed: use registriesByPrefix'
/** Renamed to `registryOptionsByUrl`. See `registries` above. */
registryOptions?: 'renamed: use registryOptionsByUrl'
},
resolveOpts?: any, // eslint-disable-line
fetchOpts?: any, // eslint-disable-line
@@ -301,15 +301,12 @@ test('installing only optional deps', async () => {
const prefix = f.prepare('simple')
await headlessInstall(await testDefaults({
development: false,
include: {
dependencies: false,
devDependencies: false,
optionalDependencies: true,
},
lockfileDir: prefix,
optional: true,
production: false,
}))
const project = assertProject(prefix)
@@ -536,7 +533,7 @@ test('installing using passed in lockfile files', async () => {
await headlessInstall(await testDefaults({
lockfileDir: prefix,
wantedLockfile,
wantedLockfile: wantedLockfile ?? undefined,
}))
const project = assertProject(prefix)
@@ -570,7 +567,7 @@ test('installing with hoistPattern=*', async () => {
const prefix = prepareFixtureWithIntegrity('simple-shamefully-flatten')
const reporter = jest.fn()
await headlessInstall(await testDefaults({ lockfileDir: prefix, reporter, hoistPattern: '*' }))
await headlessInstall(await testDefaults({ lockfileDir: prefix, reporter, hoistPattern: ['*'] }))
const project = assertProject(prefix)
expect(project.requireModule('is-positive')).toBeTruthy()
@@ -629,7 +626,7 @@ test('installing with publicHoistPattern=*', async () => {
const prefix = prepareFixtureWithIntegrity('simple-shamefully-flatten')
const reporter = jest.fn()
await headlessInstall(await testDefaults({ lockfileDir: prefix, reporter, publicHoistPattern: '*' }))
await headlessInstall(await testDefaults({ lockfileDir: prefix, reporter, publicHoistPattern: ['*'] }))
const project = assertProject(prefix)
expect(project.requireModule('is-positive')).toBeTruthy()
@@ -694,7 +691,7 @@ test('installing with publicHoistPattern=* in a project with external lockfile',
await headlessInstall(await testDefaults({
lockfileDir,
projects: [prefix],
publicHoistPattern: '*',
publicHoistPattern: ['*'],
}))
const project = assertProject(lockfileDir)
@@ -703,7 +700,7 @@ test('installing with publicHoistPattern=* in a project with external lockfile',
const ENGINE_DIR = `${process.platform}-${process.arch}-node-${process.version.split('.')[0]}`
test.each([['isolated'], ['hoisted']])('using side effects cache with nodeLinker=%s', async (nodeLinker) => {
test.each([['isolated'], ['hoisted']] as const)('using side effects cache with nodeLinker=%s', async (nodeLinker) => {
let prefix = prepareFixtureWithIntegrity('side-effects')
// Right now, hardlink does not work with side effects, so we specify copy as the packageImportMethod
@@ -764,7 +761,7 @@ test.skip('using side effects cache and hoistPattern=*', async () => {
// Right now, hardlink does not work with side effects, so we specify copy as the packageImportMethod
// We disable verifyStoreIntegrity because we are going to change the cache
const opts = await testDefaults({
hoistPattern: '*',
hoistPattern: ['*'],
lockfileDir,
sideEffectsCacheRead: true,
sideEffectsCacheWrite: true,
@@ -6,25 +6,44 @@ import { safeReadPackageJsonFromDir } from '@pnpm/pkg-manifest.reader'
import { getStorePath } from '@pnpm/store.path'
import { REGISTRY_MOCK_PORT } from '@pnpm/testing.registry-mock'
import { createTempStore } from '@pnpm/testing.temp-store'
import type { DepPath, ProjectRootDir } from '@pnpm/types'
import { temporaryDirectory } from 'tempy'
const registry = `http://localhost:${REGISTRY_MOCK_PORT}/`
/**
* The options a test may override, on top of what this helper fills in.
*
* Typed rather than `any` so a key the headless install does not read is a
* compile error here: an options object is the whole input to the subject
* under test, and a misspelled or renamed key in one silently exercises the
* default instead of the case the test is named for.
*/
export type TestHeadlessOptions = Partial<HeadlessOptions> & {
/** Project directories, expanded into `HeadlessOptions.projects`. */
projects?: string[]
/**
* Forwarded to the package store, which is what reads it — the headless
* install itself has no such option.
*/
verifyStoreIntegrity?: boolean
}
export async function testDefaults (
opts?: any, // eslint-disable-line
resolveOpts?: any, // eslint-disable-line
fetchOpts?: any, // eslint-disable-line
storeOpts?: any, // eslint-disable-line
opts?: TestHeadlessOptions,
resolveOpts?: Record<string, unknown>,
fetchOpts?: Record<string, unknown>,
storeOpts?: Record<string, unknown>
): Promise<HeadlessOptions> {
const tmp = temporaryDirectory()
let storeDir = opts?.storeDir ?? path.join(tmp, 'store')
const lockfileDir = opts?.lockfileDir ?? process.cwd()
const { include, pendingBuilds, projects } = await readProjectsContext(
opts.projects
? opts.projects.map((rootDir: string) => ({ rootDir }))
opts?.projects
? opts.projects.map((rootDir) => ({ rootDir: rootDir as ProjectRootDir }))
: [
{
rootDir: lockfileDir,
rootDir: lockfileDir as ProjectRootDir,
},
],
{ lockfileDir }
@@ -41,7 +60,13 @@ export async function testDefaults (
...resolveOpts,
...fetchOpts,
},
storeOptions: storeOpts,
storeOptions: {
// The package store reads this, not the headless install.
...(opts?.verifyStoreIntegrity != null
? { verifyStoreIntegrity: opts.verifyStoreIntegrity }
: {}),
...storeOpts,
},
}
)
return {
@@ -60,20 +85,25 @@ export async function testDefaults (
version: '1.0.0',
},
pendingBuilds,
selectedProjectDirs: opts.selectedProjectDirs ?? projects.map((project) => project.rootDir),
selectedProjectDirs: opts?.selectedProjectDirs ?? projects.map((project) => project.rootDir),
allProjects: Object.fromEntries(
await Promise.all(projects.map(async (project) => [project.rootDir, { ...project, manifest: await safeReadPackageJsonFromDir(project.rootDir) }]))
),
authConfig: {},
registriesByScope: {
default: registry,
},
sideEffectsCache: true,
skipped: new Set<string>(),
skipped: new Set<DepPath>(),
storeController,
storeDir,
configByUri: {},
globalVirtualStoreDir: path.join(storeDir, 'links'),
ignoreScripts: false,
pruneStore: false,
sideEffectsCacheRead: false,
sideEffectsCacheWrite: false,
userAgent: 'pnpm/0.0.0 npm/? node/0.0.0 test test',
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
unsafePerm: true,
verifyStoreIntegrity: true,
...opts,
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ export function createTempStore (opts?: {
fastUnpack?: boolean
storeDir?: string
clientOptions?: Partial<ClientOptions>
storeOptions?: CreatePackageStoreOptions
storeOptions?: Partial<CreatePackageStoreOptions>
}): CreateTempStoreResult {
const configByUri: ClientOptions['configByUri'] = {}
const cacheDir = path.resolve('cache')