1const os = require('os') 2const fs = require('fs').promises 3const path = require('path') 4const tap = require('tap') 5const errorMessage = require('../../lib/utils/error-message') 6const mockLogs = require('./mock-logs') 7const mockGlobals = require('@npmcli/mock-globals') 8const tmock = require('./tmock') 9const defExitCode = process.exitCode 10 11const changeDir = (dir) => { 12 if (dir) { 13 const cwd = process.cwd() 14 process.chdir(dir) 15 return () => process.chdir(cwd) 16 } 17 return () => {} 18} 19 20const setGlobalNodeModules = (globalDir) => { 21 const updateSymlinks = (obj, visit) => { 22 for (const [key, value] of Object.entries(obj)) { 23 if (/Fixture<symlink>/.test(value.toString())) { 24 obj[key] = tap.fixture('symlink', path.join('..', value.content)) 25 } else if (typeof value === 'object') { 26 obj[key] = updateSymlinks(value, visit) 27 } 28 } 29 return obj 30 } 31 32 if (globalDir.lib) { 33 throw new Error('`globalPrefixDir` should not have a top-level `lib/` directory, only a ' + 34 'top-level `node_modules/` dir that gets set in the correct location based on platform. ' + 35 `Received the following top level entries: ${Object.keys(globalDir).join(', ')}.` 36 ) 37 } 38 39 if (process.platform !== 'win32' && globalDir.node_modules) { 40 const { node_modules: nm, ...rest } = globalDir 41 return { 42 ...rest, 43 lib: { node_modules: updateSymlinks(nm) }, 44 } 45 } 46 47 return globalDir 48} 49 50const buildMocks = (t, mocks) => { 51 const allMocks = { 52 '{LIB}/utils/update-notifier.js': async () => {}, 53 ...mocks, 54 } 55 // The definitions must be mocked since they are a singleton that reads from 56 // process and environs to build defaults in order to break the requiure 57 // cache. We also need to mock them with any mocks that were passed in for the 58 // test in case those mocks are for things like ci-info which is used there. 59 const definitions = '@npmcli/config/lib/definitions' 60 allMocks[definitions] = tmock(t, definitions, allMocks) 61 62 return allMocks 63} 64 65const getMockNpm = async (t, { mocks, init, load, npm: npmOpts }) => { 66 const { logMocks, logs, display } = mockLogs(mocks) 67 const allMocks = buildMocks(t, { ...mocks, ...logMocks }) 68 const Npm = tmock(t, '{LIB}/npm.js', allMocks) 69 70 const outputs = [] 71 const outputErrors = [] 72 73 class MockNpm extends Npm { 74 async exec (...args) { 75 const [res, err] = await super.exec(...args).then((r) => [r]).catch(e => [null, e]) 76 // This mimics how the exit handler flushes output for commands that have 77 // buffered output. It also uses the same json error processing from the 78 // error message fn. This is necessary for commands with buffered output 79 // to read the output after exec is called. This is not *exactly* how it 80 // works in practice, but it is close enough for now. 81 this.flushOutput(err ? errorMessage(err, this).json : null) 82 if (err) { 83 throw err 84 } 85 return res 86 } 87 88 // lib/npm.js tests needs this to actually test the function! 89 originalOutput (...args) { 90 super.output(...args) 91 } 92 93 originalOutputError (...args) { 94 super.outputError(...args) 95 } 96 97 output (...args) { 98 outputs.push(args) 99 } 100 101 outputError (...args) { 102 outputErrors.push(args) 103 } 104 } 105 106 const npm = init ? new MockNpm(npmOpts) : null 107 if (npm && load) { 108 await npm.load() 109 } 110 111 return { 112 Npm: MockNpm, 113 npm, 114 outputs, 115 outputErrors, 116 joinedOutput: () => outputs.map(o => o.join(' ')).join('\n'), 117 logMocks, 118 logs, 119 display, 120 } 121} 122 123const mockNpms = new Map() 124 125const setupMockNpm = async (t, { 126 init = true, 127 load = init, 128 // preload a command 129 command = null, // string name of the command 130 exec = null, // optionally exec the command before returning 131 setCmd = false, 132 // test dirs 133 prefixDir = {}, 134 homeDir = {}, 135 cacheDir = {}, 136 globalPrefixDir = { node_modules: {} }, 137 otherDirs = {}, 138 chdir = ({ prefix }) => prefix, 139 // setup config, env vars, mocks, npm opts 140 config: _config = {}, 141 mocks = {}, 142 globals = {}, 143 npm: npmOpts = {}, 144 argv: rawArgv = [], 145 ...r 146} = {}) => { 147 // easy to accidentally forget to pass in tap 148 if (!(t instanceof tap.Test)) { 149 throw new Error('first argument must be a tap instance') 150 } 151 152 // mockNpm is designed to only be run once per test chain so we assign it to 153 // the test in the cache and error if it is attempted to run again 154 let tapInstance = t 155 while (tapInstance) { 156 if (mockNpms.has(tapInstance)) { 157 throw new Error('mockNpm can only be called once in each t.test chain') 158 } 159 tapInstance = tapInstance.parent 160 } 161 mockNpms.set(t, true) 162 163 if (!init && load) { 164 throw new Error('cant `load` without `init`') 165 } 166 167 // These are globals manipulated by npm itself that we need to reset to their 168 // original values between tests 169 const npmEnvs = Object.keys(process.env).filter(k => k.startsWith('npm_')) 170 mockGlobals(t, { 171 process: { 172 title: process.title, 173 execPath: process.execPath, 174 env: { 175 NODE_ENV: process.env.NODE_ENV, 176 COLOR: process.env.COLOR, 177 // further, these are npm controlled envs that we need to zero out before 178 // before the test. setting them to undefined ensures they are not set and 179 // also returned to their original value after the test 180 ...npmEnvs.reduce((acc, k) => { 181 acc[k] = undefined 182 return acc 183 }, {}), 184 }, 185 }, 186 }) 187 188 const dir = t.testdir({ 189 home: homeDir, 190 prefix: prefixDir, 191 cache: cacheDir, 192 global: setGlobalNodeModules(globalPrefixDir), 193 other: otherDirs, 194 }) 195 196 const dirs = { 197 testdir: dir, 198 prefix: path.join(dir, 'prefix'), 199 cache: path.join(dir, 'cache'), 200 globalPrefix: path.join(dir, 'global'), 201 home: path.join(dir, 'home'), 202 other: path.join(dir, 'other'), 203 } 204 205 // Option objects can also be functions that are called with all the dir paths 206 // so they can be used to set configs that need to be based on paths 207 const withDirs = (v) => typeof v === 'function' ? v(dirs) : v 208 209 const teardownDir = changeDir(withDirs(chdir)) 210 211 const defaultConfigs = { 212 // We want to fail fast when writing tests. Default this to 0 unless it was 213 // explicitly set in a test. 214 'fetch-retries': 0, 215 cache: dirs.cache, 216 } 217 218 const { argv, env, config } = Object.entries({ ...defaultConfigs, ...withDirs(_config) }) 219 .reduce((acc, [key, value]) => { 220 // nerfdart configs passed in need to be set via env var instead of argv 221 // and quoted with `"` so mock globals will ignore that it contains dots 222 if (key.startsWith('//')) { 223 acc.env[`process.env."npm_config_${key}"`] = value 224 } else { 225 const values = [].concat(value) 226 acc.argv.push(...values.flatMap(v => `--${key}=${v.toString()}`)) 227 } 228 acc.config[key] = value 229 return acc 230 }, { argv: [...rawArgv], env: {}, config: {} }) 231 232 const mockedGlobals = mockGlobals(t, { 233 'process.env.HOME': dirs.home, 234 // global prefix cannot be (easily) set via argv so this is the easiest way 235 // to set it that also closely mimics the behavior a user would see since it 236 // will already be set while `npm.load()` is being run 237 // Note that this only sets the global prefix and the prefix is set via chdir 238 'process.env.PREFIX': dirs.globalPrefix, 239 ...withDirs(globals), 240 ...env, 241 }) 242 243 const { npm, ...mockNpm } = await getMockNpm(t, { 244 init, 245 load, 246 mocks: withDirs(mocks), 247 npm: { argv, excludeNpmCwd: true, ...withDirs(npmOpts) }, 248 }) 249 250 if (config.omit?.includes('prod')) { 251 // XXX(HACK): --omit=prod is not a valid config according to the definitions but 252 // it was being hacked in via flatOptions for older tests so this is to 253 // preserve that behavior and reduce churn in the snapshots. this should be 254 // removed or fixed in the future 255 npm.flatOptions.omit.push('prod') 256 } 257 258 t.teardown(() => { 259 if (npm) { 260 npm.unload() 261 } 262 // only set exitCode back if we're passing tests 263 if (t.passing()) { 264 process.exitCode = defExitCode 265 } 266 teardownDir() 267 }) 268 269 const mockCommand = {} 270 if (command) { 271 const Cmd = mockNpm.Npm.cmd(command) 272 if (setCmd) { 273 // XXX(hack): This is a hack to allow fake-ish tests to set the currently 274 // running npm command without running exec. Generally, we should rely on 275 // actually exec-ing the command to asserting the state of the world 276 // through what is printed/on disk/etc. This is a stop-gap to allow tests 277 // that are time intensive to convert to continue setting the npm command 278 // this way. TODO: remove setCmd from all tests and remove the setCmd 279 // method from `lib/npm.js` 280 npm.setCmd(command) 281 } 282 mockCommand.cmd = new Cmd(npm) 283 mockCommand[command] = { 284 usage: Cmd.describeUsage, 285 exec: (args) => npm.exec(command, args), 286 completion: (args) => Cmd.completion(args, npm), 287 } 288 if (exec) { 289 await mockCommand[command].exec(exec === true ? [] : exec) 290 // assign string output to the command now that we have it 291 // for easier testing 292 mockCommand[command].output = mockNpm.joinedOutput() 293 } 294 } 295 296 return { 297 npm, 298 mockedGlobals, 299 ...mockNpm, 300 ...dirs, 301 ...mockCommand, 302 debugFile: async () => { 303 const readFiles = npm.logFiles.map(f => fs.readFile(f)) 304 const logFiles = await Promise.all(readFiles) 305 return logFiles 306 .flatMap((d) => d.toString().trim().split(os.EOL)) 307 .filter(Boolean) 308 .join('\n') 309 }, 310 timingFile: async () => { 311 const data = await fs.readFile(npm.timingFile, 'utf8') 312 return JSON.parse(data) 313 }, 314 } 315} 316 317module.exports = setupMockNpm 318module.exports.load = setupMockNpm 319module.exports.setGlobalNodeModules = setGlobalNodeModules 320