The reuse receipt check in node-deps.mjs accepted a tree where every locked directory existed but package contents had been stripped (partial cleanup or snapshot restore). npm trusts the hidden lockfile and never repairs such entries, so every 'hermes update' skipped npm ci, the TUI build died on the missing esbuild manifest, and the stale receipt kept the loop closed (#128935). Probe each locked entry's package.json — the same file product builders resolve tools through — so a gutted tree falls back to a real npm ci.
328 lines
20 KiB
JavaScript
328 lines
20 KiB
JavaScript
import { execFileSync } from 'node:child_process'
|
|
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync, symlinkSync } from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { afterEach, expect, test } from 'vitest'
|
|
import { npmCommand } from '../scripts/build/node-deps.mjs'
|
|
|
|
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const roots = []
|
|
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
|
|
|
|
function json(path, value) {
|
|
mkdirSync(dirname(path), { recursive: true })
|
|
writeFileSync(path, JSON.stringify(value))
|
|
}
|
|
|
|
function fixture() {
|
|
const root = mkdtempSync(join(tmpdir(), 'node union with spaces-'))
|
|
roots.push(root)
|
|
json(join(root, 'package.json'), { name: 'fixture', private: true, dependencies: { 'root-only': 'file:vendor/root-only' }, workspaces: ['ui-tui', 'web', 'apps/desktop'], engines: { node: '>=22', npm: '>=10' } })
|
|
for (const name of ['tui-only', 'web-only', 'root-only']) {
|
|
json(join(root, 'vendor', name, 'package.json'), { name, version: '1.0.0', main: 'index.cjs' })
|
|
writeFileSync(join(root, 'vendor', name, 'index.cjs'), `module.exports = '${name}'`)
|
|
}
|
|
json(join(root, 'ui-tui/package.json'), { name: 'tui', version: '1.0.0', dependencies: { 'tui-only': 'file:../vendor/tui-only' } })
|
|
json(join(root, 'web/package.json'), { name: 'web', version: '1.0.0', dependencies: { 'web-only': 'file:../vendor/web-only' } })
|
|
json(join(root, 'apps/desktop/package.json'), { name: 'desktop', version: '1.0.0', scripts: { postinstall: 'node -e "require(\'fs\').writeFileSync(\'electron-provisioned\',\'yes\')"' } })
|
|
const [node, npm] = npmCommand()
|
|
execFileSync(node, [npm, 'install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund', '--offline'], { cwd: root, env: { ...process.env, npm_config_cache: join(root, '.npm-cache') }, stdio: 'pipe' })
|
|
return root
|
|
}
|
|
|
|
test.each(['npm', 'npm-fixture'])('prepared npm discovers lib/%s without npm_execpath', (directory) => {
|
|
const [node, installedCli] = npmCommand()
|
|
const root = mkdtempSync(join(tmpdir(), 'npm layout with spaces-'))
|
|
roots.push(root)
|
|
mkdirSync(join(root, 'bin'))
|
|
mkdirSync(join(root, 'lib'))
|
|
// Real npm code in a version-suffixed package directory. The launcher name
|
|
// has no .js suffix, as with native npm.cmd and Nix's shell wrappers.
|
|
cpSync(installedCli, join(root, 'bin', process.platform === 'win32' ? 'npm.cmd' : 'npm'))
|
|
symlinkSync(dirname(dirname(installedCli)), join(root, 'lib', directory), 'junction')
|
|
const env = { ...process.env, PATH: join(root, 'bin') }
|
|
delete env.npm_execpath
|
|
const [selectedNode, cli] = npmCommand({ env })
|
|
expect(selectedNode).toBe(node)
|
|
expect(cli).toBe(join(root, 'lib', directory, 'bin/npm-cli.js'))
|
|
const version = execFileSync(selectedNode, [cli, '--version'], { cwd: tmpdir(), env, encoding: 'utf8' }).trim()
|
|
expect(version).toMatch(/^\d+\.\d+\.\d+/)
|
|
})
|
|
|
|
test('read-only dependency preparation reuses complete receipts but refuses missing inputs', async () => {
|
|
const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs')
|
|
const source = fixture()
|
|
const options = { source, workspaces: ['web'], reuse: true,
|
|
env: { ...process.env, npm_config_offline: 'true', npm_config_cache: join(source, '.npm-cache') } }
|
|
expect(() => prepareNodeDependencies({ ...options, install: false })).toThrow(/disabled/)
|
|
expect(existsSync(join(source, 'node_modules'))).toBe(false)
|
|
prepareNodeDependencies(options)
|
|
const receipt = readFileSync(join(source, 'node_modules/.hermes-node-deps'))
|
|
prepareNodeDependencies({ ...options, install: false })
|
|
rmSync(join(source, 'node_modules/web-only'), { recursive: true })
|
|
expect(() => prepareNodeDependencies({ ...options, install: false })).toThrow(/disabled/)
|
|
expect(readFileSync(join(source, 'node_modules/.hermes-node-deps'))).toEqual(receipt)
|
|
}, 30000)
|
|
|
|
test('reuse refuses a gutted package directory and repairs it with npm ci', async () => {
|
|
const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs')
|
|
const source = fixture()
|
|
const options = { source, workspaces: ['web'], reuse: true,
|
|
env: { ...process.env, npm_config_offline: 'true', npm_config_cache: join(source, '.npm-cache') } }
|
|
prepareNodeDependencies(options)
|
|
// #128935: an external cleanup or snapshot restore can leave the directory
|
|
// while stripping its contents. npm trusts the hidden lockfile and would
|
|
// only "repair" entries it does not list, so the receipt must reject first.
|
|
rmSync(join(source, 'node_modules/web-only'), { recursive: true })
|
|
mkdirSync(join(source, 'node_modules/web-only'))
|
|
expect(existsSync(join(source, 'node_modules/web-only/package.json'))).toBe(false)
|
|
expect(() => prepareNodeDependencies({ ...options, install: false })).toThrow(/disabled/)
|
|
prepareNodeDependencies(options)
|
|
expect(createRequire(join(source, 'web/package.json'))('web-only')).toBe('web-only')
|
|
}, 30000)
|
|
|
|
test('native toolchain admission rejects a changed compiler without breaking ordinary builders', async () => {
|
|
const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs')
|
|
const source = fixture()
|
|
const options = { source, workspaces: ['web'], reuse: true,
|
|
env: { ...process.env, npm_config_offline: 'true', npm_config_cache: join(source, '.npm-cache') } }
|
|
prepareNodeDependencies({ ...options, nativeToolchain: 'native-first' })
|
|
const artifact = join(source, 'node_modules/compiled-output')
|
|
writeFileSync(artifact, 'old compiler')
|
|
prepareNodeDependencies({ ...options, install: false })
|
|
prepareNodeDependencies({ ...options, nativeToolchain: 'native-first', install: false })
|
|
expect(() => prepareNodeDependencies({ ...options, nativeToolchain: 'native-second', install: false })).toThrow(/disabled/)
|
|
expect(readFileSync(artifact, 'utf8')).toBe('old compiler')
|
|
const cli = [join(repo, 'scripts/build/node-deps.mjs'), '--source', source, '--workspace', 'web', '--reuse', '--native-toolchain', 'native-second']
|
|
execFileSync(process.execPath, cli, { env: options.env, stdio: 'pipe' })
|
|
expect(existsSync(artifact)).toBe(false)
|
|
prepareNodeDependencies({ ...options, nativeToolchain: 'native-second', install: false })
|
|
prepareNodeDependencies({ ...options, install: false })
|
|
}, 30000)
|
|
|
|
test('one locked preparation retains the requested union without provisioning desktop', async () => {
|
|
const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs')
|
|
const source = fixture()
|
|
const lock = readFileSync(join(source, 'package-lock.json'))
|
|
await prepareNodeDependencies({ source, workspaces: ['ui-tui', 'web', 'ui-tui'], env: { ...process.env, npm_config_offline: 'true', npm_config_cache: join(source, '.npm-cache') } })
|
|
for (const [workspace, name] of [['ui-tui', 'tui-only'], ['web', 'web-only']]) {
|
|
expect(createRequire(join(source, workspace, 'package.json'))(name)).toBe(name)
|
|
}
|
|
expect(createRequire(join(source, 'package.json'))('root-only')).toBe('root-only')
|
|
expect(existsSync(join(source, 'node_modules/desktop'))).toBe(false)
|
|
expect(existsSync(join(source, 'apps/desktop/electron-provisioned'))).toBe(false)
|
|
expect(readFileSync(join(source, 'package-lock.json'))).toEqual(lock)
|
|
execFileSync(process.execPath, [join(repo, 'scripts/build/node-deps.mjs'), '--source', source, '--workspace', 'ui-tui', '--workspace', 'web'], { cwd: tmpdir(), env: { ...process.env, npm_config_offline: 'true', npm_config_cache: join(source, '.npm-cache') }, stdio: 'pipe' })
|
|
expect(createRequire(join(source, 'web/package.json'))('web-only')).toBe('web-only')
|
|
}, 30000)
|
|
|
|
test('web-only selection excludes siblings and rejects stale locks without rewriting them', async () => {
|
|
const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs')
|
|
const source = fixture()
|
|
const env = { ...process.env, npm_config_offline: 'true', npm_config_cache: join(source, '.npm-cache') }
|
|
rmSync(join(source, 'apps/desktop'), { recursive: true })
|
|
prepareNodeDependencies({ source, workspaces: ['web'], env })
|
|
expect(existsSync(join(source, 'node_modules/tui-only'))).toBe(false)
|
|
expect(existsSync(join(source, 'apps/desktop/electron-provisioned'))).toBe(false)
|
|
const lock = readFileSync(join(source, 'package-lock.json'))
|
|
json(join(source, 'web/package.json'), { name: 'web', version: '1.0.0', dependencies: { 'web-only': '9.0.0' } })
|
|
expect(() => prepareNodeDependencies({ source, workspaces: ['web'], env })).toThrow()
|
|
expect(readFileSync(join(source, 'package-lock.json'))).toEqual(lock)
|
|
expect(() => prepareNodeDependencies({ source, workspaces: [], env })).toThrow(/workspace/)
|
|
expect(() => prepareNodeDependencies({ source, workspaces: ['missing'], env })).toThrow(/workspace/)
|
|
}, 30000)
|
|
|
|
test('a restored completed install retains postinstall outputs only for matching inputs', async () => {
|
|
const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs')
|
|
const source = fixture()
|
|
const env = { ...process.env, npm_config_offline: 'true', npm_config_cache: join(source, '.npm-cache') }
|
|
prepareNodeDependencies({ source, workspaces: ['ui-tui', 'web'], env, reuse: true })
|
|
const artifact = 'node_modules/postinstall-output'
|
|
writeFileSync(join(source, artifact), 'built')
|
|
const restored = mkdtempSync(join(tmpdir(), 'restored node union-'))
|
|
roots.push(restored)
|
|
cpSync(source, restored, { recursive: true, verbatimSymlinks: true })
|
|
prepareNodeDependencies({ source: restored, workspaces: ['web', 'ui-tui'], env, reuse: true })
|
|
expect(readFileSync(join(restored, artifact), 'utf8')).toBe('built')
|
|
const cli = [join(repo, 'scripts/build/node-deps.mjs'), '--source', restored, '--workspace', 'web', '--workspace', 'ui-tui', '--reuse']
|
|
execFileSync(process.execPath, cli, { cwd: tmpdir(), env, stdio: 'pipe' })
|
|
expect(readFileSync(join(restored, artifact), 'utf8')).toBe('built')
|
|
expect(createRequire(join(restored, 'web/package.json'))('web-only')).toBe('web-only')
|
|
|
|
prepareNodeDependencies({ source: restored, workspaces: ['web'], env, reuse: true })
|
|
expect(existsSync(join(restored, artifact))).toBe(false)
|
|
expect(existsSync(join(restored, 'node_modules/tui-only'))).toBe(false)
|
|
writeFileSync(join(restored, artifact), 'built again')
|
|
// Even an invalid manifest must reach npm, not hide behind a warm lockfile cache.
|
|
json(join(restored, 'web/package.json'), { name: 'web', version: '1.0.0', dependencies: { 'web-only': '9.0.0' } })
|
|
expect(() => prepareNodeDependencies({ source: restored, workspaces: ['web'], env, reuse: true })).toThrow()
|
|
cpSync(join(source, 'web/package.json'), join(restored, 'web/package.json'))
|
|
prepareNodeDependencies({ source: restored, workspaces: ['web'], env, reuse: true })
|
|
expect(existsSync(join(restored, artifact))).toBe(false)
|
|
}, 30000)
|
|
|
|
test('reuse respects lifecycle configuration and repairs missing installed packages', async () => {
|
|
const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs')
|
|
const source = fixture()
|
|
const manifest = JSON.parse(readFileSync(join(source, 'package.json'), 'utf8'))
|
|
manifest.scripts = { postinstall: `node -e "require('node:fs').writeFileSync('node_modules/postinstall-output', require('node:crypto').randomUUID())"` }
|
|
json(join(source, 'package.json'), manifest)
|
|
const env = { ...process.env, npm_config_offline: 'true', npm_config_cache: join(source, '.npm-cache') }
|
|
const options = { source, workspaces: ['web'], env, reuse: true }
|
|
const artifact = join(source, 'node_modules/postinstall-output')
|
|
prepareNodeDependencies({ ...options, env: { ...env, npm_config_ignore_scripts: 'true' } })
|
|
expect(existsSync(artifact)).toBe(false)
|
|
prepareNodeDependencies(options)
|
|
const first = readFileSync(artifact, 'utf8')
|
|
prepareNodeDependencies(options)
|
|
expect(readFileSync(artifact, 'utf8')).toBe(first)
|
|
rmSync(join(source, 'node_modules/web-only'), { recursive: true, force: true })
|
|
prepareNodeDependencies(options)
|
|
const repaired = readFileSync(artifact, 'utf8')
|
|
expect(repaired).not.toBe(first)
|
|
expect(createRequire(join(source, 'web/package.json'))('web-only')).toBe('web-only')
|
|
prepareNodeDependencies({ ...options, reuse: false })
|
|
expect(readFileSync(artifact, 'utf8')).not.toBe(repaired)
|
|
}, 30000)
|
|
|
|
test('npm configuration name casing does not invalidate a completed install', async () => {
|
|
const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs')
|
|
const source = fixture()
|
|
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'npm_config_prefix'))
|
|
Object.assign(env, { npm_config_offline: 'true', npm_config_cache: join(source, '.npm-cache') })
|
|
const prefix = join(source, 'npm-prefix')
|
|
const options = { source, workspaces: ['web'], reuse: true }
|
|
prepareNodeDependencies({ ...options, env: { ...env, npm_config_prefix: prefix } })
|
|
const artifact = join(source, 'node_modules/postinstall-output')
|
|
writeFileSync(artifact, 'built')
|
|
// The Windows runner sets lowercase; Python's os.environ returns uppercase.
|
|
prepareNodeDependencies({ ...options, env: { ...env, NPM_CONFIG_PREFIX: prefix } })
|
|
expect(readFileSync(artifact, 'utf8')).toBe('built')
|
|
// Normalize names, not values: a genuinely changed setting still reinstalls.
|
|
prepareNodeDependencies({ ...options, env: { ...env, NPM_CONFIG_PREFIX: join(source, 'other-prefix') } })
|
|
expect(existsSync(artifact)).toBe(false)
|
|
}, 30000)
|
|
|
|
// A stand-in npm: `ci` fails with ENOTEMPTY (or `failure`) while a stuck
|
|
// node_modules/.bin entry survives, and records every ci run.
|
|
function fakeNpm(root, failure) {
|
|
const [, realCli] = npmCommand()
|
|
const dir = join(root, 'fake-npm')
|
|
mkdirSync(join(dir, 'node_modules'), { recursive: true })
|
|
symlinkSync(dirname(createRequire(realCli).resolve('semver/package.json')), join(dir, 'node_modules/semver'), 'junction')
|
|
const cli = join(dir, 'npm-cli.js')
|
|
writeFileSync(cli, `const fs = require('fs'), path = require('path')
|
|
const args = process.argv.slice(2)
|
|
if (args[0] === '--version') { console.log('10.9.0'); process.exit(0) }
|
|
fs.appendFileSync(path.join(${JSON.stringify(root)}, 'ci-runs'), 'ci\\n')
|
|
if (fs.existsSync('node_modules/.bin/stuck')) {
|
|
const logs = args.find(arg => arg.startsWith('--logs-dir=')).slice('--logs-dir='.length)
|
|
fs.writeFileSync(path.join(logs, 'debug-0.log'), 'error code ${failure}\\n')
|
|
process.exit(1)
|
|
}
|
|
`)
|
|
return { ...process.env, npm_execpath: cli }
|
|
}
|
|
|
|
function stuckNodeModules(root) {
|
|
mkdirSync(join(root, 'node_modules/.bin'), { recursive: true })
|
|
writeFileSync(join(root, 'node_modules/.bin/stuck'), '')
|
|
}
|
|
|
|
test('npm ci ENOTEMPTY clears node_modules and retries once', async () => {
|
|
const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs')
|
|
const source = fixture()
|
|
stuckNodeModules(source)
|
|
prepareNodeDependencies({ source, workspaces: ['web'], env: fakeNpm(source, 'ENOTEMPTY') })
|
|
expect(readFileSync(join(source, 'ci-runs'), 'utf8')).toBe('ci\nci\n')
|
|
expect(existsSync(join(source, 'node_modules/.bin/stuck'))).toBe(false)
|
|
}, 30000)
|
|
|
|
test('any other npm ci failure keeps node_modules and does not retry', async () => {
|
|
const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs')
|
|
const source = fixture()
|
|
stuckNodeModules(source)
|
|
expect(() => prepareNodeDependencies({ source, workspaces: ['web'], env: fakeNpm(source, 'EINTEGRITY') })).toThrow()
|
|
expect(readFileSync(join(source, 'ci-runs'), 'utf8')).toBe('ci\n')
|
|
expect(existsSync(join(source, 'node_modules/.bin/stuck'))).toBe(true)
|
|
}, 30000)
|
|
|
|
// A fake npm whose version probes die but whose ci succeeds: the Job-Object
|
|
// failure lane from #123933. The version must come from the manifest without
|
|
// a single child spawn, and npm_execpath resolving through a symlink (whose
|
|
// parent holds no manifest) must still find it beside the resolved CLI.
|
|
function fakeNpmTree(source, { manifest }) {
|
|
const fake = mkdtempSync(join(tmpdir(), 'npm layout with spaces-fake-'))
|
|
roots.push(fake)
|
|
const npmRoot = join(fake, 'real/node_modules/npm')
|
|
mkdirSync(join(npmRoot, 'bin'), { recursive: true })
|
|
if (manifest) json(join(npmRoot, 'package.json'), { name: 'npm', version: manifest })
|
|
json(join(fake, 'real/node_modules/semver/package.json'), { name: 'semver', version: '1.0.0', main: 'index.js' })
|
|
writeFileSync(join(fake, 'real/node_modules/semver/index.js'), 'module.exports = { satisfies: () => true }\n')
|
|
const spawned = join(source, 'spawned.jsonl')
|
|
const armProbe = ({ exit }) => writeFileSync(join(npmRoot, 'bin/npm-cli.js'), [
|
|
`const { appendFileSync } = require('node:fs')`,
|
|
`appendFileSync(${JSON.stringify(spawned)}, process.argv[2] + '\\n')`,
|
|
`if (process.argv[2] === '--version') { console.log('10.8.2'); process.exit(${exit}) }`,
|
|
].join('\n'))
|
|
armProbe({ exit: manifest ? 3 : 0 })
|
|
return { fake, npmRoot, spawned, armProbe }
|
|
}
|
|
|
|
test.skipIf(process.platform === 'win32')('the npm version reads from the manifest without a child spawn, through a symlinked npm_execpath', async () => {
|
|
const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs')
|
|
const source = fixture()
|
|
const { fake, npmRoot, spawned } = fakeNpmTree(source, { manifest: '10.8.2' })
|
|
// The symlink sits outside npm's package directory: its parent holds no
|
|
// manifest, while the resolved CLI's parent does. semver stays resolvable
|
|
// from the link, as with a real prefix bin symlink.
|
|
mkdirSync(join(fake, 'real/bin'), { recursive: true })
|
|
symlinkSync(join(npmRoot, 'bin/npm-cli.js'), join(fake, 'real/bin/npm-cli.js'))
|
|
mkdirSync(join(source, 'node_modules'))
|
|
writeFileSync(join(source, 'node_modules/.package-lock.json'), '{"packages": {}}')
|
|
const options = { source, workspaces: ['web'], reuse: true,
|
|
env: { ...process.env, npm_config_cache: join(source, '.npm-cache'), npm_execpath: join(fake, 'real/bin/npm-cli.js') } }
|
|
prepareNodeDependencies(options)
|
|
prepareNodeDependencies(options)
|
|
// Both runs reached ci or the reuse short-circuit; no version probe spawned.
|
|
expect(readFileSync(spawned, 'utf8')).toBe('ci\n')
|
|
}, 30000)
|
|
|
|
test('an npm layout without a readable manifest falls back to the child version probe', async () => {
|
|
const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs')
|
|
const source = fixture()
|
|
const { fake, spawned } = fakeNpmTree(source, { manifest: null })
|
|
mkdirSync(join(source, 'node_modules'))
|
|
writeFileSync(join(source, 'node_modules/.package-lock.json'), '{"packages": {}}')
|
|
const options = { source, workspaces: ['web'], reuse: true,
|
|
env: { ...process.env, npm_config_cache: join(source, '.npm-cache'), npm_execpath: join(fake, 'real/node_modules/npm/bin/npm-cli.js') } }
|
|
prepareNodeDependencies(options)
|
|
// The unresolvable manifest kept the pre-fix behavior: probe first, then ci.
|
|
expect(readFileSync(spawned, 'utf8')).toBe('--version\nci\n')
|
|
}, 30000)
|
|
|
|
// The receipt key hashes the npm version. An install completed by the probe
|
|
// lane must still count as complete once the manifest supplies the version,
|
|
// or every user re-runs npm ci after this change — and, as in #123933, that
|
|
// reuse must not depend on a version probe that can no longer spawn.
|
|
test('a receipt keyed by the probed version reuses under the manifest version, even when the probe fails', async () => {
|
|
const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs')
|
|
const source = fixture()
|
|
const { npmRoot, spawned, armProbe } = fakeNpmTree(source, { manifest: null })
|
|
mkdirSync(join(source, 'node_modules'))
|
|
writeFileSync(join(source, 'node_modules/.package-lock.json'), '{"packages": {}}')
|
|
const options = { source, workspaces: ['web'], reuse: true,
|
|
env: { ...process.env, npm_config_cache: join(source, '.npm-cache'), npm_execpath: join(npmRoot, 'bin/npm-cli.js') } }
|
|
prepareNodeDependencies(options)
|
|
expect(readFileSync(spawned, 'utf8')).toBe('--version\nci\n')
|
|
json(join(npmRoot, 'package.json'), { name: 'npm', version: '10.8.2' })
|
|
armProbe({ exit: 1 })
|
|
prepareNodeDependencies({ ...options, install: false })
|
|
expect(readFileSync(spawned, 'utf8')).toBe('--version\nci\n')
|
|
// A genuinely different npm still invalidates the receipt.
|
|
json(join(npmRoot, 'package.json'), { name: 'npm', version: '10.8.3' })
|
|
expect(() => prepareNodeDependencies({ ...options, install: false })).toThrow(/disabled/)
|
|
}, 30000)
|