• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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