1const t = require('tap')
2const { spawnSync } = require('child_process')
3const { resolve, join, extname, basename, sep } = require('path')
4const { copyFileSync, readFileSync, chmodSync, readdirSync, rmSync, statSync } = require('fs')
5const Diff = require('diff')
6const { sync: which } = require('which')
7const { version } = require('../../package.json')
8
9const readNonJsFiles = (dir) => readdirSync(dir).reduce((acc, shim) => {
10  const p = join(dir, shim)
11  if (extname(p) !== '.js' && !statSync(p).isDirectory()) {
12    acc[shim] = readFileSync(p, 'utf-8')
13  }
14  return acc
15}, {})
16
17const ROOT = resolve(__dirname, '../..')
18const BIN = join(ROOT, 'bin')
19const SHIMS = readNonJsFiles(BIN)
20const NODE_GYP = readNonJsFiles(join(BIN, 'node-gyp-bin'))
21const SHIM_EXTS = [...new Set(Object.keys(SHIMS).map(p => extname(p)))]
22
23// windows requires each segment of a command path to be quoted when using shell: true
24const quotePath = (cmd) => cmd
25  .split(sep)
26  .map(p => p.includes(' ') ? `"${p}"` : p)
27  .join(sep)
28
29t.test('shim contents', t => {
30  // these scripts should be kept in sync so this tests the contents of each
31  // and does a diff to ensure the only differences between them are necessary
32  const diffFiles = (npm, npx) => Diff.diffChars(npm, npx)
33    .filter(v => v.added || v.removed)
34    .reduce((acc, v) => {
35      if (v.value.length === 1) {
36        acc.letters.add(v.value.toUpperCase())
37      } else {
38        acc.diff.push(v.value)
39      }
40      return acc
41    }, { diff: [], letters: new Set() })
42
43  t.plan(SHIM_EXTS.length)
44
45  t.test('bash', t => {
46    const { diff, letters } = diffFiles(SHIMS.npm, SHIMS.npx)
47    t.match(diff[0].split('\n').reverse().join(''), /^NPX_CLI_JS=/, 'has NPX_CLI')
48    t.equal(diff.length, 1)
49    t.strictSame([...letters], ['M', 'X'], 'all other changes are m->x')
50    t.end()
51  })
52
53  t.test('cmd', t => {
54    const { diff, letters } = diffFiles(SHIMS['npm.cmd'], SHIMS['npx.cmd'])
55    t.match(diff[0], /^SET "NPX_CLI_JS=/, 'has NPX_CLI')
56    t.equal(diff.length, 1)
57    t.strictSame([...letters], ['M', 'X'], 'all other changes are m->x')
58    t.end()
59  })
60
61  t.test('pwsh', t => {
62    const { diff, letters } = diffFiles(SHIMS['npm.ps1'], SHIMS['npx.ps1'])
63    t.equal(diff.length, 0)
64    t.strictSame([...letters], ['M', 'X'], 'all other changes are m->x')
65    t.end()
66  })
67})
68
69t.test('node-gyp', t => {
70  // these files need to exist to avoid breaking yarn 1.x
71
72  for (const [key, file] of Object.entries(NODE_GYP)) {
73    t.match(file, /npm_config_node_gyp/, `${key} contains env var`)
74    t.match(
75      file,
76      /[\\/]\.\.[\\/]\.\.[\\/]node_modules[\\/]node-gyp[\\/]bin[\\/]node-gyp\.js/,
77      `${key} contains path`
78    )
79  }
80
81  t.end()
82})
83
84t.test('run shims', t => {
85  const path = t.testdir({
86    ...SHIMS,
87    // simulate the state where one version of npm is installed
88    // with node, but we should load the globally installed one
89    'global-prefix': {
90      node_modules: {
91        npm: t.fixture('symlink', ROOT),
92      },
93    },
94    // put in a shim that ONLY prints the intended global prefix,
95    // and should not be used for anything else.
96    node_modules: {
97      npm: {
98        bin: {
99          'npx-cli.js': `throw new Error('this should not be called')`,
100          'npm-cli.js': `
101            const assert = require('assert')
102            const { resolve } = require('path')
103            assert.equal(process.argv.slice(2).join(' '), 'prefix -g')
104            console.log(resolve(__dirname, '../../../global-prefix'))
105          `,
106        },
107      },
108    },
109  })
110
111  // hacky fix to decrease flakes of this test from `NOTEMPTY: directory not empty, rmdir`
112  // this should get better in tap@18 and we can try removing it then
113  copyFileSync(process.execPath, join(path, 'node.exe'))
114  t.teardown(async () => {
115    rmSync(join(path, 'node.exe'))
116    await new Promise(res => setTimeout(res, 100))
117    // this is superstition
118    rmSync(join(path, 'node.exe'), { force: true })
119  })
120
121  const spawnPath = (cmd, args, { log, stdioString = true, ...opts } = {}) => {
122    if (cmd.endsWith('bash.exe')) {
123      // only cygwin *requires* the -l, but the others are ok with it
124      args.unshift('-l')
125    }
126    const result = spawnSync(cmd, args, {
127      // don't hit the registry for the update check
128      env: { PATH: path, npm_config_update_notifier: 'false' },
129      cwd: path,
130      windowsHide: true,
131      ...opts,
132    })
133    if (stdioString) {
134      result.stdout = result.stdout?.toString()?.trim()
135      result.stderr = result.stderr?.toString()?.trim()
136    }
137    return {
138      status: result.status,
139      signal: result.signal,
140      stdout: result.stdout,
141      stderr: result.stderr,
142    }
143  }
144
145  const getWslVersion = (cmd) => {
146    const defaultVersion = 1
147    try {
148      const opts = { shell: cmd, env: process.env }
149      const wsl = spawnPath('wslpath', [`'${which('wsl')}'`], opts).stdout
150      const distrosRaw = spawnPath(wsl, ['-l', '-v'], { ...opts, stdioString: false }).stdout
151      const distros = spawnPath('iconv', ['-f', 'unicode'], { ...opts, input: distrosRaw }).stdout
152      const distroArgs = distros
153        .replace(/\r\n/g, '\n')
154        .split('\n')
155        .slice(1)
156        .find(d => d.startsWith('*'))
157        .replace(/\s+/g, ' ')
158        .split(' ')
159      return Number(distroArgs[distroArgs.length - 1]) || defaultVersion
160    } catch {
161      return defaultVersion
162    }
163  }
164
165  for (const shim of Object.keys(SHIMS)) {
166    chmodSync(join(path, shim), 0o755)
167  }
168
169  const { ProgramFiles = '/', SystemRoot = '/', NYC_CONFIG, WINDOWS_SHIMS_TEST } = process.env
170  const skipDefault = WINDOWS_SHIMS_TEST || process.platform === 'win32'
171    ? null : 'test not relevant on platform'
172
173  const shells = Object.entries({
174    cmd: 'cmd',
175    pwsh: 'pwsh',
176    git: join(ProgramFiles, 'Git', 'bin', 'bash.exe'),
177    'user git': join(ProgramFiles, 'Git', 'usr', 'bin', 'bash.exe'),
178    wsl: join(SystemRoot, 'System32', 'bash.exe'),
179    cygwin: resolve(SystemRoot, '/', 'cygwin64', 'bin', 'bash.exe'),
180  }).map(([name, cmd]) => {
181    let match = {}
182    const skip = { reason: skipDefault, fail: WINDOWS_SHIMS_TEST }
183    const isBash = cmd.endsWith('bash.exe')
184    const testName = `${name} ${isBash ? 'bash' : ''}`.trim()
185
186    if (!skip.reason) {
187      if (isBash) {
188        try {
189          // If WSL is installed, it *has* a bash.exe, but it fails if
190          // there is no distro installed, so we need to detect that.
191          if (spawnPath(cmd, ['-c', 'exit 0']).status !== 0) {
192            throw new Error('not installed')
193          }
194          if (name === 'cygwin' && NYC_CONFIG) {
195            throw new Error('does not play nicely with nyc')
196          }
197          // WSL version 1 does not work due to
198          // https://github.com/microsoft/WSL/issues/2370
199          if (name === 'wsl' && getWslVersion(cmd) === 1) {
200            match = {
201              status: 1,
202              stderr: 'WSL 1 is not supported. Please upgrade to WSL 2 or above.',
203              stdout: String,
204            }
205          }
206        } catch (err) {
207          skip.reason = err.message
208        }
209      } else {
210        try {
211          cmd = which(cmd)
212        } catch {
213          skip.reason = 'not installed'
214        }
215      }
216    }
217
218    return {
219      match,
220      cmd,
221      name: testName,
222      skip: {
223        ...skip,
224        reason: skip.reason ? `${testName} - ${skip.reason}` : null,
225      },
226    }
227  })
228
229  const matchCmd = (t, cmd, bin, match) => {
230    const args = []
231    const opts = {}
232
233    switch (basename(cmd).toLowerCase()) {
234      case 'cmd.exe':
235        cmd = `${bin}.cmd`
236        break
237      case 'bash.exe':
238        args.push(bin)
239        break
240      case 'pwsh.exe':
241        cmd = quotePath(cmd)
242        args.push(`${bin}.ps1`)
243        opts.shell = true
244        break
245      default:
246        throw new Error('unknown shell')
247    }
248
249    const isNpm = bin === 'npm'
250    const result = spawnPath(cmd, [...args, isNpm ? 'help' : '--version'], opts)
251
252    t.match(result, {
253      status: 0,
254      signal: null,
255      stderr: '',
256      stdout: isNpm ? `npm@${version} ${ROOT}` : version,
257      ...match,
258    }, `${cmd} ${bin}`)
259  }
260
261  // ensure that all tests are either run or skipped
262  t.plan(shells.length)
263
264  for (const { cmd, skip, name, match } of shells) {
265    t.test(name, t => {
266      if (skip.reason) {
267        if (skip.fail) {
268          t.fail(skip.reason)
269        } else {
270          t.skip(skip.reason)
271        }
272        return t.end()
273      }
274      t.plan(2)
275      matchCmd(t, cmd, 'npm', match)
276      matchCmd(t, cmd, 'npx', match)
277    })
278  }
279})
280