Files
pnpm/deps/status/test/checkDepsStatus.test.ts
Benjamin Staneck 180aee9ba5 fix: handle lockfile conflicts in optimistic install (#11605)
* fix: handle lockfile conflicts in optimistic install

* test: move sharedWorkspaceLockfile out of WorkspaceState.settings

`sharedWorkspaceLockfile` is not in `WORKSPACE_STATE_SETTING_KEYS`, so
placing it inside `WorkspaceState.settings` broke type checking. Pass
it directly on the `CheckDepsStatusOptions` instead.

* chore: add lockfile/fs project reference to installing/commands tsconfig

`@pnpm/lockfile.fs` was added as a runtime dependency but the tsconfig
project references were not updated. meta-updater enforces this in CI.

* perf: restore optimistic-repeat-install fast-path for conflict-free state

The first iteration of the conflict-detection fix unconditionally read
pnpm-lock.yaml on every install - once in installDeps and again inside
checkDepsStatus - defeating the point of optimisticRepeatInstall, which
was to skip reading the lockfile entirely when nothing changed.

Restore the fast path by:

- Dropping the redundant lockfile read from installDeps. checkDepsStatus
  already returns upToDate: false when the lockfile is conflicted, so
  the pre-check was dead weight.
- Gating the conflict check inside checkDepsStatus on the lockfile's
  mtime: if it hasn't been touched since the last successful install,
  it cannot have grown conflict markers, so the read is skipped.

Conflict markers introduced after a successful install (e.g. via
git pull/merge) still update the lockfile mtime, so the correctness
fix is preserved.

* perf: make lockfile-conflict check synchronous

findConflictedLockfileDir awaits its work serially with no concurrent
operations to interleave, so the async overhead (Promise.all microtasks,
event-loop hops) buys nothing. Convert to a plain for-loop with
fs.statSync and a new wantedLockfileHasMergeConflictsSync export.

Also resolves a test-mock issue: the previous version called safeStat
from deps/status, which is jest-mocked across the test file. The mocked
safeStat returned undefined (or stale stats from earlier tests), causing
the conflict check to silently no-op. Switching to fs.statSync bypasses
the mock and gets the real mtime of the temp lockfile the regression
tests write.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-14 10:03:32 +00:00

505 lines
16 KiB
TypeScript

import type { Stats } from 'node:fs'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import type { CheckDepsStatusOptions } from '@pnpm/deps.status'
import type { LockfileObject } from '@pnpm/lockfile.fs'
import type { ProjectRootDir, ProjectRootDirRealPath } from '@pnpm/types'
import type { WorkspaceState } from '@pnpm/workspace.state'
{
const original = await import('@pnpm/workspace.state')
jest.unstable_mockModule('@pnpm/workspace.state', () => ({
...original,
loadWorkspaceState: jest.fn(),
}))
}
{
const original = await import('../lib/safeStat.js')
jest.unstable_mockModule('../lib/safeStat', () => ({
...original,
safeStatSync: jest.fn(),
safeStat: jest.fn(),
}))
}
{
const original = await import('../lib/statManifestFile.js')
jest.unstable_mockModule('../lib/statManifestFile', () => ({
...original,
statManifestFile: jest.fn(),
}))
}
{
const original = await import('@pnpm/lockfile.fs')
jest.unstable_mockModule('@pnpm/lockfile.fs', () => ({
...original,
readCurrentLockfile: jest.fn(),
readWantedLockfile: jest.fn(),
}))
}
const { checkDepsStatus } = await import('@pnpm/deps.status')
const { loadWorkspaceState } = await import('@pnpm/workspace.state')
const lockfileFs = await import('@pnpm/lockfile.fs')
const fsUtils = await import('../lib/safeStat.js')
const statManifestFileUtils = await import('../lib/statManifestFile.js')
describe('checkDepsStatus - settings change detection', () => {
beforeEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('returns upToDate: false when overrides have changed', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
overrides: { foo: '1.0.0' },
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
overrides: { foo: '2.0.0' },
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The value of the overrides setting has changed')
})
it('returns upToDate: false when packageExtensions have changed', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
packageExtensions: { foo: { dependencies: { bar: '1.0.0' } } },
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
packageExtensions: { foo: { dependencies: { bar: '2.0.0' } } },
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The value of the packageExtensions setting has changed')
})
it('returns upToDate: false when ignoredOptionalDependencies have changed', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
ignoredOptionalDependencies: ['foo'],
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
ignoredOptionalDependencies: ['foo', 'bar'],
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The value of the ignoredOptionalDependencies setting has changed')
})
it('returns upToDate: false when patchedDependencies have changed', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
patchedDependencies: { foo: 'patches/foo.patch' },
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
patchedDependencies: { foo: 'patches/foo-v2.patch' },
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The value of the patchedDependencies setting has changed')
})
it('returns upToDate: false when peersSuffixMaxLength has changed', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
peersSuffixMaxLength: 1000,
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
peersSuffixMaxLength: 100,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The value of the peersSuffixMaxLength setting has changed')
})
it('returns upToDate: false when allowBuilds have changed', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
allowBuilds: { sqlite3: false },
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The value of the allowBuilds setting has changed')
})
it('skips the allowBuilds change detection when allowBuilds is in ignoredWorkspaceStateSettings', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
allowBuilds: { sqlite3: false },
ignoredWorkspaceStateSettings: ['allowBuilds'],
}
const result = await checkDepsStatus(opts)
expect(result.issue).not.toBe('The value of the allowBuilds setting has changed')
})
})
describe('checkDepsStatus - pnpmfile modification', () => {
beforeEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('returns upToDate: false when a pnpmfile was modified', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const beforeLastValidation = lastValidatedTimestamp - 10_000
const afterLastValidation = lastValidatedTimestamp + 1_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: ['pnpmfile.js', 'modifiedPnpmfile.js'],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
jest.mocked(fsUtils.safeStatSync).mockImplementation((filePath: string) => {
if (filePath === 'pnpmfile.js') {
return {
mtime: new Date(beforeLastValidation),
mtimeMs: beforeLastValidation,
} as Stats
}
if (filePath === 'modifiedPnpmfile.js') {
return {
mtime: new Date(afterLastValidation),
mtimeMs: afterLastValidation,
} as Stats
}
return undefined
})
jest.mocked(fsUtils.safeStat).mockImplementation(async () => {
return {
mtime: new Date(beforeLastValidation),
mtimeMs: beforeLastValidation,
} as Stats
})
jest.mocked(statManifestFileUtils.statManifestFile).mockImplementation(async () => {
return undefined
})
const returnEmptyLockfile = async () => ({} as LockfileObject)
jest.mocked(lockfileFs.readCurrentLockfile).mockImplementation(returnEmptyLockfile)
jest.mocked(lockfileFs.readWantedLockfile).mockImplementation(returnEmptyLockfile)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: mockWorkspaceState.pnpmfiles,
...mockWorkspaceState.settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('pnpmfile at "modifiedPnpmfile.js" was modified')
})
it('returns upToDate: false when a patch was modified and manifests were not modified', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const beforeLastValidation = lastValidatedTimestamp - 10_000
const afterLastValidation = lastValidatedTimestamp + 1_000
const projectRootDir = '/project' as ProjectRootDir
const projectRootDirRealPath = '/project' as ProjectRootDirRealPath
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {
[projectRootDir]: {
name: 'root',
version: '1.0.0',
},
},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
jest.mocked(fsUtils.safeStat).mockImplementation(async (filePath: string) => {
if (filePath === '/project/patches/foo.patch') {
return {
mtime: new Date(afterLastValidation),
mtimeMs: afterLastValidation,
} as Stats
}
return {
mtime: new Date(beforeLastValidation),
mtimeMs: beforeLastValidation,
} as Stats
})
jest.mocked(statManifestFileUtils.statManifestFile).mockImplementation(async () => ({
mtime: new Date(beforeLastValidation),
mtimeMs: beforeLastValidation,
} as Stats))
const opts: CheckDepsStatusOptions = {
allProjects: [{
rootDir: projectRootDir,
rootDirRealPath: projectRootDirRealPath,
manifest: {
name: 'root',
version: '1.0.0',
dependencies: {
foo: '1.0.0',
},
},
writeProjectManifest: async () => {},
}],
workspaceDir: '/project',
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
patchedDependencies: {
foo: '/project/patches/foo.patch',
},
...mockWorkspaceState.settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('Patches were modified')
})
})
describe('checkDepsStatus - lockfile conflicts', () => {
beforeEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('returns upToDate: false when the wanted lockfile has merge conflict markers', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pnpm-check-deps-'))
try {
await writeConflictedLockfile(projectDir)
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp: Date.now() - 10_000,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: projectDir,
pnpmfile: [],
...mockWorkspaceState.settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe(`The lockfile in ${projectDir} has merge conflicts`)
} finally {
await fs.rm(projectDir, { force: true, recursive: true })
}
})
it('returns upToDate: false when a project lockfile has merge conflict markers and sharedWorkspaceLockfile is false', async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pnpm-check-deps-workspace-'))
try {
const projectDir = path.join(workspaceDir, 'packages/project')
await fs.mkdir(projectDir, { recursive: true })
await writeConflictedLockfile(projectDir)
const projectRootDir = projectDir as ProjectRootDir
const projectRootDirRealPath = await fs.realpath(projectDir) as ProjectRootDirRealPath
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp: Date.now() - 10_000,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {
[projectRootDir]: {
name: 'project',
version: '1.0.0',
},
},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
allProjects: [{
rootDir: projectRootDir,
rootDirRealPath: projectRootDirRealPath,
manifest: {
name: 'project',
version: '1.0.0',
},
writeProjectManifest: async () => {},
}],
workspaceDir,
rootProjectManifest: {},
rootProjectManifestDir: workspaceDir,
pnpmfile: [],
sharedWorkspaceLockfile: false,
...mockWorkspaceState.settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe(`The lockfile in ${projectDir} has merge conflicts`)
} finally {
await fs.rm(workspaceDir, { force: true, recursive: true })
}
})
})
async function writeConflictedLockfile (lockfileDir: string): Promise<void> {
await fs.writeFile(path.join(lockfileDir, 'pnpm-lock.yaml'), [
"lockfileVersion: '9.0'",
'<<<<<<< HEAD',
'settings:',
' autoInstallPeers: true',
'=======',
'settings:',
' autoInstallPeers: false',
'>>>>>>> branch',
'',
].join('\n'))
}