singularity-forge/src/tests/headless-cli-surface.test.ts
2026-05-05 18:47:50 +02:00

584 lines
15 KiB
TypeScript

/**
* 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 @singularity-forge/native
* import that breaks in test environment.
*/
import assert from "node:assert/strict";
import { test } from "vitest";
// ─── Import exit code constants & mapStatusToExitCode ──────────────────────
import {
EXIT_BLOCKED,
EXIT_CANCELLED,
EXIT_ERROR,
EXIT_SUCCESS,
mapStatusToExitCode,
} from "../headless-events.js";
import type { HeadlessJsonResult, OutputFormat } 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;
bare?: boolean;
}
function parseHeadlessArgs(argv: string[]): HeadlessOptions {
const options: HeadlessOptions = {
timeout: 300_000,
json: false,
outputFormat: "text",
command: "autonomous",
commandArgs: [],
};
const args = argv.slice(2);
let commandSeen = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "headless") continue;
if (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 (arg === "--bare") {
options.bare = true;
}
} else if (!commandSeen) {
if (arg === "autonomous") {
options.command = "autonomous";
options.auto = true;
} else {
options.command = arg;
}
commandSeen = true;
} else {
options.commandArgs.push(arg);
}
}
return options;
}
// ─── --output-format flag parsing ──────────────────────────────────────────
test("--output-format text sets outputFormat to text", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"--output-format",
"text",
"autonomous",
]);
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",
"sf",
"headless",
"--output-format",
"json",
"autonomous",
]);
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",
"sf",
"headless",
"--output-format",
"stream-json",
"autonomous",
]);
assert.equal(opts.outputFormat, "stream-json");
assert.equal(opts.json, true);
});
test("default output format is text", () => {
const opts = parseHeadlessArgs(["node", "sf", "headless", "autonomous"]);
assert.equal(opts.outputFormat, "text");
assert.equal(opts.json, false);
});
test("autonomous command is accepted as headless command", () => {
const opts = parseHeadlessArgs(["node", "sf", "headless", "autonomous"]);
assert.equal(opts.command, "autonomous");
assert.deepEqual(opts.commandArgs, []);
});
test("autonomous command preserves command arguments", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"autonomous",
"M001",
"extra-context",
]);
assert.equal(opts.command, "autonomous");
assert.deepEqual(opts.commandArgs, ["M001", "extra-context"]);
});
test("invalid --output-format value throws", () => {
assert.throws(
() =>
parseHeadlessArgs([
"node",
"sf",
"headless",
"--output-format",
"yaml",
"autonomous",
]),
/Invalid output format: yaml/,
);
});
test("invalid --output-format value (empty) throws", () => {
assert.throws(
() =>
parseHeadlessArgs([
"node",
"sf",
"headless",
"--output-format",
"xml",
"autonomous",
]),
/Invalid output format/,
);
});
// ─── --json backward compatibility ─────────────────────────────────────────
test("--json is alias for --output-format stream-json", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"--json",
"autonomous",
]);
assert.equal(opts.outputFormat, "stream-json");
assert.equal(opts.json, true);
});
test("--json before --output-format json: last writer wins", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"--json",
"--output-format",
"json",
"autonomous",
]);
assert.equal(opts.outputFormat, "json");
assert.equal(opts.json, true);
});
// ─── --resume flag ─────────────────────────────────────────────────────────
test("--resume parses session ID", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"--resume",
"abc-123",
"autonomous",
]);
assert.equal(opts.resumeSession, "abc-123");
assert.equal(opts.command, "autonomous");
});
test("no --resume means undefined", () => {
const opts = parseHeadlessArgs(["node", "sf", "headless", "autonomous"]);
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 = {
schemaVersion: 1,
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.schemaVersion, 1);
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 = {
schemaVersion: 1,
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",
"sf",
"headless",
"--events",
"agent_end,tool_execution_start",
"autonomous",
]);
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",
"sf",
"headless",
"--timeout",
"60000",
"autonomous",
]);
assert.equal(opts.timeout, 60000);
});
test("--supervised still works and implies stream-json", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"--supervised",
"autonomous",
]);
assert.equal(opts.supervised, true);
assert.equal(opts.json, true);
assert.equal(opts.outputFormat, "stream-json");
});
test("--answers still works", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"--answers",
"answers.json",
"autonomous",
]);
assert.equal(opts.answers, "answers.json");
});
test("positional command parsing still works", () => {
const opts = parseHeadlessArgs(["node", "sf", "headless", "next"]);
assert.equal(opts.command, "next");
});
test("combined flags parse correctly", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"--output-format",
"json",
"--timeout",
"120000",
"--resume",
"sess-xyz",
"--verbose",
"autonomous",
]);
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, "autonomous");
});
// ─── --bare flag ───────────────────────────────────────────────────────────
test("--bare sets bare to true", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"--bare",
"autonomous",
]);
assert.equal(opts.bare, true);
assert.equal(opts.command, "autonomous");
});
test("no --bare means bare is undefined", () => {
const opts = parseHeadlessArgs(["node", "sf", "headless", "autonomous"]);
assert.equal(opts.bare, undefined);
});
test("--bare is a boolean flag (no value needed)", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"--bare",
"--json",
"autonomous",
]);
assert.equal(opts.bare, true);
assert.equal(opts.json, true);
});
test("--bare combined with --output-format json", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"--bare",
"--output-format",
"json",
"autonomous",
]);
assert.equal(opts.bare, true);
assert.equal(opts.outputFormat, "json");
assert.equal(opts.json, true);
assert.equal(opts.command, "autonomous");
});
// ─── Command-first ordering (flags after command) ─────────────────────────
test("command before flags: new-milestone --context-text --auto --verbose", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"new-milestone",
"--context-text",
"build something cool",
"--auto",
"--verbose",
]);
assert.equal(opts.command, "new-milestone");
assert.equal(opts.contextText, "build something cool");
assert.equal(opts.auto, true);
assert.equal(opts.verbose, true);
});
test("command before flags: next --json --timeout", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"next",
"--json",
"--timeout",
"60000",
]);
assert.equal(opts.command, "next");
assert.equal(opts.json, true);
assert.equal(opts.timeout, 60000);
});
test("command between flags: --auto new-milestone --verbose", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"--auto",
"new-milestone",
"--verbose",
]);
assert.equal(opts.command, "new-milestone");
assert.equal(opts.auto, true);
assert.equal(opts.verbose, true);
});
test("--bare does not affect other flags", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"--bare",
"--timeout",
"60000",
"--resume",
"sess-abc",
"autonomous",
]);
assert.equal(opts.bare, true);
assert.equal(opts.timeout, 60000);
assert.equal(opts.resumeSession, "sess-abc");
assert.equal(opts.command, "autonomous");
});