mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
* refactor(tests): standardize to node:assert/strict and t.after() per CONTRIBUTING.md
- Replace require('node:assert') with require('node:assert/strict') across
all 73 test files to enforce strict equality (no type coercion)
- Replace try/finally cleanup blocks with t.after() hooks in core.test.cjs
and hooks-opt-in.test.cjs per the test lifecycle standards
- Utility functions in codex-config and security-scan retain try/finally
as that is appropriate for per-function resource guards, not lifecycle hooks
Closes #1674
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* perf(tests): add --test-concurrency=4 to test runner for parallel file execution
Node.js --test-concurrency controls how many test files run as parallel child
processes. Set to 4 by default, configurable via TEST_CONCURRENCY env var.
Fixes tests at a known level rather than inheriting os.availableParallelism()
which varies across CI environments.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(security): allowlist verify.test.cjs in prompt-injection scanner
tests/verify.test.cjs uses <human>...</human> as GSD phase task-type
XML (meaning "a human should verify this step"), which matches the
scanner's fake-message-boundary pattern for LLM APIs. This is a
false positive — add it to the allowlist alongside the other test files
that legitimately contain injection-adjacent patterns.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
376 lines
12 KiB
JavaScript
376 lines
12 KiB
JavaScript
/**
|
|
* GSD Tools Tests - Hook Field Validation
|
|
*
|
|
* Tests for validateHookFields() which prevents silent settings.json
|
|
* rejection by removing hook entries that fail Claude Code's Zod schema.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { validateHookFields } = require('../bin/install.js');
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
/** Deep-clone to avoid cross-test mutation. */
|
|
function clone(obj) {
|
|
return JSON.parse(JSON.stringify(obj));
|
|
}
|
|
|
|
/** Build a valid command hook entry. */
|
|
function commandEntry(command, matcher = 'gsd-test') {
|
|
return {
|
|
matcher,
|
|
hooks: [{ type: 'command', command }],
|
|
};
|
|
}
|
|
|
|
/** Build a valid agent hook entry. */
|
|
function agentEntry(prompt, matcher = 'gsd-test') {
|
|
return {
|
|
matcher,
|
|
hooks: [{ type: 'agent', prompt }],
|
|
};
|
|
}
|
|
|
|
// ─── No-op / passthrough cases ──────────────────────────────────────────────
|
|
|
|
describe('validateHookFields — passthrough', () => {
|
|
test('returns settings unchanged when no hooks key exists', () => {
|
|
const settings = { theme: 'dark' };
|
|
const result = validateHookFields(clone(settings));
|
|
assert.deepStrictEqual(result, settings);
|
|
});
|
|
|
|
test('returns settings unchanged when hooks is null', () => {
|
|
const settings = { hooks: null };
|
|
const result = validateHookFields(clone(settings));
|
|
assert.deepStrictEqual(result, settings);
|
|
});
|
|
|
|
test('returns settings unchanged when hooks is a non-object primitive', () => {
|
|
const settings = { hooks: 42 };
|
|
const result = validateHookFields(clone(settings));
|
|
assert.deepStrictEqual(result, settings);
|
|
});
|
|
|
|
test('preserves valid command hooks', () => {
|
|
const settings = {
|
|
hooks: {
|
|
PostToolUse: [commandEntry('echo hello')],
|
|
},
|
|
};
|
|
const input = clone(settings);
|
|
const result = validateHookFields(input);
|
|
assert.deepStrictEqual(result.hooks.PostToolUse, [commandEntry('echo hello')]);
|
|
});
|
|
|
|
test('preserves valid agent hooks', () => {
|
|
const settings = {
|
|
hooks: {
|
|
SessionStart: [agentEntry('do something')],
|
|
},
|
|
};
|
|
const input = clone(settings);
|
|
const result = validateHookFields(input);
|
|
assert.deepStrictEqual(result.hooks.SessionStart, [agentEntry('do something')]);
|
|
});
|
|
|
|
test('preserves mixed valid hooks across event types', () => {
|
|
const settings = {
|
|
hooks: {
|
|
PostToolUse: [commandEntry('echo a')],
|
|
Stop: [agentEntry('summarize')],
|
|
},
|
|
};
|
|
const input = clone(settings);
|
|
const result = validateHookFields(input);
|
|
assert.strictEqual(Object.keys(result.hooks).length, 2);
|
|
assert.strictEqual(result.hooks.PostToolUse.length, 1);
|
|
assert.strictEqual(result.hooks.Stop.length, 1);
|
|
});
|
|
|
|
test('skips non-array event type values', () => {
|
|
const settings = {
|
|
hooks: {
|
|
PostToolUse: 'not-an-array',
|
|
Stop: [commandEntry('echo ok')],
|
|
},
|
|
};
|
|
const input = clone(settings);
|
|
const result = validateHookFields(input);
|
|
// Non-array value left untouched
|
|
assert.strictEqual(result.hooks.PostToolUse, 'not-an-array');
|
|
assert.strictEqual(result.hooks.Stop.length, 1);
|
|
});
|
|
});
|
|
|
|
// ─── Removal of invalid hooks ───────────────────────────────────────────────
|
|
|
|
describe('validateHookFields — removes invalid hooks', () => {
|
|
test('removes agent hook missing prompt field', () => {
|
|
const settings = {
|
|
hooks: {
|
|
Stop: [{
|
|
matcher: 'test',
|
|
hooks: [{ type: 'agent' }], // missing prompt
|
|
}],
|
|
},
|
|
};
|
|
const result = validateHookFields(clone(settings));
|
|
// Entry had only one hook and it was invalid, so entry is dropped
|
|
// Event array is now empty, so the key is removed
|
|
assert.strictEqual(result.hooks.Stop, undefined);
|
|
});
|
|
|
|
test('removes command hook missing command field', () => {
|
|
const settings = {
|
|
hooks: {
|
|
PostToolUse: [{
|
|
matcher: 'test',
|
|
hooks: [{ type: 'command' }], // missing command
|
|
}],
|
|
},
|
|
};
|
|
const result = validateHookFields(clone(settings));
|
|
assert.strictEqual(result.hooks.PostToolUse, undefined);
|
|
});
|
|
|
|
test('keeps valid hooks and removes invalid ones within same entry', () => {
|
|
const settings = {
|
|
hooks: {
|
|
Stop: [{
|
|
matcher: 'test',
|
|
hooks: [
|
|
{ type: 'command', command: 'echo valid' },
|
|
{ type: 'agent' }, // invalid — no prompt
|
|
{ type: 'command' }, // invalid — no command
|
|
],
|
|
}],
|
|
},
|
|
};
|
|
const result = validateHookFields(clone(settings));
|
|
assert.strictEqual(result.hooks.Stop.length, 1);
|
|
assert.strictEqual(result.hooks.Stop[0].hooks.length, 1);
|
|
assert.strictEqual(result.hooks.Stop[0].hooks[0].command, 'echo valid');
|
|
});
|
|
|
|
test('removes entry when all its hooks are invalid', () => {
|
|
const settings = {
|
|
hooks: {
|
|
SessionStart: [
|
|
{
|
|
matcher: 'bad',
|
|
hooks: [
|
|
{ type: 'agent' }, // no prompt
|
|
{ type: 'command' }, // no command
|
|
],
|
|
},
|
|
commandEntry('echo keeper'),
|
|
],
|
|
},
|
|
};
|
|
const result = validateHookFields(clone(settings));
|
|
assert.strictEqual(result.hooks.SessionStart.length, 1);
|
|
assert.strictEqual(result.hooks.SessionStart[0].hooks[0].command, 'echo keeper');
|
|
});
|
|
});
|
|
|
|
// ─── Entries without hooks sub-array (issue #2 from review) ─────────────────
|
|
|
|
describe('validateHookFields — entries without hooks sub-array', () => {
|
|
test('removes entry with missing hooks property', () => {
|
|
const settings = {
|
|
hooks: {
|
|
Stop: [{ matcher: 'orphan' }], // no hooks sub-array
|
|
},
|
|
};
|
|
const result = validateHookFields(clone(settings));
|
|
assert.strictEqual(result.hooks.Stop, undefined);
|
|
});
|
|
|
|
test('removes entry with null hooks property', () => {
|
|
const settings = {
|
|
hooks: {
|
|
Stop: [{ matcher: 'orphan', hooks: null }],
|
|
},
|
|
};
|
|
const result = validateHookFields(clone(settings));
|
|
assert.strictEqual(result.hooks.Stop, undefined);
|
|
});
|
|
|
|
test('removes entry with non-array hooks property', () => {
|
|
const settings = {
|
|
hooks: {
|
|
Stop: [{ matcher: 'orphan', hooks: 'not-an-array' }],
|
|
},
|
|
};
|
|
const result = validateHookFields(clone(settings));
|
|
assert.strictEqual(result.hooks.Stop, undefined);
|
|
});
|
|
|
|
test('removes structurally invalid entry but keeps valid sibling', () => {
|
|
const settings = {
|
|
hooks: {
|
|
PostToolUse: [
|
|
{ matcher: 'bad' }, // no hooks sub-array
|
|
commandEntry('echo good'),
|
|
],
|
|
},
|
|
};
|
|
const result = validateHookFields(clone(settings));
|
|
assert.strictEqual(result.hooks.PostToolUse.length, 1);
|
|
assert.strictEqual(result.hooks.PostToolUse[0].hooks[0].command, 'echo good');
|
|
});
|
|
});
|
|
|
|
// ─── Empty event array cleanup ──────────────────────────────────────────────
|
|
|
|
describe('validateHookFields — empty event array cleanup', () => {
|
|
test('removes event type key when all entries are invalid', () => {
|
|
const settings = {
|
|
hooks: {
|
|
Stop: [{ matcher: 'a', hooks: [{ type: 'agent' }] }],
|
|
PostToolUse: [commandEntry('echo keep')],
|
|
},
|
|
};
|
|
const result = validateHookFields(clone(settings));
|
|
assert.strictEqual(result.hooks.Stop, undefined);
|
|
assert.strictEqual(result.hooks.PostToolUse.length, 1);
|
|
});
|
|
|
|
test('removes event type key when array was already empty', () => {
|
|
const settings = {
|
|
hooks: {
|
|
Stop: [],
|
|
PostToolUse: [commandEntry('echo keep')],
|
|
},
|
|
};
|
|
const result = validateHookFields(clone(settings));
|
|
assert.strictEqual(result.hooks.Stop, undefined);
|
|
assert.ok(result.hooks.PostToolUse);
|
|
});
|
|
|
|
test('removes all event types when all are invalid', () => {
|
|
const settings = {
|
|
hooks: {
|
|
Stop: [{ matcher: 'a', hooks: [{ type: 'agent' }] }],
|
|
SessionStart: [{ matcher: 'b', hooks: [{ type: 'command' }] }],
|
|
},
|
|
};
|
|
const result = validateHookFields(clone(settings));
|
|
assert.deepStrictEqual(result.hooks, {});
|
|
});
|
|
});
|
|
|
|
// ─── No mutation of original entries (issue #3 from review) ─────────────────
|
|
|
|
describe('validateHookFields — no input mutation', () => {
|
|
test('does not mutate the original entry objects', () => {
|
|
const original = {
|
|
matcher: 'test',
|
|
hooks: [
|
|
{ type: 'command', command: 'echo valid' },
|
|
{ type: 'agent' }, // invalid
|
|
],
|
|
};
|
|
const settings = {
|
|
hooks: {
|
|
Stop: [original],
|
|
},
|
|
};
|
|
// Capture original hooks array length before validation
|
|
const origHooksLength = original.hooks.length;
|
|
validateHookFields(settings);
|
|
// Original entry's hooks array must not be modified
|
|
assert.strictEqual(original.hooks.length, origHooksLength);
|
|
});
|
|
|
|
test('result entry is a copy, not the same object reference', () => {
|
|
const entry = commandEntry('echo test');
|
|
const settings = {
|
|
hooks: {
|
|
Stop: [entry],
|
|
},
|
|
};
|
|
const result = validateHookFields(settings);
|
|
assert.notStrictEqual(result.hooks.Stop[0], entry);
|
|
assert.deepStrictEqual(result.hooks.Stop[0], entry);
|
|
});
|
|
});
|
|
|
|
// ─── Unknown hook types pass through (issue #4 — scope) ─────────────────────
|
|
|
|
describe('validateHookFields — unknown hook types', () => {
|
|
test('preserves hooks with unrecognized type (future-proof)', () => {
|
|
const settings = {
|
|
hooks: {
|
|
Stop: [{
|
|
matcher: 'test',
|
|
hooks: [{ type: 'webhook', url: 'https://example.com' }],
|
|
}],
|
|
},
|
|
};
|
|
const result = validateHookFields(clone(settings));
|
|
assert.strictEqual(result.hooks.Stop.length, 1);
|
|
assert.strictEqual(result.hooks.Stop[0].hooks[0].type, 'webhook');
|
|
});
|
|
|
|
test('preserves hooks with no type field', () => {
|
|
const settings = {
|
|
hooks: {
|
|
Stop: [{
|
|
matcher: 'test',
|
|
hooks: [{ command: 'echo untyped' }],
|
|
}],
|
|
},
|
|
};
|
|
const result = validateHookFields(clone(settings));
|
|
assert.strictEqual(result.hooks.Stop.length, 1);
|
|
});
|
|
});
|
|
|
|
// ─── Iteration safety (issue #5 — no delete during Object.keys iteration) ──
|
|
|
|
describe('validateHookFields — iteration safety', () => {
|
|
test('handles multiple event types with mixed validity without corruption', () => {
|
|
const settings = {
|
|
hooks: {
|
|
A: [{ matcher: 'a', hooks: [{ type: 'agent' }] }], // invalid
|
|
B: [commandEntry('echo b')], // valid
|
|
C: [{ matcher: 'c', hooks: [{ type: 'command' }] }], // invalid
|
|
D: [agentEntry('do d')], // valid
|
|
E: [{ matcher: 'e', hooks: [{ type: 'agent' }] }], // invalid
|
|
},
|
|
};
|
|
const result = validateHookFields(clone(settings));
|
|
assert.strictEqual(result.hooks.A, undefined);
|
|
assert.strictEqual(result.hooks.B.length, 1);
|
|
assert.strictEqual(result.hooks.C, undefined);
|
|
assert.strictEqual(result.hooks.D.length, 1);
|
|
assert.strictEqual(result.hooks.E, undefined);
|
|
});
|
|
});
|
|
|
|
// ─── Preserves non-hook settings ────────────────────────────────────────────
|
|
|
|
describe('validateHookFields — does not touch non-hook settings', () => {
|
|
test('preserves all other settings keys', () => {
|
|
const settings = {
|
|
theme: 'dark',
|
|
plugins: ['a', 'b'],
|
|
statusLine: { command: 'echo hi' },
|
|
hooks: {
|
|
Stop: [commandEntry('echo keep')],
|
|
},
|
|
};
|
|
const result = validateHookFields(clone(settings));
|
|
assert.strictEqual(result.theme, 'dark');
|
|
assert.deepStrictEqual(result.plugins, ['a', 'b']);
|
|
assert.deepStrictEqual(result.statusLine, { command: 'echo hi' });
|
|
});
|
|
});
|