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