const fs = require('node:fs')
const tspawk = require('../../fixtures/tspawk')
const {
  cleanCwd,
  cleanTime,
  cleanDate,
  cleanPackumentCache,
} = require('../../fixtures/clean-snapshot.js')

const path = require('node:path')
const t = require('tap')

t.cleanSnapshot = (str) => cleanPackumentCache(cleanDate(cleanTime(cleanCwd(str))))

const {
  loadNpmWithRegistry: loadMockNpm,
  workspaceMock,
} = require('../../fixtures/mock-npm')

// tspawk calls preventUnmatched which assures that no scripts run if we don't mock any
const spawk = tspawk(t)

const abbrev = {
  'package.json': '{"name": "abbrev", "version": "1.0.0"}',
  test: 'test file',
}

const packageJson = {
  name: '@npmcli/test-package',
  version: '1.0.0',
  dependencies: {
    abbrev: '^1.0.0',
  },
}

t.test('exec commands', async t => {
  await t.test('with args does not run lifecycle scripts', async t => {
    const { npm, registry } = await loadMockNpm(t, {
      config: {
        audit: false,
      },
      prefixDir: {
        'package.json': JSON.stringify({
          ...packageJson,
          scripts: {
            install: 'echo install',
          },
        }),
        abbrev,
      },
    })
    const manifest = registry.manifest({ name: 'abbrev' })
    await registry.package({ manifest })
    await registry.tarball({
      manifest: manifest.versions['1.0.0'],
      tarball: path.join(npm.prefix, 'abbrev'),
    })

    await npm.exec('install', ['abbrev'])
  })

  await t.test('without args runs lifecycle scripts', async t => {
    const lifecycleScripts = [
      'preinstall',
      'install',
      'postinstall',
      'prepublish',
      'preprepare',
      'prepare',
      'postprepare',
    ]
    const scripts = {}
    for (const script of lifecycleScripts) {
      spawk.spawn(/.*/, a => {
        runOrder.push(script)
        return a.includes(`${script} lifecycle script`)
      })
      scripts[script] = `${script} lifecycle script`
    }
    const { npm, registry } = await loadMockNpm(t, {
      config: {
        audit: false,
      },
      prefixDir: {
        'package.json': JSON.stringify({
          ...packageJson,
          scripts,
        }),
        abbrev,
      },
    })
    const runOrder = []
    const manifest = registry.manifest({ name: 'abbrev' })
    await registry.package({ manifest })
    await registry.tarball({
      manifest: manifest.versions['1.0.0'],
      tarball: path.join(npm.prefix, 'abbrev'),
    })

    await npm.exec('install')
    t.strictSame(lifecycleScripts, runOrder, 'all script ran in the correct order')
  })

  // Regression test: root `preinstall` must run before any dependency is fetched/unpacked, while `install` and `postinstall` run after reify has populated node_modules.
  await t.test('preinstall runs before reify, post-reify scripts run after', async t => {
    const events = []
    const { npm, registry } = await loadMockNpm(t, {
      config: { audit: false },
      prefixDir: {
        'package.json': JSON.stringify({
          ...packageJson,
          scripts: {
            preinstall: 'echo preinstall',
            install: 'echo install',
            postinstall: 'echo postinstall',
          },
        }),
        abbrev,
      },
      mocks: {
        '@npmcli/run-script': async (opts) => {
          // Only record scripts targeted at the project root, not any that arborist may run for dependencies during reify.
          if (opts.path === npm.prefix) {
            const abbrevPkg = path.join(npm.prefix, 'node_modules', 'abbrev', 'package.json')
            events.push({ event: opts.event, depInstalled: fs.existsSync(abbrevPkg) })
          }
        },
      },
    })
    const manifest = registry.manifest({ name: 'abbrev' })
    await registry.package({ manifest })
    await registry.tarball({
      manifest: manifest.versions['1.0.0'],
      tarball: path.join(npm.prefix, 'abbrev'),
    })

    await npm.exec('install')

    const pre = events.find(e => e.event === 'preinstall')
    const post = events.find(e => e.event === 'postinstall')
    t.ok(pre, 'preinstall ran')
    t.ok(post, 'postinstall ran')
    t.equal(pre.depInstalled, false, 'preinstall runs before dependencies are installed')
    t.equal(post.depInstalled, true, 'postinstall runs after dependencies are installed')
  })

  await t.test('without args, --ignore-scripts skips preinstall entirely', async t => {
    const events = []
    const { npm, registry } = await loadMockNpm(t, {
      config: { audit: false, 'ignore-scripts': true },
      prefixDir: {
        'package.json': JSON.stringify({
          ...packageJson,
          scripts: {
            preinstall: 'echo preinstall',
            postinstall: 'echo postinstall',
          },
        }),
        abbrev,
      },
      mocks: {
        '@npmcli/run-script': async (opts) => {
          if (opts.path === npm.prefix) {
            events.push(opts.event)
          }
        },
      },
    })
    const manifest = registry.manifest({ name: 'abbrev' })
    await registry.package({ manifest })
    await registry.tarball({
      manifest: manifest.versions['1.0.0'],
      tarball: path.join(npm.prefix, 'abbrev'),
    })

    await npm.exec('install')
    t.strictSame(events, [], 'no root lifecycle scripts run when --ignore-scripts is set')
  })

  // Regression test: a failing root `preinstall` must short-circuit before reify runs, so dependencies never reach disk on failure. This is the cleaner failure mode the PR was motivated by; future refactors that swallow the rejection and still call reify must fail here.
  await t.test('a failing preinstall prevents reify', async t => {
    const events = []
    const { npm } = await loadMockNpm(t, {
      config: { audit: false },
      prefixDir: {
        'package.json': JSON.stringify({
          ...packageJson,
          scripts: {
            preinstall: 'exit 1',
            postinstall: 'echo postinstall',
          },
        }),
        abbrev,
      },
      mocks: {
        '@npmcli/run-script': async (opts) => {
          if (opts.path === npm.prefix) {
            events.push(opts.event)
            if (opts.event === 'preinstall') {
              throw Object.assign(new Error('preinstall failed'), { code: 'ELIFECYCLE' })
            }
          }
        },
      },
    })

    await t.rejects(npm.exec('install'), /preinstall failed/, 'install rejects when preinstall fails')
    t.strictSame(events, ['preinstall'], 'only preinstall ran; no post-reify scripts')
    t.equal(
      fs.existsSync(path.join(npm.prefix, 'node_modules', 'abbrev', 'package.json')),
      false,
      'no dependency reached disk after preinstall failure'
    )
  })

  await t.test('should ignore scripts with --ignore-scripts', async t => {
    const { npm, registry } = await loadMockNpm(t, {
      config: {
        'ignore-scripts': true,
        audit: false,
      },
      prefixDir: {
        'package.json': JSON.stringify({
          ...packageJson,
          scripts: {
            install: 'echo install',
          },
        }),
        abbrev,
      },
    })
    const manifest = registry.manifest({ name: 'abbrev' })
    await registry.package({ manifest })
    await registry.tarball({
      manifest: manifest.versions['1.0.0'],
      tarball: path.join(npm.prefix, 'abbrev'),
    })

    await npm.exec('install')
  })

  await t.test('should not self-install package if prefix is the same as CWD', async t => {
    let REIFY_CALLED_WITH = null
    const { npm } = await loadMockNpm(t, {
      mocks: {
        '{LIB}/utils/reify-finish.js': async () => {},
        '@npmcli/run-script': () => {},
        '@npmcli/arborist': function () {
          this.reify = (opts) => {
            REIFY_CALLED_WITH = opts
          }
        },
      },
      prefixOverride: process.cwd(),
    })

    await npm.exec('install')
    t.equal(REIFY_CALLED_WITH.add.length, 0, 'did not install current directory as a dependency')
  })

  await t.test('should not install invalid global package name', async t => {
    const { npm } = await loadMockNpm(t, {
      config: {
        global: true,
      },
    })
    await t.rejects(
      npm.exec('install', ['']),
      /Usage:/,
      'should not install invalid package name'
    )
  })

  await t.test('npm i -g npm engines check success', async t => {
    const { npm, registry } = await loadMockNpm(t, {
      prefixDir: {
        npm: {
          'package.json': JSON.stringify({ name: 'npm', version: '1.0.0' }),
          'index.js': 'console.log("this is npm")',
        },
      },
      config: { global: true },
    })
    const manifest = registry.manifest({
      name: 'npm',
      packuments: [{ version: '1.0.0', engines: { node: '>1' } }],
    })
    await registry.package({ manifest, times: 2 })
    await registry.tarball({
      manifest: manifest.versions['1.0.0'],
      tarball: path.join(npm.localPrefix, 'npm'),
    })
    await npm.exec('install', ['npm'])
    t.ok('No exceptions happen')
  })

  await t.test('npm i -g npm engines check failure', async t => {
    const { npm, registry } = await loadMockNpm(t, {
      prefixDir: {
        npm: {
          'package.json': JSON.stringify({ name: 'npm', version: '1.0.0' }),
          'index.js': 'console.log("this is the npm we are installing")',
        },
      },
      config: { global: true },
    })

    const manifest = registry.manifest({
      name: 'npm',
      packuments: [{ version: '1.0.0', engines: { node: '~1' } }],
    })
    await registry.package({ manifest })
    await t.rejects(
      npm.exec('install', ['npm']),
      {
        message: 'Unsupported engine',
        pkgid: 'npm@1.0.0',
        current: {
          node: process.version,
          npm: '1.0.0',
        },
        required: {
          node: '~1',
        },
        code: 'EBADENGINE',
      }
    )
  })

  await t.test('npm i -g npm engines check failure forced override', async t => {
    const { npm, registry } = await loadMockNpm(t, {
      prefixDir: {
        npm: {
          'package.json': JSON.stringify({ name: 'npm', version: '1.0.0' }),
          'index.js': 'console.log("this is npm")',
        },
      },
      config: { global: true, force: true },
    })
    const manifest = registry.manifest({
      name: 'npm',
      packuments: [{ version: '1.0.0', engines: { node: '~1' } }],
    })
    await registry.package({ manifest, times: 2 })
    await registry.tarball({
      manifest: manifest.versions['1.0.0'],
      tarball: path.join(npm.localPrefix, 'npm'),
    })
    await npm.exec('install', ['npm'])
    t.ok('No exceptions happen')
  })

  t.test('allow-git=none', async t => {
    const { npm } = await loadMockNpm(t, {
      config: {
        'allow-git': 'none',
        audit: false,
      },
    })
    await t.rejects(
      npm.exec('install', ['npm/npm']),
      {
        code: 'EALLOWGIT',
        message: 'Fetching packages of type "git" have been disabled',
        package: 'github:npm/npm',
      }
    )
  })

  t.test('allow-git default rejects git deps', async t => {
    const { npm } = await loadMockNpm(t, {
      config: { audit: false },
    })
    await t.rejects(
      npm.exec('install', ['npm/npm']),
      {
        code: 'EALLOWGIT',
        package: 'github:npm/npm',
      },
      'no explicit allow-git config still blocks git installs'
    )
  })

  t.test('allow-git=root refuses non-root git dependency', async t => {
    const { npm } = await loadMockNpm(t, {
      config: {
        'allow-git': 'root',
        audit: false,
      },
      prefixDir: {
        'package.json': JSON.stringify({ name: '@npmcli/test-package', version: '1.0.0' }),
        abbrev: {
          'package.json': JSON.stringify({ name: 'abbrev', version: '1.0.0', dependencies: { npm: 'npm/npm' } }),
        },
      },
    })
    await t.rejects(
      npm.exec('install', ['./abbrev']),
      /Fetching non-root packages of type "git" have been disabled/
    )
  })

  t.test('allow-directory=none blocks default symlink install', async t => {
    const { npm } = await loadMockNpm(t, {
      config: {
        'allow-directory': 'none',
        audit: false,
      },
      prefixDir: {
        'package.json': JSON.stringify({
          name: '@npmcli/test-package',
          version: '1.0.0',
          dependencies: { 'dir-dep': 'file:./dir-dep' },
        }),
        'dir-dep': {
          'package.json': JSON.stringify({ name: 'dir-dep', version: '1.0.0' }),
        },
      },
    })
    await t.rejects(
      npm.exec('install', []),
      {
        code: 'EALLOWDIRECTORY',
        message: 'Fetching packages of type "directory" have been disabled',
      }
    )
  })

  t.test('allow-directory=root permits top-level directory dependency', async t => {
    const { npm } = await loadMockNpm(t, {
      config: {
        'allow-directory': 'root',
        audit: false,
      },
      prefixDir: {
        'package.json': JSON.stringify({
          name: '@npmcli/test-package',
          version: '1.0.0',
          dependencies: { 'dir-dep': 'file:./dir-dep' },
        }),
        'dir-dep': {
          'package.json': JSON.stringify({ name: 'dir-dep', version: '1.0.0' }),
        },
      },
    })
    await npm.exec('install', [])
    const installedPkg = require(path.join(npm.prefix, 'node_modules', 'dir-dep', 'package.json'))
    t.equal(installedPkg.name, 'dir-dep', 'dir-dep is installed and readable through node_modules')
  })

  t.test('allow-git=root soft-skips transitive optional git dependency', async t => {
    const { npm } = await loadMockNpm(t, {
      config: {
        'allow-git': 'root',
        audit: false,
      },
      prefixDir: {
        'package.json': JSON.stringify({ name: '@npmcli/test-package', version: '1.0.0' }),
        abbrev: {
          'package.json': JSON.stringify({
            name: 'abbrev',
            version: '1.0.0',
            optionalDependencies: { npm: 'npm/npm' },
          }),
        },
      },
    })
    await npm.exec('install', ['./abbrev'])
    t.ok(
      fs.existsSync(path.join(npm.prefix, 'node_modules', 'abbrev', 'package.json')),
      'abbrev (the legitimate parent) is installed'
    )
    t.notOk(
      fs.existsSync(path.join(npm.prefix, 'node_modules', 'npm')),
      'optional transitive git dep is silently skipped'
    )
  })

  t.test('allow-remote=none does not block registry tarballs', async t => {
    const { npm, registry } = await loadMockNpm(t, {
      config: {
        'allow-remote': 'none',
        audit: false,
      },
      prefixDir: {
        'package.json': JSON.stringify({
          ...packageJson,
          dependencies: { abbrev: '^1.0.0' },
        }),
        abbrev,
      },
    })
    const manifest = registry.manifest({ name: 'abbrev' })
    await registry.package({ manifest })
    await registry.tarball({
      manifest: manifest.versions['1.0.0'],
      tarball: path.join(npm.prefix, 'abbrev'),
    })
    await npm.exec('install', [])
    const installed = require(path.join(npm.prefix, 'node_modules', 'abbrev', 'package.json'))
    t.equal(installed.name, 'abbrev', 'registry dep is installed despite allow-remote=none')
  })

  t.test('allow-remote=none still blocks a user-supplied remote URL', async t => {
    const { npm } = await loadMockNpm(t, {
      config: {
        'allow-remote': 'none',
        audit: false,
      },
      prefixDir: {
        'package.json': JSON.stringify({
          name: '@npmcli/test-package',
          version: '1.0.0',
          dependencies: { abbrev: 'https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz' },
        }),
      },
    })
    await t.rejects(
      npm.exec('install', []),
      { code: 'EALLOWREMOTE' },
      'user-supplied remote URL is still blocked'
    )
  })

  t.test('allow-remote default rejects a user-supplied remote URL', async t => {
    const { npm } = await loadMockNpm(t, {
      config: { audit: false },
      prefixDir: {
        'package.json': JSON.stringify({
          name: '@npmcli/test-package',
          version: '1.0.0',
          dependencies: { abbrev: 'https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz' },
        }),
      },
    })
    await t.rejects(
      npm.exec('install', []),
      { code: 'EALLOWREMOTE' },
      'no explicit allow-remote config still blocks user-supplied tarball URLs'
    )
  })
})

t.test('completion', async t => {
  const mockComp = async (t, { noChdir } = {}) => loadMockNpm(t, {
    command: 'install',
    prefixDir: {
      arborist: {
        'package.json': '{}',
      },
      'arborist.txt': 'just a file',
      'other-dir': { a: 'a' },
    },
    ...(noChdir ? { chdir: false } : {}),
  })

  await t.test('completion to folder - has a match', async t => {
    const { install } = await mockComp(t)
    const res = await install.completion({ partialWord: './ar' })
    t.strictSame(res, ['arborist'], 'package dir match')
  })

  await t.test('completion to folder - invalid dir', async t => {
    const { install } = await mockComp(t, { noChdir: true })
    const res = await install.completion({ partialWord: '/does/not/exist' })
    t.strictSame(res, [], 'invalid dir: no matching')
  })

  await t.test('completion to folder - no matches', async t => {
    const { install } = await mockComp(t)
    const res = await install.completion({ partialWord: './pa' })
    t.strictSame(res, [], 'no name match')
  })

  await t.test('completion to folder - match is not a package', async t => {
    const { install } = await mockComp(t)
    const res = await install.completion({ partialWord: './othe' })
    t.strictSame(res, [], 'no name match')
  })

  await t.test('completion to url', async t => {
    const { install } = await mockComp(t)
    const res = await install.completion({ partialWord: 'http://path/to/url' })
    t.strictSame(res, [])
  })

  await t.test('no /', async t => {
    const { install } = await mockComp(t)
    const res = await install.completion({ partialWord: 'toto' })
    t.notOk(res)
  })

  await t.test('only /', async t => {
    const { install } = await mockComp(t)
    const res = await install.completion({ partialWord: '/' })
    t.strictSame(res, [])
  })
})

t.test('should install in workspace with unhoisted module', async t => {
  const { npm, registry, assert } = await loadMockNpm(t, {
    prefixDir: workspaceMock(t, {
      clean: true,
      workspaces: {
        'workspace-a': {
          'abbrev@1.1.0': { hoist: true },
        },
        'workspace-b': {
          'abbrev@1.1.1': { hoist: false },
        },
      },
    }),
  })
  await registry.setup({
    'abbrev@1.1.0': path.join(npm.prefix, 'tarballs/abbrev@1.1.0'),
    'abbrev@1.1.1': path.join(npm.prefix, 'tarballs/abbrev@1.1.1'),
  })
  registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {})
  assert.packageMissing('node_modules/abbrev@1.1.0')
  assert.packageMissing('workspace-b/node_modules/abbrev@1.1.1')
  await npm.exec('install', [])
  assert.packageInstalled('node_modules/abbrev@1.1.0')
  assert.packageInstalled('workspace-b/node_modules/abbrev@1.1.1')
})

t.test('should install in workspace with hoisted modules', async t => {
  const prefixDir = workspaceMock(t, {
    clean: true,
    workspaces: {
      'workspace-a': {
        'abbrev@1.1.0': { hoist: true },
      },
      'workspace-b': {
        'lodash@1.1.1': { hoist: true },
      },
    },
  })
  const { npm, registry, assert } = await loadMockNpm(t, { prefixDir })
  await registry.setup({
    'abbrev@1.1.0': path.join(npm.prefix, 'tarballs/abbrev@1.1.0'),
    'lodash@1.1.1': path.join(npm.prefix, 'tarballs/lodash@1.1.1'),
  })
  registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {})
  assert.packageMissing('node_modules/abbrev@1.1.0')
  assert.packageMissing('node_modules/lodash@1.1.1')
  await npm.exec('install', [])
  assert.packageInstalled('node_modules/abbrev@1.1.0')
  assert.packageInstalled('node_modules/lodash@1.1.1')
})

t.test('should install unhoisted module with --workspace flag', async t => {
  const { npm, registry, assert } = await loadMockNpm(t, {
    config: {
      workspace: 'workspace-b',
    },
    prefixDir: workspaceMock(t, {
      clean: true,
      workspaces: {
        'workspace-a': {
          'abbrev@1.1.0': { hoist: true },
        },
        'workspace-b': {
          'abbrev@1.1.1': { hoist: false },
        },
      },
    }),
  })
  await registry.setup({
    'abbrev@1.1.1': path.join(npm.prefix, 'tarballs/abbrev@1.1.1'),
  })
  registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {})
  assert.packageMissing('node_modules/abbrev@1.1.0')
  assert.packageMissing('workspace-b/node_modules/abbrev@1.1.1')
  await npm.exec('install', [])
  assert.packageMissing('node_modules/abbrev@1.1.0')
  assert.packageInstalled('workspace-b/node_modules/abbrev@1.1.1')
})

t.test('should install hoisted module with --workspace flag', async t => {
  const { npm, registry, assert } = await loadMockNpm(t, {
    config: {
      workspace: 'workspace-b',
    },
    prefixDir: workspaceMock(t, {
      clean: true,
      workspaces: {
        'workspace-a': {
          'abbrev@1.1.0': { hoist: true },
        },
        'workspace-b': {
          'lodash@1.1.1': { hoist: true },
        },
      },
    }),
  })
  await registry.setup({
    'lodash@1.1.1': path.join(npm.prefix, 'tarballs/lodash@1.1.1'),
  })
  registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {})
  assert.packageMissing('node_modules/abbrev@1.1.0')
  assert.packageMissing('node_modules/lodash@1.1.1')
  await npm.exec('install', [])
  assert.packageMissing('node_modules/abbrev@1.1.0')
  assert.packageInstalled('node_modules/lodash@1.1.1')
})

t.test('should show install keeps dirty --workspace flag', async t => {
  const { npm, registry, assert } = await loadMockNpm(t, {
    config: {
      workspace: 'workspace-b',
    },
    prefixDir: workspaceMock(t, {
      workspaces: {
        'workspace-a': {
          'abbrev@1.1.0': { clean: false, hoist: true },
        },
        'workspace-b': {
          'lodash@1.1.1': { clean: true, hoist: true },
        },
      },
    }),
  })
  await registry.setup({
    'lodash@1.1.1': path.join(npm.prefix, 'tarballs/lodash@1.1.1'),
  })
  registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {})
  assert.packageDirty('node_modules/abbrev@1.1.0')
  assert.packageMissing('node_modules/lodash@1.1.1')
  await npm.exec('install', [])
  assert.packageDirty('node_modules/abbrev@1.1.0')
  assert.packageInstalled('node_modules/lodash@1.1.1')
})

t.test('devEngines', async t => {
  const mockArguments = {
    globals: {
      'process.platform': 'linux',
      'process.arch': 'x86',
      'process.version': 'v1337.0.0',
    },
    mocks: {
      '{ROOT}/package.json': { version: '42.0.0' },
    },
  }

  t.test('should utilize devEngines success case', async t => {
    const { npm, joinedFullOutput } = await loadMockNpm(t, {
      ...mockArguments,
      prefixDir: {
        'package.json': JSON.stringify({
          name: 'test-package',
          version: '1.0.0',
          devEngines: {
            runtime: {
              name: 'node',
            },
          },
        }),
      },
    })
    await npm.exec('install', [])
    const output = joinedFullOutput()
    t.matchSnapshot(output)
    t.ok(!output.includes('EBADDEVENGINES'))
  })

  t.test('should utilize devEngines failure case', async t => {
    const { npm, joinedFullOutput } = await loadMockNpm(t, {
      ...mockArguments,
      prefixDir: {
        'package.json': JSON.stringify({
          name: 'test-package',
          version: '1.0.0',
          devEngines: {
            runtime: {
              name: 'nondescript',
            },
          },
        }),
      },
    })
    await t.rejects(
      npm.exec('install', [])
    )
    const output = joinedFullOutput()
    t.matchSnapshot(output)
    t.ok(output.includes('error EBADDEVENGINES'))
  })

  t.test('should utilize devEngines failure force case', async t => {
    const { npm, joinedFullOutput } = await loadMockNpm(t, {
      ...mockArguments,
      config: {
        force: true,
      },
      prefixDir: {
        'package.json': JSON.stringify({
          name: 'test-package',
          version: '1.0.0',
          devEngines: {
            runtime: {
              name: 'nondescript',
            },
          },
        }),
      },
    })
    await npm.exec('install', [])
    const output = joinedFullOutput()
    t.matchSnapshot(output)
    t.ok(output.includes('warn EBADDEVENGINES'))
  })

  t.test('should utilize devEngines 2x warning case', async t => {
    const { npm, joinedFullOutput } = await loadMockNpm(t, {
      ...mockArguments,
      prefixDir: {
        'package.json': JSON.stringify({
          name: 'test-package',
          version: '1.0.0',
          devEngines: {
            runtime: {
              name: 'nondescript',
              onFail: 'warn',
            },
            cpu: {
              name: 'risv',
              onFail: 'warn',
            },
          },
        }),
      },
    })
    await npm.exec('install', [])
    const output = joinedFullOutput()
    t.matchSnapshot(output)
    t.ok(output.includes('warn EBADDEVENGINES'))
  })

  t.test('should utilize devEngines 2x error case', async t => {
    const { npm, joinedFullOutput } = await loadMockNpm(t, {
      ...mockArguments,
      prefixDir: {
        'package.json': JSON.stringify({
          name: 'test-package',
          version: '1.0.0',
          devEngines: {
            runtime: {
              name: 'nondescript',
              onFail: 'error',
            },
            cpu: {
              name: 'risv',
              onFail: 'error',
            },
          },
        }),
      },
    })
    await t.rejects(
      npm.exec('install', [])
    )
    const output = joinedFullOutput()
    t.matchSnapshot(output)
    t.ok(output.includes('error EBADDEVENGINES'))
  })

  t.test('should utilize devEngines failure and warning case', async t => {
    const { npm, joinedFullOutput } = await loadMockNpm(t, {
      ...mockArguments,
      prefixDir: {
        'package.json': JSON.stringify({
          name: 'test-package',
          version: '1.0.0',
          devEngines: {
            runtime: {
              name: 'nondescript',
            },
            cpu: {
              name: 'risv',
              onFail: 'warn',
            },
          },
        }),
      },
    })
    await t.rejects(
      npm.exec('install', [])
    )
    const output = joinedFullOutput()
    t.matchSnapshot(output)
    t.ok(output.includes('EBADDEVENGINES'))
  })

  t.test('should show devEngines has no effect on package install', async t => {
    const { npm, joinedFullOutput } = await loadMockNpm(t, {
      ...mockArguments,
      prefixDir: {
        alpha: {
          'package.json': JSON.stringify({
            name: 'alpha',
            devEngines: { runtime: { name: 'node', version: '1.0.0' } },
          }),
          'index.js': 'console.log("this is alpha index")',
        },
        'package.json': JSON.stringify({
          name: 'project',
        }),
      },
    })
    await npm.exec('install', ['./alpha'])
    const output = joinedFullOutput()
    t.matchSnapshot(output)
    t.ok(!output.includes('EBADDEVENGINES'))
  })

  t.test('should show devEngines has no effect on dev package install', async t => {
    const { npm, joinedFullOutput } = await loadMockNpm(t, {
      ...mockArguments,
      prefixDir: {
        alpha: {
          'package.json': JSON.stringify({
            name: 'alpha',
            devEngines: { runtime: { name: 'node', version: '1.0.0' } },
          }),
          'index.js': 'console.log("this is alpha index")',
        },
        'package.json': JSON.stringify({
          name: 'project',
        }),
      },
      config: {
        'save-dev': true,
      },
    })
    await npm.exec('install', ['./alpha'])
    const output = joinedFullOutput()
    t.matchSnapshot(output)
    t.ok(!output.includes('EBADDEVENGINES'))
  })

  t.test('should show devEngines doesnt break engines', async t => {
    const { npm, joinedFullOutput } = await loadMockNpm(t, {
      ...mockArguments,
      prefixDir: {
        alpha: {
          'package.json': JSON.stringify({
            name: 'alpha',
            devEngines: { runtime: { name: 'node', version: '1.0.0' } },
            engines: { node: '1.0.0' },
          }),
          'index.js': 'console.log("this is alpha index")',
        },
        'package.json': JSON.stringify({
          name: 'project',
        }),
      },
      config: { global: true },
    })
    await npm.exec('install', ['./alpha'])
    const output = joinedFullOutput()
    t.matchSnapshot(output)
    t.ok(output.includes('warn EBADENGINE'))
  })

  t.test('should not utilize engines in root if devEngines is provided', async t => {
    const { npm, joinedFullOutput } = await loadMockNpm(t, {
      ...mockArguments,
      prefixDir: {
        'package.json': JSON.stringify({
          name: 'alpha',
          engines: {
            node: '0.0.1',
          },
          devEngines: {
            runtime: {
              name: 'node',
              version: '0.0.1',
              onFail: 'warn',
            },
          },
        }),
        'index.js': 'console.log("this is alpha index")',
      },
    })
    await npm.exec('install')
    const output = joinedFullOutput()
    t.matchSnapshot(output)
    t.ok(!output.includes('EBADENGINE'))
    t.ok(output.includes('warn EBADDEVENGINES'))
  })

  t.test('should utilize engines in root if devEngines is not provided', async t => {
    const { npm, joinedFullOutput } = await loadMockNpm(t, {
      ...mockArguments,
      prefixDir: {
        'package.json': JSON.stringify({
          name: 'alpha',
          engines: {
            node: '0.0.1',
          },
        }),
        'index.js': 'console.log("this is alpha index")',
      },
    })
    await npm.exec('install')
    const output = joinedFullOutput()
    t.matchSnapshot(output)
    t.ok(output.includes('EBADENGINE'))
    t.ok(!output.includes('EBADDEVENGINES'))
  })

  t.test('should show devEngines has no effect on global package install', async t => {
    const { npm, joinedFullOutput } = await loadMockNpm(t, {
      ...mockArguments,
      prefixDir: {
        'package.json': JSON.stringify({
          name: 'alpha',
          bin: {
            alpha: 'index.js',
          },
          devEngines: {
            runtime: {
              name: 'node',
              version: '0.0.1',
            },
          },
        }),
        'index.js': 'console.log("this is alpha index")',
      },
      config: {
        global: true,
      },
    })
    await npm.exec('install', ['.'])
    const output = joinedFullOutput()
    t.matchSnapshot(output)
    t.ok(!output.includes('EBADENGINE'))
    t.ok(!output.includes('EBADDEVENGINES'))
  })
})

// Issue #8726 - npm install should re-resolve to satisfy peerOptional constraints
// https://github.com/npm/cli/issues/8726
//
// When a lockfile has fetcher@1.1.0 but a peerOptional wants fetcher@1.0.0 (exact), npm install (save: true) should re-resolve fetcher to 1.0.0 to satisfy both the regular dep range (^1.0.0) and the exact peerOptional constraint.
t.test('issue-8726: npm install re-resolves to satisfy peerOptional constraint', async t => {
  const { npm, registry } = await loadMockNpm(t, {
    config: { audit: false, 'ignore-scripts': true },
    prefixDir: {
      'linter-tarball': {
        'package.json': JSON.stringify({
          name: 'linter',
          version: '1.0.0',
          dependencies: { scanner: '1.0.0' },
        }),
      },
      'scanner-tarball': {
        'package.json': JSON.stringify({
          name: 'scanner',
          version: '1.0.0',
          peerDependencies: { fetcher: '1.0.0' },
          peerDependenciesMeta: { fetcher: { optional: true } },
        }),
      },
      'hint-tarball': {
        'package.json': JSON.stringify({
          name: 'hint',
          version: '1.0.0',
          dependencies: { fetcher: '^1.0.0' },
        }),
      },
      'fetcher-1.0.0-tarball': {
        'package.json': JSON.stringify({ name: 'fetcher', version: '1.0.0' }),
      },
      'fetcher-1.1.0-tarball': {
        'package.json': JSON.stringify({ name: 'fetcher', version: '1.1.0' }),
      },
      'package.json': JSON.stringify({
        name: 'test-package',
        version: '1.0.0',
        devDependencies: {
          linter: '1.0.0',
          hint: '1.0.0',
        },
      }),
      'package-lock.json': JSON.stringify({
        name: 'test-package',
        version: '1.0.0',
        lockfileVersion: 3,
        requires: true,
        packages: {
          '': {
            name: 'test-package',
            version: '1.0.0',
            devDependencies: { linter: '1.0.0', hint: '1.0.0' },
          },
          'node_modules/linter': {
            version: '1.0.0',
            resolved: 'https://registry.npmjs.org/linter/-/linter-1.0.0.tgz',
            dev: true,
            dependencies: { scanner: '1.0.0' },
          },
          'node_modules/scanner': {
            version: '1.0.0',
            resolved: 'https://registry.npmjs.org/scanner/-/scanner-1.0.0.tgz',
            dev: true,
            peerDependencies: { fetcher: '1.0.0' },
            peerDependenciesMeta: { fetcher: { optional: true } },
          },
          'node_modules/hint': {
            version: '1.0.0',
            resolved: 'https://registry.npmjs.org/hint/-/hint-1.0.0.tgz',
            dev: true,
            dependencies: { fetcher: '^1.0.0' },
          },
          'node_modules/fetcher': {
            version: '1.1.0',
            resolved: 'https://registry.npmjs.org/fetcher/-/fetcher-1.1.0.tgz',
            dev: true,
          },
        },
      }),
    },
  })

  // Only set up mocks that npm install actually needs: tarballs for all installed packages (linter, scanner, hint, fetcher@1.0.0) and the fetcher packument (needed for re-resolution via #problemEdges).
  // Packuments for linter/scanner/hint are NOT needed (already in lockfile).
  // Fetcher@1.1.0 tarball is NOT needed (gets replaced by 1.0.0).
  const linterManifest = registry.manifest({ name: 'linter' })
  await registry.tarball({
    manifest: linterManifest.versions['1.0.0'],
    tarball: path.join(npm.prefix, 'linter-tarball'),
  })

  const scannerManifest = registry.manifest({ name: 'scanner' })
  await registry.tarball({
    manifest: scannerManifest.versions['1.0.0'],
    tarball: path.join(npm.prefix, 'scanner-tarball'),
  })

  const hintManifest = registry.manifest({ name: 'hint' })
  await registry.tarball({
    manifest: hintManifest.versions['1.0.0'],
    tarball: path.join(npm.prefix, 'hint-tarball'),
  })

  const fetcherManifest = registry.manifest({
    name: 'fetcher',
    versions: ['1.0.0', '1.1.0'],
  })
  await registry.package({ manifest: fetcherManifest })
  await registry.tarball({
    manifest: fetcherManifest.versions['1.0.0'],
    tarball: path.join(npm.prefix, 'fetcher-1.0.0-tarball'),
  })

  await npm.exec('install', [])

  // Read the updated lockfile and verify fetcher was re-resolved to 1.0.0
  const lockfile = JSON.parse(
    fs.readFileSync(path.join(npm.prefix, 'package-lock.json'), 'utf8')
  )
  t.equal(
    lockfile.packages['node_modules/fetcher'].version,
    '1.0.0',
    'lockfile updated fetcher to satisfy peerOptional constraint'
  )

  // Also verify the installed package
  const installedFetcher = JSON.parse(
    fs.readFileSync(
      path.join(npm.prefix, 'node_modules', 'fetcher', 'package.json'), 'utf8'
    )
  )
  t.equal(
    installedFetcher.version,
    '1.0.0',
    'installed fetcher version satisfies peerOptional constraint'
  )
})

// Issue #8726 - fresh npm install (no lockfile) should pick a version that satisfies both the regular dep range AND the exact peerOptional constraint, even when the peerOptional holder is processed BEFORE the dep is placed.
// https://github.com/npm/cli/issues/8726
//
// This test uses package names that reproduce the real-world alphabetical ordering from the original issue (addons-linter < htmlhint), which causes addons-scanner to be processed from the queue BEFORE htmlhint places node-fetcher.
// At that point the peerOptional edge has no destination (MISSING, valid for peerOptional).
// Later, htmlhint places node-fetcher@1.1.0 and the edge becomes INVALID.
// The fix re-queues addons-scanner so #problemEdges can trigger re-resolution of node-fetcher to 1.0.0.
//
// Dependency graph:
//   root -> addons-linter@1.0.0 -> addons-scanner@1.0.0 -> PEER_OPTIONAL node-fetcher@1.0.0
//   root -> htmlhint@1.0.0      -> node-fetcher@^1.0.0
//
// Processing order (alphabetical): addons-linter, then addons-scanner (dep of addons-linter), THEN htmlhint (which places node-fetcher@1.1.0)
t.test('issue-8726: fresh install re-queues scanner when dep placed later', async t => {
  const { npm, registry } = await loadMockNpm(t, {
    config: { audit: false, 'ignore-scripts': true },
    prefixDir: {
      'addons-linter-tarball': {
        'package.json': JSON.stringify({
          name: 'addons-linter',
          version: '1.0.0',
          dependencies: { 'addons-scanner': '1.0.0' },
        }),
      },
      'addons-scanner-tarball': {
        'package.json': JSON.stringify({
          name: 'addons-scanner',
          version: '1.0.0',
          peerDependencies: { 'node-fetcher': '1.0.0' },
          peerDependenciesMeta: { 'node-fetcher': { optional: true } },
        }),
      },
      'htmlhint-tarball': {
        'package.json': JSON.stringify({
          name: 'htmlhint',
          version: '1.0.0',
          dependencies: { 'node-fetcher': '^1.0.0' },
        }),
      },
      'node-fetcher-1.0.0-tarball': {
        'package.json': JSON.stringify({ name: 'node-fetcher', version: '1.0.0' }),
      },
      'node-fetcher-1.1.0-tarball': {
        'package.json': JSON.stringify({ name: 'node-fetcher', version: '1.1.0' }),
      },
      'package.json': JSON.stringify({
        name: 'test-package',
        version: '1.0.0',
        devDependencies: {
          'addons-linter': '1.0.0',
          htmlhint: '1.0.0',
        },
      }),
      // NO package-lock.json — this is a fresh install
    },
  })

  // Fresh install needs packuments for all packages
  const linterManifest = registry.manifest({
    name: 'addons-linter',
    packuments: [{ version: '1.0.0', dependencies: { 'addons-scanner': '1.0.0' } }],
  })
  await registry.package({ manifest: linterManifest })
  await registry.tarball({
    manifest: linterManifest.versions['1.0.0'],
    tarball: path.join(npm.prefix, 'addons-linter-tarball'),
  })

  const scannerManifest = registry.manifest({
    name: 'addons-scanner',
    packuments: [{
      version: '1.0.0',
      peerDependencies: { 'node-fetcher': '1.0.0' },
      peerDependenciesMeta: { 'node-fetcher': { optional: true } },
    }],
  })
  await registry.package({ manifest: scannerManifest })
  await registry.tarball({
    manifest: scannerManifest.versions['1.0.0'],
    tarball: path.join(npm.prefix, 'addons-scanner-tarball'),
  })

  const hintManifest = registry.manifest({
    name: 'htmlhint',
    packuments: [{ version: '1.0.0', dependencies: { 'node-fetcher': '^1.0.0' } }],
  })
  await registry.package({ manifest: hintManifest })
  await registry.tarball({
    manifest: hintManifest.versions['1.0.0'],
    tarball: path.join(npm.prefix, 'htmlhint-tarball'),
  })

  const fetcherManifest = registry.manifest({
    name: 'node-fetcher',
    packuments: [{ version: '1.0.0' }, { version: '1.1.0' }],
  })
  // Packument is fetched twice: once when htmlhint resolves node-fetcher@^1.0.0 (picking 1.1.0), and again when addons-scanner is re-queued and re-resolves node-fetcher (picking 1.0.0 to satisfy the exact peerOptional spec).
  await registry.package({ manifest: fetcherManifest, times: 2 })
  await registry.tarball({
    manifest: fetcherManifest.versions['1.0.0'],
    tarball: path.join(npm.prefix, 'node-fetcher-1.0.0-tarball'),
  })
  // node-fetcher@1.1.0 tarball is NOT needed: it's replaced by 1.0.0 during tree building (before reification), so it's never downloaded.

  await npm.exec('install', [])

  // Verify the lockfile has node-fetcher@1.0.0
  const lockfile = JSON.parse(
    fs.readFileSync(path.join(npm.prefix, 'package-lock.json'), 'utf8')
  )
  t.equal(
    lockfile.packages['node_modules/node-fetcher'].version,
    '1.0.0',
    'fresh install picks node-fetcher@1.0.0 satisfying peerOptional constraint'
  )

  // Also verify the installed package
  const installedFetcher = JSON.parse(
    fs.readFileSync(
      path.join(npm.prefix, 'node_modules', 'node-fetcher', 'package.json'), 'utf8'
    )
  )
  t.equal(
    installedFetcher.version,
    '1.0.0',
    'installed node-fetcher version satisfies peerOptional constraint'
  )
})
