Files
msd-core/tests/cjs-command-router-adapter.test.cjs
Tom Boucher 6214039358 refactor(#1644): Hub extension — exitReason? field on InvalidArgs + adapter honestification (#1645)
Phase 1 of parent #1641. Implements the contract documented in the
Phase 0 ADR-0174 §5 amendment (#1642 / #1643).

src/command-routing-hub.cts
  * InvalidArgsResult interface gains optional exitReason?: string
    (carries an ERROR_REASON enum value, separate from reason which is
    the explanation text).
  * makeInvalidArgs(arg, reason, exitReason?) factory conditionally adds
    the field only when the third arg is truthy — preserves the strict-
    keys invariant tested at command-routing-hub.test.cjs:444.
  * _VARIANT_SCHEMA.InvalidArgs.allowed Set extended to include
    'exitReason' so the runtime validator does not coerce well-formed
    extended Results to HandlerFailure.

src/cjs-command-router-adapter.cts
  * Honestified the wrapper comment: the runtime check ('ok' in result)
    already passes any {ok:*} object through, so the historical
    {ok:true, data} return type was a lie for err Results. The lying
    cast is preserved because the Hub's export = syntax doesn't expose
    HubResult for import; the Hub's _validateErrResult runtime-validates
    the actual shape.
  * Result→error() translation branched: when InvalidArgs carries
    exitReason, the adapter calls error(result.reason, result.exitReason)
    so the JSON-error envelope (GSD_JSON_ERRORS=1) preserves the typed
    ERROR_REASON value. When exitReason is absent, error(msg) is called
    with exactly one arg — byte-identical with prior behavior.
  * RouteCjsCommandFamilyOptions.error and RouteHubCommandFamilyOptions
    .error callback types widened from (message) to (message, reason?)
    to match io.cts's actual error() signature.

CONTEXT.md
  * Command Routing Hub predicate updated to document the new field,
    factory signature, and dispatcher translation contract.

Tests (TDD red→green)
  * tests/command-routing-hub.test.cjs: 8 new tests covering 2-arg
    (strict-keys), 3-arg (key present), undefined, empty string, frozen
    result, hub.dispatch propagation, and validator acceptance.
  * tests/cjs-command-router-adapter.test.cjs: 2 new tests covering
    exitReason passed as second arg + byte-identical prior behavior when
    absent.

Verification
  * npm run test:unit: 2448 tests, 0 fail (no regressions)
  * gsd-test-summary on docker: outcome=passed, 0 failures
    (RULESET.PR-FLOW.docker-before-push)

Memtrace blast radius: LOW (get_impact makeInvalidArgs → 3 nodes; the
optional field is non-breaking for the 1 existing caller routePhaseCommand).
2026-06-23 22:47:57 -04:00

244 lines
6.7 KiB
JavaScript

'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { routeCjsCommandFamily, routeHubCommandFamily } = require('../gsd-core/bin/lib/cjs-command-router-adapter.cjs');
const { makeInvalidArgs } = require('../gsd-core/bin/lib/command-routing-hub.cjs');
describe('cjs-command-router-adapter routeHubCommandFamily', () => {
test('routes known subcommand handler through the hub', () => {
let calls = 0;
let errorMessage = null;
routeHubCommandFamily({
family: 'unit',
args: ['unit', 'ok'],
subcommands: ['ok'],
handlers: {
ok: () => {
calls += 1;
},
},
unknownMessage: (subcommand, available) => `Unknown ${subcommand}. Available: ${available.join(', ')}`,
error: (message) => {
errorMessage = message;
},
cwd: '/tmp/proj',
raw: false,
});
assert.equal(calls, 1);
assert.equal(errorMessage, null);
});
test('maps unknown subcommands via unknownMessage and filtered availability', () => {
let errorMessage = null;
routeHubCommandFamily({
family: 'unit',
args: ['unit', 'missing'],
subcommands: ['ok', 'legacy'],
unsupported: { legacy: 'legacy disabled' },
handlers: { ok: () => {} },
unknownMessage: (subcommand, available) => `Unknown ${subcommand}. Available: ${available.join(', ')}`,
error: (message) => {
errorMessage = message;
},
cwd: '/tmp/proj',
raw: false,
});
assert.equal(errorMessage, 'Unknown missing. Available: ok');
});
test('returns unsupported subcommand error before dispatch', () => {
let errorMessage = null;
routeHubCommandFamily({
family: 'unit',
args: ['unit', 'legacy'],
subcommands: ['ok', 'legacy'],
unsupported: { legacy: 'legacy disabled' },
handlers: { ok: () => {} },
unknownMessage: () => 'should not be used',
error: (message) => {
errorMessage = message;
},
cwd: '/tmp/proj',
raw: false,
});
assert.equal(errorMessage, 'legacy disabled');
});
test('projects InvalidArgs result reason via error callback', () => {
let errorMessage = null;
routeHubCommandFamily({
family: 'unit',
args: ['unit', 'invalid'],
subcommands: ['invalid'],
handlers: {
invalid: () => makeInvalidArgs('--phase', '--phase must be an integer'),
},
unknownMessage: () => 'should not be used',
error: (message) => {
errorMessage = message;
},
cwd: '/tmp/proj',
raw: false,
});
assert.equal(errorMessage, '--phase must be an integer');
});
test('projects InvalidArgs exitReason as second error() arg when present (#1644)', () => {
let capturedMessage = null;
let capturedExitReason = null;
let callCount = 0;
routeHubCommandFamily({
family: 'unit',
args: ['unit', 'invalid'],
subcommands: ['invalid'],
handlers: {
invalid: () => makeInvalidArgs('--phase', '--phase must be an integer', 'USAGE'),
},
unknownMessage: () => 'should not be used',
error: (message, exitReason) => {
callCount += 1;
capturedMessage = message;
capturedExitReason = exitReason;
},
cwd: '/tmp/proj',
raw: false,
});
assert.equal(callCount, 1);
assert.equal(capturedMessage, '--phase must be an integer',
`error() message must be the InvalidArgs.reason; got: ${JSON.stringify(capturedMessage)}`);
assert.equal(capturedExitReason, 'USAGE',
`error() exitReason must be passed as second arg; got: ${JSON.stringify(capturedExitReason)}`);
});
test('omits second error() arg when InvalidArgs has no exitReason (byte-identical with prior behavior)', () => {
let capturedArgs = null;
routeHubCommandFamily({
family: 'unit',
args: ['unit', 'invalid'],
subcommands: ['invalid'],
handlers: {
invalid: () => makeInvalidArgs('--phase', '--phase must be an integer'),
},
unknownMessage: () => 'should not be used',
error: (...args) => {
capturedArgs = args;
},
cwd: '/tmp/proj',
raw: false,
});
assert.equal(capturedArgs.length, 1,
`error() must be called with EXACTLY one arg when exitReason absent (preserve byte-identical prior behavior); got ${capturedArgs.length} args`);
assert.equal(capturedArgs[0], '--phase must be an integer');
});
test('projects thrown handler exceptions as HandlerFailure message', () => {
let errorMessage = null;
routeHubCommandFamily({
family: 'unit',
args: ['unit', 'boom'],
subcommands: ['boom'],
handlers: {
boom: () => {
throw new Error('boom');
},
},
unknownMessage: () => 'should not be used',
error: (message) => {
errorMessage = message;
},
cwd: '/tmp/proj',
raw: false,
});
assert.equal(errorMessage, 'boom');
});
});
describe('cjs-command-router-adapter routeCjsCommandFamily', () => {
test('routes known subcommand handler via the legacy adapter', () => {
let calls = 0;
let errorMessage = null;
routeCjsCommandFamily({
args: ['unit', 'ok'],
subcommands: ['ok'],
handlers: {
ok: () => {
calls += 1;
},
},
unknownMessage: (subcommand, available) => `Unknown ${subcommand}. Available: ${available.join(', ')}`,
error: (message) => {
errorMessage = message;
},
cwd: '/tmp/proj',
raw: false,
});
assert.equal(calls, 1);
assert.equal(errorMessage, null);
});
test('honors defaultSubcommand when args[1] is absent', () => {
let calls = 0;
let errorMessage = null;
routeCjsCommandFamily({
args: ['unit'],
subcommands: ['load'],
defaultSubcommand: 'load',
handlers: {
load: () => {
calls += 1;
},
},
unknownMessage: (subcommand, available) => `Unknown ${subcommand}. Available: ${available.join(', ')}`,
error: (message) => {
errorMessage = message;
},
cwd: '/tmp/proj',
raw: false,
});
assert.equal(calls, 1);
assert.equal(errorMessage, null);
});
test('converts thrown handler exceptions into error callback messages', () => {
let errorMessage = null;
routeCjsCommandFamily({
args: ['unit', 'boom'],
subcommands: ['boom'],
handlers: {
boom: () => {
throw new Error('boom');
},
},
unknownMessage: () => 'should not be used',
error: (message) => {
errorMessage = message;
},
cwd: '/tmp/proj',
raw: false,
});
assert.equal(errorMessage, 'boom');
});
});