mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-31 18:30:39 +02:00
feat(test): shared runBin helper for bin-script unit tests
~36 free test files each carry a near-identical local run() (spawnSync
+ utf-8 + {status, stdout, stderr}) differing only in env composition,
cwd, and timeout. runBin absorbs the invariant core; options carry the
variance (gstackHome sets BOTH GSTACK_HOME and GSTACK_STATE_DIR — the
config-precedence trap several locals rediscovered independently; home
for $HOME-anchored bins; input/trim/timeout/maxBuffer). Free-test-only
by design so it never becomes a de facto global touchfile. Migration of
the 36 call sites lands separately (mechanical batches).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
bc81d39013
commit
d87e73fa55
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | undefined>;
|
||||
/**
|
||||
* 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<string, string | undefined> = { ...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<string, string>,
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user