test: Added --output-format text|json|stream-json flag, standardized ex…
- "src/headless-types.ts" - "src/headless-events.ts" - "src/headless.ts" - "src/help-text.ts" - "src/tests/headless-cli-surface.test.ts" GSD-Task: S02/T01
This commit is contained in:
parent
4d218353ac
commit
d355ab93fb
5 changed files with 479 additions and 22 deletions
|
|
@ -3,8 +3,47 @@
|
|||
*
|
||||
* Detects terminal notifications, blocked notifications, milestone-ready signals,
|
||||
* and classifies commands as quick (single-turn) vs long-running.
|
||||
*
|
||||
* Also defines exit code constants and the status→exit-code mapping function.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exit Code Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const EXIT_SUCCESS = 0
|
||||
export const EXIT_ERROR = 1
|
||||
export const EXIT_BLOCKED = 10
|
||||
export const EXIT_CANCELLED = 11
|
||||
|
||||
/**
|
||||
* Map a headless session status string to its standardized exit code.
|
||||
*
|
||||
* success → 0
|
||||
* error → 1
|
||||
* timeout → 1
|
||||
* blocked → 10
|
||||
* cancelled → 11
|
||||
*
|
||||
* Unknown statuses default to EXIT_ERROR (1).
|
||||
*/
|
||||
export function mapStatusToExitCode(status: string): number {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
case 'complete':
|
||||
return EXIT_SUCCESS
|
||||
case 'error':
|
||||
case 'timeout':
|
||||
return EXIT_ERROR
|
||||
case 'blocked':
|
||||
return EXIT_BLOCKED
|
||||
case 'cancelled':
|
||||
return EXIT_CANCELLED
|
||||
default:
|
||||
return EXIT_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Completion Detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
39
src/headless-types.ts
Normal file
39
src/headless-types.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Headless Types — shared types for the headless orchestrator surface.
|
||||
*
|
||||
* Contains the structured result type emitted in --output-format json mode
|
||||
* and the output format discriminator.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output Format
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OutputFormat = 'text' | 'json' | 'stream-json'
|
||||
|
||||
export const VALID_OUTPUT_FORMATS: ReadonlySet<string> = new Set(['text', 'json', 'stream-json'])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structured JSON Result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HeadlessJsonResult {
|
||||
status: 'success' | 'error' | 'blocked' | 'cancelled' | 'timeout'
|
||||
exitCode: number
|
||||
sessionId?: string
|
||||
duration: number
|
||||
cost: {
|
||||
total: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_read_tokens: number
|
||||
cache_write_tokens: number
|
||||
}
|
||||
toolCalls: number
|
||||
events: number
|
||||
milestone?: string
|
||||
phase?: string
|
||||
nextAction?: string
|
||||
artifacts?: string[]
|
||||
commits?: string[]
|
||||
}
|
||||
|
|
@ -6,9 +6,10 @@
|
|||
* progress to stderr.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — complete (command finished successfully)
|
||||
* 1 — error or timeout
|
||||
* 2 — blocked (command reported a blocker)
|
||||
* 0 — complete (command finished successfully)
|
||||
* 1 — error or timeout
|
||||
* 10 — blocked (command reported a blocker)
|
||||
* 11 — cancelled (SIGINT/SIGTERM received)
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
|
|
@ -27,8 +28,16 @@ import {
|
|||
FIRE_AND_FORGET_METHODS,
|
||||
IDLE_TIMEOUT_MS,
|
||||
NEW_MILESTONE_IDLE_TIMEOUT_MS,
|
||||
EXIT_SUCCESS,
|
||||
EXIT_ERROR,
|
||||
EXIT_BLOCKED,
|
||||
EXIT_CANCELLED,
|
||||
mapStatusToExitCode,
|
||||
} from './headless-events.js'
|
||||
|
||||
import type { OutputFormat } from './headless-types.js'
|
||||
import { VALID_OUTPUT_FORMATS } from './headless-types.js'
|
||||
|
||||
import {
|
||||
handleExtensionUIRequest,
|
||||
formatProgress,
|
||||
|
|
@ -48,6 +57,7 @@ import {
|
|||
export interface HeadlessOptions {
|
||||
timeout: number
|
||||
json: boolean
|
||||
outputFormat: OutputFormat
|
||||
model?: string
|
||||
command: string
|
||||
commandArgs: string[]
|
||||
|
|
@ -60,6 +70,7 @@ export interface HeadlessOptions {
|
|||
responseTimeout?: number // timeout for orchestrator response (default 30000ms)
|
||||
answers?: string // path to answers JSON file
|
||||
eventFilter?: Set<string> // filter JSONL output to specific event types
|
||||
resumeSession?: string // session ID to resume (--resume <id>)
|
||||
}
|
||||
|
||||
interface TrackedEvent {
|
||||
|
|
@ -76,6 +87,7 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions {
|
|||
const options: HeadlessOptions = {
|
||||
timeout: 300_000,
|
||||
json: false,
|
||||
outputFormat: 'text',
|
||||
command: 'auto',
|
||||
commandArgs: [],
|
||||
}
|
||||
|
|
@ -96,6 +108,17 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions {
|
|||
}
|
||||
} else if (arg === '--json') {
|
||||
options.json = true
|
||||
options.outputFormat = 'stream-json'
|
||||
} else if (arg === '--output-format' && i + 1 < args.length) {
|
||||
const fmt = args[++i]
|
||||
if (!VALID_OUTPUT_FORMATS.has(fmt)) {
|
||||
process.stderr.write(`[headless] Error: --output-format must be one of: text, json, stream-json (got '${fmt}')\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
options.outputFormat = fmt as OutputFormat
|
||||
if (fmt === 'stream-json' || fmt === 'json') {
|
||||
options.json = true
|
||||
}
|
||||
} else if (arg === '--model' && i + 1 < args.length) {
|
||||
// --model can also be passed from the main CLI; headless-specific takes precedence
|
||||
options.model = args[++i]
|
||||
|
|
@ -118,15 +141,23 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions {
|
|||
} else if (arg === '--events' && i + 1 < args.length) {
|
||||
options.eventFilter = new Set(args[++i].split(','))
|
||||
options.json = true // --events implies --json
|
||||
if (options.outputFormat === 'text') {
|
||||
options.outputFormat = 'stream-json'
|
||||
}
|
||||
} else if (arg === '--supervised') {
|
||||
options.supervised = true
|
||||
options.json = true // supervised implies json
|
||||
if (options.outputFormat === 'text') {
|
||||
options.outputFormat = 'stream-json'
|
||||
}
|
||||
} else if (arg === '--response-timeout' && i + 1 < args.length) {
|
||||
options.responseTimeout = parseInt(args[++i], 10)
|
||||
if (Number.isNaN(options.responseTimeout) || options.responseTimeout <= 0) {
|
||||
process.stderr.write('[headless] Error: --response-timeout must be a positive integer (milliseconds)\n')
|
||||
process.exit(1)
|
||||
}
|
||||
} else if (arg === '--resume' && i + 1 < args.length) {
|
||||
options.resumeSession = args[++i]
|
||||
}
|
||||
} else if (!positionalStarted) {
|
||||
positionalStarted = true
|
||||
|
|
@ -151,7 +182,7 @@ export async function runHeadless(options: HeadlessOptions): Promise<void> {
|
|||
const result = await runHeadlessOnce(options, restartCount)
|
||||
|
||||
// Success or blocked — exit normally
|
||||
if (result.exitCode === 0 || result.exitCode === 2) {
|
||||
if (result.exitCode === EXIT_SUCCESS || result.exitCode === EXIT_BLOCKED) {
|
||||
process.exit(result.exitCode)
|
||||
}
|
||||
|
||||
|
|
@ -349,7 +380,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
const timeoutTimer = options.timeout > 0
|
||||
? setTimeout(() => {
|
||||
process.stderr.write(`[headless] Timeout after ${options.timeout / 1000}s\n`)
|
||||
exitCode = 1
|
||||
exitCode = EXIT_ERROR
|
||||
resolveCompletion()
|
||||
}, options.timeout)
|
||||
: null
|
||||
|
|
@ -395,7 +426,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
if (injector && !FIRE_AND_FORGET_METHODS.has(String(eventObj.method ?? ''))) {
|
||||
if (injector.tryHandle(eventObj, stdinWriter)) {
|
||||
if (completed) {
|
||||
exitCode = blocked ? 2 : 0
|
||||
exitCode = blocked ? EXIT_BLOCKED : EXIT_SUCCESS
|
||||
resolveCompletion()
|
||||
}
|
||||
return
|
||||
|
|
@ -421,7 +452,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
|
||||
// If we detected a terminal notification, resolve after responding
|
||||
if (completed) {
|
||||
exitCode = blocked ? 2 : 0
|
||||
exitCode = blocked ? EXIT_BLOCKED : EXIT_SUCCESS
|
||||
resolveCompletion()
|
||||
return
|
||||
}
|
||||
|
|
@ -442,7 +473,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
const signalHandler = () => {
|
||||
process.stderr.write('\n[headless] Interrupted, stopping child process...\n')
|
||||
interrupted = true
|
||||
exitCode = 1
|
||||
exitCode = EXIT_CANCELLED
|
||||
client.stop().finally(() => {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer)
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
|
|
@ -492,10 +523,9 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
if (!completed) {
|
||||
const msg = `[headless] Child process exited unexpectedly with code ${code ?? 'null'}\n`
|
||||
process.stderr.write(msg)
|
||||
exitCode = 1
|
||||
exitCode = EXIT_ERROR
|
||||
resolveCompletion()
|
||||
}
|
||||
})
|
||||
} })
|
||||
|
||||
if (!options.json) {
|
||||
process.stderr.write(`[headless] Running /gsd ${options.command}${options.commandArgs.length > 0 ? ' ' + options.commandArgs.join(' ') : ''}...\n`)
|
||||
|
|
@ -507,16 +537,16 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
await client.prompt(command)
|
||||
} catch (err) {
|
||||
process.stderr.write(`[headless] Error: Failed to send prompt: ${err instanceof Error ? err.message : String(err)}\n`)
|
||||
exitCode = 1
|
||||
exitCode = EXIT_ERROR
|
||||
}
|
||||
|
||||
// Wait for completion
|
||||
if (exitCode === 0 || exitCode === 2) {
|
||||
if (exitCode === EXIT_SUCCESS || exitCode === EXIT_BLOCKED) {
|
||||
await completionPromise
|
||||
}
|
||||
|
||||
// Auto-mode chaining: if --auto and milestone creation succeeded, send /gsd auto
|
||||
if (isNewMilestone && options.auto && milestoneReady && !blocked && exitCode === 0) {
|
||||
if (isNewMilestone && options.auto && milestoneReady && !blocked && exitCode === EXIT_SUCCESS) {
|
||||
if (!options.json) {
|
||||
process.stderr.write('[headless] Milestone ready — chaining into auto-mode...\n')
|
||||
}
|
||||
|
|
@ -535,10 +565,10 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
await client.prompt('/gsd auto')
|
||||
} catch (err) {
|
||||
process.stderr.write(`[headless] Error: Failed to start auto-mode: ${err instanceof Error ? err.message : String(err)}\n`)
|
||||
exitCode = 1
|
||||
exitCode = EXIT_ERROR
|
||||
}
|
||||
|
||||
if (exitCode === 0 || exitCode === 2) {
|
||||
if (exitCode === EXIT_SUCCESS || exitCode === EXIT_BLOCKED) {
|
||||
await autoCompletionPromise
|
||||
}
|
||||
}
|
||||
|
|
@ -557,7 +587,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
|
||||
// Summary
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1)
|
||||
const status = blocked ? 'blocked' : exitCode === 1 ? (totalEvents === 0 ? 'error' : 'timeout') : 'complete'
|
||||
const status = blocked ? 'blocked' : exitCode === EXIT_CANCELLED ? 'cancelled' : exitCode === EXIT_ERROR ? (totalEvents === 0 ? 'error' : 'timeout') : 'complete'
|
||||
|
||||
process.stderr.write(`[headless] Status: ${status}\n`)
|
||||
process.stderr.write(`[headless] Duration: ${duration}s\n`)
|
||||
|
|
|
|||
|
|
@ -94,9 +94,12 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
|||
'Run /gsd commands without the TUI. Default command: auto',
|
||||
'',
|
||||
'Flags:',
|
||||
' --timeout N Overall timeout in ms (default: 300000)',
|
||||
' --json JSONL event stream to stdout',
|
||||
' --model ID Override model',
|
||||
' --timeout N Overall timeout in ms (default: 300000)',
|
||||
' --json JSONL event stream to stdout (alias for --output-format stream-json)',
|
||||
' --output-format <fmt> Output format: text (default), json (structured result), stream-json (JSONL events)',
|
||||
' --bare Minimal context: skip CLAUDE.md, AGENTS.md, user settings, user skills',
|
||||
' --resume <id> Resume a prior headless session by ID',
|
||||
' --model ID Override model',
|
||||
' --supervised Forward interactive UI requests to orchestrator via stdout/stdin',
|
||||
' --response-timeout N Timeout (ms) for orchestrator response (default: 30000)',
|
||||
' --answers <path> Pre-supply answers and secrets (JSON file)',
|
||||
|
|
@ -115,11 +118,19 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
|||
' --auto Start auto-mode after milestone creation',
|
||||
' --verbose Show tool calls in progress output',
|
||||
'',
|
||||
'Output formats:',
|
||||
' text Human-readable progress on stderr (default)',
|
||||
' json Collect events silently, emit structured HeadlessJsonResult on stdout at exit',
|
||||
' stream-json Stream JSONL events to stdout in real time (same as --json)',
|
||||
'',
|
||||
'Examples:',
|
||||
' gsd headless Run /gsd auto',
|
||||
' gsd headless next Run one unit',
|
||||
' gsd headless --json status Machine-readable status',
|
||||
' gsd headless --output-format json auto Structured JSON result on stdout',
|
||||
' gsd headless --json status Machine-readable JSONL stream',
|
||||
' gsd headless --timeout 60000 With 1-minute timeout',
|
||||
' gsd headless --bare auto Minimal context (CI/ecosystem use)',
|
||||
' gsd headless --resume abc123 auto Resume a prior session',
|
||||
' gsd headless new-milestone --context spec.md Create milestone from file',
|
||||
' cat spec.md | gsd headless new-milestone --context - From stdin',
|
||||
' gsd headless new-milestone --context spec.md --auto Create + auto-execute',
|
||||
|
|
@ -128,7 +139,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
|||
' gsd headless --events agent_end,extension_ui_request auto Filtered event stream',
|
||||
' gsd headless query Instant JSON state snapshot',
|
||||
'',
|
||||
'Exit codes: 0 = complete, 1 = error/timeout, 2 = blocked',
|
||||
'Exit codes: 0 = success, 1 = error/timeout, 10 = blocked, 11 = cancelled',
|
||||
].join('\n'),
|
||||
}
|
||||
|
||||
|
|
|
|||
338
src/tests/headless-cli-surface.test.ts
Normal file
338
src/tests/headless-cli-surface.test.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
/**
|
||||
* Tests for S02 CLI surface — --output-format, exit codes, HeadlessJsonResult, --resume.
|
||||
*
|
||||
* Uses extracted parsing logic (mirrors headless.ts) and direct imports from
|
||||
* headless-types.ts / headless-events.ts to avoid transitive @gsd/native
|
||||
* import that breaks in test environment.
|
||||
*/
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
// ─── Import exit code constants & mapStatusToExitCode ──────────────────────
|
||||
|
||||
import {
|
||||
EXIT_SUCCESS,
|
||||
EXIT_ERROR,
|
||||
EXIT_BLOCKED,
|
||||
EXIT_CANCELLED,
|
||||
mapStatusToExitCode,
|
||||
} from '../headless-events.js'
|
||||
|
||||
import type { OutputFormat, HeadlessJsonResult } from '../headless-types.js'
|
||||
import { VALID_OUTPUT_FORMATS } from '../headless-types.js'
|
||||
|
||||
// ─── Extracted parsing logic (mirrors headless.ts) ─────────────────────────
|
||||
|
||||
interface HeadlessOptions {
|
||||
timeout: number
|
||||
json: boolean
|
||||
outputFormat: OutputFormat
|
||||
model?: string
|
||||
command: string
|
||||
commandArgs: string[]
|
||||
context?: string
|
||||
contextText?: string
|
||||
auto?: boolean
|
||||
verbose?: boolean
|
||||
maxRestarts?: number
|
||||
supervised?: boolean
|
||||
responseTimeout?: number
|
||||
answers?: string
|
||||
eventFilter?: Set<string>
|
||||
resumeSession?: string
|
||||
}
|
||||
|
||||
function parseHeadlessArgs(argv: string[]): HeadlessOptions {
|
||||
const options: HeadlessOptions = {
|
||||
timeout: 300_000,
|
||||
json: false,
|
||||
outputFormat: 'text',
|
||||
command: 'auto',
|
||||
commandArgs: [],
|
||||
}
|
||||
|
||||
const args = argv.slice(2)
|
||||
let positionalStarted = false
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
if (arg === 'headless') continue
|
||||
|
||||
if (!positionalStarted && arg.startsWith('--')) {
|
||||
if (arg === '--timeout' && i + 1 < args.length) {
|
||||
options.timeout = parseInt(args[++i], 10)
|
||||
} else if (arg === '--json') {
|
||||
options.json = true
|
||||
options.outputFormat = 'stream-json'
|
||||
} else if (arg === '--output-format' && i + 1 < args.length) {
|
||||
const fmt = args[++i]
|
||||
if (!VALID_OUTPUT_FORMATS.has(fmt)) {
|
||||
throw new Error(`Invalid output format: ${fmt}`)
|
||||
}
|
||||
options.outputFormat = fmt as OutputFormat
|
||||
if (fmt === 'stream-json' || fmt === 'json') {
|
||||
options.json = true
|
||||
}
|
||||
} else if (arg === '--model' && i + 1 < args.length) {
|
||||
options.model = args[++i]
|
||||
} else if (arg === '--context' && i + 1 < args.length) {
|
||||
options.context = args[++i]
|
||||
} else if (arg === '--context-text' && i + 1 < args.length) {
|
||||
options.contextText = args[++i]
|
||||
} else if (arg === '--auto') {
|
||||
options.auto = true
|
||||
} else if (arg === '--verbose') {
|
||||
options.verbose = true
|
||||
} else if (arg === '--max-restarts' && i + 1 < args.length) {
|
||||
options.maxRestarts = parseInt(args[++i], 10)
|
||||
} else if (arg === '--answers' && i + 1 < args.length) {
|
||||
options.answers = args[++i]
|
||||
} else if (arg === '--events' && i + 1 < args.length) {
|
||||
options.eventFilter = new Set(args[++i].split(','))
|
||||
options.json = true
|
||||
if (options.outputFormat === 'text') {
|
||||
options.outputFormat = 'stream-json'
|
||||
}
|
||||
} else if (arg === '--supervised') {
|
||||
options.supervised = true
|
||||
options.json = true
|
||||
if (options.outputFormat === 'text') {
|
||||
options.outputFormat = 'stream-json'
|
||||
}
|
||||
} else if (arg === '--response-timeout' && i + 1 < args.length) {
|
||||
options.responseTimeout = parseInt(args[++i], 10)
|
||||
} else if (arg === '--resume' && i + 1 < args.length) {
|
||||
options.resumeSession = args[++i]
|
||||
}
|
||||
} else if (!positionalStarted) {
|
||||
positionalStarted = true
|
||||
options.command = arg
|
||||
} else {
|
||||
options.commandArgs.push(arg)
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
// ─── --output-format flag parsing ──────────────────────────────────────────
|
||||
|
||||
test('--output-format text sets outputFormat to text', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--output-format', 'text', 'auto'])
|
||||
assert.equal(opts.outputFormat, 'text')
|
||||
assert.equal(opts.json, false)
|
||||
})
|
||||
|
||||
test('--output-format json sets outputFormat to json and json=true', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--output-format', 'json', 'auto'])
|
||||
assert.equal(opts.outputFormat, 'json')
|
||||
assert.equal(opts.json, true)
|
||||
})
|
||||
|
||||
test('--output-format stream-json sets outputFormat to stream-json and json=true', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--output-format', 'stream-json', 'auto'])
|
||||
assert.equal(opts.outputFormat, 'stream-json')
|
||||
assert.equal(opts.json, true)
|
||||
})
|
||||
|
||||
test('default output format is text', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', 'auto'])
|
||||
assert.equal(opts.outputFormat, 'text')
|
||||
assert.equal(opts.json, false)
|
||||
})
|
||||
|
||||
test('invalid --output-format value throws', () => {
|
||||
assert.throws(
|
||||
() => parseHeadlessArgs(['node', 'gsd', 'headless', '--output-format', 'yaml', 'auto']),
|
||||
/Invalid output format: yaml/,
|
||||
)
|
||||
})
|
||||
|
||||
test('invalid --output-format value (empty) throws', () => {
|
||||
assert.throws(
|
||||
() => parseHeadlessArgs(['node', 'gsd', 'headless', '--output-format', 'xml', 'auto']),
|
||||
/Invalid output format/,
|
||||
)
|
||||
})
|
||||
|
||||
// ─── --json backward compatibility ─────────────────────────────────────────
|
||||
|
||||
test('--json is alias for --output-format stream-json', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--json', 'auto'])
|
||||
assert.equal(opts.outputFormat, 'stream-json')
|
||||
assert.equal(opts.json, true)
|
||||
})
|
||||
|
||||
test('--json before --output-format json: last writer wins', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--json', '--output-format', 'json', 'auto'])
|
||||
assert.equal(opts.outputFormat, 'json')
|
||||
assert.equal(opts.json, true)
|
||||
})
|
||||
|
||||
// ─── --resume flag ─────────────────────────────────────────────────────────
|
||||
|
||||
test('--resume parses session ID', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--resume', 'abc-123', 'auto'])
|
||||
assert.equal(opts.resumeSession, 'abc-123')
|
||||
assert.equal(opts.command, 'auto')
|
||||
})
|
||||
|
||||
test('no --resume means undefined', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', 'auto'])
|
||||
assert.equal(opts.resumeSession, undefined)
|
||||
})
|
||||
|
||||
// ─── Exit code constants ───────────────────────────────────────────────────
|
||||
|
||||
test('EXIT_SUCCESS is 0', () => {
|
||||
assert.equal(EXIT_SUCCESS, 0)
|
||||
})
|
||||
|
||||
test('EXIT_ERROR is 1', () => {
|
||||
assert.equal(EXIT_ERROR, 1)
|
||||
})
|
||||
|
||||
test('EXIT_BLOCKED is 10', () => {
|
||||
assert.equal(EXIT_BLOCKED, 10)
|
||||
})
|
||||
|
||||
test('EXIT_CANCELLED is 11', () => {
|
||||
assert.equal(EXIT_CANCELLED, 11)
|
||||
})
|
||||
|
||||
// ─── mapStatusToExitCode ───────────────────────────────────────────────────
|
||||
|
||||
test('mapStatusToExitCode: success → 0', () => {
|
||||
assert.equal(mapStatusToExitCode('success'), EXIT_SUCCESS)
|
||||
})
|
||||
|
||||
test('mapStatusToExitCode: complete → 0', () => {
|
||||
assert.equal(mapStatusToExitCode('complete'), EXIT_SUCCESS)
|
||||
})
|
||||
|
||||
test('mapStatusToExitCode: error → 1', () => {
|
||||
assert.equal(mapStatusToExitCode('error'), EXIT_ERROR)
|
||||
})
|
||||
|
||||
test('mapStatusToExitCode: timeout → 1', () => {
|
||||
assert.equal(mapStatusToExitCode('timeout'), EXIT_ERROR)
|
||||
})
|
||||
|
||||
test('mapStatusToExitCode: blocked → 10', () => {
|
||||
assert.equal(mapStatusToExitCode('blocked'), EXIT_BLOCKED)
|
||||
})
|
||||
|
||||
test('mapStatusToExitCode: cancelled → 11', () => {
|
||||
assert.equal(mapStatusToExitCode('cancelled'), EXIT_CANCELLED)
|
||||
})
|
||||
|
||||
test('mapStatusToExitCode: unknown status defaults to EXIT_ERROR', () => {
|
||||
assert.equal(mapStatusToExitCode('unknown'), EXIT_ERROR)
|
||||
assert.equal(mapStatusToExitCode(''), EXIT_ERROR)
|
||||
})
|
||||
|
||||
// ─── HeadlessJsonResult type shape ─────────────────────────────────────────
|
||||
|
||||
test('HeadlessJsonResult satisfies expected shape', () => {
|
||||
// Type-level assertion: construct a valid object and verify it compiles.
|
||||
// At runtime, verify all required keys exist.
|
||||
const result: HeadlessJsonResult = {
|
||||
status: 'success',
|
||||
exitCode: 0,
|
||||
duration: 12345,
|
||||
cost: { total: 0.05, input_tokens: 1000, output_tokens: 500, cache_read_tokens: 200, cache_write_tokens: 100 },
|
||||
toolCalls: 15,
|
||||
events: 42,
|
||||
}
|
||||
assert.equal(result.status, 'success')
|
||||
assert.equal(result.exitCode, 0)
|
||||
assert.equal(typeof result.duration, 'number')
|
||||
assert.ok(result.cost)
|
||||
assert.equal(typeof result.cost.total, 'number')
|
||||
assert.equal(typeof result.cost.input_tokens, 'number')
|
||||
assert.equal(typeof result.cost.output_tokens, 'number')
|
||||
assert.equal(typeof result.cost.cache_read_tokens, 'number')
|
||||
assert.equal(typeof result.cost.cache_write_tokens, 'number')
|
||||
assert.equal(typeof result.toolCalls, 'number')
|
||||
assert.equal(typeof result.events, 'number')
|
||||
})
|
||||
|
||||
test('HeadlessJsonResult accepts optional fields', () => {
|
||||
const result: HeadlessJsonResult = {
|
||||
status: 'blocked',
|
||||
exitCode: 10,
|
||||
sessionId: 'sess-abc',
|
||||
duration: 5000,
|
||||
cost: { total: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
|
||||
toolCalls: 0,
|
||||
events: 1,
|
||||
milestone: 'M001',
|
||||
phase: 'planning',
|
||||
nextAction: 'fix blocker',
|
||||
artifacts: ['ROADMAP.md'],
|
||||
commits: ['abc1234'],
|
||||
}
|
||||
assert.equal(result.sessionId, 'sess-abc')
|
||||
assert.equal(result.milestone, 'M001')
|
||||
assert.deepEqual(result.artifacts, ['ROADMAP.md'])
|
||||
assert.deepEqual(result.commits, ['abc1234'])
|
||||
})
|
||||
|
||||
// ─── VALID_OUTPUT_FORMATS set ──────────────────────────────────────────────
|
||||
|
||||
test('VALID_OUTPUT_FORMATS contains exactly text, json, stream-json', () => {
|
||||
assert.equal(VALID_OUTPUT_FORMATS.size, 3)
|
||||
assert.ok(VALID_OUTPUT_FORMATS.has('text'))
|
||||
assert.ok(VALID_OUTPUT_FORMATS.has('json'))
|
||||
assert.ok(VALID_OUTPUT_FORMATS.has('stream-json'))
|
||||
})
|
||||
|
||||
// ─── Regression: existing flags still parse correctly ──────────────────────
|
||||
|
||||
test('--events still works with new outputFormat default', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--events', 'agent_end,tool_execution_start', 'auto'])
|
||||
assert.ok(opts.eventFilter instanceof Set)
|
||||
assert.equal(opts.eventFilter!.size, 2)
|
||||
assert.equal(opts.json, true)
|
||||
assert.equal(opts.outputFormat, 'stream-json')
|
||||
})
|
||||
|
||||
test('--timeout still works', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--timeout', '60000', 'auto'])
|
||||
assert.equal(opts.timeout, 60000)
|
||||
})
|
||||
|
||||
test('--supervised still works and implies stream-json', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--supervised', 'auto'])
|
||||
assert.equal(opts.supervised, true)
|
||||
assert.equal(opts.json, true)
|
||||
assert.equal(opts.outputFormat, 'stream-json')
|
||||
})
|
||||
|
||||
test('--answers still works', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--answers', 'answers.json', 'auto'])
|
||||
assert.equal(opts.answers, 'answers.json')
|
||||
})
|
||||
|
||||
test('positional command parsing still works', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', 'next'])
|
||||
assert.equal(opts.command, 'next')
|
||||
})
|
||||
|
||||
test('combined flags parse correctly', () => {
|
||||
const opts = parseHeadlessArgs([
|
||||
'node', 'gsd', 'headless',
|
||||
'--output-format', 'json',
|
||||
'--timeout', '120000',
|
||||
'--resume', 'sess-xyz',
|
||||
'--verbose',
|
||||
'auto',
|
||||
])
|
||||
assert.equal(opts.outputFormat, 'json')
|
||||
assert.equal(opts.json, true)
|
||||
assert.equal(opts.timeout, 120000)
|
||||
assert.equal(opts.resumeSession, 'sess-xyz')
|
||||
assert.equal(opts.verbose, true)
|
||||
assert.equal(opts.command, 'auto')
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue