import fs from 'fs' import path from 'path' import { type PackageFilesIndex } from '@pnpm/store.cafs' import { ENGINE_NAME } from '@pnpm/constants' import { install } from '@pnpm/core' import { type IgnoredScriptsLog } from '@pnpm/core-loggers' import { createHexHashFromFile } from '@pnpm/crypto.hash' import { readMsgpackFileSync } from '@pnpm/fs.msgpack-file' import { prepareEmpty } from '@pnpm/prepare' import { fixtures } from '@pnpm/test-fixtures' import { jest } from '@jest/globals' import { sync as rimraf } from '@zkochan/rimraf' import sinon from 'sinon' import { testDefaults } from '../utils/index.js' const f = fixtures(import.meta.dirname) test('patch package with exact version', async () => { const reporter = sinon.spy() const project = prepareEmpty() const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch') const patchedDependencies = { 'is-positive@1.0.0': patchPath, } const opts = testDefaults({ neverBuiltDependencies: undefined, allowBuilds: {}, fastUnpack: false, sideEffectsCacheRead: true, sideEffectsCacheWrite: true, patchedDependencies, reporter, }, {}, {}, { packageImportMethod: 'hardlink' }) await install({ dependencies: { 'is-positive': '1.0.0', }, }, opts) expect(reporter.calledWithMatch({ packageNames: [], level: 'debug', name: 'pnpm:ignored-scripts', } as IgnoredScriptsLog)).toBeTruthy() expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched') const patchFileHash = await createHexHashFromFile(patchPath) const lockfile = project.readLockfile() expect(lockfile.patchedDependencies).toStrictEqual({ 'is-positive@1.0.0': { path: path.relative(process.cwd(), patchedDependencies['is-positive@1.0.0']).replaceAll('\\', '/'), hash: patchFileHash, }, }) expect(lockfile.snapshots[`is-positive@1.0.0(patch_hash=${patchFileHash})`]).toBeTruthy() const filesIndexFile = path.join(opts.storeDir, 'index/c7/1ccf199e0fdae37aad13946b937d67bcd35fa111b84d21b3a19439cfdc2812-is-positive@1.0.0.mpk') const filesIndex = readMsgpackFileSync(filesIndexFile) expect(filesIndex.sideEffects).toBeTruthy() const sideEffectsKey = `${ENGINE_NAME};patch=${patchFileHash}` expect(filesIndex.sideEffects!.has(sideEffectsKey)).toBeTruthy() expect(filesIndex.sideEffects!.get(sideEffectsKey)!.added).toBeTruthy() const patchedFileDigest = filesIndex.sideEffects!.get(sideEffectsKey)!.added!.get('index.js')?.digest expect(patchedFileDigest).toBeTruthy() const originalFileDigest = filesIndex.files.get('index.js')!.digest expect(originalFileDigest).toBeTruthy() // The digest of the original file differs from the digest of the patched file expect(originalFileDigest).not.toEqual(patchedFileDigest) // The same with frozen lockfile rimraf('node_modules') await install({ dependencies: { 'is-positive': '1.0.0', }, }, { ...opts, frozenLockfile: true, }) expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched') // The same with frozen lockfile and hoisted node_modules rimraf('node_modules') await install({ dependencies: { 'is-positive': '1.0.0', }, }, { ...opts, frozenLockfile: true, nodeLinker: 'hoisted', }) expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched') process.chdir('..') fs.mkdirSync('project2') process.chdir('project2') await install({ dependencies: { 'is-positive': '1.0.0', }, }, testDefaults({ fastUnpack: false, sideEffectsCacheRead: true, sideEffectsCacheWrite: true, offline: true, }, {}, {}, { packageImportMethod: 'hardlink' })) // The original file did not break, when a patched version was created expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).not.toContain('// patched') }) test('patch package with version range', async () => { const reporter = sinon.spy() const project = prepareEmpty() const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch') const patchedDependencies = { 'is-positive@1': patchPath, } const opts = testDefaults({ neverBuiltDependencies: undefined, allowBuilds: {}, fastUnpack: false, sideEffectsCacheRead: true, sideEffectsCacheWrite: true, patchedDependencies, reporter, }, {}, {}, { packageImportMethod: 'hardlink' }) await install({ dependencies: { 'is-positive': '1.0.0', }, }, opts) expect(reporter.calledWithMatch({ packageNames: [], level: 'debug', name: 'pnpm:ignored-scripts', } as IgnoredScriptsLog)).toBeTruthy() expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched') const patchFileHash = await createHexHashFromFile(patchPath) const lockfile = project.readLockfile() expect(lockfile.patchedDependencies).toStrictEqual({ 'is-positive@1': { path: path.relative(process.cwd(), patchedDependencies['is-positive@1']).replaceAll('\\', '/'), hash: patchFileHash, }, }) expect(lockfile.snapshots[`is-positive@1.0.0(patch_hash=${patchFileHash})`]).toBeTruthy() const filesIndexFile = path.join(opts.storeDir, 'index/c7/1ccf199e0fdae37aad13946b937d67bcd35fa111b84d21b3a19439cfdc2812-is-positive@1.0.0.mpk') const filesIndex = readMsgpackFileSync(filesIndexFile) expect(filesIndex.sideEffects).toBeTruthy() const sideEffectsKey = `${ENGINE_NAME};patch=${patchFileHash}` expect(filesIndex.sideEffects!.has(sideEffectsKey)).toBeTruthy() expect(filesIndex.sideEffects!.get(sideEffectsKey)!.added).toBeTruthy() const patchedFileDigest = filesIndex.sideEffects!.get(sideEffectsKey)!.added!.get('index.js')?.digest expect(patchedFileDigest).toBeTruthy() const originalFileDigest = filesIndex.files.get('index.js')!.digest expect(originalFileDigest).toBeTruthy() // The digest of the original file differs from the digest of the patched file expect(originalFileDigest).not.toEqual(patchedFileDigest) // The same with frozen lockfile rimraf('node_modules') await install({ dependencies: { 'is-positive': '1.0.0', }, }, { ...opts, frozenLockfile: true, }) expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched') // The same with frozen lockfile and hoisted node_modules rimraf('node_modules') await install({ dependencies: { 'is-positive': '1.0.0', }, }, { ...opts, frozenLockfile: true, nodeLinker: 'hoisted', }) expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched') process.chdir('..') fs.mkdirSync('project2') process.chdir('project2') await install({ dependencies: { 'is-positive': '1.0.0', }, }, testDefaults({ fastUnpack: false, sideEffectsCacheRead: true, sideEffectsCacheWrite: true, offline: true, }, {}, {}, { packageImportMethod: 'hardlink' })) // The original file did not break, when a patched version was created expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).not.toContain('// patched') }) test('patch package reports warning if not all patches are applied and allowUnusedPatches is set', async () => { prepareEmpty() const reporter = jest.fn() const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch') const patchedDependencies = { 'is-positive@1.0.0': patchPath, 'is-negative@1.0.0': patchPath, } const opts = testDefaults({ fastUnpack: false, sideEffectsCacheRead: true, sideEffectsCacheWrite: true, patchedDependencies, allowUnusedPatches: true, reporter, }, {}, {}, { packageImportMethod: 'hardlink' }) await install({ dependencies: { 'is-positive': '1.0.0', }, }, opts) expect(reporter).toHaveBeenCalledWith( expect.objectContaining({ level: 'warn', message: 'The following patches were not used: is-negative@1.0.0', }) ) }) test('patch package throws an exception if not all patches are applied', async () => { prepareEmpty() const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch') const patchedDependencies = { 'is-positive@1.0.0': patchPath, 'is-negative@1.0.0': patchPath, } const opts = testDefaults({ fastUnpack: false, sideEffectsCacheRead: true, sideEffectsCacheWrite: true, patchedDependencies, }, {}, {}, { packageImportMethod: 'hardlink' }) await expect( install({ dependencies: { 'is-positive': '1.0.0', }, }, opts) ).rejects.toThrow('The following patches were not used: is-negative@1.0.0') }) test('the patched package is updated if the patch is modified', async () => { prepareEmpty() f.copy('patch-pkg', 'patches') const patchPath = path.resolve('patches', 'is-positive@1.0.0.patch') const patchedDependencies = { 'is-positive@1.0.0': patchPath, } const opts = testDefaults({ fastUnpack: false, sideEffectsCacheRead: true, sideEffectsCacheWrite: true, patchedDependencies, }, {}, {}, { packageImportMethod: 'hardlink' }) const manifest = { dependencies: { 'is-positive': '1.0.0', }, } await install(manifest, opts) const patchContent = fs.readFileSync(patchPath, 'utf8') fs.writeFileSync(patchPath, patchContent.replace('// patched', '// edited patch'), 'utf8') await install(manifest, opts) expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// edited patch') }) test('patch package when scripts are ignored', async () => { const project = prepareEmpty() const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch') const patchedDependencies = { 'is-positive@1.0.0': patchPath, } const opts = testDefaults({ fastUnpack: false, ignoreScripts: true, sideEffectsCacheRead: true, sideEffectsCacheWrite: true, patchedDependencies, }, {}, {}, { packageImportMethod: 'hardlink' }) await install({ dependencies: { 'is-positive': '1.0.0', }, }, opts) expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched') const patchFileHash = await createHexHashFromFile(patchPath) const lockfile = project.readLockfile() expect(lockfile.patchedDependencies).toStrictEqual({ 'is-positive@1.0.0': { path: path.relative(process.cwd(), patchedDependencies['is-positive@1.0.0']).replaceAll('\\', '/'), hash: patchFileHash, }, }) expect(lockfile.snapshots[`is-positive@1.0.0(patch_hash=${patchFileHash})`]).toBeTruthy() const filesIndexFile = path.join(opts.storeDir, 'index/c7/1ccf199e0fdae37aad13946b937d67bcd35fa111b84d21b3a19439cfdc2812-is-positive@1.0.0.mpk') const filesIndex = readMsgpackFileSync(filesIndexFile) expect(filesIndex.sideEffects).toBeTruthy() const sideEffectsKey = `${ENGINE_NAME};patch=${patchFileHash}` expect(filesIndex.sideEffects!.has(sideEffectsKey)).toBeTruthy() expect(filesIndex.sideEffects!.get(sideEffectsKey)!.added).toBeTruthy() const patchedFileDigest = filesIndex.sideEffects!.get(sideEffectsKey)!.added!.get('index.js')?.digest expect(patchedFileDigest).toBeTruthy() const originalFileDigest = filesIndex.files.get('index.js')!.digest expect(originalFileDigest).toBeTruthy() // The digest of the original file differs from the digest of the patched file expect(originalFileDigest).not.toEqual(patchedFileDigest) // The same with frozen lockfile rimraf('node_modules') await install({ dependencies: { 'is-positive': '1.0.0', }, }, { ...opts, frozenLockfile: true, }) expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched') // The same with frozen lockfile and hoisted node_modules rimraf('node_modules') await install({ dependencies: { 'is-positive': '1.0.0', }, }, { ...opts, frozenLockfile: true, nodeLinker: 'hoisted', }) expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched') process.chdir('..') fs.mkdirSync('project2') process.chdir('project2') await install({ dependencies: { 'is-positive': '1.0.0', }, }, testDefaults({ fastUnpack: false, ignoreScripts: true, sideEffectsCacheRead: true, sideEffectsCacheWrite: true, offline: true, }, {}, {}, { packageImportMethod: 'hardlink' })) // The original file did not break, when a patched version was created expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).not.toContain('// patched') }) test('patch package when the package is not in allowBuilds list', async () => { const project = prepareEmpty() const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch') const patchedDependencies = { 'is-positive@1.0.0': patchPath, } const opts = testDefaults({ fastUnpack: false, sideEffectsCacheRead: true, sideEffectsCacheWrite: true, patchedDependencies, neverBuiltDependencies: undefined, allowBuilds: {}, }, {}, {}, { packageImportMethod: 'hardlink' }) await install({ dependencies: { 'is-positive': '1.0.0', }, }, opts) expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched') const patchFileHash = await createHexHashFromFile(patchPath) const lockfile = project.readLockfile() expect(lockfile.patchedDependencies).toStrictEqual({ 'is-positive@1.0.0': { path: path.relative(process.cwd(), patchedDependencies['is-positive@1.0.0']).replaceAll('\\', '/'), hash: patchFileHash, }, }) expect(lockfile.snapshots[`is-positive@1.0.0(patch_hash=${patchFileHash})`]).toBeTruthy() const filesIndexFile = path.join(opts.storeDir, 'index/c7/1ccf199e0fdae37aad13946b937d67bcd35fa111b84d21b3a19439cfdc2812-is-positive@1.0.0.mpk') const filesIndex = readMsgpackFileSync(filesIndexFile) expect(filesIndex.sideEffects).toBeTruthy() const sideEffectsKey = `${ENGINE_NAME};patch=${patchFileHash}` expect(filesIndex.sideEffects!.has(sideEffectsKey)).toBeTruthy() expect(filesIndex.sideEffects!.get(sideEffectsKey)!.added).toBeTruthy() const patchedFileDigest = filesIndex.sideEffects!.get(sideEffectsKey)!.added!.get('index.js')?.digest expect(patchedFileDigest).toBeTruthy() const originalFileDigest = filesIndex.files.get('index.js')!.digest expect(originalFileDigest).toBeTruthy() // The digest of the original file differs from the digest of the patched file expect(originalFileDigest).not.toEqual(patchedFileDigest) // The same with frozen lockfile rimraf('node_modules') await install({ dependencies: { 'is-positive': '1.0.0', }, }, { ...opts, frozenLockfile: true, }) expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched') // The same with frozen lockfile and hoisted node_modules rimraf('node_modules') await install({ dependencies: { 'is-positive': '1.0.0', }, }, { ...opts, frozenLockfile: true, nodeLinker: 'hoisted', }) expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched') process.chdir('..') fs.mkdirSync('project2') process.chdir('project2') await install({ dependencies: { 'is-positive': '1.0.0', }, }, testDefaults({ fastUnpack: false, sideEffectsCacheRead: true, sideEffectsCacheWrite: true, neverBuiltDependencies: undefined, allowBuilds: {}, offline: true, }, {}, {}, { packageImportMethod: 'hardlink' })) // The original file did not break, when a patched version was created expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).not.toContain('// patched') }) test('patch package when the patched package has no dependencies and appears multiple times', async () => { const project = prepareEmpty() const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch') const patchedDependencies = { 'is-positive@1.0.0': patchPath, } const opts = testDefaults({ fastUnpack: false, sideEffectsCacheRead: true, sideEffectsCacheWrite: true, patchedDependencies, overrides: { 'is-positive': '1.0.0', }, }, {}, {}, { packageImportMethod: 'hardlink' }) await install({ dependencies: { 'is-positive': '1.0.0', 'is-not-positive': '1.0.0', }, }, opts) expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched') const patchFileHash = await createHexHashFromFile(patchPath) const lockfile = project.readLockfile() expect(Object.keys(lockfile.snapshots).sort()).toStrictEqual([ 'is-not-positive@1.0.0', `is-positive@1.0.0(patch_hash=${patchFileHash})`, ].sort()) }) test('patch package should fail when the exact version patch fails to apply', async () => { prepareEmpty() const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch') const patchedDependencies = { 'is-positive@3.1.0': patchPath, } const opts = testDefaults({ fastUnpack: false, sideEffectsCacheRead: true, sideEffectsCacheWrite: true, patchedDependencies, }, {}, {}, { packageImportMethod: 'hardlink' }) await expect(install({ dependencies: { 'is-positive': '3.1.0', }, }, opts)).rejects.toThrow(/Could not apply patch/) expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).not.toContain('// patched') }) test('patch package should fail when the version range patch fails to apply', async () => { prepareEmpty() const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch') const patchedDependencies = { 'is-positive@>=3': patchPath, } const opts = testDefaults({ fastUnpack: false, sideEffectsCacheRead: true, sideEffectsCacheWrite: true, patchedDependencies, }, {}, {}, { packageImportMethod: 'hardlink' }) await expect(install({ dependencies: { 'is-positive': '3.1.0', }, }, opts)).rejects.toThrow(/Could not apply patch/) expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).not.toContain('// patched') }) test('patch package should fail when the name-only range patch fails to apply', async () => { prepareEmpty() const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch') const patchedDependencies = { 'is-positive': patchPath, } const opts = testDefaults({ fastUnpack: false, sideEffectsCacheRead: true, sideEffectsCacheWrite: true, patchedDependencies, }, {}, {}, { packageImportMethod: 'hardlink' }) await expect(install({ dependencies: { 'is-positive': '3.1.0', }, }, opts)).rejects.toThrow(/Could not apply patch/) expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).not.toContain('// patched') })