diff --git a/tests/args.test.mjs b/tests/args.test.mjs new file mode 100644 index 0000000..41862f6 --- /dev/null +++ b/tests/args.test.mjs @@ -0,0 +1,161 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { parseArgs, splitRawArgumentString } from "../plugins/codex/scripts/lib/args.mjs"; + +// --------------------------------------------------------------------------- +// parseArgs +// --------------------------------------------------------------------------- + +test("parseArgs collects bare positional tokens", () => { + const { options, positionals } = parseArgs(["foo", "bar", "baz"]); + assert.deepEqual(options, {}); + assert.deepEqual(positionals, ["foo", "bar", "baz"]); +}); + +test("parseArgs recognises a boolean flag", () => { + const { options, positionals } = parseArgs(["--wait"], { + booleanOptions: ["wait"] + }); + assert.equal(options.wait, true); + assert.deepEqual(positionals, []); +}); + +test("parseArgs recognises an inline-value boolean flag set to false", () => { + const { options } = parseArgs(["--wait=false"], { + booleanOptions: ["wait"] + }); + assert.equal(options.wait, false); +}); + +test("parseArgs recognises an inline-value boolean flag set to a non-false string", () => { + const { options } = parseArgs(["--wait=yes"], { + booleanOptions: ["wait"] + }); + assert.equal(options.wait, true); +}); + +test("parseArgs reads a value option from the next token", () => { + const { options, positionals } = parseArgs(["--base", "main", "extra"], { + valueOptions: ["base"] + }); + assert.equal(options.base, "main"); + assert.deepEqual(positionals, ["extra"]); +}); + +test("parseArgs reads a value option supplied as --key=value", () => { + const { options } = parseArgs(["--base=main"], { + valueOptions: ["base"] + }); + assert.equal(options.base, "main"); +}); + +test("parseArgs throws when a value option is missing its argument", () => { + assert.throws( + () => parseArgs(["--base"], { valueOptions: ["base"] }), + /Missing value for --base/ + ); +}); + +test("parseArgs resolves long-option aliases", () => { + const { options } = parseArgs(["--bg"], { + booleanOptions: ["background"], + aliasMap: { bg: "background" } + }); + assert.equal(options.background, true); +}); + +test("parseArgs handles a short boolean flag via aliasMap", () => { + const { options } = parseArgs(["-w"], { + booleanOptions: ["wait"], + aliasMap: { w: "wait" } + }); + assert.equal(options.wait, true); +}); + +test("parseArgs handles a short value flag via aliasMap", () => { + const { options } = parseArgs(["-b", "main"], { + valueOptions: ["base"], + aliasMap: { b: "base" } + }); + assert.equal(options.base, "main"); +}); + +test("parseArgs throws when a short value flag is missing its argument", () => { + assert.throws( + () => parseArgs(["-b"], { valueOptions: ["base"], aliasMap: { b: "base" } }), + /Missing value for -b/ + ); +}); + +test("parseArgs treats everything after -- as positional", () => { + const { options, positionals } = parseArgs(["--wait", "--", "--not-a-flag", "pos"], { + booleanOptions: ["wait"] + }); + assert.equal(options.wait, true); + assert.deepEqual(positionals, ["--not-a-flag", "pos"]); +}); + +test("parseArgs treats unknown long flags as positionals", () => { + const { positionals } = parseArgs(["--unknown-flag"]); + assert.deepEqual(positionals, ["--unknown-flag"]); +}); + +test("parseArgs treats a bare - as a positional (stdin convention)", () => { + const { positionals } = parseArgs(["-"]); + assert.deepEqual(positionals, ["-"]); +}); + +test("parseArgs handles multiple flags and positionals together", () => { + const { options, positionals } = parseArgs( + ["--scope", "working-tree", "--wait", "src/app.js"], + { valueOptions: ["scope"], booleanOptions: ["wait"] } + ); + assert.equal(options.scope, "working-tree"); + assert.equal(options.wait, true); + assert.deepEqual(positionals, ["src/app.js"]); +}); + +// --------------------------------------------------------------------------- +// splitRawArgumentString +// --------------------------------------------------------------------------- + +test("splitRawArgumentString splits on whitespace", () => { + assert.deepEqual(splitRawArgumentString("foo bar baz"), ["foo", "bar", "baz"]); +}); + +test("splitRawArgumentString ignores leading and trailing whitespace", () => { + assert.deepEqual(splitRawArgumentString(" foo bar "), ["foo", "bar"]); +}); + +test("splitRawArgumentString handles double-quoted tokens with spaces", () => { + assert.deepEqual(splitRawArgumentString('"hello world" next'), ["hello world", "next"]); +}); + +test("splitRawArgumentString handles single-quoted tokens with spaces", () => { + assert.deepEqual(splitRawArgumentString("'hello world' next"), ["hello world", "next"]); +}); + +test("splitRawArgumentString handles backslash-escaped spaces", () => { + assert.deepEqual(splitRawArgumentString("hello\\ world next"), ["hello world", "next"]); +}); + +test("splitRawArgumentString handles trailing backslash as literal backslash", () => { + assert.deepEqual(splitRawArgumentString("foo\\"), ["foo\\"]); +}); + +test("splitRawArgumentString returns empty array for empty string", () => { + assert.deepEqual(splitRawArgumentString(""), []); +}); + +test("splitRawArgumentString returns empty array for whitespace-only string", () => { + assert.deepEqual(splitRawArgumentString(" "), []); +}); + +test("splitRawArgumentString handles adjacent quoted tokens", () => { + assert.deepEqual(splitRawArgumentString('"a b""c d"'), ["a bc d"]); +}); + +test("splitRawArgumentString handles backslash-escaped quote inside unquoted token", () => { + assert.deepEqual(splitRawArgumentString('say\\"hi'), ['say"hi']); +}); diff --git a/tests/broker-endpoint.test.mjs b/tests/broker-endpoint.test.mjs index b3fc114..da4728a 100644 --- a/tests/broker-endpoint.test.mjs +++ b/tests/broker-endpoint.test.mjs @@ -20,3 +20,50 @@ test("createBrokerEndpoint uses named pipes on Windows", () => { path: "\\\\.\\pipe\\cxc-12345-codex-app-server" }); }); + +// --------------------------------------------------------------------------- +// parseBrokerEndpoint — error cases +// --------------------------------------------------------------------------- + +test("parseBrokerEndpoint throws for an empty string", () => { + assert.throws(() => parseBrokerEndpoint(""), /Missing broker endpoint/); +}); + +test("parseBrokerEndpoint throws for a null/undefined value", () => { + assert.throws(() => parseBrokerEndpoint(null), /Missing broker endpoint/); + assert.throws(() => parseBrokerEndpoint(undefined), /Missing broker endpoint/); +}); + +test("parseBrokerEndpoint throws for an unsupported scheme", () => { + assert.throws( + () => parseBrokerEndpoint("tcp://localhost:1234"), + /Unsupported broker endpoint/ + ); +}); + +test("parseBrokerEndpoint throws when a pipe: endpoint has no path", () => { + assert.throws( + () => parseBrokerEndpoint("pipe:"), + /Broker pipe endpoint is missing its path/ + ); +}); + +test("parseBrokerEndpoint throws when a unix: endpoint has no path", () => { + assert.throws( + () => parseBrokerEndpoint("unix:"), + /Broker Unix socket endpoint is missing its path/ + ); +}); + +// --------------------------------------------------------------------------- +// createBrokerEndpoint — Linux platform +// --------------------------------------------------------------------------- + +test("createBrokerEndpoint uses Unix socket on Linux", () => { + const endpoint = createBrokerEndpoint("/tmp/cxc-linux", "linux"); + assert.equal(endpoint, "unix:/tmp/cxc-linux/broker.sock"); + assert.deepEqual(parseBrokerEndpoint(endpoint), { + kind: "unix", + path: "/tmp/cxc-linux/broker.sock" + }); +}); diff --git a/tests/fs.test.mjs b/tests/fs.test.mjs new file mode 100644 index 0000000..31fafcb --- /dev/null +++ b/tests/fs.test.mjs @@ -0,0 +1,127 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + ensureAbsolutePath, + createTempDir, + readJsonFile, + writeJsonFile, + safeReadFile, + isProbablyText +} from "../plugins/codex/scripts/lib/fs.mjs"; + +import { makeTempDir } from "./helpers.mjs"; + +// --------------------------------------------------------------------------- +// ensureAbsolutePath +// --------------------------------------------------------------------------- + +test("ensureAbsolutePath returns an absolute path unchanged", () => { + const absolute = "/usr/local/bin/codex"; + assert.equal(ensureAbsolutePath("/any/cwd", absolute), absolute); +}); + +test("ensureAbsolutePath resolves a relative path against cwd", () => { + const cwd = makeTempDir(); + const result = ensureAbsolutePath(cwd, "subdir/file.txt"); + assert.equal(result, path.join(cwd, "subdir", "file.txt")); + assert.equal(path.isAbsolute(result), true); +}); + +test("ensureAbsolutePath resolves a dot-relative path", () => { + const cwd = makeTempDir(); + const result = ensureAbsolutePath(cwd, "./file.txt"); + assert.equal(result, path.resolve(cwd, "file.txt")); +}); + +// --------------------------------------------------------------------------- +// createTempDir +// --------------------------------------------------------------------------- + +test("createTempDir creates an existing directory under os.tmpdir()", () => { + const dir = createTempDir(); + assert.equal(fs.existsSync(dir), true); + assert.equal(fs.statSync(dir).isDirectory(), true); + assert.ok(dir.startsWith(os.tmpdir())); + fs.rmdirSync(dir); +}); + +test("createTempDir respects a custom prefix", () => { + const dir = createTempDir("my-prefix-"); + assert.ok(path.basename(dir).startsWith("my-prefix-")); + fs.rmdirSync(dir); +}); + +// --------------------------------------------------------------------------- +// readJsonFile / writeJsonFile +// --------------------------------------------------------------------------- + +test("writeJsonFile writes valid JSON and readJsonFile round-trips it", () => { + const dir = makeTempDir(); + const filePath = path.join(dir, "data.json"); + const payload = { hello: "world", count: 42, nested: { ok: true } }; + + writeJsonFile(filePath, payload); + const content = fs.readFileSync(filePath, "utf8"); + assert.ok(content.endsWith("\n"), "file should end with a newline"); + + const parsed = readJsonFile(filePath); + assert.deepEqual(parsed, payload); +}); + +test("readJsonFile throws on malformed JSON", () => { + const dir = makeTempDir(); + const filePath = path.join(dir, "bad.json"); + fs.writeFileSync(filePath, "{ not valid json }", "utf8"); + assert.throws(() => readJsonFile(filePath)); +}); + +// --------------------------------------------------------------------------- +// safeReadFile +// --------------------------------------------------------------------------- + +test("safeReadFile returns the file contents when the file exists", () => { + const dir = makeTempDir(); + const filePath = path.join(dir, "hello.txt"); + fs.writeFileSync(filePath, "hello codex\n", "utf8"); + assert.equal(safeReadFile(filePath), "hello codex\n"); +}); + +test("safeReadFile returns empty string when the file does not exist", () => { + const dir = makeTempDir(); + const missing = path.join(dir, "missing.txt"); + assert.equal(safeReadFile(missing), ""); +}); + +// --------------------------------------------------------------------------- +// isProbablyText +// --------------------------------------------------------------------------- + +test("isProbablyText returns true for an ASCII text buffer", () => { + const buffer = Buffer.from("Hello, world!\n", "utf8"); + assert.equal(isProbablyText(buffer), true); +}); + +test("isProbablyText returns true for a UTF-8 text buffer without null bytes", () => { + const buffer = Buffer.from("こんにちは世界\n", "utf8"); + assert.equal(isProbablyText(buffer), true); +}); + +test("isProbablyText returns false for a buffer containing a null byte", () => { + const buffer = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00]); + assert.equal(isProbablyText(buffer), false); +}); + +test("isProbablyText returns true for an empty buffer", () => { + assert.equal(isProbablyText(Buffer.alloc(0)), true); +}); + +test("isProbablyText only samples the first 4096 bytes", () => { + // Build a buffer that has a null byte only beyond the 4096-byte sample + const safe = Buffer.alloc(4096, 0x61); // 'a' x 4096 + const withNull = Buffer.concat([safe, Buffer.from([0x00])]); + assert.equal(isProbablyText(withNull), true); +}); diff --git a/tests/git.test.mjs b/tests/git.test.mjs index 7ea1a04..7465846 100644 --- a/tests/git.test.mjs +++ b/tests/git.test.mjs @@ -68,3 +68,147 @@ test("resolveReviewTarget requires an explicit base when no default branch can b /Unable to detect the repository default branch\. Pass --base or use --scope working-tree\./ ); }); + +import { ensureGitRepository, getWorkingTreeState, getCurrentBranch } from "../plugins/codex/scripts/lib/git.mjs"; + +// --------------------------------------------------------------------------- +// ensureGitRepository +// --------------------------------------------------------------------------- + +test("ensureGitRepository returns the repo root for a valid git repo", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "file.txt"), "hello\n"); + run("git", ["add", "file.txt"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + + const root = ensureGitRepository(cwd); + // The returned root should be a non-empty path that contains our file + assert.ok(typeof root === "string" && root.length > 0); + assert.ok(fs.existsSync(path.join(root, "file.txt"))); +}); + +test("ensureGitRepository throws when the directory is not a git repo", () => { + const cwd = makeTempDir(); // plain directory, no git init + assert.throws( + () => ensureGitRepository(cwd), + /This command must run inside a Git repository\./ + ); +}); + +// --------------------------------------------------------------------------- +// getWorkingTreeState +// --------------------------------------------------------------------------- + +test("getWorkingTreeState reports clean state after initial commit", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "a.js"), "console.log(1);\n"); + run("git", ["add", "a.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + + const state = getWorkingTreeState(cwd); + assert.equal(state.isDirty, false); + assert.deepEqual(state.staged, []); + assert.deepEqual(state.unstaged, []); + assert.deepEqual(state.untracked, []); +}); + +test("getWorkingTreeState detects staged changes", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "a.js"), "v1\n"); + run("git", ["add", "a.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + + fs.writeFileSync(path.join(cwd, "a.js"), "v2\n"); + run("git", ["add", "a.js"], { cwd }); + + const state = getWorkingTreeState(cwd); + assert.equal(state.isDirty, true); + assert.ok(state.staged.includes("a.js")); +}); + +test("getWorkingTreeState detects unstaged changes", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "b.js"), "v1\n"); + run("git", ["add", "b.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + + fs.writeFileSync(path.join(cwd, "b.js"), "v2\n"); + + const state = getWorkingTreeState(cwd); + assert.equal(state.isDirty, true); + assert.ok(state.unstaged.includes("b.js")); +}); + +test("getWorkingTreeState detects untracked files", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "tracked.js"), "v1\n"); + run("git", ["add", "tracked.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + fs.writeFileSync(path.join(cwd, "new.js"), "new file\n"); + + const state = getWorkingTreeState(cwd); + assert.equal(state.isDirty, true); + assert.ok(state.untracked.includes("new.js")); +}); + +// --------------------------------------------------------------------------- +// getCurrentBranch +// --------------------------------------------------------------------------- + +test("getCurrentBranch returns the current branch name", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "x.js"), "x\n"); + run("git", ["add", "x.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + + const branch = getCurrentBranch(cwd); + assert.equal(branch, "main"); +}); + +test("getCurrentBranch returns the name of a checked-out feature branch", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "x.js"), "x\n"); + run("git", ["add", "x.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + run("git", ["checkout", "-b", "feature/my-work"], { cwd }); + + const branch = getCurrentBranch(cwd); + assert.equal(branch, "feature/my-work"); +}); + +// --------------------------------------------------------------------------- +// resolveReviewTarget — additional scope cases +// --------------------------------------------------------------------------- + +test("resolveReviewTarget honours explicit working-tree scope even when repo is clean", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "app.js"), "v1\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + + const target = resolveReviewTarget(cwd, { scope: "working-tree" }); + + assert.equal(target.mode, "working-tree"); + assert.equal(target.explicit, true); +}); + +test("resolveReviewTarget throws for an unsupported scope string", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "app.js"), "v1\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + + assert.throws( + () => resolveReviewTarget(cwd, { scope: "staged" }), + /Unsupported review scope/ + ); +}); diff --git a/tests/process.test.mjs b/tests/process.test.mjs index 80e0715..a9a04b4 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -53,3 +53,130 @@ test("terminateProcessTree treats missing Windows processes as already stopped", assert.equal(outcome.result.status, 128); assert.match(outcome.result.stdout, /not found/i); }); + +import { binaryAvailable, formatCommandFailure, runCommand, runCommandChecked } from "../plugins/codex/scripts/lib/process.mjs"; + +// --------------------------------------------------------------------------- +// terminateProcessTree — Unix paths +// --------------------------------------------------------------------------- + +test("terminateProcessTree uses process-group SIGTERM on Unix", () => { + let capturedPid = null; + let capturedSignal = null; + const outcome = terminateProcessTree(5678, { + platform: "linux", + killImpl(pid, signal) { + capturedPid = pid; + capturedSignal = signal; + } + }); + + assert.equal(capturedPid, -5678); + assert.equal(capturedSignal, "SIGTERM"); + assert.equal(outcome.delivered, true); + assert.equal(outcome.method, "process-group"); +}); + +test("terminateProcessTree falls back to individual process SIGTERM when group kill fails with a non-ESRCH error", () => { + let killCallCount = 0; + const outcome = terminateProcessTree(9999, { + platform: "linux", + killImpl(pid, signal) { + killCallCount += 1; + if (pid < 0) { + const err = new Error("EPERM"); + err.code = "EPERM"; + throw err; + } + } + }); + + assert.equal(killCallCount, 2); + assert.equal(outcome.delivered, true); + assert.equal(outcome.method, "process"); +}); + +test("terminateProcessTree returns not-delivered for process-group when group kill throws ESRCH", () => { + const outcome = terminateProcessTree(1111, { + platform: "linux", + killImpl(pid) { + const err = new Error("ESRCH"); + err.code = "ESRCH"; + throw err; + } + }); + + assert.equal(outcome.attempted, true); + assert.equal(outcome.delivered, false); + assert.equal(outcome.method, "process-group"); +}); + +test("terminateProcessTree returns not-attempted for a non-finite pid", () => { + const outcome = terminateProcessTree(NaN, { platform: "linux" }); + assert.equal(outcome.attempted, false); + assert.equal(outcome.delivered, false); + assert.equal(outcome.method, null); +}); + +// --------------------------------------------------------------------------- +// binaryAvailable +// --------------------------------------------------------------------------- + +test("binaryAvailable returns available:true for a known binary", () => { + // node is always available in this environment + const result = binaryAvailable("node", ["--version"]); + assert.equal(result.available, true); + assert.match(result.detail, /v\d+/); +}); + +test("binaryAvailable returns available:false for a non-existent binary", () => { + const result = binaryAvailable("__this_binary_does_not_exist__"); + assert.equal(result.available, false); + assert.equal(result.detail, "not found"); +}); + +// --------------------------------------------------------------------------- +// formatCommandFailure +// --------------------------------------------------------------------------- + +test("formatCommandFailure includes command and exit code", () => { + const msg = formatCommandFailure({ command: "git", args: ["status"], status: 128, signal: null, stdout: "", stderr: "fatal: not a git repo" }); + assert.match(msg, /git status/); + assert.match(msg, /exit=128/); + assert.match(msg, /fatal: not a git repo/); +}); + +test("formatCommandFailure uses signal instead of exit code when a signal is present", () => { + const msg = formatCommandFailure({ command: "node", args: ["app.js"], status: null, signal: "SIGTERM", stdout: "", stderr: "" }); + assert.match(msg, /signal=SIGTERM/); + assert.doesNotMatch(msg, /exit=/); +}); + +test("formatCommandFailure falls back to stdout when stderr is empty", () => { + const msg = formatCommandFailure({ command: "echo", args: ["hi"], status: 1, signal: null, stdout: "hi", stderr: "" }); + assert.match(msg, /hi/); +}); + +// --------------------------------------------------------------------------- +// runCommand / runCommandChecked +// --------------------------------------------------------------------------- + +test("runCommand returns stdout, stderr, and status 0 for a successful command", () => { + const result = runCommand("node", ["--version"]); + assert.equal(result.status, 0); + assert.match(result.stdout, /v\d+\.\d+\.\d+/); + assert.equal(result.error, null); +}); + +test("runCommandChecked throws on non-zero exit", () => { + assert.throws( + () => runCommandChecked("node", ["-e", "process.exit(1)"]), + /exit=1/ + ); +}); + +test("runCommandChecked throws when the binary is not found", () => { + assert.throws( + () => runCommandChecked("__totally_missing_binary__", []) + ); +}); diff --git a/tests/prompts.test.mjs b/tests/prompts.test.mjs new file mode 100644 index 0000000..0bb0022 --- /dev/null +++ b/tests/prompts.test.mjs @@ -0,0 +1,73 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { interpolateTemplate, loadPromptTemplate } from "../plugins/codex/scripts/lib/prompts.mjs"; +import { makeTempDir } from "./helpers.mjs"; + +// --------------------------------------------------------------------------- +// interpolateTemplate +// --------------------------------------------------------------------------- + +test("interpolateTemplate replaces a single variable", () => { + const result = interpolateTemplate("Hello, {{NAME}}!", { NAME: "Codex" }); + assert.equal(result, "Hello, Codex!"); +}); + +test("interpolateTemplate replaces multiple distinct variables", () => { + const result = interpolateTemplate("{{GREETING}}, {{NAME}}!", { + GREETING: "Hi", + NAME: "World" + }); + assert.equal(result, "Hi, World!"); +}); + +test("interpolateTemplate replaces the same variable appearing multiple times", () => { + const result = interpolateTemplate("{{X}} and {{X}}", { X: "codex" }); + assert.equal(result, "codex and codex"); +}); + +test("interpolateTemplate leaves an unrecognised placeholder as empty string", () => { + const result = interpolateTemplate("prefix {{MISSING}} suffix", {}); + assert.equal(result, "prefix suffix"); +}); + +test("interpolateTemplate does not replace lowercase placeholders (only ALL_CAPS)", () => { + const result = interpolateTemplate("{{lower}} {{UPPER}}", { lower: "no", UPPER: "yes" }); + assert.equal(result, "{{lower}} yes"); +}); + +test("interpolateTemplate handles an empty template", () => { + assert.equal(interpolateTemplate("", { X: "value" }), ""); +}); + +test("interpolateTemplate handles a template with no placeholders", () => { + const template = "No placeholders here."; + assert.equal(interpolateTemplate(template, { X: "value" }), template); +}); + +test("interpolateTemplate handles a variable whose value contains braces", () => { + const result = interpolateTemplate("{{BLOCK}}", { BLOCK: "{{inner}}" }); + assert.equal(result, "{{inner}}"); +}); + +// --------------------------------------------------------------------------- +// loadPromptTemplate +// --------------------------------------------------------------------------- + +test("loadPromptTemplate loads a prompt file by name", () => { + const dir = makeTempDir(); + const promptsDir = path.join(dir, "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(promptsDir, "my-prompt.md"), "# My Prompt\n\n{{CONTEXT}}\n", "utf8"); + + const content = loadPromptTemplate(dir, "my-prompt"); + assert.equal(content, "# My Prompt\n\n{{CONTEXT}}\n"); +}); + +test("loadPromptTemplate throws when the prompt file does not exist", () => { + const dir = makeTempDir(); + fs.mkdirSync(path.join(dir, "prompts"), { recursive: true }); + assert.throws(() => loadPromptTemplate(dir, "nonexistent")); +}); diff --git a/tests/render.test.mjs b/tests/render.test.mjs index ab68038..8960fa0 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -1,7 +1,16 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { renderReviewResult, renderStoredJobResult } from "../plugins/codex/scripts/lib/render.mjs"; +import { + renderReviewResult, + renderStoredJobResult, + renderSetupReport, + renderNativeReviewResult, + renderTaskResult, + renderStatusReport, + renderJobStatusReport, + renderCancelReport +} from "../plugins/codex/scripts/lib/render.mjs"; test("renderReviewResult degrades gracefully when JSON is missing required review fields", () => { const output = renderReviewResult( @@ -57,3 +66,402 @@ test("renderStoredJobResult prefers rendered output for structured review jobs", assert.match(output, /Codex session ID: thr_123/); assert.match(output, /Resume in Codex: codex resume thr_123/); }); + +// --------------------------------------------------------------------------- +// renderReviewResult — additional cases +// --------------------------------------------------------------------------- + +test("renderReviewResult emits no-findings message for a clean approve result", () => { + const output = renderReviewResult( + { + parsed: { + verdict: "approve", + summary: "All good.", + findings: [], + next_steps: [] + }, + rawOutput: '{"verdict":"approve","summary":"All good.","findings":[],"next_steps":[]}', + parseError: null + }, + { reviewLabel: "Review", targetLabel: "working tree diff" } + ); + + assert.match(output, /Verdict: approve/); + assert.match(output, /All good\./); + assert.match(output, /No material findings\./); + assert.doesNotMatch(output, /Findings:/); +}); + +test("renderReviewResult renders findings sorted by severity", () => { + const output = renderReviewResult( + { + parsed: { + verdict: "needs-attention", + summary: "Issues found.", + findings: [ + { severity: "low", title: "Minor lint", body: "Lint warning.", file: "a.js", line_start: 1, line_end: 1, recommendation: "" }, + { severity: "critical", title: "SQL injection", body: "Unsafe query.", file: "b.js", line_start: 5, line_end: 5, recommendation: "Use prepared statements." }, + { severity: "medium", title: "Null deref", body: "Possible null.", file: "c.js", line_start: 10, line_end: 12, recommendation: "" } + ], + next_steps: ["Fix SQL issue.", "Suppress lint warning."] + }, + rawOutput: null, + parseError: null + }, + { reviewLabel: "Review", targetLabel: "branch diff" } + ); + + assert.match(output, /Findings:/); + // critical should appear before medium and low + const critIdx = output.indexOf("[critical]"); + const medIdx = output.indexOf("[medium]"); + const lowIdx = output.indexOf("[low]"); + assert.ok(critIdx < medIdx, "critical should precede medium"); + assert.ok(medIdx < lowIdx, "medium should precede low"); + // line range + assert.match(output, /c\.js:10-12/); + assert.match(output, /b\.js:5/); + // recommendation + assert.match(output, /Use prepared statements\./); + // next steps + assert.match(output, /Next steps:/); + assert.match(output, /Fix SQL issue\./); +}); + +test("renderReviewResult degrades gracefully when parsed is null", () => { + const output = renderReviewResult( + { + parsed: null, + rawOutput: "some raw text", + parseError: "Unexpected token" + }, + { reviewLabel: "Review", targetLabel: "working tree diff" } + ); + + assert.match(output, /Codex did not return valid structured JSON\./); + assert.match(output, /Unexpected token/); + assert.match(output, /some raw text/); +}); + +test("renderReviewResult includes reasoning summary when provided", () => { + const output = renderReviewResult( + { + parsed: { + verdict: "approve", + summary: "Looks good.", + findings: [], + next_steps: [] + }, + rawOutput: null, + parseError: null + }, + { + reviewLabel: "Review", + targetLabel: "working tree diff", + reasoningSummary: ["Checked all the critical paths.", "No regressions found."] + } + ); + + assert.match(output, /Reasoning:/); + assert.match(output, /Checked all the critical paths\./); +}); + +// --------------------------------------------------------------------------- +// renderSetupReport +// --------------------------------------------------------------------------- + +test("renderSetupReport shows ready status and no next steps when everything is fine", () => { + const output = renderSetupReport({ + ready: true, + node: { detail: "v20.0.0" }, + npm: { detail: "10.0.0" }, + codex: { detail: "1.2.3" }, + auth: { detail: "authenticated" }, + sessionRuntime: { label: "app-server" }, + reviewGateEnabled: false, + actionsTaken: [], + nextSteps: [] + }); + + assert.match(output, /# Codex Setup/); + assert.match(output, /Status: ready/); + assert.match(output, /node: v20\.0\.0/); + assert.match(output, /review gate: disabled/); + assert.doesNotMatch(output, /Next steps:/); + assert.doesNotMatch(output, /Actions taken:/); +}); + +test("renderSetupReport shows needs-attention status, actions taken and next steps", () => { + const output = renderSetupReport({ + ready: false, + node: { detail: "v18.0.0" }, + npm: { detail: "9.0.0" }, + codex: { detail: "not found" }, + auth: { detail: "not authenticated" }, + sessionRuntime: { label: "none" }, + reviewGateEnabled: true, + actionsTaken: ["Installed Codex CLI."], + nextSteps: ["Run `codex login`."] + }); + + assert.match(output, /Status: needs attention/); + assert.match(output, /review gate: enabled/); + assert.match(output, /Actions taken:/); + assert.match(output, /Installed Codex CLI\./); + assert.match(output, /Next steps:/); + assert.match(output, /Run `codex login`\./); +}); + +// --------------------------------------------------------------------------- +// renderNativeReviewResult +// --------------------------------------------------------------------------- + +test("renderNativeReviewResult renders stdout when present", () => { + const output = renderNativeReviewResult( + { stdout: "No issues found.", stderr: "", status: 0 }, + { reviewLabel: "Review", targetLabel: "branch diff" } + ); + + assert.match(output, /# Codex Review/); + assert.match(output, /Target: branch diff/); + assert.match(output, /No issues found\./); + assert.doesNotMatch(output, /stderr:/); +}); + +test("renderNativeReviewResult shows fallback message when stdout is empty and exit is 0", () => { + const output = renderNativeReviewResult( + { stdout: "", stderr: "", status: 0 }, + { reviewLabel: "Review", targetLabel: "working tree diff" } + ); + + assert.match(output, /Codex review completed without any stdout output\./); +}); + +test("renderNativeReviewResult shows failure message when stdout is empty and exit is non-zero", () => { + const output = renderNativeReviewResult( + { stdout: "", stderr: "fatal error", status: 1 }, + { reviewLabel: "Review", targetLabel: "working tree diff" } + ); + + assert.match(output, /Codex review failed\./); + assert.match(output, /stderr:/); + assert.match(output, /fatal error/); +}); + +// --------------------------------------------------------------------------- +// renderTaskResult +// --------------------------------------------------------------------------- + +test("renderTaskResult returns rawOutput when present", () => { + const output = renderTaskResult( + { rawOutput: "Task done.\n", failureMessage: null }, + {} + ); + assert.equal(output, "Task done.\n"); +}); + +test("renderTaskResult appends a newline to rawOutput when missing", () => { + const output = renderTaskResult({ rawOutput: "Task done.", failureMessage: null }, {}); + assert.equal(output, "Task done.\n"); +}); + +test("renderTaskResult falls back to failureMessage when rawOutput is absent", () => { + const output = renderTaskResult( + { rawOutput: "", failureMessage: "Something went wrong." }, + {} + ); + assert.equal(output, "Something went wrong.\n"); +}); + +test("renderTaskResult uses generic fallback when both rawOutput and failureMessage are absent", () => { + const output = renderTaskResult({}, {}); + assert.equal(output, "Codex did not return a final message.\n"); +}); + +// --------------------------------------------------------------------------- +// renderStatusReport +// --------------------------------------------------------------------------- + +test("renderStatusReport shows no-jobs message when there are no jobs", () => { + const output = renderStatusReport({ + sessionRuntime: { label: "none" }, + config: { stopReviewGate: false }, + running: [], + latestFinished: null, + recent: [], + needsReview: false + }); + + assert.match(output, /# Codex Status/); + assert.match(output, /No jobs recorded yet\./); + assert.doesNotMatch(output, /Active jobs:/); +}); + +test("renderStatusReport shows running jobs table and live details", () => { + const output = renderStatusReport({ + sessionRuntime: { label: "app-server" }, + config: { stopReviewGate: false }, + running: [ + { + id: "job-abc", + status: "running", + kindLabel: "review", + title: "Codex Review", + phase: "reviewing", + elapsed: "5s", + threadId: "thr_1", + summary: null, + logFile: "/tmp/job-abc.log" + } + ], + latestFinished: null, + recent: [], + needsReview: false + }); + + assert.match(output, /Active jobs:/); + assert.match(output, /job-abc/); + assert.match(output, /Elapsed: 5s/); + assert.match(output, /Live details:/); +}); + +test("renderStatusReport shows the review-gate warning when needsReview is true", () => { + const output = renderStatusReport({ + sessionRuntime: { label: "none" }, + config: { stopReviewGate: true }, + running: [], + latestFinished: null, + recent: [], + needsReview: true + }); + + assert.match(output, /stop-time review gate is enabled/); +}); + +test("renderStatusReport shows latest finished job", () => { + const output = renderStatusReport({ + sessionRuntime: { label: "none" }, + config: { stopReviewGate: false }, + running: [], + latestFinished: { + id: "job-xyz", + status: "completed", + kindLabel: "task", + title: "Codex Task", + phase: "done", + duration: "12s", + threadId: "thr_2", + summary: "Did things.", + logFile: "/tmp/job-xyz.log" + }, + recent: [], + needsReview: false + }); + + assert.match(output, /Latest finished:/); + assert.match(output, /job-xyz/); + assert.match(output, /Duration: 12s/); +}); + +// --------------------------------------------------------------------------- +// renderJobStatusReport +// --------------------------------------------------------------------------- + +test("renderJobStatusReport renders a running job with cancel hint", () => { + const output = renderJobStatusReport({ + id: "job-run", + status: "running", + kindLabel: "review", + title: "Codex Review", + phase: "reviewing", + elapsed: "3s", + threadId: null, + logFile: "/tmp/job-run.log" + }); + + assert.match(output, /# Codex Job Status/); + assert.match(output, /job-run/); + assert.match(output, /Cancel: \/codex:cancel job-run/); +}); + +test("renderJobStatusReport renders a completed job with result hint", () => { + const output = renderJobStatusReport({ + id: "job-done", + status: "completed", + kindLabel: "task", + title: "Codex Task", + phase: "done", + duration: "8s", + threadId: "thr_5", + logFile: "/tmp/job-done.log", + jobClass: "task", + write: true + }); + + assert.match(output, /Result: \/codex:result job-done/); + assert.match(output, /Resume in Codex: codex resume thr_5/); + assert.match(output, /Review changes: \/codex:review --wait/); +}); + +// --------------------------------------------------------------------------- +// renderCancelReport +// --------------------------------------------------------------------------- + +test("renderCancelReport shows the cancelled job id", () => { + const output = renderCancelReport({ id: "job-cancel", title: null, summary: null }); + assert.match(output, /# Codex Cancel/); + assert.match(output, /Cancelled job-cancel\./); + assert.match(output, /\/codex:status/); +}); + +test("renderCancelReport includes title and summary when present", () => { + const output = renderCancelReport({ + id: "job-cancel", + title: "My Task", + summary: "Was running a sweep." + }); + assert.match(output, /Title: My Task/); + assert.match(output, /Summary: Was running a sweep\./); +}); + +// --------------------------------------------------------------------------- +// renderStoredJobResult — additional cases +// --------------------------------------------------------------------------- + +test("renderStoredJobResult falls back to rawOutput from codex.stdout when result has no rawOutput", () => { + const output = renderStoredJobResult( + { id: "job-1", status: "completed", title: "My Task", threadId: null }, + { + result: { + codex: { stdout: "Task output here." } + } + } + ); + + assert.match(output, /Task output here\./); +}); + +test("renderStoredJobResult renders fallback block when no result and no rendered output", () => { + const output = renderStoredJobResult( + { id: "job-2", status: "completed", title: "Codex Task", threadId: null, summary: null, errorMessage: null }, + null + ); + + assert.match(output, /# Codex Task/); + assert.match(output, /Job: job-2/); + assert.match(output, /No captured result payload/); +}); + +test("renderStoredJobResult appends session ID and resume command to rendered output", () => { + const output = renderStoredJobResult( + { id: "job-3", status: "completed", title: "Codex Task", threadId: "thr_99" }, + { + threadId: "thr_99", + rendered: "Some task output." + } + ); + + assert.match(output, /Codex session ID: thr_99/); + assert.match(output, /Resume in Codex: codex resume thr_99/); +}); + diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 0f8f57c..743b4aa 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -5,7 +5,21 @@ import test from "node:test"; import assert from "node:assert/strict"; import { makeTempDir } from "./helpers.mjs"; -import { resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState } from "../plugins/codex/scripts/lib/state.mjs"; +import { + resolveJobFile, + resolveJobLogFile, + resolveStateDir, + resolveStateFile, + saveState, + loadState, + generateJobId, + upsertJob, + listJobs, + setConfig, + getConfig, + writeJobFile, + readJobFile +} from "../plugins/codex/scripts/lib/state.mjs"; test("resolveStateDir uses a temp-backed per-workspace directory", () => { const workspace = makeTempDir(); @@ -103,3 +117,154 @@ test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", .sort() ); }); + +// --------------------------------------------------------------------------- +// loadState +// --------------------------------------------------------------------------- + +test("loadState returns default state when the state file does not exist", () => { + const workspace = makeTempDir(); + const state = loadState(workspace); + + assert.equal(state.version, 1); + assert.deepEqual(state.config, { stopReviewGate: false }); + assert.deepEqual(state.jobs, []); +}); + +test("loadState returns default state when the state file contains invalid JSON", () => { + const workspace = makeTempDir(); + const stateFile = resolveStateFile(workspace); + fs.mkdirSync(path.dirname(stateFile), { recursive: true }); + fs.writeFileSync(stateFile, "{ not valid json }", "utf8"); + + const state = loadState(workspace); + assert.deepEqual(state.jobs, []); +}); + +test("loadState merges config defaults when some config keys are missing", () => { + const workspace = makeTempDir(); + const stateFile = resolveStateFile(workspace); + fs.mkdirSync(path.dirname(stateFile), { recursive: true }); + fs.writeFileSync( + stateFile, + JSON.stringify({ version: 1, config: {}, jobs: [] }), + "utf8" + ); + + const state = loadState(workspace); + assert.equal(state.config.stopReviewGate, false); +}); + +test("loadState round-trips a persisted state file", () => { + const workspace = makeTempDir(); + const original = { + version: 1, + config: { stopReviewGate: true }, + jobs: [{ id: "job-1", status: "completed", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }] + }; + const stateFile = resolveStateFile(workspace); + fs.mkdirSync(path.dirname(stateFile), { recursive: true }); + fs.writeFileSync(stateFile, `${JSON.stringify(original, null, 2)}\n`, "utf8"); + + const loaded = loadState(workspace); + assert.equal(loaded.config.stopReviewGate, true); + assert.equal(loaded.jobs.length, 1); + assert.equal(loaded.jobs[0].id, "job-1"); +}); + +// --------------------------------------------------------------------------- +// generateJobId +// --------------------------------------------------------------------------- + +test("generateJobId produces unique identifiers for successive calls", () => { + const id1 = generateJobId(); + const id2 = generateJobId(); + assert.notEqual(id1, id2); +}); + +test("generateJobId starts with the default prefix", () => { + const id = generateJobId(); + assert.match(id, /^job-/); +}); + +test("generateJobId honours a custom prefix", () => { + const id = generateJobId("review"); + assert.match(id, /^review-/); +}); + +// --------------------------------------------------------------------------- +// upsertJob / listJobs +// --------------------------------------------------------------------------- + +test("upsertJob inserts a new job and listJobs returns it", () => { + const workspace = makeTempDir(); + + upsertJob(workspace, { id: "job-a", status: "queued", title: "My Job" }); + const jobs = listJobs(workspace); + + assert.equal(jobs.length, 1); + assert.equal(jobs[0].id, "job-a"); + assert.equal(jobs[0].status, "queued"); +}); + +test("upsertJob merges a patch into an existing job", () => { + const workspace = makeTempDir(); + + upsertJob(workspace, { id: "job-b", status: "queued", title: "Task" }); + upsertJob(workspace, { id: "job-b", status: "running", phase: "executing" }); + + const jobs = listJobs(workspace); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].status, "running"); + assert.equal(jobs[0].phase, "executing"); + assert.equal(jobs[0].title, "Task"); +}); + +test("upsertJob prepends new jobs so the newest appears first", () => { + const workspace = makeTempDir(); + + upsertJob(workspace, { id: "job-first", status: "completed" }); + upsertJob(workspace, { id: "job-second", status: "completed" }); + + const jobs = listJobs(workspace); + assert.equal(jobs[0].id, "job-second"); + assert.equal(jobs[1].id, "job-first"); +}); + +// --------------------------------------------------------------------------- +// setConfig / getConfig +// --------------------------------------------------------------------------- + +test("setConfig persists a config value and getConfig retrieves it", () => { + const workspace = makeTempDir(); + + setConfig(workspace, "stopReviewGate", true); + const config = getConfig(workspace); + + assert.equal(config.stopReviewGate, true); +}); + +test("setConfig can toggle a config value back", () => { + const workspace = makeTempDir(); + + setConfig(workspace, "stopReviewGate", true); + setConfig(workspace, "stopReviewGate", false); + + assert.equal(getConfig(workspace).stopReviewGate, false); +}); + +// --------------------------------------------------------------------------- +// writeJobFile / readJobFile +// --------------------------------------------------------------------------- + +test("writeJobFile writes a job payload and readJobFile round-trips it", () => { + const workspace = makeTempDir(); + const jobId = "job-wf"; + const payload = { id: jobId, status: "completed", result: { value: 42 } }; + + const jobFile = writeJobFile(workspace, jobId, payload); + assert.equal(fs.existsSync(jobFile), true); + + const loaded = readJobFile(jobFile); + assert.deepEqual(loaded, payload); +});