diff --git a/src/headless-events.ts b/src/headless-events.ts index c0ecd3ca8..d2199ef64 100644 --- a/src/headless-events.ts +++ b/src/headless-events.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/src/headless-types.ts b/src/headless-types.ts new file mode 100644 index 000000000..6a4650ed9 --- /dev/null +++ b/src/headless-types.ts @@ -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 = 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[] +} diff --git a/src/headless.ts b/src/headless.ts index 29e9614f2..b91fabd92 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -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 // filter JSONL output to specific event types + resumeSession?: string // session ID to resume (--resume ) } 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 { 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`) diff --git a/src/help-text.ts b/src/help-text.ts index f2a1e75c3..4976c0591 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -94,9 +94,12 @@ const SUBCOMMAND_HELP: Record = { '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 Output format: text (default), json (structured result), stream-json (JSONL events)', + ' --bare Minimal context: skip CLAUDE.md, AGENTS.md, user settings, user skills', + ' --resume 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 Pre-supply answers and secrets (JSON file)', @@ -115,11 +118,19 @@ const SUBCOMMAND_HELP: Record = { ' --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 = { ' 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'), } diff --git a/src/tests/headless-cli-surface.test.ts b/src/tests/headless-cli-surface.test.ts new file mode 100644 index 000000000..a1b81ae29 --- /dev/null +++ b/src/tests/headless-cli-surface.test.ts @@ -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 + 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') +})