Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
364 lines
14 KiB
JavaScript
364 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* git-fixture.test.cjs
|
|
*
|
|
* Behavioral tests for tests/helpers/git-fixture.cjs's `gitOrThrow`, driving
|
|
* the real seam against real `git` in a temp fixture repo. Covers matrix
|
|
* section E of .msd/phase/chore-3143-no-unbounded-spawn-guard/50-test-matrix.md.
|
|
*
|
|
* E10 needs to observe the exact `timeoutMs` value `gitOrThrow` forwards to
|
|
* the seam without any wall-clock measurement (both the documented default
|
|
* and the seam's own bare default would let a normal git command succeed,
|
|
* so a black-box timing test cannot distinguish them). This file installs a
|
|
* pass-through call-recording spy on `process-seam.cjs`'s `runGit` *before*
|
|
* `helpers/git-fixture.cjs` is required for the first time in this process,
|
|
* so `gitOrThrow`'s own `const { runGit } = require('./process-seam.cjs')`
|
|
* destructures the spy. With no custom implementation, `mock.method()`
|
|
* calls straight through to the real `runGit` — every test below still
|
|
* exercises real git — while additionally recording each call's arguments.
|
|
*/
|
|
|
|
const { describe, test, mock, after, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const processSeam = require('./helpers/process-seam.cjs');
|
|
const { OUTCOME } = processSeam;
|
|
|
|
const runGitSpy = mock.method(processSeam, 'runGit');
|
|
after(() => mock.restoreAll());
|
|
|
|
const { gitOrThrow, throwIfFailed, toLegacyResult, DEFAULT_GIT_TIMEOUT_MS } = require('./helpers/git-fixture.cjs');
|
|
|
|
/**
|
|
* Deliberately NOT DEFAULT_GIT_TIMEOUT_MS or any real class norm — chosen
|
|
* specifically to differ from it, to prove gitOrThrow's timeoutMs override
|
|
* plumbing actually threads a caller-supplied value through rather than
|
|
* silently keeping the default. See the E11 test's own
|
|
* assert.notEqual(..., DEFAULT_GIT_TIMEOUT_MS) a few lines below.
|
|
*/
|
|
const ARBITRARY_OVERRIDE_TIMEOUT_MS = 12345;
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
/**
|
|
* Runs `fn`, returning the error it throws. Fails the calling test with a
|
|
* clear assertion if `fn` does not throw. A standalone helper (not inline
|
|
* in a test body) is the CONTRIBUTING.md-compliant place for a try/catch of
|
|
* this shape — "try/finally is only permitted inside standalone utility or
|
|
* helper functions".
|
|
*/
|
|
function captureThrown(fn) {
|
|
let caught;
|
|
try {
|
|
fn();
|
|
} catch (e) {
|
|
caught = e;
|
|
}
|
|
assert.ok(caught, 'expected fn to throw');
|
|
return caught;
|
|
}
|
|
|
|
/** Initialize a fresh repo with a known branch name and one commit. */
|
|
function initRepo(prefix = 'git-fixture-test-') {
|
|
const dir = createTempDir(prefix);
|
|
gitOrThrow(['init', '--quiet', '-b', 'mainline'], { cwd: dir });
|
|
gitOrThrow(['config', 'user.email', 'git-fixture-test@example.com'], { cwd: dir });
|
|
gitOrThrow(['config', 'user.name', 'git-fixture-test'], { cwd: dir });
|
|
gitOrThrow(['commit', '--allow-empty', '-m', 'initial commit'], { cwd: dir });
|
|
return dir;
|
|
}
|
|
|
|
describe('git-fixture: E — gitOrThrow', () => {
|
|
let dir;
|
|
|
|
beforeEach(() => {
|
|
dir = initRepo();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(dir);
|
|
});
|
|
|
|
test('E1: returns stdout as a string on success', () => {
|
|
const r = gitOrThrow(['--version'], { cwd: dir });
|
|
assert.equal(typeof r, 'string');
|
|
});
|
|
|
|
test('E2: returns real command output', () => {
|
|
const r = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir });
|
|
assert.equal(r.trim(), 'mainline');
|
|
});
|
|
|
|
test('E3: throws on non-zero exit', () => {
|
|
assert.throws(() => gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir }));
|
|
});
|
|
|
|
test('E4: thrown error exposes .status (legacy execSync idiom)', () => {
|
|
const raw = processSeam.runGit(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir });
|
|
assert.notEqual(raw.exitCode, 0);
|
|
const caught = captureThrown(() =>
|
|
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
|
|
);
|
|
assert.equal(caught.status, raw.exitCode);
|
|
});
|
|
|
|
test('E5: thrown error exposes .exitCode (seam idiom), aliasing .status', () => {
|
|
const caught = captureThrown(() =>
|
|
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
|
|
);
|
|
assert.equal(caught.exitCode, caught.status);
|
|
});
|
|
|
|
test('E6: thrown error carries both streams as strings', () => {
|
|
const caught = captureThrown(() =>
|
|
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
|
|
);
|
|
assert.equal(typeof caught.stdout, 'string');
|
|
assert.equal(typeof caught.stderr, 'string');
|
|
assert.ok(caught.stderr.length > 0, 'expected git to write a fatal message to stderr');
|
|
});
|
|
|
|
test('E7: non-zero exit is EXITED, not a failure outcome', () => {
|
|
const caught = captureThrown(() =>
|
|
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
|
|
);
|
|
assert.equal(caught.outcome, OUTCOME.EXITED);
|
|
assert.equal(caught.timedOut, false);
|
|
});
|
|
|
|
test('E8: spawn failure throws with SPAWN_FAILED', () => {
|
|
const caught = captureThrown(() =>
|
|
gitOrThrow(['--version'], { cwd: path.join(dir, 'no-such-subdirectory') })
|
|
);
|
|
assert.equal(caught.outcome, OUTCOME.SPAWN_FAILED);
|
|
});
|
|
|
|
test('E9: gitOrThrow propagates a TIMED_OUT seam result as a throw', () => {
|
|
// Not an integration timeout test: a real `git rev-parse HEAD` racing a
|
|
// 1ms bound is a real-race test (on a warm/unloaded container the
|
|
// command can finish first, spawnSync then reports a clean EXITED with
|
|
// no error, and gitOrThrow correctly does not throw — see #3148). This
|
|
// drives `gitOrThrow` with a synthetic TIMED_OUT result from the
|
|
// `runGit` spy installed at module scope (line 30), so it exercises the
|
|
// exact propagation behavior with zero timing dependence. Deterministic
|
|
// coverage of `throwIfFailed`'s TIMED_OUT branch itself already lives in
|
|
// the `throwIfFailed` describe block below (the `for (const outcome of
|
|
// [OUTCOME.TIMED_OUT, ...])` case).
|
|
runGitSpy.mock.mockImplementationOnce(() => ({
|
|
outcome: OUTCOME.TIMED_OUT,
|
|
exitCode: null,
|
|
stdout: '',
|
|
stderr: '',
|
|
timedOut: true,
|
|
signal: 'SIGTERM',
|
|
code: 'ETIMEDOUT',
|
|
}));
|
|
const caught = captureThrown(() => gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir }));
|
|
assert.equal(caught.timedOut, true);
|
|
assert.equal(caught.outcome, OUTCOME.TIMED_OUT);
|
|
});
|
|
|
|
test('E10: omitted timeout uses the documented default, not silence', () => {
|
|
runGitSpy.mock.resetCalls();
|
|
gitOrThrow(['--version'], { cwd: dir });
|
|
assert.equal(runGitSpy.mock.calls.length, 1);
|
|
assert.equal(runGitSpy.mock.calls[0].arguments[1].timeoutMs, DEFAULT_GIT_TIMEOUT_MS);
|
|
assert.equal(DEFAULT_GIT_TIMEOUT_MS, 15000);
|
|
});
|
|
|
|
test('E11: explicit timeoutMs overrides the default', () => {
|
|
runGitSpy.mock.resetCalls();
|
|
gitOrThrow(['--version'], { cwd: dir, timeoutMs: ARBITRARY_OVERRIDE_TIMEOUT_MS });
|
|
assert.equal(runGitSpy.mock.calls.length, 1);
|
|
assert.equal(runGitSpy.mock.calls[0].arguments[1].timeoutMs, ARBITRARY_OVERRIDE_TIMEOUT_MS);
|
|
assert.notEqual(ARBITRARY_OVERRIDE_TIMEOUT_MS, DEFAULT_GIT_TIMEOUT_MS);
|
|
});
|
|
|
|
test('E12: shell-string args are rejected', () => {
|
|
assert.throws(() => gitOrThrow('status', { cwd: dir }), TypeError);
|
|
});
|
|
|
|
test('E13: argv is never shell-interpreted', () => {
|
|
const marker = path.join(dir, 'PWNED_MARKER');
|
|
// A ref name containing shell metacharacters. spawnSync never invokes a
|
|
// shell, so this whole string reaches git as ONE literal argv element
|
|
// (the candidate ref name) — never tokenized or command-substituted.
|
|
const hostileRef = ';touch ' + marker + ';`id`;$(id)';
|
|
|
|
let threw = false;
|
|
try {
|
|
gitOrThrow(['rev-parse', '--verify', hostileRef], { cwd: dir });
|
|
} catch (_e) {
|
|
threw = true;
|
|
}
|
|
assert.ok(threw, 'expected the hostile string to fail resolution as a literal (bad) ref');
|
|
assert.equal(fs.existsSync(marker), false, 'a shell-interpreted argv would have created this file');
|
|
});
|
|
|
|
test('E14: string return is toString-compatible', () => {
|
|
const r = gitOrThrow(['--version'], { cwd: dir });
|
|
assert.equal(r.toString(), r);
|
|
});
|
|
|
|
test('E15: string return is trim-compatible', () => {
|
|
const r = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir });
|
|
assert.equal(typeof r.trim(), 'string');
|
|
assert.equal(r.trim(), 'mainline');
|
|
});
|
|
|
|
test('E16: cwd is forwarded to the seam', () => {
|
|
const otherDir = initRepo('git-fixture-test-other-');
|
|
gitOrThrow(['checkout', '-b', 'other-branch'], { cwd: otherDir });
|
|
|
|
const branchInDir = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir }).trim();
|
|
const branchInOtherDir = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: otherDir }).trim();
|
|
|
|
assert.equal(branchInDir, 'mainline');
|
|
assert.equal(branchInOtherDir, 'other-branch');
|
|
|
|
cleanup(otherDir);
|
|
});
|
|
|
|
test('E17: env is forwarded to the seam', () => {
|
|
gitOrThrow(
|
|
['commit', '--allow-empty', '-m', 'env-authored commit'],
|
|
{
|
|
cwd: dir,
|
|
env: {
|
|
...process.env,
|
|
GIT_AUTHOR_NAME: 'Env Author',
|
|
GIT_AUTHOR_EMAIL: 'env-author@example.com',
|
|
GIT_COMMITTER_NAME: 'Env Author',
|
|
GIT_COMMITTER_EMAIL: 'env-author@example.com',
|
|
},
|
|
}
|
|
);
|
|
const author = gitOrThrow(['log', '-1', '--format=%an'], { cwd: dir }).trim();
|
|
assert.equal(author, 'Env Author');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* `throwIfFailed` is exercised only indirectly above (via `gitOrThrow`) and by
|
|
* six other per-suite wrappers elsewhere in the tree. It takes a plain
|
|
* process-seam result object, so it is tested directly here with literal
|
|
* result objects — no subprocess needed.
|
|
*/
|
|
describe('throwIfFailed', () => {
|
|
/** A minimal clean-exit result, spread over in each test below. */
|
|
const BASE = { stdout: '', stderr: '', signal: null, timedOut: false };
|
|
|
|
test('does not throw for outcome=EXITED, exitCode=0', () => {
|
|
assert.doesNotThrow(() => throwIfFailed({ ...BASE, outcome: OUTCOME.EXITED, exitCode: 0 }, 'ok'));
|
|
});
|
|
|
|
test('throws when outcome=EXITED but exitCode=1', () => {
|
|
assert.throws(() => throwIfFailed({ ...BASE, outcome: OUTCOME.EXITED, exitCode: 1 }, 'bad'));
|
|
});
|
|
|
|
test('throws when outcome=EXITED but exitCode=128', () => {
|
|
assert.throws(() => throwIfFailed({ ...BASE, outcome: OUTCOME.EXITED, exitCode: 128 }, 'bad'));
|
|
});
|
|
|
|
for (const outcome of [OUTCOME.TIMED_OUT, OUTCOME.KILLED, OUTCOME.BUFFER_OVERFLOW, OUTCOME.SPAWN_FAILED]) {
|
|
test(`throws for non-EXITED outcome: ${outcome}`, () => {
|
|
assert.throws(() => throwIfFailed({ ...BASE, outcome, exitCode: null }, 'bad'));
|
|
});
|
|
}
|
|
|
|
test('boundary: exitCode=0 with a non-EXITED outcome still throws (0 alone is not success)', () => {
|
|
assert.throws(() => throwIfFailed({ ...BASE, outcome: OUTCOME.KILLED, exitCode: 0 }, 'bad'));
|
|
});
|
|
|
|
test('thrown error carries .status and .exitCode as equal aliases of the same value', () => {
|
|
const caught = captureThrown(() => throwIfFailed({ ...BASE, outcome: OUTCOME.EXITED, exitCode: 42 }, 'bad'));
|
|
assert.equal(caught.status, 42);
|
|
assert.equal(caught.exitCode, 42);
|
|
assert.equal(caught.status, caught.exitCode);
|
|
});
|
|
|
|
test('thrown error carries stdout, stderr, signal, timedOut, outcome from the input result', () => {
|
|
const input = {
|
|
outcome: OUTCOME.KILLED,
|
|
exitCode: null,
|
|
stdout: 'input stdout',
|
|
stderr: 'input stderr',
|
|
signal: 'SIGTERM',
|
|
timedOut: false,
|
|
};
|
|
const caught = captureThrown(() => throwIfFailed(input, 'bad'));
|
|
assert.equal(caught.stdout, input.stdout);
|
|
assert.equal(caught.stderr, input.stderr);
|
|
assert.equal(caught.signal, input.signal);
|
|
assert.equal(caught.timedOut, input.timedOut);
|
|
assert.equal(caught.outcome, input.outcome);
|
|
});
|
|
|
|
test('exitCode: null propagates to .status as null, not coerced', () => {
|
|
const caught = captureThrown(() =>
|
|
throwIfFailed({ ...BASE, outcome: OUTCOME.TIMED_OUT, exitCode: null, timedOut: true }, 'bad')
|
|
);
|
|
assert.equal(caught.status, null);
|
|
assert.notEqual(caught.status, undefined);
|
|
});
|
|
|
|
test('displayName appears in the thrown error message', () => {
|
|
const caught = captureThrown(() =>
|
|
throwIfFailed({ ...BASE, outcome: OUTCOME.EXITED, exitCode: 1 }, 'my distinctive display name')
|
|
);
|
|
assert.ok(caught.message.includes('my distinctive display name'));
|
|
});
|
|
});
|
|
|
|
/**
|
|
* `toLegacyResult` is the non-throwing sibling of `throwIfFailed` (#3147):
|
|
* a bare mapping onto `{ status, stdout, stderr }`, never throwing. Tested
|
|
* directly with literal result objects — no subprocess needed.
|
|
*/
|
|
describe('toLegacyResult', () => {
|
|
test('never throws, unlike throwIfFailed, for a non-zero exit', () => {
|
|
assert.doesNotThrow(() =>
|
|
toLegacyResult({ outcome: OUTCOME.EXITED, exitCode: 1, stdout: '', stderr: 'boom' })
|
|
);
|
|
});
|
|
|
|
test('maps exitCode to .status (the legacy field name)', () => {
|
|
const r = toLegacyResult({ outcome: OUTCOME.EXITED, exitCode: 1, stdout: 'out', stderr: 'err' });
|
|
assert.equal(r.status, 1);
|
|
});
|
|
|
|
test('exitCode: 0 maps to .status: 0, not falsy-coerced', () => {
|
|
const r = toLegacyResult({ outcome: OUTCOME.EXITED, exitCode: 0, stdout: '', stderr: '' });
|
|
assert.equal(r.status, 0);
|
|
assert.notEqual(r.status, undefined);
|
|
});
|
|
|
|
test('exitCode: null (non-EXITED outcome) propagates to .status as null, not coerced', () => {
|
|
const r = toLegacyResult({ outcome: OUTCOME.TIMED_OUT, exitCode: null, stdout: '', stderr: '' });
|
|
assert.equal(r.status, null);
|
|
assert.notEqual(r.status, undefined);
|
|
});
|
|
|
|
test('stdout and stderr pass through unchanged', () => {
|
|
const r = toLegacyResult({ outcome: OUTCOME.EXITED, exitCode: 0, stdout: 'the stdout', stderr: 'the stderr' });
|
|
assert.equal(r.stdout, 'the stdout');
|
|
assert.equal(r.stderr, 'the stderr');
|
|
});
|
|
|
|
test('returns exactly the three legacy fields, nothing extra from the seam result', () => {
|
|
const r = toLegacyResult({
|
|
outcome: OUTCOME.EXITED,
|
|
exitCode: 0,
|
|
stdout: '',
|
|
stderr: '',
|
|
signal: null,
|
|
timedOut: false,
|
|
killed: false,
|
|
code: null,
|
|
});
|
|
assert.deepEqual(Object.keys(r).sort(), ['status', 'stderr', 'stdout']);
|
|
});
|
|
});
|