current commit
1db885c84 · feat: native dependency patching (npm patch add/commit/update/ls/rm) (#9439)
const t = require('tap')
const { resolve } = require('node:path')
const { readFileSync, existsSync, symlinkSync } = require('node:fs')
const { diffDirs } = require('../../../lib/utils/patch-diff.js')
const { applyPatchToDir } = require('@npmcli/arborist/lib/patch.js')

// Helper to read a file from a dir as utf8.
const read = (...p) => readFileSync(resolve(...p), 'utf8')

t.test('modified file produces a unified diff', async t => {
  const dir = t.testdir({
    orig: { 'index.js': 'hello\n' },
    edit: { 'index.js': 'world\n' },
  })
  const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
  t.match(diff, '--- a/index.js', 'has old header')
  t.match(diff, '+++ b/index.js', 'has new header')
  t.match(diff, '-hello', 'removes old line')
  t.match(diff, '+world', 'adds new line')
  t.notMatch(diff, '====', 'index separator is stripped')
})

t.test('added file uses --- /dev/null', async t => {
  const dir = t.testdir({
    orig: { 'keep.js': 'same\n' },
    edit: { 'keep.js': 'same\n', 'added.js': 'brand new\n' },
  })
  const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
  t.match(diff, '--- /dev/null', 'old side is /dev/null')
  t.match(diff, '+++ b/added.js', 'new side names the added file')
  t.match(diff, '+brand new', 'includes added content')
  t.notMatch(diff, 'keep.js', 'identical file is not in the diff')
})

t.test('deleted file uses +++ /dev/null', async t => {
  const dir = t.testdir({
    orig: { 'gone.js': 'remove me\n' },
    edit: {},
  })
  const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
  t.match(diff, '--- a/gone.js', 'old side names the deleted file')
  t.match(diff, '+++ /dev/null', 'new side is /dev/null')
  t.match(diff, '-remove me', 'includes removed content')
})

t.test('nested file path is posix-separated in the diff', async t => {
  const dir = t.testdir({
    orig: { lib: { deep: { 'x.js': 'a\n' } } },
    edit: { lib: { deep: { 'x.js': 'b\n' } } },
  })
  const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
  t.match(diff, '--- a/lib/deep/x.js', 'old header uses posix separators')
  t.match(diff, '+++ b/lib/deep/x.js', 'new header uses posix separators')
})

t.test('identical files produce no diff', async t => {
  const dir = t.testdir({
    orig: { 'a.js': 'x\n', sub: { 'b.js': 'y\n' } },
    edit: { 'a.js': 'x\n', sub: { 'b.js': 'y\n' } },
  })
  const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
  t.equal(diff, '', 'empty diff for identical trees')
})

t.test('node_modules and .git are ignored', async t => {
  const dir = t.testdir({
    orig: {
      'index.js': 'v1\n',
      node_modules: { dep: { 'index.js': 'old\n' } },
      '.git': { HEAD: 'ref: refs/heads/main\n' },
    },
    edit: {
      'index.js': 'v2\n',
      node_modules: { dep: { 'index.js': 'changed\n' } },
      '.git': { HEAD: 'ref: refs/heads/other\n' },
    },
  })
  const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
  t.match(diff, 'index.js', 'top-level change is captured')
  t.notMatch(diff, 'node_modules', 'node_modules contents are excluded')
  t.notMatch(diff, 'HEAD', '.git contents are excluded')
})

t.test('root package.json is excluded and flagged, nested is kept', async t => {
  const dir = t.testdir({
    orig: {
      'package.json': '{ "version": "1.0.0" }\n',
      'index.js': 'a\n',
      sub: { 'package.json': '{ "private": true }\n' },
    },
    edit: {
      'package.json': '{ "version": "2.0.0" }\n',
      'index.js': 'b\n',
      sub: { 'package.json': '{ "private": false }\n' },
    },
  })
  const { diff, packageJsonChanged } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
  t.equal(packageJsonChanged, true, 'root package.json change is flagged')
  t.notMatch(diff, 'a/package.json\t', 'root package.json is not in the diff')
  t.match(diff, 'a/sub/package.json', 'nested package.json is still diffed')
  t.match(diff, 'a/index.js', 'other files are still diffed')
})

t.test('packageJsonChanged is false when only other files change', async t => {
  const dir = t.testdir({
    orig: { 'package.json': '{ "version": "1.0.0" }\n', 'index.js': 'a\n' },
    edit: { 'package.json': '{ "version": "1.0.0" }\n', 'index.js': 'b\n' },
  })
  const { diff, packageJsonChanged } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
  t.equal(packageJsonChanged, false, 'unchanged package.json is not flagged')
  t.match(diff, 'a/index.js', 'the real change is captured')
})

t.test('non-file entries like symlinks are skipped', async t => {
  const dir = t.testdir({
    orig: { 'real.js': 'a\n' },
    edit: { 'real.js': 'b\n' },
  })
  // A symlink is neither a directory nor a regular file so it is ignored.
  symlinkSync(resolve(dir, 'orig', 'real.js'), resolve(dir, 'edit', 'link.js'))
  const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
  t.match(diff, 'real.js', 'regular file is diffed')
  t.notMatch(diff, 'link.js', 'symlink entry is skipped')
})

t.test('round-trip: applying the diff reproduces the edited tree', async t => {
  const dir = t.testdir({
    orig: {
      'mod.js': 'original line\n',
      'del.js': 'doomed\n',
      lib: { deep: { 'x.js': 'before\n' } },
    },
    edit: {
      'mod.js': 'patched line\n',
      'add.js': 'fresh content\n',
      lib: { deep: { 'x.js': 'after\n' } },
    },
  })
  const orig = resolve(dir, 'orig')
  const { diff } = await diffDirs(orig, resolve(dir, 'edit'))

  // Apply the diff back onto a copy of the original and check the result.
  await applyPatchToDir({ patch: diff, cwd: orig })

  t.equal(read(orig, 'mod.js'), 'patched line\n', 'modified file matches edit')
  t.equal(read(orig, 'add.js'), 'fresh content\n', 'added file was created')
  t.equal(read(orig, 'lib', 'deep', 'x.js'), 'after\n', 'nested file matches edit')
  t.notOk(existsSync(resolve(orig, 'del.js')), 'deleted file was removed')
})