/*
RFC-1309: unit tests for --quiet/--batch CLI flags, the batch runner, and quiet preamble gating in registry/config loaders.
- Do not boot a real workspace registry — registry behavior is asserted through buildRegistry's own stderr writes on a stub config.
- Fixtures stay inline and minimal so each case states its own setup.
- RFC-1309: AC-1/2/3/5 coverage — quiet preamble gating, sequential batch execution over one registry boot, failure aggregation naming the entry, manifest validation before any execution.
*/
// @ai-invariant fixtures must not couple to internals beyond the public surface under test
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { KernelExecutionReport, ModuleExport } from "@warpgogol/werkstatt-shared/kernel";
import {
COMMON_CLI_FLAG_NAMES,
consumeCommonFlags,
isJsonFlagToken,
argvRequestsHelp,
} from "./flags.ts";
import {
assertBatchHasNoPositional,
BatchUsageError,
runBatch,
type BatchExecutor,
} from "./batch.ts";
import { buildRegistry } from "../runtime/registry.ts";
import { clearRegistryCache, getOrBuildRegistry } from "../runtime/registry-cache.ts";
import { loadKernelAppConfig } from "../discovery.ts";
import type { DiscoveredSiteWorkspace } from "@warpgogol/werkstatt-shared/kernel";
function makeReport(commandName: string, exitCode: number): KernelExecutionReport {
return {
commandName,
exitCode,
ok: exitCode === 0,
summary: `${commandName} ${exitCode === 0 ? "passed" : "failed"}`,
metadata: { name: commandName } as KernelExecutionReport["metadata"],
logs: [],
timing: { durationMs: 1, exceededTimeout: false },
filesModified: [],
};
}
async function withTempDir(fn: (dir: string) => Promise): Promise {
const dir = await mkdtemp(join(tmpdir(), "rfc-1309-"));
try {
await fn(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
async function writeManifest(dir: string, name: string, content: string): Promise {
const path = join(dir, name);
await writeFile(path, content);
return path;
}
describe("consumeCommonFlags — RFC-1309 flags", () => {
it("parses --quiet, --batch , and --stop-on-fail", () => {
const flags = consumeCommonFlags(["--quiet", "--batch", ".werkstatt/v.yaml", "--stop-on-fail"]);
expect(flags.quiet).toBe(true);
expect(flags.batch).toBe(".werkstatt/v.yaml");
expect(flags.stopOnFail).toBe(true);
expect(flags.remaining).toEqual([]);
});
it("parses --batch= and leaves the positional command in remaining", () => {
const flags = consumeCommonFlags(["rfc.validate", "--batch=m.yaml", "--id", "RFC-1"]);
expect(flags.batch).toBe("m.yaml");
expect(flags.remaining).toEqual(["rfc.validate", "--id", "RFC-1"]);
});
it("keeps existing common flags intact alongside the new ones", () => {
const flags = consumeCommonFlags([
"--site",
"alpha",
"--dry-run",
"--json",
"--quiet",
"--batch",
"b.yaml",
]);
expect(flags.siteName).toBe("alpha");
expect(flags.dryRun).toBe(true);
expect(flags.outputFormat).toBe("json");
expect(flags.quiet).toBe(true);
expect(flags.batch).toBe("b.yaml");
});
it("COMMON_CLI_FLAG_NAMES stays in sync with the envelope parsers (RFC-1313 drift guard)", () => {
// Every listed name must be consumed by exactly one envelope surface:
// consumeCommonFlags, isJsonFlagToken, or argvRequestsHelp. A flag added
// to a parser without updating the const fails here, and vice versa.
for (const name of COMMON_CLI_FLAG_NAMES) {
if (name === "h") {
expect(argvRequestsHelp(["-h"])).toBe(true);
continue;
}
if (name === "help") {
expect(argvRequestsHelp(["--help"])).toBe(true);
continue;
}
if (name === "json" || name === "output") {
expect(isJsonFlagToken(`--${name}`)).toBe(true);
continue;
}
const valueFlags = new Set(["site", "concurrency", "batch"]);
const argv = valueFlags.has(name) ? [`--${name}`, "4"] : [`--${name}`];
const flags = consumeCommonFlags(argv);
expect(flags.remaining).toEqual([]);
}
});
});
describe("--batch + positional command rejection", () => {
it("rejects a positional command name alongside --batch", () => {
expect(() => assertBatchHasNoPositional(["rfc.validate", "--id", "RFC-1"])).toThrow(
BatchUsageError,
);
});
it("accepts an empty remaining argv", () => {
expect(() => assertBatchHasNoPositional([])).not.toThrow();
});
});
describe("runBatch manifest validation — AC-5", () => {
const noExecute = vi.fn();
beforeEach(() => {
noExecute.mockClear();
});
it("rejects a missing manifest path before running any command", async () => {
await expect(
runBatch({
workspaceRoot: "/ws",
manifestPath: "/does/not/exist.yaml",
globals: {},
execute: noExecute,
}),
).rejects.toThrow(BatchUsageError);
expect(noExecute).not.toHaveBeenCalled();
});
it("rejects unparseable YAML before running any command", async () => {
await withTempDir(async (dir) => {
const manifest = await writeManifest(dir, "bad.yaml", "commands: [unclosed\n - :");
await expect(
runBatch({ workspaceRoot: "/ws", manifestPath: manifest, globals: {}, execute: noExecute }),
).rejects.toThrow(BatchUsageError);
expect(noExecute).not.toHaveBeenCalled();
});
});
it("rejects an empty commands array before running any command", async () => {
await withTempDir(async (dir) => {
const manifest = await writeManifest(dir, "empty.yaml", "commands: []\n");
await expect(
runBatch({ workspaceRoot: "/ws", manifestPath: manifest, globals: {}, execute: noExecute }),
).rejects.toThrow(/non-empty `commands` array/);
expect(noExecute).not.toHaveBeenCalled();
});
});
it("rejects a manifest without commands before running any command", async () => {
await withTempDir(async (dir) => {
const manifest = await writeManifest(dir, "no-commands.yaml", "steps: []\n");
await expect(
runBatch({ workspaceRoot: "/ws", manifestPath: manifest, globals: {}, execute: noExecute }),
).rejects.toThrow(BatchUsageError);
expect(noExecute).not.toHaveBeenCalled();
});
});
});
describe("runBatch execution — AC-2/AC-3", () => {
it("runs every manifest entry sequentially through the executor (AC-2)", async () => {
await withTempDir(async (dir) => {
const manifest = await writeManifest(
dir,
"batch.yaml",
[
"commands:",
' - run: "rfc.validate --id RFC-1309"',
' - argv: ["cosmic.name.unique", "--all"]',
' - run: "rfc.verification.emit --id RFC-1309"',
"",
].join("\n"),
);
const calls: string[] = [];
const execute = vi.fn(async (options) => {
calls.push(
[
options.commandName,
...(options.argv ?? []),
...(options.allSites ? ["[--all]"] : []),
].join(" "),
);
return makeReport(options.commandName, 0);
});
const { report, exitCode } = await runBatch({
workspaceRoot: "/ws",
manifestPath: manifest,
globals: { outputFormat: "json" },
execute,
});
expect(calls).toEqual([
"rfc.validate --id RFC-1309",
"cosmic.name.unique [--all]",
"rfc.verification.emit --id RFC-1309",
]);
expect(exitCode).toBe(0);
expect(report.command).toBe("werkstatt.run.batch");
expect(report.status).toBe("pass");
expect(report.results).toHaveLength(3);
expect(report.results.every((r) => r.status === "pass" && r.exitCode === 0)).toBe(true);
});
});
it("keeps the registry build process-cached across batch entries (AC-2 single boot)", async () => {
clearRegistryCache();
const writes: string[] = [];
const spy = vi
.spyOn(process.stderr, "write")
.mockImplementation((chunk: string | Uint8Array) => {
writes.push(String(chunk));
return true;
});
try {
const mod: ModuleExport = { name: "probe-mod" } as ModuleExport;
const config = { modules: [mod] };
await getOrBuildRegistry("rfc-1309-test", config);
await getOrBuildRegistry("rfc-1309-test", config);
const loads = writes.filter((line) => line.includes("loading module probe-mod"));
const hits = writes.filter((line) => line.includes("cache hit"));
expect(loads).toHaveLength(1);
expect(hits).toHaveLength(1);
} finally {
spy.mockRestore();
clearRegistryCache();
}
});
it("aggregates a failing entry, names it, and exits non-zero (AC-3)", async () => {
await withTempDir(async (dir) => {
const manifest = await writeManifest(
dir,
"batch.yaml",
[
"commands:",
' - run: "first.ok"',
' - run: "second.fails --flag x"',
' - run: "third.ok"',
"",
].join("\n"),
);
const execute = vi.fn(async (options) =>
makeReport(options.commandName, options.commandName === "second.fails" ? 2 : 0),
);
const stdout: string[] = [];
const logSpy = vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
stdout.push(args.map(String).join(" "));
});
try {
const { report, exitCode } = await runBatch({
workspaceRoot: "/ws",
manifestPath: manifest,
globals: {},
execute,
});
expect(exitCode).toBe(2);
expect(report.status).toBe("fail");
const failing = report.results.find((r) => r.status !== "pass");
expect(failing?.run).toBe("second.fails --flag x");
expect(failing?.exitCode).toBe(2);
expect(execute).toHaveBeenCalledTimes(3);
expect(
stdout.some((line) => line.includes("[batch] failed: second.fails --flag x (exit 2)")),
"pretty summary must name the failing entry",
).toBe(true);
} finally {
logSpy.mockRestore();
}
});
});
it("stops after the first failure under --stop-on-fail", async () => {
await withTempDir(async (dir) => {
const manifest = await writeManifest(
dir,
"batch.yaml",
["commands:", ' - run: "first.fails"', ' - run: "second.skipped"', ""].join("\n"),
);
const execute = vi.fn(async (options) => makeReport(options.commandName, 1));
const { report, exitCode } = await runBatch({
workspaceRoot: "/ws",
manifestPath: manifest,
globals: { stopOnFail: true, outputFormat: "json" },
execute,
});
expect(execute).toHaveBeenCalledTimes(1);
expect(exitCode).toBe(1);
expect(report.results).toHaveLength(1);
});
});
it("records an executor throw as an entry error and continues", async () => {
await withTempDir(async (dir) => {
const manifest = await writeManifest(
dir,
"batch.yaml",
["commands:", ' - run: "no.such.command"', ' - run: "after.ok"', ""].join("\n"),
);
const execute = vi.fn(async (options) => {
if (options.commandName === "no.such.command") {
throw new Error("Kernel command `no.such.command` is not registered.");
}
return makeReport(options.commandName, 0);
});
const { report, exitCode } = await runBatch({
workspaceRoot: "/ws",
manifestPath: manifest,
globals: { outputFormat: "json" },
execute,
});
expect(report.results[0]?.status).toBe("error");
expect(report.results[0]?.exitCode).toBe(1);
expect(report.results[1]?.status).toBe("pass");
expect(exitCode).toBe(1);
});
});
it("lets per-entry flags override globals for that entry only", async () => {
await withTempDir(async (dir) => {
const manifest = await writeManifest(
dir,
"batch.yaml",
["commands:", ' - run: "cmd.a --site alpha --dry-run"', ' - run: "cmd.b"', ""].join("\n"),
);
const seen: Record[] = [];
const execute = vi.fn(async (options) => {
seen.push({
commandName: options.commandName,
siteName: options.siteName,
dryRun: options.dryRun,
});
return makeReport(options.commandName, 0);
});
await runBatch({
workspaceRoot: "/ws",
manifestPath: manifest,
globals: { siteName: "global-site", outputFormat: "json" },
execute,
});
expect(seen[0]).toEqual({ commandName: "cmd.a", siteName: "alpha", dryRun: true });
expect(seen[1]).toEqual({ commandName: "cmd.b", siteName: "global-site", dryRun: false });
});
});
it("supports the argv entry form for values containing spaces", async () => {
await withTempDir(async (dir) => {
const manifest = await writeManifest(
dir,
"batch.yaml",
'commands:\n - argv: ["ecosystem.commit", "--message", "has a space"]\n',
);
const execute = vi.fn(async (options) => makeReport(options.commandName, 0));
const { report } = await runBatch({
workspaceRoot: "/ws",
manifestPath: manifest,
globals: { outputFormat: "json" },
execute,
});
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ commandName: "ecosystem.commit" }),
);
expect(report.results[0]?.run).toBe("ecosystem.commit --message has a space");
});
});
});
describe("quiet preamble gating — AC-1", () => {
beforeEach(() => clearRegistryCache());
afterEach(() => clearRegistryCache());
function captureStderr(): { writes: string[]; restore: () => void } {
const writes: string[] = [];
const spy = vi
.spyOn(process.stderr, "write")
.mockImplementation((chunk: string | Uint8Array) => {
writes.push(String(chunk));
return true;
});
return { writes, restore: () => spy.mockRestore() };
}
it("suppresses [registry] module-loading and cache-hit lines under quiet", async () => {
const { writes, restore } = captureStderr();
try {
const mod: ModuleExport = { name: "quiet-probe" } as ModuleExport;
const config = { modules: [mod] };
await getOrBuildRegistry("rfc-1309-quiet", config, { quiet: true });
await getOrBuildRegistry("rfc-1309-quiet", config, { quiet: true });
expect(writes.filter((line) => line.includes("[registry]"))).toHaveLength(0);
} finally {
restore();
}
});
it("still emits [registry] lines without quiet (control)", async () => {
const { writes, restore } = captureStderr();
try {
const mod: ModuleExport = { name: "loud-probe" } as ModuleExport;
await buildRegistry({ modules: [mod] });
expect(writes.some((line) => line.includes("loading module loud-probe"))).toBe(true);
} finally {
restore();
}
});
it("suppresses [config] lines on the site-config loader under quiet", async () => {
await withTempDir(async (dir) => {
const configPath = join(dir, "kernel.config.ts");
await writeFile(configPath, "export default { modules: [] };\n");
const site: DiscoveredSiteWorkspace = {
name: "quiet-site",
directory: dir,
toolsDirectory: dir,
configPath,
};
const { writes, restore } = captureStderr();
try {
await loadKernelAppConfig(site, { quiet: true });
expect(writes.filter((line) => line.includes("[config]"))).toHaveLength(0);
} finally {
restore();
}
});
});
});
describe("json flag detection", () => {
it("treats --output and --json variants as json", () => {
for (const token of ["--json", "--json=1", "--output", "--output=json"]) {
expect(isJsonFlagToken(token)).toBe(true);
}
expect(isJsonFlagToken("--pretty")).toBe(false);
});
});