* test(#178): update DispatchEvent factory tests to propagate parentTraceId P1.3 test 'parentTraceId is always undefined' replaced with four P1.4 contracts: absent → undefined, string → propagated, null → undefined, non-string → undefined (defensive normalization policy). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(#178): propagate parentTraceId through DispatchEvent factory Stop ignoring the parentTraceId parameter added as a forward-compat hook in P1.3. Defensive normalization: only non-null strings are propagated; null, non-string values, and absent callers all yield undefined, keeping P1.3 behavior intact for all existing dispatch call sites. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(#178): add Hub-level parentTraceId propagation tests Four new assertions: req.parentTraceId propagates to event, absent → undefined (P1.3 regression), shared parentTraceId across multiple dispatches, and unique traceId invariant despite shared parentTraceId. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(#178): plumb parentTraceId through Hub dispatch and _notifyLogger dispatch() now reads req.parentTraceId and passes it to _notifyLogger, which forwards it to makeDispatchEvent. Backward-compatible: callers that omit parentTraceId emit events with parentTraceId: undefined, identical to P1.3 behavior. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(#178): add trace correlation end-to-end test Dispatches a root command then 3 children with parentTraceId=rootTraceId. Reads the real .gsd-trace.jsonl audit file and verifies: 4 events total, root has no parentTraceId, all children carry rootTraceId, all traceIds unique, JS filter returns exactly the 3 children given the root's traceId. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(#178): document traceId/parentTraceId in audit file Update Observability section to note that audit events now carry both traceId and parentTraceId, and explain the correlation filter pattern. Note that leaf dispatches emit parentTraceId: undefined until the Phase 2 composer wires it automatically. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(#178): add changeset for trace correlation seam Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(#178): cover invalid parentTraceId values in DispatchEvent factory Adds 9 new test cases for UUID v4 validation of parentTraceId: empty string, whitespace, non-UUID, oversized, UUID v1, missing-hyphen, extra-char (all dropped to undefined), plus UPPERCASE and lowercase v4 (both propagated). Tests are intentionally red until the implementation commit that follows. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(#178): validate parentTraceId against UUID v4 before propagation Adds UUID_V4_REGEX constant and isValidParentTraceId() helper to event.cjs. makeDispatchEvent now silently coerces any parentTraceId that fails the UUID v4 check (wrong version nibble, wrong variant, missing hyphens, oversized, empty, etc.) to undefined. No stderr warn is emitted — the factory remains pure and side-effect-free. Closes the correlation- poisoning vector identified in the Codex adversarial review of PR #225. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(#178): assert Hub silently drops invalid parentTraceId at the seam Adds two tests to hub-logger-integration.test.cjs: 1. dispatch with 'junk' parentTraceId emits event with parentTraceId===undefined. 2. The logger-failure warn path is NOT triggered — the factory coerces the bad value before onEvent is called, confirmed by zero stderr output even when a logger that would throw on non-undefined parentTraceId is installed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(#178): assert invalid parentTraceId does not poison correlation siblings Adds one test to trace-correlation.test.cjs: dispatches a root, a valid child (parentTraceId = rootTraceId), and an invalid child (parentTraceId = 'junk'). Asserts: valid child carries correct parentTraceId, invalid child has parentTraceId dropped to undefined, filtering by rootTraceId yields exactly 1 event (the valid child only), and all 3 events have unique traceIds. Uses an isolated Hub + tmpdir to avoid shared fixture interference. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(#178): document UUID v4 contract for parentTraceId Appends one sentence to the Observability audit-trail paragraph in CONFIGURATION.md: parentTraceId must be canonical UUID v4 (RFC 4122); values that don't match are silently dropped from audit output. No section restructuring — single sentence addition only. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
296 lines
10 KiB
JavaScript
296 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Tests for DispatchEvent shape factory (issue #177).
|
|
*
|
|
* Each test exercises the real module code path and asserts on
|
|
* observable behaviour (return values). No mocks, no vacuous truths.
|
|
*/
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const {
|
|
makeDispatchEvent,
|
|
} = require('../../get-shit-done/bin/lib/observability/event.cjs');
|
|
|
|
describe('makeDispatchEvent — shape', () => {
|
|
test('returns an object with required top-level fields', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
args: ['--tdd'],
|
|
result: { kind: 'ok', data: null },
|
|
});
|
|
|
|
assert.ok(typeof event.traceId === 'string', 'traceId must be a string');
|
|
assert.ok(typeof event.command === 'string', 'command must be a string');
|
|
assert.ok(typeof event.timestamp === 'string', 'timestamp must be a string');
|
|
assert.ok('result' in event, 'result must be present');
|
|
});
|
|
|
|
test('traceId is a UUID v4 (format check)', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
});
|
|
// UUID v4: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
|
const uuidV4Re = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
assert.match(event.traceId, uuidV4Re, `traceId '${event.traceId}' is not a valid UUID v4`);
|
|
});
|
|
|
|
test('each call produces a unique traceId', () => {
|
|
const a = makeDispatchEvent({ command: 'plan', result: { kind: 'ok', data: null } });
|
|
const b = makeDispatchEvent({ command: 'plan', result: { kind: 'ok', data: null } });
|
|
assert.notEqual(a.traceId, b.traceId, 'consecutive calls must produce different traceIds');
|
|
});
|
|
|
|
test('parentTraceId is undefined when not provided (default, backward-compat with P1.3)', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
});
|
|
// P1.4: when no parentTraceId supplied, field is still undefined
|
|
assert.strictEqual(event.parentTraceId, undefined, 'parentTraceId must be undefined when not provided');
|
|
});
|
|
|
|
test('parentTraceId propagates when provided as a string (P1.4)', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
|
});
|
|
assert.strictEqual(event.parentTraceId, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
|
'parentTraceId must be propagated when provided as a string');
|
|
});
|
|
|
|
test('parentTraceId is undefined when null is passed (defensive normalization)', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: null,
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'null parentTraceId must be normalised to undefined');
|
|
});
|
|
|
|
test('non-string parentTraceId is set to undefined for defensive safety', () => {
|
|
// Style choice: surrounding code uses undefined for absent/invalid optional fields
|
|
// (e.g. args is omitted rather than coerced). Consistent policy: non-string → undefined.
|
|
const eventNum = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: 42,
|
|
});
|
|
assert.strictEqual(eventNum.parentTraceId, undefined,
|
|
'number parentTraceId must be normalised to undefined');
|
|
|
|
const eventObj = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: { id: 'x' },
|
|
});
|
|
assert.strictEqual(eventObj.parentTraceId, undefined,
|
|
'object parentTraceId must be normalised to undefined');
|
|
});
|
|
|
|
test('command is set from input', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'discuss',
|
|
result: { kind: 'ok', data: null },
|
|
});
|
|
assert.equal(event.command, 'discuss');
|
|
});
|
|
|
|
test('timestamp is a valid ISO 8601 string', () => {
|
|
const before = Date.now();
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
});
|
|
const after = Date.now();
|
|
const parsed = Date.parse(event.timestamp);
|
|
assert.ok(!isNaN(parsed), `timestamp '${event.timestamp}' must parse as a date`);
|
|
assert.ok(parsed >= before, 'timestamp must not be in the past');
|
|
assert.ok(parsed <= after + 5, 'timestamp must not be in the future');
|
|
});
|
|
|
|
test('result field is passed through', () => {
|
|
const result = { kind: 'UnknownCommand', command: 'bogus' };
|
|
const event = makeDispatchEvent({ command: 'bogus', result });
|
|
assert.deepStrictEqual(event.result, result);
|
|
});
|
|
});
|
|
|
|
describe('makeDispatchEvent — args field', () => {
|
|
test('args is omitted when not provided', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
});
|
|
assert.ok(!('args' in event), 'args must not be present when not supplied');
|
|
});
|
|
|
|
test('args is omitted when provided (default redaction)', () => {
|
|
// The event factory itself does NOT decide to include args — that is the
|
|
// redaction layer's job. makeDispatchEvent simply stores args as supplied.
|
|
// By default (no includeArgs), args should NOT appear in the returned event.
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
args: ['--foo', 'bar'],
|
|
result: { kind: 'ok', data: null },
|
|
includeArgs: false,
|
|
});
|
|
assert.ok(!('args' in event), 'args must be absent when includeArgs is false');
|
|
});
|
|
|
|
test('args is included when includeArgs is true', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
args: ['--foo', 'bar'],
|
|
result: { kind: 'ok', data: null },
|
|
includeArgs: true,
|
|
});
|
|
assert.ok('args' in event, 'args must be present when includeArgs is true');
|
|
assert.deepStrictEqual(event.args, ['--foo', 'bar']);
|
|
});
|
|
});
|
|
|
|
describe('makeDispatchEvent — parentTraceId UUID v4 validation', () => {
|
|
const { randomUUID } = require('crypto');
|
|
|
|
test('invalid empty string parentTraceId is dropped to undefined', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: '',
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'empty string parentTraceId must be coerced to undefined');
|
|
});
|
|
|
|
test('invalid whitespace-only parentTraceId is dropped to undefined', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: ' ',
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'whitespace-only parentTraceId must be coerced to undefined');
|
|
});
|
|
|
|
test('invalid non-UUID string parentTraceId is dropped to undefined', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: 'junk',
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'"junk" parentTraceId must be coerced to undefined');
|
|
});
|
|
|
|
test('invalid oversized string parentTraceId is dropped to undefined', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: 'a'.repeat(10000),
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'10000-char string parentTraceId must be coerced to undefined');
|
|
});
|
|
|
|
test('invalid UUID v1 parentTraceId is dropped to undefined', () => {
|
|
// Version nibble is 1, not 4 — rejected by UUID v4 regex
|
|
const uuidV1Like = 'aaaaaaaa-bbbb-1ccc-8ddd-eeeeeeeeeeee';
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: uuidV1Like,
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'UUID v1 parentTraceId must be dropped to undefined');
|
|
});
|
|
|
|
test('invalid UUID v4 missing hyphens parentTraceId is dropped to undefined', () => {
|
|
// 32 hex chars, no hyphens
|
|
const noHyphens = '1234567812345678123456781234567812';
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: noHyphens,
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'UUID v4 missing hyphens must be coerced to undefined');
|
|
});
|
|
|
|
test('invalid UUID v4 with extra chars parentTraceId is dropped to undefined', () => {
|
|
const withExtra = randomUUID() + 'x';
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: withExtra,
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'UUID v4 with trailing extra char must be coerced to undefined');
|
|
});
|
|
|
|
test('valid UPPERCASE UUID v4 parentTraceId is propagated (case-insensitive)', () => {
|
|
const upperUUID = randomUUID().toUpperCase();
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: upperUUID,
|
|
});
|
|
assert.strictEqual(event.parentTraceId, upperUUID,
|
|
'uppercase UUID v4 parentTraceId must be propagated as-is');
|
|
});
|
|
|
|
test('valid lowercase UUID v4 parentTraceId is propagated', () => {
|
|
const lowerUUID = randomUUID(); // crypto.randomUUID() is always lowercase
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: lowerUUID,
|
|
});
|
|
assert.strictEqual(event.parentTraceId, lowerUUID,
|
|
'lowercase UUID v4 parentTraceId must be propagated');
|
|
});
|
|
});
|
|
|
|
describe('makeDispatchEvent — result variants', () => {
|
|
test('ok result shape', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: 42 },
|
|
});
|
|
assert.equal(event.result.kind, 'ok');
|
|
assert.equal(event.result.data, 42);
|
|
});
|
|
|
|
test('UnknownCommand result shape', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'bogus',
|
|
result: { kind: 'UnknownCommand', command: 'bogus' },
|
|
});
|
|
assert.equal(event.result.kind, 'UnknownCommand');
|
|
assert.equal(event.result.command, 'bogus');
|
|
});
|
|
|
|
test('InvalidArgs result shape', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'InvalidArgs', arg: '--bad', reason: 'not recognised' },
|
|
});
|
|
assert.equal(event.result.kind, 'InvalidArgs');
|
|
assert.equal(event.result.arg, '--bad');
|
|
});
|
|
|
|
test('HandlerFailure result shape', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'HandlerFailure', message: 'boom' },
|
|
});
|
|
assert.equal(event.result.kind, 'HandlerFailure');
|
|
assert.equal(event.result.message, 'boom');
|
|
});
|
|
});
|