1const t = require('tap')
2const os = require('os')
3const fs = require('fs')
4const fsMiniPass = require('fs-minipass')
5const { join, resolve } = require('path')
6const EventEmitter = require('events')
7const { format } = require('../../../lib/utils/log-file')
8const { load: loadMockNpm } = require('../../fixtures/mock-npm')
9const mockGlobals = require('@npmcli/mock-globals')
10const { cleanCwd, cleanDate } = require('../../fixtures/clean-snapshot')
11const tmock = require('../../fixtures/tmock')
12
13const pick = (obj, ...keys) => keys.reduce((acc, key) => {
14  acc[key] = obj[key]
15  return acc
16}, {})
17
18t.formatSnapshot = (obj) => {
19  if (Array.isArray(obj)) {
20    return obj
21      .map((i) => Array.isArray(i) ? i.join(' ') : i)
22      .join('\n')
23  }
24  return obj
25}
26
27t.cleanSnapshot = (path) => cleanDate(cleanCwd(path))
28  // Config loading is dependent on env so strip those from snapshots
29  .replace(/.*timing config:load:.*\n/gm, '')
30  // logfile cleaning is not awaited so it races with the process.exit
31  // in this test and can cause snapshot failures so we always remove them
32  .replace(/.*silly logfile.*cleaning.*\n/gm, '')
33  .replace(/(Completed in )\d+(ms)/g, '$1{TIME}$2')
34  .replace(/(removing )\d+( files)/g, '$1${NUM}2')
35
36// cut off process from script so that it won't quit the test runner
37// while trying to run through the myriad of cases.  need to make it
38// have all the functions signal-exit relies on so that it doesn't
39// nerf itself, thinking global.process is broken or gone.
40mockGlobals(t, {
41  process: Object.assign(new EventEmitter(), {
42    // these are process properties that are needed in the running code and tests
43    ...pick(process, 'execPath', 'stdout', 'stderr', 'stdin', 'cwd', 'chdir', 'env', 'umask'),
44    argv: ['/node', ...process.argv.slice(1)],
45    version: 'v1.0.0',
46    kill: () => {},
47    reallyExit: (code) => process.exit(code),
48    pid: 123456,
49    exit: (code) => {
50      process.exitCode = code || process.exitCode || 0
51      process.emit('exit', process.exitCode)
52    },
53  }),
54}, { replace: true })
55
56const mockExitHandler = async (t, { config, mocks, files, ...opts } = {}) => {
57  const errors = []
58
59  const { npm, logMocks, ...rest } = await loadMockNpm(t, {
60    ...opts,
61    mocks: {
62      '{ROOT}/package.json': {
63        version: '1.0.0',
64      },
65      ...mocks,
66    },
67    config: (dirs) => ({
68      loglevel: 'notice',
69      ...(typeof config === 'function' ? config(dirs) : config),
70    }),
71    globals: {
72      'console.error': (err) => errors.push(err),
73    },
74  })
75
76  const exitHandler = tmock(t, '{LIB}/utils/exit-handler.js', {
77    '{LIB}/utils/error-message.js': (err) => ({
78      summary: [['ERR SUMMARY', err.message]],
79      detail: [['ERR DETAIL', err.message]],
80      ...(files ? { files } : {}),
81      json: {
82        error: {
83          code: err.code,
84          summary: err.message,
85          detail: err.message,
86        },
87      },
88    }),
89    os: {
90      type: () => 'Foo',
91      release: () => '1.0.0',
92    },
93    ...logMocks,
94    ...mocks,
95  })
96
97  if (npm) {
98    exitHandler.setNpm(npm)
99  }
100
101  t.teardown(() => {
102    process.removeAllListeners('exit')
103  })
104
105  return {
106    ...rest,
107    errors,
108    npm,
109    // Make it async to make testing ergonomics a little easier so we dont need
110    // to t.plan() every test to make sure we get process.exit called.
111    exitHandler: (...args) => new Promise(res => {
112      process.once('exit', res)
113      exitHandler(...args)
114    }),
115  }
116}
117
118// Create errors with properties to be used in tests
119const err = (message = '', options = {}, noStack = false) => {
120  const e = Object.assign(
121    new Error(message),
122    typeof options !== 'object' ? { code: options } : options
123  )
124  e.stack = options.stack || `Error: ${message}`
125  if (noStack) {
126    delete e.stack
127  }
128  return e
129}
130
131t.test('handles unknown error with logs and debug file', async (t) => {
132  const { exitHandler, debugFile, logs } = await mockExitHandler(t)
133
134  await exitHandler(err('Unknown error', 'ECODE'))
135  // force logfile cleaning logs to happen since those are purposefully not awaited
136  await require('timers/promises').setTimeout(200)
137
138  const fileLogs = await debugFile()
139  const fileLines = fileLogs.split('\n')
140
141  const lineNumber = /^(\d+)\s/
142  const lastLog = fileLines[fileLines.length - 1].match(lineNumber)[1]
143
144  t.equal(process.exitCode, 1)
145
146  let skippedLogs = 0
147  logs.forEach((logItem, i) => {
148    const logLines = format(i, ...logItem).trim().split(os.EOL)
149    for (const line of logLines) {
150      if (line.includes('logfile') && line.includes('cleaning')) {
151        skippedLogs++
152        continue
153      }
154      t.match(fileLogs.trim(), line, 'log appears in debug file')
155    }
156  })
157
158  t.equal(logs.length - skippedLogs, parseInt(lastLog) + 1)
159  t.match(logs.error, [
160    ['code', 'ECODE'],
161    ['ERR SUMMARY', 'Unknown error'],
162    ['ERR DETAIL', 'Unknown error'],
163  ])
164  t.match(fileLogs, /\d+ error code ECODE/)
165  t.match(fileLogs, /\d+ error ERR SUMMARY Unknown error/)
166  t.match(fileLogs, /\d+ error ERR DETAIL Unknown error/)
167  t.matchSnapshot(logs, 'logs')
168  t.matchSnapshot(fileLines.map(l => l.replace(lineNumber, 'XX ')), 'debug file contents')
169})
170
171t.test('exit handler never called - loglevel silent', async (t) => {
172  const { logs, errors } = await mockExitHandler(t, {
173    config: { loglevel: 'silent' },
174  })
175  process.emit('exit', 1)
176  t.match(logs.error, [
177    ['', /Exit handler never called/],
178    ['', /error with npm itself/],
179  ])
180  t.strictSame(errors, [''], 'logs one empty string to console.error')
181})
182
183t.test('exit handler never called - loglevel notice', async (t) => {
184  const { logs, errors } = await mockExitHandler(t)
185  process.emit('exit', 1)
186  t.equal(process.exitCode, 1)
187  t.match(logs.error, [
188    ['', /Exit handler never called/],
189    ['', /error with npm itself/],
190  ])
191  t.strictSame(errors, ['', ''], 'logs two empty strings to console.error')
192})
193
194t.test('exit handler never called - no npm', async (t) => {
195  const { logs, errors } = await mockExitHandler(t, { init: false })
196  process.emit('exit', 1)
197  t.equal(process.exitCode, 1)
198  t.match(logs.error, [
199    ['', /Exit handler never called/],
200    ['', /error with npm itself/],
201  ])
202  t.strictSame(errors, [''], 'logs one empty string to console.error')
203})
204
205t.test('exit handler called - no npm', async (t) => {
206  const { exitHandler, errors } = await mockExitHandler(t, { init: false })
207  await exitHandler()
208  t.equal(process.exitCode, 1)
209  t.match(errors, [/Error: Exit prior to setting npm in exit handler/])
210})
211
212t.test('exit handler called - no npm with error', async (t) => {
213  const { exitHandler, errors } = await mockExitHandler(t, { init: false })
214  await exitHandler(err('something happened'))
215  t.equal(process.exitCode, 1)
216  t.match(errors, [/Error: something happened/])
217})
218
219t.test('exit handler called - no npm with error without stack', async (t) => {
220  const { exitHandler, errors } = await mockExitHandler(t, { init: false })
221  await exitHandler(err('something happened', {}, true))
222  t.equal(process.exitCode, 1)
223  t.match(errors, [/something happened/])
224})
225
226t.test('console.log output using --json', async (t) => {
227  const { exitHandler, outputs } = await mockExitHandler(t, {
228    config: { json: true },
229  })
230
231  await exitHandler(err('Error: EBADTHING Something happened'))
232
233  t.equal(process.exitCode, 1)
234  t.same(
235    JSON.parse(outputs[0]),
236    {
237      error: {
238        code: 'EBADTHING', // should default error code to E[A-Z]+
239        summary: 'Error: EBADTHING Something happened',
240        detail: 'Error: EBADTHING Something happened',
241      },
242    },
243    'should output expected json output'
244  )
245})
246
247t.test('merges output buffers errors with --json', async (t) => {
248  const { exitHandler, outputs, npm } = await mockExitHandler(t, {
249    config: { json: true },
250  })
251
252  npm.outputBuffer({ output_data: 1 })
253  npm.outputBuffer(JSON.stringify({ more_data: 2 }))
254  npm.outputBuffer('not json, will be ignored')
255
256  await exitHandler(err('Error: EBADTHING Something happened'))
257
258  t.equal(process.exitCode, 1)
259  t.same(
260    JSON.parse(outputs[0]),
261    {
262      output_data: 1,
263      more_data: 2,
264      error: {
265        code: 'EBADTHING', // should default error code to E[A-Z]+
266        summary: 'Error: EBADTHING Something happened',
267        detail: 'Error: EBADTHING Something happened',
268      },
269    },
270    'should output expected json output'
271  )
272})
273
274t.test('output buffer without json', async (t) => {
275  const { exitHandler, outputs, npm, logs } = await mockExitHandler(t)
276
277  npm.outputBuffer('output_data')
278  npm.outputBuffer('more_data')
279
280  await exitHandler(err('Error: EBADTHING Something happened'))
281
282  t.equal(process.exitCode, 1)
283  t.same(
284    outputs,
285    [['output_data'], ['more_data']],
286    'should output expected output'
287  )
288  t.match(logs.error, [['code', 'EBADTHING']])
289})
290
291t.test('throw a non-error obj', async (t) => {
292  const { exitHandler, logs } = await mockExitHandler(t)
293
294  await exitHandler({
295    code: 'ESOMETHING',
296    message: 'foo bar',
297  })
298
299  t.equal(process.exitCode, 1)
300  t.match(logs.error, [
301    ['weird error', { code: 'ESOMETHING', message: 'foo bar' }],
302  ])
303})
304
305t.test('throw a string error', async (t) => {
306  const { exitHandler, logs } = await mockExitHandler(t)
307
308  await exitHandler('foo bar')
309
310  t.equal(process.exitCode, 1)
311  t.match(logs.error, [
312    ['', 'foo bar'],
313  ])
314})
315
316t.test('update notification', async (t) => {
317  const { exitHandler, logs, npm } = await mockExitHandler(t)
318  npm.updateNotification = 'you should update npm!'
319
320  await exitHandler()
321
322  t.match(logs.notice, [
323    ['', 'you should update npm!'],
324  ])
325})
326
327t.test('npm.config not ready', async (t) => {
328  const { exitHandler, logs, errors } = await mockExitHandler(t, {
329    load: false,
330  })
331
332  await exitHandler()
333
334  t.equal(process.exitCode, 1)
335  t.match(errors, [
336    /Error: Exit prior to config file resolving./,
337  ], 'should exit with config error msg')
338  t.match(logs.verbose, [
339    ['stack', /Error: Exit prior to config file resolving./],
340  ], 'should exit with config error msg')
341})
342
343t.test('no logs dir', async (t) => {
344  const { exitHandler, logs } = await mockExitHandler(t, {
345    config: { 'logs-max': 0 },
346  })
347  await exitHandler(new Error())
348
349  t.match(logs.error.filter(([t]) => t === ''), [
350    ['', 'Log files were not written due to the config logs-max=0'],
351  ])
352  t.match(logs.filter(([_, task]) => task === 'npm.load.mkdirplogs'), [])
353})
354
355t.test('timers fail to write', async (t) => {
356  // we want the fs.writeFileSync in the Timers class to fail
357  const mockTimers = tmock(t, '{LIB}/utils/timers.js', {
358    fs: {
359      ...fs,
360      writeFileSync: (file, ...rest) => {
361        if (file.includes('LOGS_DIR')) {
362          throw new Error('err')
363        }
364
365        return fs.writeFileSync(file, ...rest)
366      },
367    },
368  })
369
370  const { exitHandler, logs } = await mockExitHandler(t, {
371    config: (dirs) => ({
372      'logs-dir': resolve(dirs.prefix, 'LOGS_DIR'),
373      timing: true,
374    }),
375    mocks: {
376      // note, this is relative to test/fixtures/mock-npm.js not this file
377      '{LIB}/utils/timers.js': mockTimers,
378    },
379  })
380
381  await exitHandler(new Error())
382
383  t.match(logs.error.filter(([t]) => t === ''), [['', `error writing to the directory`]])
384})
385
386t.test('log files fail to write', async (t) => {
387  // we want the fsMiniPass.WriteStreamSync in the LogFile class to fail
388  const mockLogFile = tmock(t, '{LIB}/utils/log-file.js', {
389    'fs-minipass': {
390      ...fsMiniPass,
391      WriteStreamSync: (file, ...rest) => {
392        if (file.includes('LOGS_DIR')) {
393          throw new Error('err')
394        }
395      },
396    },
397  })
398
399  const { exitHandler, logs } = await mockExitHandler(t, {
400    config: (dirs) => ({
401      'logs-dir': resolve(dirs.prefix, 'LOGS_DIR'),
402    }),
403    mocks: {
404      // note, this is relative to test/fixtures/mock-npm.js not this file
405      '{LIB}/utils/log-file.js': mockLogFile,
406    },
407  })
408
409  await exitHandler(new Error())
410
411  t.match(logs.error.filter(([t]) => t === ''), [['', `error writing to the directory`]])
412})
413
414t.test('files from error message', async (t) => {
415  const { exitHandler, logs, cache } = await mockExitHandler(t, {
416    files: [
417      ['error-file.txt', '# error file content'],
418    ],
419  })
420
421  await exitHandler(err('Error message'))
422
423  const logFiles = fs.readdirSync(join(cache, '_logs'))
424  const errorFileName = logFiles.find(f => f.endsWith('error-file.txt'))
425  const errorFile = fs.readFileSync(join(cache, '_logs', errorFileName)).toString()
426
427  const [log] = logs.error.filter(([t]) => t === '')
428
429  t.match(log[1], /For a full report see:\n.*-error-file\.txt/)
430  t.match(errorFile, '# error file content')
431  t.match(errorFile, 'Log files:')
432})
433
434t.test('files from error message with error', async (t) => {
435  const { exitHandler, logs } = await mockExitHandler(t, {
436    config: (dirs) => ({
437      'logs-dir': resolve(dirs.prefix, 'LOGS_DIR'),
438    }),
439    files: [
440      ['error-file.txt', '# error file content'],
441    ],
442    mocks: {
443      fs: {
444        ...fs,
445        writeFileSync: (dir) => {
446          if (dir.includes('LOGS_DIR') && dir.endsWith('error-file.txt')) {
447            throw new Error('err')
448          }
449        },
450      },
451    },
452  })
453
454  await exitHandler(err('Error message'))
455
456  const [log] = logs.warn.filter(([t]) => t === '')
457
458  t.match(log[1], /Could not write error message to.*error-file\.txt.*err/)
459})
460
461t.test('timing with no error', async (t) => {
462  const { exitHandler, timingFile, npm, logs } = await mockExitHandler(t, {
463    config: { timing: true },
464  })
465
466  await exitHandler()
467  const timingFileData = await timingFile()
468
469  t.equal(process.exitCode, 0)
470
471  const msg = logs.info.filter(([t]) => t === '')[0][1]
472  t.match(msg, /A complete log of this run can be found in:/)
473  t.match(msg, /Timing info written to:/)
474
475  t.match(
476    timingFileData.timers,
477    Object.keys(npm.finishedTimers).reduce((acc, k) => {
478      acc[k] = Number
479      return acc
480    }, {})
481  )
482  t.strictSame(npm.unfinishedTimers, new Map())
483  t.match(timingFileData, {
484    metadata: {
485      command: [],
486      version: '1.0.0',
487      logfiles: [String],
488    },
489    timers: {
490      npm: Number,
491    },
492  })
493})
494
495t.test('unfinished timers', async (t) => {
496  const { exitHandler, timingFile, npm } = await mockExitHandler(t, {
497    config: { timing: true },
498  })
499
500  process.emit('time', 'foo')
501  process.emit('time', 'bar')
502
503  await exitHandler()
504  const timingFileData = await timingFile()
505
506  t.equal(process.exitCode, 0)
507  t.match(npm.unfinishedTimers, new Map([['foo', Number], ['bar', Number]]))
508  t.match(timingFileData, {
509    metadata: {
510      command: [],
511      version: '1.0.0',
512      logfiles: [String],
513    },
514    timers: {
515      npm: Number,
516    },
517    unfinishedTimers: {
518      foo: [Number, Number],
519      bar: [Number, Number],
520    },
521  })
522})
523
524t.test('uses code from errno', async (t) => {
525  const { exitHandler, logs } = await mockExitHandler(t)
526
527  await exitHandler(err('Error with errno', { errno: 127 }))
528  t.equal(process.exitCode, 127)
529  t.match(logs.error, [['errno', 127]])
530})
531
532t.test('uses code from number', async (t) => {
533  const { exitHandler, logs } = await mockExitHandler(t)
534
535  await exitHandler(err('Error with code type number', 404))
536  t.equal(process.exitCode, 404)
537  t.match(logs.error, [['code', 404]])
538})
539
540t.test('uses all err special properties', async t => {
541  const { exitHandler, logs } = await mockExitHandler(t)
542
543  const keys = ['code', 'syscall', 'file', 'path', 'dest', 'errno']
544  const properties = keys.reduce((acc, k) => {
545    acc[k] = `${k}-hey`
546    return acc
547  }, {})
548
549  await exitHandler(err('Error with code type number', properties))
550  t.equal(process.exitCode, 1)
551  t.match(logs.error, keys.map((k) => [k, `${k}-hey`]), 'all special keys get logged')
552})
553
554t.test('verbose logs replace info on err props', async t => {
555  const { exitHandler, logs } = await mockExitHandler(t)
556
557  const keys = ['type', 'stack', 'pkgid']
558  const properties = keys.reduce((acc, k) => {
559    acc[k] = `${k}-https://user:pass@registry.npmjs.org/`
560    return acc
561  }, {})
562
563  await exitHandler(err('Error with code type number', properties))
564  t.equal(process.exitCode, 1)
565  t.match(
566    logs.verbose.filter(([p]) => !['logfile', 'title', 'argv'].includes(p)),
567    keys.map((k) => [k, `${k}-https://user:***@registry.npmjs.org/`]),
568    'all special keys get replaced'
569  )
570})
571
572t.test('call exitHandler with no error', async (t) => {
573  const { exitHandler, logs } = await mockExitHandler(t)
574
575  await exitHandler()
576
577  t.equal(process.exitCode, 0)
578  t.match(logs.error, [])
579})
580
581t.test('defaults to log error msg if stack is missing when unloaded', async (t) => {
582  const { exitHandler, logs, errors } = await mockExitHandler(t, { load: false })
583
584  await exitHandler(err('Error with no stack', { code: 'ENOSTACK', errno: 127 }, true))
585  t.equal(process.exitCode, 127)
586  t.same(errors, ['Error with no stack'], 'should use error msg')
587  t.match(logs.error, [
588    ['code', 'ENOSTACK'],
589    ['errno', 127],
590  ])
591})
592
593t.test('exits uncleanly when only emitting exit event', async (t) => {
594  const { logs } = await mockExitHandler(t)
595
596  process.emit('exit')
597
598  t.match(logs.error, [['', 'Exit handler never called!']])
599  t.equal(process.exitCode, 1, 'exitCode coerced to 1')
600})
601
602t.test('do no fancy handling for shellouts', async t => {
603  const { exitHandler, logs } = await mockExitHandler(t, {
604    command: 'exec',
605    exec: true,
606    argv: ['-c', 'exit'],
607  })
608
609  const loudNoises = () =>
610    logs.filter(([level]) => ['warn', 'error'].includes(level))
611
612  t.test('shellout with a numeric error code', async t => {
613    await exitHandler(err('', 5))
614    t.equal(process.exitCode, 5, 'got expected exit code')
615    t.strictSame(loudNoises(), [], 'no noisy warnings')
616  })
617
618  t.test('shellout without a numeric error code (something in npm)', async t => {
619    await exitHandler(err('', 'banana stand'))
620    t.equal(process.exitCode, 1, 'got expected exit code')
621    // should log some warnings and errors, because something weird happened
622    t.strictNotSame(loudNoises(), [], 'bring the noise')
623  })
624
625  t.test('shellout with code=0 (extra weird?)', async t => {
626    await exitHandler(Object.assign(new Error(), { code: 0 }))
627    t.equal(process.exitCode, 1, 'got expected exit code')
628    t.strictNotSame(loudNoises(), [], 'bring the noise')
629  })
630})
631