Commit 9fa4d33

Anton Golub <antongolub@antongolub.com>
2025-03-31 11:51:05
feat: `ProcessOutput.getExitMessage` extracts errors from stdout if s… (#1166)
* feat: `ProcessOutput.getExitMessage` extracts errors from stdout if stderr is empty * chore: rm `test.only` * fix: improve reasonable stack line picking * chore: formatting * chore: fix test
1 parent a765dcd
build/core.cjs
@@ -206,7 +206,7 @@ function getErrnoMessage(errno) {
 function getExitCodeInfo(exitCode) {
   return EXIT_CODES[exitCode];
 }
-var formatExitMessage = (code, signal, stderr, from) => {
+var formatExitMessage = (code, signal, stderr, from, details = "") => {
   let message = `exit code: ${code}`;
   if (code != 0 || signal != null) {
     message = `${stderr || "\n"}    at ${from}`;
@@ -216,6 +216,11 @@ var formatExitMessage = (code, signal, stderr, from) => {
       message += `
     signal: ${signal}`;
     }
+    if (details) {
+      message += `
+    details: 
+${details}`;
+    }
   }
   return message;
 };
@@ -229,8 +234,15 @@ function getCallerLocation(err = new Error("zx error")) {
   return getCallerLocationFromString(err.stack);
 }
 function getCallerLocationFromString(stackString = "unknown") {
-  var _a;
-  return ((_a = stackString.split(/^\s*(at\s)?/m).filter((s) => s == null ? void 0 : s.includes(":"))[2]) == null ? void 0 : _a.trim()) || stackString;
+  const lines = stackString.split(/^\s*(at\s)?/m).filter((s) => s == null ? void 0 : s.includes(":"));
+  return (lines.find((l) => l.includes("file://")) || lines[3] || // skip getCallerLocation and Proxy.set
+  stackString).trim();
+}
+function findErrors(lines = []) {
+  if (lines.length < 20) return lines.join("\n");
+  let errors = lines.filter((l) => /(fail|error|not ok|exception)/i.test(l));
+  if (errors.length === 0) errors = lines;
+  return errors.slice(0, 20).join("\n") + (errors.length > 20 ? "\n..." : "");
 }
 
 // src/core.ts
@@ -853,7 +865,7 @@ var _ProcessOutput = class _ProcessOutput extends Error {
       stdall: { get: (0, import_util.once)(() => (0, import_util.bufArrJoin)(dto.store.stdall)) },
       message: {
         get: (0, import_util.once)(
-          () => message || dto.error ? _ProcessOutput.getErrorMessage(dto.error, dto.from) : _ProcessOutput.getExitMessage(dto.code, dto.signal, this.stderr, dto.from)
+          () => message || dto.error ? _ProcessOutput.getErrorMessage(dto.error || new Error(message), dto.from) : _ProcessOutput.getExitMessage(dto.code, dto.signal, this.stderr, dto.from, this.stderr.trim() ? "" : findErrors(this.lines()))
         )
       }
     });
build/core.d.ts
@@ -170,7 +170,7 @@ export declare class ProcessOutput extends Error {
     lines(): string[];
     valueOf(): string;
     [Symbol.iterator](): Iterator<string>;
-    static getExitMessage: (code: number | null, signal: NodeJS.Signals | null, stderr: string, from: string) => string;
+    static getExitMessage: (code: number | null, signal: NodeJS.Signals | null, stderr: string, from: string, details?: string) => string;
     static getErrorMessage: (err: NodeJS.ErrnoException, from: string) => string;
     [inspect.custom](): string;
 }
src/core.ts
@@ -27,6 +27,7 @@ import { inspect } from 'node:util'
 import { EOL as _EOL } from 'node:os'
 import { EventEmitter } from 'node:events'
 import {
+  findErrors,
   formatErrorMessage,
   formatExitMessage,
   getCallerLocation,
@@ -733,8 +734,8 @@ export class ProcessOutput extends Error {
       stdall: { get: once(() => bufArrJoin(dto.store.stdall)) },
       message: { get: once(() =>
           message || dto.error
-            ? ProcessOutput.getErrorMessage(dto.error, dto.from)
-            : ProcessOutput.getExitMessage(dto.code, dto.signal, this.stderr, dto.from)
+            ? ProcessOutput.getErrorMessage(dto.error || new Error(message), dto.from)
+            : ProcessOutput.getExitMessage(dto.code, dto.signal, this.stderr, dto.from, this.stderr.trim() ? '' : findErrors(this.lines()))
         ),
       },
     })
src/error.ts
@@ -184,7 +184,8 @@ export const formatExitMessage = (
   code: number | null,
   signal: NodeJS.Signals | null,
   stderr: string,
-  from: string
+  from: string,
+  details: string = ''
 ): string => {
   let message = `exit code: ${code}`
   if (code != 0 || signal != null) {
@@ -195,6 +196,9 @@ export const formatExitMessage = (
     if (signal != null) {
       message += `\n    signal: ${signal}`
     }
+    if (details) {
+      message += `\n    details: \n${details}`
+    }
   }
 
   return message
@@ -217,10 +221,21 @@ export function getCallerLocation(err = new Error('zx error')): string {
 }
 
 export function getCallerLocationFromString(stackString = 'unknown'): string {
+  const lines = stackString
+    .split(/^\s*(at\s)?/m)
+    .filter((s) => s?.includes(':'))
+
   return (
+    lines.find((l) => l.includes('file://')) ||
+    lines[3] || // skip getCallerLocation and Proxy.set
     stackString
-      .split(/^\s*(at\s)?/m)
-      .filter((s) => s?.includes(':'))[2]
-      ?.trim() || stackString
-  )
+  ).trim()
+}
+
+export function findErrors(lines: string[] = []): string {
+  if (lines.length < 20) return lines.join('\n')
+
+  let errors = lines.filter((l) => /(fail|error|not ok|exception)/i.test(l))
+  if (errors.length === 0) errors = lines
+  return errors.slice(0, 20).join('\n') + (errors.length > 20 ? '\n...' : '')
 }
test/core.test.js
@@ -203,8 +203,10 @@ describe('core', () => {
         err = p
       }
       assert.ok(err.exitCode > 0)
-      assert.ok(err.stderr.includes('wtf: command not found'))
-      assert.ok(err[inspect.custom]().includes('Command not found'))
+      assert.match(err.toString(), /command not found/)
+      assert.match(err.valueOf(), /command not found/)
+      assert.match(err.stderr, /wtf: command not found/)
+      assert.match(err[inspect.custom](), /Command not found/)
     })
 
     test('error event is handled', async () => {
@@ -1162,11 +1164,30 @@ describe('core', () => {
 
       assert.equal(o.stdout, '')
       assert.equal(o.stderr, '')
+      assert.equal(o.stdall, 'foo\n')
       assert.equal(o.signal, 'SIGTERM')
       assert.equal(o.exitCode, -1)
       assert.equal(o.duration, 20)
       assert.equal(o.ok, false)
+      assert.equal(
+        o.message,
+        'msg\n    errno: undefined (Unknown error)\n    code: undefined\n    at '
+      )
       assert.equal(Object.prototype.toString.call(o), '[object ProcessOutput]')
+
+      const o1 = new ProcessOutput({
+        code: -1,
+        from: 'file.js(12:34)',
+        store: {
+          stdall: ['error in stdout'],
+          stdout: [],
+          stderr: [],
+        },
+      })
+      assert.equal(
+        o1.message,
+        '\n    at file.js(12:34)\n    exit code: -1\n    details: \nerror in stdout'
+      )
     })
 
     test('[Symbol.toPrimitive]', () => {
test/error.test.ts
@@ -21,6 +21,7 @@ import {
   getCallerLocationFromString,
   formatExitMessage,
   formatErrorMessage,
+  findErrors,
 } from '../src/error.ts'
 
 describe('error', () => {
@@ -34,8 +35,11 @@ describe('error', () => {
     assert.equal(getErrnoMessage(undefined), 'Unknown error')
   })
 
-  describe('getCallerLocation()', () => {
-    assert.match(getCallerLocation(new Error('Foo')), /Suite\.runInAsyncScope/)
+  test('getCallerLocation()', () => {
+    assert.match(
+      getCallerLocation(new Error('Foo')),
+      /TestContext\.<anonymous>/
+    )
   })
 
   describe('getCallerLocationFromString()', () => {
@@ -54,6 +58,7 @@ describe('error', () => {
       const stack = `
     Error
       at getCallerLocation (/Users/user/test.js:22:17)
+      at Proxy.set (/Users/user/test.js:40:10)
       at e (/Users/user/test.js:34:13)
       at d (/Users/user/test.js:11:5)
       at c (/Users/user/test.js:8:5)
@@ -71,6 +76,7 @@ describe('error', () => {
     test(`getCallerLocationFromString-JSC`, () => {
       const stack = `
     getCallerLocation@/Users/user/test.js:22:17
+    Proxy.set@/Users/user/test.js:40:10)
     e@/Users/user/test.js:34:13
     d@/Users/user/test.js:11:5
     c@/Users/user/test.js:8:5
@@ -109,4 +115,21 @@ describe('error', () => {
       /Unknown error/
     )
   })
+
+  test('findErrors()', () => {
+    const lines = [...Array(40).keys()].map((v) => v + '')
+
+    assert.equal(findErrors([]), '', 'empty returns empty')
+    assert.equal(findErrors(['foo', 'bar']), 'foo\nbar', 'squashes a few')
+    assert.equal(
+      findErrors(['failure: foo', 'NOT OK smth', ...lines]),
+      'failure: foo\nNOT OK smth',
+      'extracts errors'
+    )
+    assert.equal(
+      findErrors(lines),
+      '0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n...',
+      'shows a sample'
+    )
+  })
 })
test/vendor.test.js
@@ -45,6 +45,7 @@ describe('vendor API', () => {
 
   test('which() available', async () => {
     assert.equal(which.sync('npm'), await which('npm'))
+    assert.throws(() => which.sync('not-found-cmd'), /not-found-cmd/)
   })
 
   test('minimist available', async () => {
.size-limit.json
@@ -17,14 +17,14 @@
       "README.md",
       "LICENSE"
     ],
-    "limit": "113.7 kB",
+    "limit": "114.25 kB",
     "brotli": false,
     "gzip": false
   },
   {
     "name": "js parts",
     "path": "build/*.{js,cjs}",
-    "limit": "811.6 kB",
+    "limit": "812.15 kB",
     "brotli": false,
     "gzip": false
   },
@@ -45,7 +45,7 @@
   {
     "name": "all",
     "path": ["build/*", "man/*", "README.md", "LICENSE"],
-    "limit": "867.5 kB",
+    "limit": "868.0 kB",
     "brotli": false,
     "gzip": false
   }