singularity-forge/src/tests/headless-cli-surface.test.ts
Mikael Hugo b24f426f2b batch: snapshot of in-flight v2 work
This commit captures uncommitted modifications that accumulated in the
working tree across multiple in-progress workstreams. It is a snapshot
to clear the deck before sf v3 work begins; individual workstreams
should land separately on top of this.

Notable additions:
- trace-collector.ts, traces.ts, src/tests/trace-export.test.ts —
  trace export plumbing
- biome.json — Biome linter configuration
- .gitignore — exclude native/npm/**/*.node compiled binaries

The bulk of the diff is across src/resources/extensions/sf/ (301 files)
and src/resources/extensions/sf/tests/ (277 files), reflecting the
ongoing sf extension work. Specific feature commits should follow this
snapshot rather than being archaeology'd out of it.

The 76MB native/npm/linux-x64-gnu/forge_engine.node compiled binary
was left out of the commit — it's now gitignored and built locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 12:42:31 +02:00

543 lines
14 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 "node:test";
// ─── 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: "auto",
commandArgs: [],
};
const args = argv.slice(2);
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 (options.command === "auto") {
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",
"sf",
"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",
"sf",
"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",
"sf",
"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", "sf", "headless", "auto"]);
assert.equal(opts.outputFormat, "text");
assert.equal(opts.json, false);
});
test("invalid --output-format value throws", () => {
assert.throws(
() =>
parseHeadlessArgs([
"node",
"sf",
"headless",
"--output-format",
"yaml",
"auto",
]),
/Invalid output format: yaml/,
);
});
test("invalid --output-format value (empty) throws", () => {
assert.throws(
() =>
parseHeadlessArgs([
"node",
"sf",
"headless",
"--output-format",
"xml",
"auto",
]),
/Invalid output format/,
);
});
// ─── --json backward compatibility ─────────────────────────────────────────
test("--json is alias for --output-format stream-json", () => {
const opts = parseHeadlessArgs(["node", "sf", "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",
"sf",
"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",
"sf",
"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", "sf", "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",
"sf",
"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",
"sf",
"headless",
"--timeout",
"60000",
"auto",
]);
assert.equal(opts.timeout, 60000);
});
test("--supervised still works and implies stream-json", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"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",
"sf",
"headless",
"--answers",
"answers.json",
"auto",
]);
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",
"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");
});
// ─── --bare flag ───────────────────────────────────────────────────────────
test("--bare sets bare to true", () => {
const opts = parseHeadlessArgs(["node", "sf", "headless", "--bare", "auto"]);
assert.equal(opts.bare, true);
assert.equal(opts.command, "auto");
});
test("no --bare means bare is undefined", () => {
const opts = parseHeadlessArgs(["node", "sf", "headless", "auto"]);
assert.equal(opts.bare, undefined);
});
test("--bare is a boolean flag (no value needed)", () => {
const opts = parseHeadlessArgs([
"node",
"sf",
"headless",
"--bare",
"--json",
"auto",
]);
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",
"auto",
]);
assert.equal(opts.bare, true);
assert.equal(opts.outputFormat, "json");
assert.equal(opts.json, true);
assert.equal(opts.command, "auto");
});
// ─── 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",
"auto",
]);
assert.equal(opts.bare, true);
assert.equal(opts.timeout, 60000);
assert.equal(opts.resumeSession, "sess-abc");
assert.equal(opts.command, "auto");
});