584 lines
15 KiB
TypeScript
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");
|
|
});
|