* 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>
210 lines
9.0 KiB
JavaScript
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}`);
|
|
});
|
|
});
|