Skip to content
Merged
137 changes: 137 additions & 0 deletions experiments/issue-38-error-exitcode-competitors.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Which property carries the exit status of a failing command?
// Node.js `child_process` names it `code`, while Execa, zx, nano-spawn and the
// Bun shell name it `exitCode`. Issue #38 asks command-stream to answer to both
// names, so this probe prints what every implementation actually exposes.
// Optional packages are reported as unavailable instead of being required by
// this repository.
//
// References:
// https://github.com/link-foundation/command-stream/issues/38
// https://nodejs.org/api/child_process.html#child_processexeccommand-options-callback
// https://github.com/sindresorhus/execa/blob/main/docs/errors.md
// https://google.github.io/zx/process-output
// https://bun.com/docs/runtime/shell
//
// Run: bun experiments/issue-38-error-exitcode-competitors.mjs

import { exec as nodeExec } from 'node:child_process';
import { $, shell } from '../js/src/$.mjs';

const EXIT_CODE = 23;
const FAILING_COMMAND = `node -e "process.exit(${EXIT_CODE})"`;

async function optionalImport(name) {
try {
return await import(name);
} catch (error) {
if (error?.code === 'ERR_MODULE_NOT_FOUND') {
return null;
}
throw error;
}
}

// Returns the value thrown (or resolved) by a failing command, or null when the
// implementation is not installed here.
async function commandStream() {
shell.errexit(true);
try {
return await $({ mirror: false })`node -e "process.exit(${EXIT_CODE})"`;
} catch (error) {
return error;
} finally {
shell.errexit(false);
}
}

function nodeChildProcess() {
return new Promise((resolve) => {
nodeExec(FAILING_COMMAND, (error) => resolve(error));
});
}

async function bunShell() {
if (typeof Bun === 'undefined') {
return null;
}
const { $: bun$ } = await import('bun');
try {
return await bun$`node -e ${`process.exit(${EXIT_CODE})`}`.quiet();
} catch (error) {
return error;
}
}

async function zx() {
const module = await optionalImport('zx');
if (!module) {
return null;
}
try {
return await module.$({
quiet: true,
})`node -e ${`process.exit(${EXIT_CODE})`}`;
} catch (error) {
return error;
}
}

async function execa() {
const module = await optionalImport('execa');
if (!module) {
return null;
}
try {
return await module.execa('node', ['-e', `process.exit(${EXIT_CODE})`]);
} catch (error) {
return error;
}
}

async function nanoSpawn() {
const module = await optionalImport('nano-spawn');
if (!module) {
return null;
}
try {
return await module.default('node', ['-e', `process.exit(${EXIT_CODE})`]);
} catch (error) {
return error;
}
}

const implementations = [
['command-stream', commandStream],
['Node.js exec', nodeChildProcess],
['Bun shell', bunShell],
['zx', zx],
['Execa', execa],
['nano-spawn', nanoSpawn],
];

const describe = (value) => {
const has = (name) =>
value?.[name] === undefined ? '-' : String(value[name]);
return `code=${has('code').padEnd(6)} exitCode=${has('exitCode')}`;
};

console.log(`failing command: ${FAILING_COMMAND}\n`);

let failures = 0;
for (const [name, run] of implementations) {
const thrown = await run();
if (thrown === null) {
console.log(` ${name.padEnd(14)} unavailable`);
continue;
}
console.log(` ${name.padEnd(14)} ${describe(thrown)}`);

if (name === 'command-stream') {
// command-stream must satisfy both conventions at once (issue #38).
if (thrown.code !== EXIT_CODE || thrown.exitCode !== EXIT_CODE) {
failures += 1;
}
}
}

process.exitCode = failures === 0 ? 0 : 1;
35 changes: 35 additions & 0 deletions experiments/issue-38-hook-scope/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Issue #38: why `js/tests/test-helper.mjs` does not clean up every test file

`js/tests/test-helper.mjs` calls `beforeEach`/`afterEach` at module scope and
every test file imports it for "automatic" cleanup. ES modules are evaluated
once, so those hooks are registered in the scope of whichever test file Bun
evaluates first; every other file runs with no cleanup hooks at all.

Run it:

```
bun test experiments/issue-38-hook-scope/
```

Output (the winning file depends on the order Bun picks):

```
experiments/issue-38-hook-scope/b.test.mjs:
[b] shared beforeEach active for this file: true

experiments/issue-38-hook-scope/a.test.mjs:
[a] shared beforeEach active for this file: false
```

Consequence: global state (the `enableVirtualCommands`/`disableVirtualCommands`
flag, the virtual command registry, shell settings) leaks from one test file to
the next, and whether it leaks depends on an ordering that differs per platform.
That is what made
`error exitCode alias for error code > carries both aliases for a failing pipeline`
fail on macOS only: an earlier file left virtual commands disabled, and with them
disabled `exit 19 | cat` spawns the shell builtin `exit` as a real executable
(see `../issue-38-virtual-disabled-pipeline.mjs`), so the rejection carries
`code: "ENOENT"` instead of `19`.

`js/tests/error-exitcode-alias.test.mjs` therefore re-enables virtual commands in
its own `beforeEach` instead of trusting the shared helper.
19 changes: 19 additions & 0 deletions experiments/issue-38-hook-scope/a.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { test, expect } from 'bun:test';
import './helper.mjs';

// Two tests are needed to tell whether the shared hook is active for this file:
// the counter can only grow between them if the hook runs for this file's tests.
let runsBeforeSecondTest = null;

test('a: records how often the shared hook has run', () => {
runsBeforeSecondTest = globalThis.__hookRuns;
expect(typeof runsBeforeSecondTest).toBe('number');
});

test('a: only the file that imported the helper first gets the hook', () => {
const active = globalThis.__hookRuns > runsBeforeSecondTest;
globalThis.__filesWithHook =
(globalThis.__filesWithHook ?? 0) + (active ? 1 : 0);
console.log('[a] shared beforeEach active for this file:', active);
expect(globalThis.__filesWithHook).toBeLessThanOrEqual(1);
});
19 changes: 19 additions & 0 deletions experiments/issue-38-hook-scope/b.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { test, expect } from 'bun:test';
import './helper.mjs';

// Two tests are needed to tell whether the shared hook is active for this file:
// the counter can only grow between them if the hook runs for this file's tests.
let runsBeforeSecondTest = null;

test('b: records how often the shared hook has run', () => {
runsBeforeSecondTest = globalThis.__hookRuns;
expect(typeof runsBeforeSecondTest).toBe('number');
});

test('b: only the file that imported the helper first gets the hook', () => {
const active = globalThis.__hookRuns > runsBeforeSecondTest;
globalThis.__filesWithHook =
(globalThis.__filesWithHook ?? 0) + (active ? 1 : 0);
console.log('[b] shared beforeEach active for this file:', active);
expect(globalThis.__filesWithHook).toBeLessThanOrEqual(1);
});
9 changes: 9 additions & 0 deletions experiments/issue-38-hook-scope/helper.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { beforeEach } from 'bun:test';

// Registering the hook while this module is evaluated binds it to the scope of
// the test file that imported it *first*. ES module caching means the body
// never runs again, so no other file gets the hook.
globalThis.__hookRuns = 0;
beforeEach(() => {
globalThis.__hookRuns += 1;
});
37 changes: 37 additions & 0 deletions experiments/issue-38-virtual-disabled-pipeline.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bun
// Issue #38 investigation: why `exit 19 | cat` reports ENOENT instead of 19.
//
// With virtual commands disabled, a parsed pipeline is handed to Bun.spawn one
// command at a time, so the shell builtin `exit` is looked up in $PATH and the
// spawn fails with ENOENT. Test files leak that disabled flag (see
// experiments/issue-38-test-helper-hook-scope.mjs), which is how the CI failure
// on macOS was produced.
import {
$,
shell,
disableVirtualCommands,
enableVirtualCommands,
} from '../js/src/$.mjs';

shell.errexit(true);
shell.pipefail(true);

for (const virtual of [true, false]) {
if (virtual) {
enableVirtualCommands();
} else {
disableVirtualCommands();
}

const error = await $`exit 19 | cat`.catch((thrown) => thrown);
console.log(
`virtualCommands=${virtual ? 'enabled' : 'disabled'} ->`,
JSON.stringify({
code: error?.code,
exitCode: error?.exitCode,
message: error?.message,
})
);
}

enableVirtualCommands();
10 changes: 10 additions & 0 deletions js/.changeset/error-exitcode-alias.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'command-stream': patch
---

Expose the exit status of a failing command under both `error.code` and
`error.exitCode`, so handlers written for Node.js `child_process` and for
Execa, zx, nano-spawn or the Bun shell work unchanged. The attached
`error.result` carries both names as well, and a command that could not be
launched at all reports its shell-compatible status (127, 126) through
`error.exitCode` while `error.code` keeps the POSIX errno.
6 changes: 4 additions & 2 deletions js/BEST-PRACTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,10 @@ shell.errexit(true);
try {
await $`critical-operation`;
} catch (error) {
console.error('Critical operation failed:', error);
process.exit(1);
// The status is available under both names: `code` (Node.js
// `child_process`) and `exitCode` (Execa, zx, nano-spawn, Bun shell).
console.error('Critical operation failed with', error.exitCode);
process.exit(error.code);
}
```

Expand Down
6 changes: 6 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,10 @@ console.log(result.code); // exit code
console.log(result.exitCode); // alias for result.code
```

Errors thrown in `errexit` mode carry the same pair of names, so handlers
written for Node.js `child_process` (`error.code`) and for Execa, zx,
nano-spawn or the Bun shell (`error.exitCode`) both work unchanged.

### Custom Options with $({ options }) Syntax (NEW!)

```javascript
Expand Down Expand Up @@ -1617,6 +1621,8 @@ try {
await $`ls nonexistent-file`; // Throws error
} catch (error) {
console.log('Command failed:', error.code); // → 2
console.log('Same status:', error.exitCode); // → 2 (alias for error.code)
console.log('Full result:', error.result.exitCode); // → 2
}

// ✅ Disable errexit: Back to non-throwing behavior
Expand Down
2 changes: 1 addition & 1 deletion js/docs/COMPETITOR_TEST_AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ summary-level assertion.
| `newline-preservation` | Captured output preserves final and repeated newlines. |
| `unicode-output` | UTF-8 output is decoded without loss. |
| `large-output` | One MiB of output is captured without truncation or deadlock. |
| `nonzero-exit` | Non-zero status is returned through `code` and `exitCode`. |
| `nonzero-exit` | Non-zero status reads through `code` and `exitCode`, on results and on errors. |
| `result-text` | `text()` returns captured stdout. |
| `stdin-string` | String input is written completely and stdin is closed. |
| `stdin-buffer` | Buffer input is written without textual coercion. |
Expand Down
44 changes: 44 additions & 0 deletions js/examples/error-exitcode-alias.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env node

/**
* Handling a failed command through either property name.
*
* With `shell.errexit(true)` a non-zero exit throws, and the thrown error
* carries the status under both names: `code` (Node.js `child_process`) and
* `exitCode` (Execa, zx, nano-spawn, Bun shell). Code written for either
* convention works unchanged (issue #38).
*
* Run: node js/examples/error-exitcode-alias.mjs
*/

import { $ as $raw, shell } from '../src/$.mjs';

// Keep the example output tidy: capture instead of mirroring child output.
const $ = $raw({ mirror: false });

shell.errexit(true);

// Node.js style: read the status from `error.code`.
try {
await $`exit 3`;
} catch (error) {
console.log(`node style -> error.code = ${error.code}`);
}

// Execa/zx style: read the very same status from `error.exitCode`.
try {
await $`node -e "process.exit(42)"`;
} catch (error) {
console.log(`execa style -> error.exitCode = ${error.exitCode}`);
console.log(
`result alias -> error.result.exitCode = ${error.result.exitCode}`
);
}

// Without errexit a failing command resolves, and the result carries both
// names as well.
shell.errexit(false);
const result = await $`exit 7`;
console.log(
`result -> code = ${result.code}, exitCode = ${result.exitCode}`
);
Loading
Loading