Files
msd-core/tests/feat-3255-json-errors-mode.test.cjs
Tom Boucher 31e2c22309 feat(3255): add --json-errors structured error mode to gsd-tools (#3304)
* test(3255): add red/green tests for --json-errors structured error mode

Ten tests covering the --json-errors mode contract:
- Unknown command → sdk_unknown_command
- Dotted unknown command → sdk_unknown_command
- Missing --pick value → usage
- Config key not found → config_key_not_found
- Unknown subcommand → sdk_unknown_command
- GSD_JSON_ERRORS=1 env var activation
- Successful command unaffected
- Stable error shape ({ok, reason, message})
- Single error line per invocation
- Unknown flag → usage

All assertions use JSON.parse on stderr captures, never .includes() on
text (#2974 / CONTRIBUTING.md "Prohibited: Raw Text Matching" rule).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(3255): add typed ERROR_REASON codes and GSD_JSON_ERRORS env var support

- Destructure ERROR_REASON from core in gsd-tools.cjs
- Add GSD_JSON_ERRORS=1 env var as alternative to --json-errors CLI flag
- Pass ERROR_REASON.SDK_UNKNOWN_COMMAND to unknown top-level command default path
- Pass ERROR_REASON.SDK_UNKNOWN_COMMAND to unknown intel subcommand path
- Pass ERROR_REASON.USAGE to --pick missing value error path
- Pass ERROR_REASON.USAGE to --version flag rejection path

All ten tests in feat-3255-json-errors-mode.test.cjs pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(3255): add json-errors taxonomy doc, changeset, and CHANGELOG entry

- docs/json-errors.md: full error code taxonomy, wire format spec, and
  test-authoring guidelines for the --json-errors mode
- .changeset/gentle-tigers-roar.md: changeset fragment (pr will be updated
  after PR is opened)
- CHANGELOG.md: Unreleased → Added entry for the new structured error mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: update changeset PR number to 3304

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(gsd-tools): document --json-errors in usage/help text (#3255)

Add [--json-errors] to the TOP_LEVEL_USAGE synopsis line and introduce a
"Global flags:" section describing all four global flags (--raw, --pick,
--cwd, --ws) plus --json-errors with its GSD_JSON_ERRORS=1 env-var
alternative, so operators can discover the flag via `gsd-tools --help`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: drop redundant CHANGELOG.md edit (use .changeset/ fragment per CONTRIBUTING.md)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:46:34 -04:00

210 lines
9.0 KiB
JavaScript

/**
* Tests for the --json-errors mode added in #3255.
*
* When gsd-tools is invoked with --json-errors, all error() calls emit a
* structured JSON object to stderr:
*
* { ok: false, reason: "<error_code>", message: "<human text>" }
*
* This lets tests assert on typed reason codes instead of grepping free-form
* stderr text. All assertions below parse the captured stderr via JSON.parse
* and inspect typed fields — never result.error.includes() (#2974 / k001).
*
* Covered error paths (representative set, each exercises a different branch):
* 1. Unknown top-level command → reason: "sdk_unknown_command"
* 2. Unknown dotted command → reason: "sdk_unknown_command"
* 3. Missing required argument → reason: "usage" (--pick without value)
* 4. Config key not found → reason: "config_key_not_found"
* 5. Unknown subcommand → reason: "sdk_unknown_command"
* 6. GSD_JSON_ERRORS=1 env var → same structured output without --flag
* 7. Successful command unaffected
* 8. Error object shape is stable ({ok, reason, message})
* 9. Single error line per invocation
* 10. Unknown flag → reason: "usage"
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// Helper: run gsd-tools with --json-errors and parse the structured stderr.
// Returns the parsed object, or throws if stderr is not valid JSON.
function runJsonErrors(args, tmpDir, env = {}) {
const allArgs = ['--json-errors', ...args];
const result = runGsdTools(allArgs, tmpDir, env);
// Must have failed
assert.strictEqual(result.success, false,
`Expected failure with --json-errors for args: ${args.join(' ')}\nstdout: ${result.output}\nstderr: ${result.error}`);
let parsed;
try {
parsed = JSON.parse(result.error);
} catch (e) {
throw new Error(
`--json-errors must emit valid JSON on stderr.\n` +
`Args: ${args.join(' ')}\n` +
`stderr: ${result.error}\n` +
`parse error: ${e.message}`
);
}
return parsed;
}
describe('feat #3255: --json-errors mode emits structured error objects', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── 1. Unknown top-level command ─────────────────────────────────────────
test('unknown top-level command emits { ok: false, reason: "sdk_unknown_command" }', () => {
const parsed = runJsonErrors(['totally-unknown-command-xyzzy'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`reason must be "sdk_unknown_command", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 2. Unknown dotted command ────────────────────────────────────────────
test('unknown dotted command (foo.bar) emits { ok: false, reason: "sdk_unknown_command" }', () => {
const parsed = runJsonErrors(['foo.bar'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`dotted unknown command reason must be "sdk_unknown_command", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 3. Missing --pick value ───────────────────────────────────────────────
test('--pick without value emits { ok: false, reason: "usage" }', () => {
const parsed = runJsonErrors(['generate-slug', 'test-text', '--pick'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'usage',
`missing --pick value reason must be "usage", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 4. Config key not found ───────────────────────────────────────────────
test('config-get for absent key emits { ok: false, reason: "config_key_not_found" }', () => {
// Initialise config.json first so we reach the "key not found" branch
// rather than the "no config.json" branch.
runGsdTools(['config-ensure-section'], tmpDir);
const parsed = runJsonErrors(['config-get', 'nonexistent_config_key_xyzzy'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'config_key_not_found',
`reason must be "config_key_not_found", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 5. Unknown subcommand within a domain ────────────────────────────────
test('unknown intel subcommand emits { ok: false, reason: "sdk_unknown_command" }', () => {
const parsed = runJsonErrors(['intel', 'bogus-subcommand-xyzzy'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`unknown subcommand reason must be "sdk_unknown_command", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 6. GSD_JSON_ERRORS=1 env var activates structured mode ───────────────
test('GSD_JSON_ERRORS=1 env var produces same structured error as --json-errors flag', () => {
// Run with env var instead of --json-errors flag
const result = runGsdTools(
['totally-unknown-command-xyzzy'],
tmpDir,
{ GSD_JSON_ERRORS: '1' }
);
assert.strictEqual(result.success, false,
'command must fail');
let parsed;
try {
parsed = JSON.parse(result.error);
} catch (e) {
throw new Error(
`GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` +
`stderr: ${result.error}\n` +
`parse error: ${e.message}`
);
}
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`reason must be "sdk_unknown_command", got: ${parsed.reason}`);
});
// ── 7. Successful commands are unaffected by --json-errors ───────────────
test('successful command with --json-errors flag still succeeds normally', () => {
const result = runGsdTools(
['--json-errors', 'generate-slug', 'hello-world'],
tmpDir
);
assert.strictEqual(result.success, true,
`Successful command must not be broken by --json-errors flag.\nstderr: ${result.error}`);
assert.ok(result.output.length > 0,
'stdout must be non-empty for successful generate-slug');
});
// ── 8. Error object shape is stable (no extra top-level keys) ────────────
test('error object contains exactly {ok, reason, message} — no extra keys', () => {
const parsed = runJsonErrors(['totally-unknown-command-xyzzy'], tmpDir);
const keys = Object.keys(parsed).sort();
assert.deepStrictEqual(keys, ['message', 'ok', 'reason'],
`error object must have exactly {ok, reason, message}. Got keys: ${keys.join(', ')}`);
});
// ── 9. Multiple errors in one session: only the first error is emitted ───
test('only one error JSON line is emitted per invocation (process exits on first error)', () => {
const result = runGsdTools(
['--json-errors', 'totally-unknown-command-xyzzy'],
tmpDir
);
assert.strictEqual(result.success, false, 'must fail');
const lines = result.error.trim().split('\n').filter(l => l.length > 0);
assert.strictEqual(lines.length, 1,
`stderr must contain exactly one JSON line, got ${lines.length}:\n${result.error}`);
// Also verify the single line is valid JSON
const parsed = JSON.parse(lines[0]);
assert.strictEqual(parsed.ok, false);
});
// ── 10. Unknown flag emits { ok: false, reason: "usage" } ────────────────
test('unknown version flag emits { ok: false, reason: "usage" }', () => {
const parsed = runJsonErrors(['--version', 'generate-slug', 'x'], tmpDir);
assert.strictEqual(parsed.ok, false, 'error object must have ok: false');
assert.strictEqual(parsed.reason, 'usage',
`--version flag reason must be "usage", got: ${parsed.reason}`);
});
});