diff --git a/test/helpers/run-bin.test.ts b/test/helpers/run-bin.test.ts new file mode 100644 index 000000000..9f8e79486 --- /dev/null +++ b/test/helpers/run-bin.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { runBin } from './run-bin'; + +describe('runBin', () => { + test('captures status/stdout/stderr with utf-8 shaping', () => { + const r = runBin('sh', ['-c', 'printf out; printf err >&2; exit 3']); + expect(r).toEqual({ status: 3, stdout: 'out', stderr: 'err' }); + }); + + test('gstackHome sets both GSTACK_HOME and GSTACK_STATE_DIR (config precedence)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'run-bin-')); + try { + const r = runBin('sh', ['-c', 'printf "%s|%s" "$GSTACK_HOME" "$GSTACK_STATE_DIR"'], { gstackHome: dir }); + expect(r.stdout).toBe(`${dir}|${dir}`); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('env undefined deletes a key; input feeds stdin; trim shapes output', () => { + const r = runBin('sh', ['-c', 'cat; printf " padded "; test -z "$LANG" && printf noLANG >&2'], { + env: { LANG: undefined }, + input: 'piped|', + trim: true, + }); + expect(r.stdout).toBe('piped|padded'); + expect(r.stderr).toBe('noLANG'); + }); + + test('spawn failure yields -1, never a fake success', () => { + const r = runBin('/definitely/not/a/binary'); + expect(r.status).toBe(-1); + }); +}); diff --git a/test/helpers/run-bin.ts b/test/helpers/run-bin.ts new file mode 100644 index 000000000..09b0184e4 --- /dev/null +++ b/test/helpers/run-bin.ts @@ -0,0 +1,71 @@ +/** + * Shared spawnSync wrapper for free unit tests that shell out to bin/ + * scripts. Before this helper, ~36 test files each carried a near-identical + * local `run()` (spawnSync + utf-8 + {status, stdout, stderr} normalization) + * differing only in env composition, cwd, and timeout — drift-prone copies + * of one idea. + * + * Free-test-only by design: nothing under the paid globs should import this, + * so it never becomes a de facto global touchfile (paid selection is owned + * by test/helpers/e2e-helpers.ts and friends). + */ +import { spawnSync } from 'node:child_process'; + +export interface RunBinResult { + status: number; + stdout: string; + stderr: string; +} + +export interface RunBinOptions { + cwd?: string; + /** Merged over process.env (an `undefined` value deletes the key). */ + env?: Record; + /** + * Isolation shorthand: sets GSTACK_HOME + GSTACK_STATE_DIR (gstack-config + * precedence is GSTACK_HOME > GSTACK_STATE_DIR > $HOME/.gstack, so both + * must move to isolate from the operator's real ~/.gstack). + */ + gstackHome?: string; + /** Also move $HOME (bins that write $HOME-anchored files, e.g. artifacts-remote pointers). */ + home?: string; + input?: string; + /** Default 60s — a wedged bin fails the test, never the shard wall. */ + timeoutMs?: number; + maxBuffer?: number; + /** Trim stdout/stderr (config-getter style bins). */ + trim?: boolean; +} + +export function runBin(command: string, args: string[] = [], opts: RunBinOptions = {}): RunBinResult { + const env: Record = { ...process.env, ...opts.env }; + if (opts.gstackHome !== undefined) { + env.GSTACK_HOME = opts.gstackHome; + env.GSTACK_STATE_DIR = opts.gstackHome; + } + if (opts.home !== undefined) env.HOME = opts.home; + for (const key of Object.keys(env)) { + if (env[key] === undefined) delete env[key]; + } + + const result = spawnSync(command, args, { + cwd: opts.cwd, + env: env as Record, + encoding: 'utf-8', + input: opts.input, + timeout: opts.timeoutMs ?? 60_000, + maxBuffer: opts.maxBuffer, + }); + + const shape = (text: string | null | undefined): string => { + const value = text ?? ''; + return opts.trim ? value.trim() : value; + }; + return { + // -1 for spawn failure/kill mirrors the strictest of the old locals: a + // null status must never alias a real exit code. + status: result.status ?? -1, + stdout: shape(result.stdout), + stderr: shape(result.stderr), + }; +}