1const t = require('tap')
2const { resolve, dirname, join } = require('path')
3const fs = require('fs')
4const { load: loadMockNpm } = require('../fixtures/mock-npm.js')
5const mockGlobals = require('@npmcli/mock-globals')
6const { commands } = require('../../lib/utils/cmd-list.js')
7
8t.test('not yet loaded', async t => {
9  const { npm, logs } = await loadMockNpm(t, { load: false })
10  t.match(npm, {
11    started: Number,
12    command: null,
13    config: {
14      loaded: false,
15      get: Function,
16      set: Function,
17    },
18    version: String,
19  })
20  t.throws(() => npm.config.set('foo', 'bar'))
21  t.throws(() => npm.config.get('foo'))
22  t.same(logs, [])
23})
24
25t.test('npm.load', async t => {
26  await t.test('load error', async t => {
27    const { npm } = await loadMockNpm(t, { load: false })
28    const loadError = new Error('load error')
29    npm.config.load = async () => {
30      throw loadError
31    }
32    await t.rejects(
33      () => npm.load(),
34      /load error/
35    )
36
37    t.equal(npm.loadErr, loadError)
38    npm.config.load = async () => {
39      throw new Error('different error')
40    }
41    await t.rejects(
42      () => npm.load(),
43      /load error/,
44      'loading again returns the original error'
45    )
46    t.equal(npm.loadErr, loadError)
47  })
48
49  await t.test('basic loading', async t => {
50    const { npm, logs, prefix: dir, cache, other } = await loadMockNpm(t, {
51      prefixDir: { node_modules: {} },
52      otherDirs: {
53        newCache: {},
54      },
55    })
56
57    t.equal(npm.loaded, true)
58    t.equal(npm.config.loaded, true)
59    t.equal(npm.config.get('force'), false)
60    t.ok(npm.usage, 'has usage')
61
62    t.match(npm, {
63      flatOptions: {},
64    })
65    t.match(logs.timing.filter(([p]) => p === 'npm:load'), [
66      ['npm:load', /Completed in [0-9.]+ms/],
67    ])
68
69    mockGlobals(t, { process: { platform: 'posix' } })
70    t.equal(resolve(npm.cache), resolve(cache), 'cache is cache')
71    npm.cache = other.newCache
72    t.equal(npm.config.get('cache'), other.newCache, 'cache setter sets config')
73    t.equal(npm.cache, other.newCache, 'cache getter gets new config')
74    t.equal(npm.lockfileVersion, 2, 'lockfileVersion getter')
75    t.equal(npm.prefix, npm.localPrefix, 'prefix is local prefix')
76    t.not(npm.prefix, npm.globalPrefix, 'prefix is not global prefix')
77    npm.globalPrefix = npm.prefix
78    t.equal(npm.prefix, npm.globalPrefix, 'globalPrefix setter')
79    npm.localPrefix = dir + '/extra/prefix'
80    t.equal(npm.prefix, npm.localPrefix, 'prefix is local prefix after localPrefix setter')
81    t.not(npm.prefix, npm.globalPrefix, 'prefix is not global prefix after localPrefix setter')
82
83    npm.prefix = dir + '/some/prefix'
84    t.equal(npm.prefix, npm.localPrefix, 'prefix is local prefix after prefix setter')
85    t.not(npm.prefix, npm.globalPrefix, 'prefix is not global prefix after prefix setter')
86    t.equal(npm.bin, npm.localBin, 'bin is local bin after prefix setter')
87    t.not(npm.bin, npm.globalBin, 'bin is not global bin after prefix setter')
88    t.equal(npm.dir, npm.localDir, 'dir is local dir after prefix setter')
89    t.not(npm.dir, npm.globalDir, 'dir is not global dir after prefix setter')
90
91    npm.config.set('global', true)
92    t.equal(npm.prefix, npm.globalPrefix, 'prefix is global prefix after setting global')
93    t.not(npm.prefix, npm.localPrefix, 'prefix is not local prefix after setting global')
94    t.equal(npm.bin, npm.globalBin, 'bin is global bin after setting global')
95    t.not(npm.bin, npm.localBin, 'bin is not local bin after setting global')
96    t.equal(npm.dir, npm.globalDir, 'dir is global dir after setting global')
97    t.not(npm.dir, npm.localDir, 'dir is not local dir after setting global')
98
99    npm.prefix = dir + '/new/global/prefix'
100    t.equal(npm.prefix, npm.globalPrefix, 'prefix is global prefix after prefix setter')
101    t.not(npm.prefix, npm.localPrefix, 'prefix is not local prefix after prefix setter')
102    t.equal(npm.bin, npm.globalBin, 'bin is global bin after prefix setter')
103    t.not(npm.bin, npm.localBin, 'bin is not local bin after prefix setter')
104
105    mockGlobals(t, { process: { platform: 'win32' } })
106    t.equal(npm.bin, npm.globalBin, 'bin is global bin in windows mode')
107    t.equal(npm.dir, npm.globalDir, 'dir is global dir in windows mode')
108  })
109
110  await t.test('forceful loading', async t => {
111    const { logs } = await loadMockNpm(t, {
112      globals: {
113        'process.argv': [...process.argv, '--force', '--color', 'always'],
114      },
115    })
116    t.match(logs.warn, [
117      [
118        'using --force',
119        'Recommended protections disabled.',
120      ],
121    ])
122  })
123
124  await t.test('node is a symlink', async t => {
125    const node = process.platform === 'win32' ? 'node.exe' : 'node'
126    const { Npm, npm, logs, outputs, prefix } = await loadMockNpm(t, {
127      prefixDir: {
128        bin: t.fixture('symlink', dirname(process.execPath)),
129      },
130      globals: (dirs) => ({
131        'process.env.PATH': resolve(dirs.prefix, 'bin'),
132        'process.argv': [
133          node,
134          process.argv[1],
135          '--usage',
136          '--scope=foo',
137          'token',
138          'revoke',
139          'blergggg',
140        ],
141      }),
142    })
143
144    t.equal(npm.config.get('scope'), '@foo', 'added the @ sign to scope')
145    t.match([
146      ...logs.timing.filter(([p]) => p === 'npm:load:whichnode'),
147      ...logs.verbose,
148      ...logs.timing.filter(([p]) => p === 'npm:load'),
149    ], [
150      ['npm:load:whichnode', /Completed in [0-9.]+ms/],
151      ['node symlink', resolve(prefix, 'bin', node)],
152      ['title', 'npm token revoke blergggg'],
153      ['argv', '"--usage" "--scope" "foo" "token" "revoke" "blergggg"'],
154      ['logfile', /logs-max:\d+ dir:.*/],
155      ['logfile', /.*-debug-0.log/],
156      ['npm:load', /Completed in [0-9.]+ms/],
157    ])
158    t.equal(process.execPath, resolve(prefix, 'bin', node))
159
160    outputs.length = 0
161    logs.length = 0
162    await npm.exec('ll', [])
163
164    t.equal(npm.command, 'll', 'command set to first npm command')
165    t.equal(npm.flatOptions.npmCommand, 'll', 'npmCommand flatOption set')
166
167    const ll = Npm.cmd('ll')
168    t.same(outputs, [[ll.describeUsage]], 'print usage')
169    npm.config.set('usage', false)
170
171    outputs.length = 0
172    logs.length = 0
173    await npm.exec('get', ['scope', '\u2010not-a-dash'])
174
175    t.strictSame([npm.command, npm.flatOptions.npmCommand], ['ll', 'll'],
176      'does not change npm.command when another command is called')
177
178    t.match(logs, [
179      [
180        'error',
181        'arg',
182        'Argument starts with non-ascii dash, this is probably invalid:',
183        '\u2010not-a-dash',
184      ],
185      [
186        'timing',
187        'command:config',
188        /Completed in [0-9.]+ms/,
189      ],
190      [
191        'timing',
192        'command:get',
193        /Completed in [0-9.]+ms/,
194      ],
195    ])
196    t.same(outputs, [['scope=@foo\n\u2010not-a-dash=undefined']])
197  })
198
199  await t.test('--no-workspaces with --workspace', async t => {
200    const { npm } = await loadMockNpm(t, {
201      prefixDir: {
202        packages: {
203          a: {
204            'package.json': JSON.stringify({
205              name: 'a',
206              version: '1.0.0',
207              scripts: { test: 'echo test a' },
208            }),
209          },
210        },
211        'package.json': JSON.stringify({
212          name: 'root',
213          version: '1.0.0',
214          workspaces: ['./packages/*'],
215        }),
216      },
217      globals: {
218        'process.argv': [
219          process.execPath,
220          process.argv[1],
221          '--color', 'false',
222          '--workspaces', 'false',
223          '--workspace', 'a',
224        ],
225      },
226    })
227    await t.rejects(
228      npm.exec('run', []),
229      /Can not use --no-workspaces and --workspace at the same time/
230    )
231  })
232
233  await t.test('workspace-aware configs and commands', async t => {
234    const { npm, outputs } = await loadMockNpm(t, {
235      prefixDir: {
236        packages: {
237          a: {
238            'package.json': JSON.stringify({
239              name: 'a',
240              version: '1.0.0',
241              scripts: { test: 'echo test a' },
242            }),
243          },
244          b: {
245            'package.json': JSON.stringify({
246              name: 'b',
247              version: '1.0.0',
248              scripts: { test: 'echo test b' },
249            }),
250          },
251        },
252        'package.json': JSON.stringify({
253          name: 'root',
254          version: '1.0.0',
255          workspaces: ['./packages/*'],
256        }),
257      },
258      globals: {
259        'process.argv': [
260          process.execPath,
261          process.argv[1],
262          '--color', 'false',
263          '--workspaces', 'true',
264        ],
265      },
266    })
267
268    await npm.exec('run', [])
269
270    t.equal(npm.command, 'run-script', 'npm.command set to canonical name')
271
272    t.match(
273      outputs,
274      [
275        ['Lifecycle scripts included in a@1.0.0:'],
276        ['  test\n    echo test a'],
277        [''],
278        ['Lifecycle scripts included in b@1.0.0:'],
279        ['  test\n    echo test b'],
280        [''],
281      ],
282      'should exec workspaces version of commands'
283    )
284  })
285
286  await t.test('workspaces in global mode', async t => {
287    const { npm } = await loadMockNpm(t, {
288      prefixDir: {
289        packages: {
290          a: {
291            'package.json': JSON.stringify({
292              name: 'a',
293              version: '1.0.0',
294              scripts: { test: 'echo test a' },
295            }),
296          },
297          b: {
298            'package.json': JSON.stringify({
299              name: 'b',
300              version: '1.0.0',
301              scripts: { test: 'echo test b' },
302            }),
303          },
304        },
305        'package.json': JSON.stringify({
306          name: 'root',
307          version: '1.0.0',
308          workspaces: ['./packages/*'],
309        }),
310      },
311      globals: {
312        'process.argv': [
313          process.execPath,
314          process.argv[1],
315          '--color',
316          'false',
317          '--workspaces',
318          '--global',
319          'true',
320        ],
321      },
322    })
323
324    await t.rejects(
325      npm.exec('run', []),
326      /Workspaces not supported for global packages/
327    )
328  })
329})
330
331t.test('set process.title', async t => {
332  t.test('basic title setting', async t => {
333    const { npm } = await loadMockNpm(t, {
334      globals: {
335        'process.argv': [
336          process.execPath,
337          process.argv[1],
338          '--usage',
339          '--scope=foo',
340          'ls',
341        ],
342      },
343    })
344    t.equal(npm.title, 'npm ls')
345    t.equal(process.title, 'npm ls')
346  })
347
348  t.test('do not expose token being revoked', async t => {
349    const { npm } = await loadMockNpm(t, {
350      globals: {
351        'process.argv': [
352          process.execPath,
353          process.argv[1],
354          '--usage',
355          '--scope=foo',
356          'token',
357          'revoke',
358          `npm_${'a'.repeat(36)}`,
359        ],
360      },
361    })
362    t.equal(npm.title, 'npm token revoke npm_***')
363    t.equal(process.title, 'npm token revoke npm_***')
364  })
365
366  t.test('do show *** unless a token is actually being revoked', async t => {
367    const { npm } = await loadMockNpm(t, {
368      globals: {
369        'process.argv': [
370          process.execPath,
371          process.argv[1],
372          '--usage',
373          '--scope=foo',
374          'token',
375          'revoke',
376          'notatoken',
377        ],
378      },
379    })
380    t.equal(npm.title, 'npm token revoke notatoken')
381    t.equal(process.title, 'npm token revoke notatoken')
382  })
383})
384
385t.test('debug log', async t => {
386  t.test('writes log file', async t => {
387    const { npm, debugFile } = await loadMockNpm(t, { load: false })
388
389    const log1 = ['silly', 'test', 'before load']
390    const log2 = ['silly', 'test', 'after load']
391    const log3 = ['silly', 'test', 'hello\x00world']
392
393    process.emit('log', ...log1)
394    await npm.load()
395    process.emit('log', ...log2)
396    process.emit('log', ...log3)
397
398    const debug = await debugFile()
399    t.equal(npm.logFiles.length, 1, 'one debug file')
400    t.match(debug, log1.join(' '), 'before load appears')
401    t.match(debug, log2.join(' '), 'after load log appears')
402    t.match(debug, 'hello^@world')
403  })
404
405  t.test('can load with bad dir', async t => {
406    const { npm, testdir } = await loadMockNpm(t, {
407      load: false,
408      config: (dirs) => ({
409        'logs-dir': join(dirs.testdir, 'my_logs_dir'),
410      }),
411    })
412    const logsDir = join(testdir, 'my_logs_dir')
413
414    // make logs dir a file before load so it files
415    fs.writeFileSync(logsDir, 'A_TEXT_FILE')
416    await t.resolves(npm.load(), 'loads with invalid logs dir')
417
418    t.equal(npm.logFiles.length, 0, 'no log files array')
419    t.strictSame(fs.readFileSync(logsDir, 'utf-8'), 'A_TEXT_FILE')
420  })
421})
422
423t.test('cache dir', async t => {
424  t.test('creates a cache dir', async t => {
425    const { npm } = await loadMockNpm(t)
426
427    t.ok(fs.existsSync(npm.cache), 'cache dir exists')
428  })
429
430  t.test('can load with a bad cache dir', async t => {
431    const { npm, cache } = await loadMockNpm(t, {
432      load: false,
433      // The easiest way to make mkdir(cache) fail is to make it a file.
434      // This will have the same effect as if its read only or inaccessible.
435      cacheDir: 'A_TEXT_FILE',
436    })
437
438    await t.resolves(npm.load(), 'loads with cache dir as a file')
439
440    t.equal(fs.readFileSync(cache, 'utf-8'), 'A_TEXT_FILE')
441  })
442})
443
444t.test('timings', async t => {
445  t.test('gets/sets timers', async t => {
446    const { npm, logs } = await loadMockNpm(t, { load: false })
447    process.emit('time', 'foo')
448    process.emit('time', 'bar')
449    t.match(npm.unfinishedTimers.get('foo'), Number, 'foo timer is a number')
450    t.match(npm.unfinishedTimers.get('bar'), Number, 'foo timer is a number')
451    process.emit('timeEnd', 'foo')
452    process.emit('timeEnd', 'bar')
453    process.emit('timeEnd', 'baz')
454    // npm timer is started by default
455    process.emit('timeEnd', 'npm')
456    t.match(logs.timing, [
457      ['foo', /Completed in [0-9]+ms/],
458      ['bar', /Completed in [0-9]+ms/],
459      ['npm', /Completed in [0-9]+ms/],
460    ])
461    t.match(logs.silly, [[
462      'timing',
463      "Tried to end timer that doesn't exist:",
464      'baz',
465    ]])
466    t.notOk(npm.unfinishedTimers.has('foo'), 'foo timer is gone')
467    t.notOk(npm.unfinishedTimers.has('bar'), 'bar timer is gone')
468    t.match(npm.finishedTimers, { foo: Number, bar: Number, npm: Number })
469  })
470
471  t.test('writes timings file', async t => {
472    const { npm, cache, timingFile } = await loadMockNpm(t, {
473      config: { timing: true },
474    })
475    process.emit('time', 'foo')
476    process.emit('timeEnd', 'foo')
477    process.emit('time', 'bar')
478    npm.writeTimingFile()
479    t.match(npm.timingFile, cache)
480    t.match(npm.timingFile, /-timing.json$/)
481    const timings = await timingFile()
482    t.match(timings, {
483      metadata: {
484        command: [],
485        logfiles: [String],
486        version: String,
487      },
488      unfinishedTimers: {
489        bar: [Number, Number],
490        npm: [Number, Number],
491      },
492      timers: {
493        foo: Number,
494        'npm:load': Number,
495      },
496    })
497  })
498
499  t.test('does not write timings file with timers:false', async t => {
500    const { npm, timingFile } = await loadMockNpm(t, {
501      config: { timing: false },
502    })
503    npm.writeTimingFile()
504    await t.rejects(() => timingFile())
505  })
506
507  const timingDisplay = [
508    [{ loglevel: 'silly' }, true, false],
509    [{ loglevel: 'silly', timing: true }, true, true],
510    [{ loglevel: 'silent', timing: true }, false, false],
511  ]
512
513  for (const [config, expectedDisplay, expectedTiming] of timingDisplay) {
514    const msg = `${JSON.stringify(config)}, display:${expectedDisplay}, timing:${expectedTiming}`
515    await t.test(`timing display: ${msg}`, async t => {
516      const { display } = await loadMockNpm(t, { config })
517      t.equal(!!display.length, expectedDisplay, 'display')
518      t.equal(!!display.timing.length, expectedTiming, 'timing display')
519    })
520  }
521})
522
523t.test('output clears progress and console.logs cleaned messages', async t => {
524  t.plan(4)
525  let showingProgress = true
526  const logs = []
527  const errors = []
528  const { npm } = await loadMockNpm(t, {
529    load: false,
530    mocks: {
531      npmlog: {
532        clearProgress: () => showingProgress = false,
533        showProgress: () => showingProgress = true,
534      },
535    },
536    globals: {
537      'console.log': (...args) => {
538        t.equal(showingProgress, false, 'should not be showing progress right now')
539        logs.push(args)
540      },
541      'console.error': (...args) => {
542        t.equal(showingProgress, false, 'should not be showing progress right now')
543        errors.push(args)
544      },
545    },
546  })
547  npm.originalOutput('hello\x00world')
548  npm.originalOutputError('error\x00world')
549
550  t.match(logs, [['hello^@world']])
551  t.match(errors, [['error^@world']])
552})
553
554t.test('aliases and typos', async t => {
555  const { Npm } = await loadMockNpm(t, { init: false })
556  t.throws(() => Npm.cmd('thisisnotacommand'), { code: 'EUNKNOWNCOMMAND' })
557  t.throws(() => Npm.cmd(''), { code: 'EUNKNOWNCOMMAND' })
558  t.throws(() => Npm.cmd('birthday'), { code: 'EUNKNOWNCOMMAND' })
559  t.match(Npm.cmd('it').name, 'install-test')
560  t.match(Npm.cmd('installTe').name, 'install-test')
561  t.match(Npm.cmd('access').name, 'access')
562  t.match(Npm.cmd('auth').name, 'owner')
563})
564
565t.test('explicit workspace rejection', async t => {
566  const mock = await loadMockNpm(t, {
567    prefixDir: {
568      packages: {
569        a: {
570          'package.json': JSON.stringify({
571            name: 'a',
572            version: '1.0.0',
573            scripts: { test: 'echo test a' },
574          }),
575        },
576      },
577      'package.json': JSON.stringify({
578        name: 'root',
579        version: '1.0.0',
580        workspaces: ['./packages/a'],
581      }),
582    },
583    globals: {
584      'process.argv': [
585        process.execPath,
586        process.argv[1],
587        '--color', 'false',
588        '--workspace', './packages/a',
589      ],
590    },
591  })
592  await t.rejects(
593    mock.npm.exec('ping', []),
594    /This command does not support workspaces/
595  )
596})
597
598t.test('implicit workspace rejection', async t => {
599  const mock = await loadMockNpm(t, {
600    prefixDir: {
601      packages: {
602        a: {
603          'package.json': JSON.stringify({
604            name: 'a',
605            version: '1.0.0',
606            scripts: { test: 'echo test a' },
607          }),
608        },
609      },
610      'package.json': JSON.stringify({
611        name: 'root',
612        version: '1.0.0',
613        workspaces: ['./packages/a'],
614      }),
615    },
616    chdir: ({ prefix }) => join(prefix, 'packages', 'a'),
617    globals: {
618      'process.argv': [
619        process.execPath,
620        process.argv[1],
621        '--color', 'false',
622        '--workspace', './packages/a',
623      ],
624    },
625  })
626  await t.rejects(
627    mock.npm.exec('team', []),
628    /This command does not support workspaces/
629  )
630})
631
632t.test('implicit workspace accept', async t => {
633  const mock = await loadMockNpm(t, {
634    prefixDir: {
635      packages: {
636        a: {
637          'package.json': JSON.stringify({
638            name: 'a',
639            version: '1.0.0',
640            scripts: { test: 'echo test a' },
641          }),
642        },
643      },
644      'package.json': JSON.stringify({
645        name: 'root',
646        version: '1.0.0',
647        workspaces: ['./packages/a'],
648      }),
649    },
650    chdir: ({ prefix }) => join(prefix, 'packages', 'a'),
651    globals: {
652      'process.argv': [
653        process.execPath,
654        process.argv[1],
655        '--color', 'false',
656      ],
657    },
658  })
659  await t.rejects(mock.npm.exec('org', []), /.*Usage/)
660})
661
662t.test('usage', async t => {
663  t.test('with browser', async t => {
664    const { npm } = await loadMockNpm(t, { globals: { process: { platform: 'posix' } } })
665    const usage = npm.usage
666    npm.config.set('viewer', 'browser')
667    const browserUsage = npm.usage
668    t.notMatch(usage, '(in a browser)')
669    t.match(browserUsage, '(in a browser)')
670  })
671
672  t.test('windows always uses browser', async t => {
673    const { npm } = await loadMockNpm(t, { globals: { process: { platform: 'win32' } } })
674    const usage = npm.usage
675    npm.config.set('viewer', 'browser')
676    const browserUsage = npm.usage
677    t.match(usage, '(in a browser)')
678    t.match(browserUsage, '(in a browser)')
679  })
680
681  t.test('includes commands', async t => {
682    const { npm } = await loadMockNpm(t)
683    const usage = npm.usage
684    npm.config.set('long', true)
685    const longUsage = npm.usage
686
687    const lastCmd = commands[commands.length - 1]
688    for (const cmd of commands) {
689      const isLast = cmd === lastCmd
690      const shortCmd = new RegExp(`\\s${cmd}${isLast ? '\\n' : ',[\\s\\n]'}`)
691      const longCmd = new RegExp(`^\\s+${cmd}\\s+\\w.*\n\\s+Usage:\\n`, 'm')
692
693      t.match(usage, shortCmd, `usage includes ${cmd}`)
694      t.notMatch(usage, longCmd, `usage does not include long ${cmd}`)
695
696      t.match(longUsage, longCmd, `long usage includes ${cmd}`)
697      if (!isLast) {
698        // long usage includes false positives for the last command since it is
699        // not followed by a comma
700        t.notMatch(longUsage, shortCmd, `long usage does not include short ${cmd}`)
701      }
702    }
703  })
704
705  t.test('set process.stdout.columns', async t => {
706    const { npm } = await loadMockNpm(t, {
707      config: { viewer: 'man' },
708    })
709    t.cleanSnapshot = str =>
710      str.replace(npm.config.get('userconfig'), '{USERCONFIG}')
711        .replace(npm.npmRoot, '{NPMROOT}')
712        .replace(`npm@${npm.version}`, 'npm@{VERSION}')
713
714    const widths = [0, 1, 10, 24, 40, 41, 75, 76, 90, 100]
715    for (const width of widths) {
716      t.test(`column width ${width}`, async t => {
717        mockGlobals(t, { 'process.stdout.columns': width })
718        const usage = npm.usage
719        t.matchSnapshot(usage)
720      })
721    }
722  })
723})
724