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