test(omo-codex): batch 36 (4 files)
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { type CodexPostToolUseInput, runCommentCheckerPostToolUse } from "../src/codex-hook.ts";
|
||||
|
||||
function postToolUseInput(): CodexPostToolUseInput {
|
||||
return {
|
||||
session_id: "thread-1",
|
||||
turn_id: "turn-1",
|
||||
transcript_path: null,
|
||||
cwd: "/repo",
|
||||
hook_event_name: "PostToolUse",
|
||||
model: "gpt-5.5",
|
||||
permission_mode: "never",
|
||||
tool_name: "apply_patch",
|
||||
tool_input: {
|
||||
command: [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: src/example.ts",
|
||||
"@@",
|
||||
"-const value = 1;",
|
||||
"+// explains value",
|
||||
"+const value = 2;",
|
||||
"*** End Patch",
|
||||
].join("\n"),
|
||||
},
|
||||
tool_response: "Success. Updated files.",
|
||||
tool_use_id: "call-1",
|
||||
};
|
||||
}
|
||||
|
||||
describe("comment-checker hook newline rendering", () => {
|
||||
it("#given checker warning with CRLF and bare CR #when hook runs #then returns normalized blocking feedback JSON", async () => {
|
||||
// given
|
||||
const output = await runCommentCheckerPostToolUse(postToolUseInput(), {
|
||||
run: async () => ({
|
||||
status: "warning",
|
||||
message: "\r\nfirst warning line\r\n indented detail\rthird warning line\r\n",
|
||||
}),
|
||||
});
|
||||
|
||||
// when
|
||||
const parsed: unknown = JSON.parse(output);
|
||||
|
||||
// then
|
||||
expect(parsed).toEqual({
|
||||
decision: "block",
|
||||
reason:
|
||||
"comment-checker found issues in src/example.ts:\nfirst warning line\n indented detail\nthird warning line",
|
||||
});
|
||||
expect(output).not.toContain("\r");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type CodexPostToolUseInput,
|
||||
extractCodexCommentCheckRequests,
|
||||
runCommentCheckerPostToolUse,
|
||||
} from "../src/codex-hook.ts";
|
||||
|
||||
type CliResult = {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
const CLI_PATH = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function runHookCli(input: string): Promise<CliResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [CLI_PATH, "hook", "post-tool-use"], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("close", (exitCode) => {
|
||||
resolve({ exitCode, stdout, stderr });
|
||||
});
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
function postToolUseInput(overrides: Partial<CodexPostToolUseInput> = {}): CodexPostToolUseInput {
|
||||
return {
|
||||
session_id: "thread-1",
|
||||
turn_id: "turn-1",
|
||||
transcript_path: null,
|
||||
cwd: "/repo",
|
||||
hook_event_name: "PostToolUse",
|
||||
model: "gpt-5.5",
|
||||
permission_mode: "never",
|
||||
tool_name: "apply_patch",
|
||||
tool_input: {
|
||||
command: [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: src/example.ts",
|
||||
"@@",
|
||||
"-const value = 1;",
|
||||
"+// explains value",
|
||||
"+const value = 2;",
|
||||
"*** End Patch",
|
||||
].join("\n"),
|
||||
},
|
||||
tool_response: "Success. Updated files.",
|
||||
tool_use_id: "call-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("extractCodexCommentCheckRequests", () => {
|
||||
it("#given codex apply_patch command #when extracting #then returns edit request for changed file", () => {
|
||||
const requests = extractCodexCommentCheckRequests(postToolUseInput());
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
sourceToolName: "apply_patch",
|
||||
toolName: "Edit",
|
||||
filePath: "src/example.ts",
|
||||
toolInput: {
|
||||
file_path: "src/example.ts",
|
||||
old_string: "const value = 1;\n",
|
||||
new_string: "// explains value\nconst value = 2;\n",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("#given unsupported post tool event #when extracting #then returns no requests", () => {
|
||||
const requests = extractCodexCommentCheckRequests(
|
||||
postToolUseInput({
|
||||
tool_name: "read",
|
||||
tool_input: { file_path: "src/example.ts", content: "// hi\nconst value = 1;\n" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
it("#given codex write payload #when extracting #then returns write request", () => {
|
||||
const requests = extractCodexCommentCheckRequests(
|
||||
postToolUseInput({
|
||||
tool_name: "write",
|
||||
tool_input: {
|
||||
file_path: "src/example.ts",
|
||||
content: "// explains value\nconst value = 1;\n",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
sourceToolName: "write",
|
||||
toolName: "Write",
|
||||
filePath: "src/example.ts",
|
||||
toolInput: {
|
||||
file_path: "src/example.ts",
|
||||
content: "// explains value\nconst value = 1;\n",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("#given codex edit payload #when extracting #then returns edit request", () => {
|
||||
const requests = extractCodexCommentCheckRequests(
|
||||
postToolUseInput({
|
||||
tool_name: "edit",
|
||||
tool_input: {
|
||||
path: "src/example.ts",
|
||||
oldString: "const value = 1;\n",
|
||||
newString: "// explains value\nconst value = 2;\n",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
sourceToolName: "edit",
|
||||
toolName: "Edit",
|
||||
filePath: "src/example.ts",
|
||||
toolInput: {
|
||||
file_path: "src/example.ts",
|
||||
old_string: "const value = 1;\n",
|
||||
new_string: "// explains value\nconst value = 2;\n",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("#given one-sided codex edit payload #when extracting #then returns no requests", () => {
|
||||
const requests = extractCodexCommentCheckRequests(
|
||||
postToolUseInput({
|
||||
tool_name: "edit",
|
||||
tool_input: {
|
||||
path: "src/example.ts",
|
||||
oldString: "const value = 1;\n",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
it("#given codex multi_edit payload #when extracting #then returns multiedit request", () => {
|
||||
const requests = extractCodexCommentCheckRequests(
|
||||
postToolUseInput({
|
||||
tool_name: "multi_edit",
|
||||
tool_input: {
|
||||
filePath: "src/example.ts",
|
||||
edits: [
|
||||
{ old_string: "const a = 1;\n", new_string: "// explains a\nconst a = 2;\n" },
|
||||
{ oldString: "const b = 1;\n", newString: "// explains b\nconst b = 2;\n" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
sourceToolName: "multi_edit",
|
||||
toolName: "MultiEdit",
|
||||
filePath: "src/example.ts",
|
||||
toolInput: {
|
||||
file_path: "src/example.ts",
|
||||
edits: [
|
||||
{ old_string: "const a = 1;\n", new_string: "// explains a\nconst a = 2;\n" },
|
||||
{ old_string: "const b = 1;\n", new_string: "// explains b\nconst b = 2;\n" },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCommentCheckerPostToolUse", () => {
|
||||
it("#given checker warning #when hook runs #then returns blocking feedback JSON", async () => {
|
||||
const output = await runCommentCheckerPostToolUse(postToolUseInput(), {
|
||||
run: async () => ({
|
||||
status: "warning",
|
||||
message: "comment warning: explain less",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(JSON.parse(output)).toEqual({
|
||||
decision: "block",
|
||||
reason: "comment-checker found issues in src/example.ts:\ncomment warning: explain less",
|
||||
});
|
||||
});
|
||||
|
||||
it("#given missing checker binary #when hook runs #then emits no hook output", async () => {
|
||||
const output = await runCommentCheckerPostToolUse(postToolUseInput(), {
|
||||
run: async () => ({
|
||||
status: "missing",
|
||||
message: "not installed",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(output).toBe("");
|
||||
});
|
||||
|
||||
it("#given transcript path #when hook runs #then forwards it to checker input", async () => {
|
||||
let transcriptPath = "";
|
||||
|
||||
await runCommentCheckerPostToolUse(
|
||||
postToolUseInput({
|
||||
transcript_path: "/tmp/codex-comment-checker-transcript.jsonl",
|
||||
tool_name: "write",
|
||||
tool_input: {
|
||||
file_path: "src/example.ts",
|
||||
content: "// explains value\nconst value = 1;\n",
|
||||
},
|
||||
}),
|
||||
{
|
||||
run: async (input) => {
|
||||
transcriptPath = input.transcript_path;
|
||||
return {
|
||||
status: "pass",
|
||||
message: "",
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(transcriptPath).toBe("/tmp/codex-comment-checker-transcript.jsonl");
|
||||
});
|
||||
|
||||
it("#given null transcript path #when hook runs #then forwards empty string fallback", async () => {
|
||||
let transcriptPath = "unset";
|
||||
|
||||
await runCommentCheckerPostToolUse(
|
||||
postToolUseInput({
|
||||
transcript_path: null,
|
||||
tool_name: "write",
|
||||
tool_input: {
|
||||
file_path: "src/example.ts",
|
||||
content: "// explains value\nconst value = 1;\n",
|
||||
},
|
||||
}),
|
||||
{
|
||||
run: async (input) => {
|
||||
transcriptPath = input.transcript_path;
|
||||
return {
|
||||
status: "pass",
|
||||
message: "",
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(transcriptPath).toBe("");
|
||||
});
|
||||
|
||||
it("#given Codex canonical context-window transcript and long checker warning #when hook blocks #then it caps feedback", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "codex-comment-checker-context-pressure-"));
|
||||
tempDirs.push(root);
|
||||
const transcriptPath = path.join(root, "transcript.jsonl");
|
||||
writeFileSync(
|
||||
transcriptPath,
|
||||
[
|
||||
"context_length_exceeded",
|
||||
"Codex ran out of room in the model's context window. Start a new thread before retrying.",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const output = await runCommentCheckerPostToolUse(postToolUseInput({ transcript_path: transcriptPath }), {
|
||||
run: async () => ({
|
||||
status: "warning",
|
||||
message: `comment warning: explain less\n${"x".repeat(10_000)}`,
|
||||
}),
|
||||
});
|
||||
|
||||
const parsed: unknown = JSON.parse(output);
|
||||
if (!isBlockingOutput(parsed)) throw new TypeError("Expected blocking output");
|
||||
|
||||
expect(parsed.reason.length).toBeLessThanOrEqual(1200);
|
||||
expect(parsed.reason).toContain("comment-checker found issues in src/example.ts");
|
||||
expect(parsed.reason).toContain("[Truncated hook output");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCodexHookCli", () => {
|
||||
it("#given malformed post-tool-use stdin #when hook CLI runs #then it no-ops without stderr", async () => {
|
||||
// given
|
||||
const input = "break;\n";
|
||||
|
||||
// when
|
||||
const result = await runHookCli(input);
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("#given non-object post-tool-use JSON #when hook CLI runs #then it no-ops without stderr", async () => {
|
||||
// given
|
||||
const input = '"break;"\n';
|
||||
|
||||
// when
|
||||
const result = await runHookCli(input);
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("#given non-string transcript path #when hook CLI runs #then it no-ops without stderr", async () => {
|
||||
// given
|
||||
const input = `${JSON.stringify({ ...postToolUseInput(), transcript_path: 42 })}\n`;
|
||||
|
||||
// when
|
||||
const result = await runHookCli(input);
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface BlockingOutput {
|
||||
readonly decision: "block";
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
function isBlockingOutput(value: unknown): value is BlockingOutput {
|
||||
return isRecord(value) && value["decision"] === "block" && typeof value["reason"] === "string";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type PackageJson = {
|
||||
readonly type: string;
|
||||
readonly packageManager: string;
|
||||
readonly bin: Record<string, string>;
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
readonly optionalDependencies: Record<string, string>;
|
||||
};
|
||||
|
||||
type HookCommand = {
|
||||
readonly command: string;
|
||||
};
|
||||
|
||||
type HookEntry = {
|
||||
readonly hooks: readonly HookCommand[];
|
||||
};
|
||||
|
||||
type HooksJson = {
|
||||
readonly hooks: Record<string, readonly HookEntry[]>;
|
||||
};
|
||||
|
||||
function readPackageJson(path: string): PackageJson {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readHooksJson(path: string): HooksJson {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!isHooksJson(parsed)) throw new TypeError(`Invalid hooks metadata: ${path}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
describe("plugin package metadata", () => {
|
||||
it("#given packaged plugin files #when validating entrypoints #then hook command uses portable plugin root interpolation", () => {
|
||||
// given
|
||||
const packageJson = readPackageJson("package.json");
|
||||
const hooksJson = readHooksJson("hooks/hooks.json");
|
||||
const cliSource = readFileSync("src/cli.ts", "utf8");
|
||||
|
||||
// when
|
||||
const command = hooksJson.hooks["PostToolUse"]?.[0]?.hooks[0]?.command;
|
||||
const pluginRoot = ["$", "{PLUGIN_ROOT}"].join("");
|
||||
|
||||
// then
|
||||
expect(packageJson.type).toBe("module");
|
||||
expect(packageJson.packageManager).toBe("npm@11.12.1");
|
||||
expect(packageJson.dependencies ?? {}).not.toHaveProperty("@code-yeongyu/comment-checker");
|
||||
expect(packageJson.optionalDependencies).toHaveProperty("@code-yeongyu/comment-checker");
|
||||
expect(packageJson.bin["omo-comment-checker"]).toBe("./dist/cli.js");
|
||||
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
|
||||
expect(command).toBe(`node "${pluginRoot}/dist/cli.js" hook post-tool-use`);
|
||||
});
|
||||
});
|
||||
|
||||
function isPackageJson(value: unknown): value is PackageJson {
|
||||
if (!isRecord(value)) return false;
|
||||
const dependencies = value["dependencies"];
|
||||
return (
|
||||
value["type"] === "module" &&
|
||||
value["packageManager"] === "npm@11.12.1" &&
|
||||
isStringRecord(value["bin"]) &&
|
||||
isStringRecord(value["optionalDependencies"]) &&
|
||||
(dependencies === undefined || isRecord(dependencies))
|
||||
);
|
||||
}
|
||||
|
||||
function isHooksJson(value: unknown): value is HooksJson {
|
||||
if (!isRecord(value) || !isRecord(value["hooks"])) return false;
|
||||
return Object.values(value["hooks"]).every(isHookEntries);
|
||||
}
|
||||
|
||||
function isHookEntries(value: unknown): value is readonly HookEntry[] {
|
||||
return Array.isArray(value) && value.every(isHookEntry);
|
||||
}
|
||||
|
||||
function isHookEntry(value: unknown): value is HookEntry {
|
||||
return isRecord(value) && Array.isArray(value["hooks"]) && value["hooks"].every(isHookCommand);
|
||||
}
|
||||
|
||||
function isHookCommand(value: unknown): value is HookCommand {
|
||||
return isRecord(value) && typeof value["command"] === "string";
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MAX_PROCESS_OUTPUT_BYTES,
|
||||
resolveCommentCheckerBinary,
|
||||
runCommentChecker,
|
||||
spawnProcess,
|
||||
} from "../src/runner.js";
|
||||
|
||||
describe("spawnProcess", () => {
|
||||
it("#given noisy checker process #when output exceeds cap #then stderr is bounded", async () => {
|
||||
// given
|
||||
const maxOutputBytes = 16;
|
||||
|
||||
// when
|
||||
const result = await spawnProcess(
|
||||
process.execPath,
|
||||
["-e", "process.stderr.write('x'.repeat(40)); process.exit(2);"],
|
||||
"",
|
||||
maxOutputBytes,
|
||||
);
|
||||
|
||||
// then
|
||||
expect(MAX_PROCESS_OUTPUT_BYTES).toBeGreaterThan(maxOutputBytes);
|
||||
expect(result.exitCode).toBe(2);
|
||||
expect(result.stderr).toBe(`${"x".repeat(maxOutputBytes)}\n[stderr truncated after 16 bytes]`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCommentCheckerBinary", () => {
|
||||
it("#given installed checker package #when resolving binary #then returns existing checker binary", () => {
|
||||
// given / when
|
||||
const binaryPath = resolveCommentCheckerBinary();
|
||||
|
||||
// then
|
||||
expect(binaryPath).toBeDefined();
|
||||
expect(binaryPath ?? "").toContain("comment-checker");
|
||||
expect(existsSync(binaryPath ?? "")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCommentChecker", () => {
|
||||
it("#given missing checker binary #when runner starts #then returns missing result", async () => {
|
||||
// given / when
|
||||
const result = await runCommentChecker(
|
||||
{
|
||||
session_id: "session-1",
|
||||
tool_name: "Write",
|
||||
transcript_path: "",
|
||||
cwd: "/repo",
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_input: {
|
||||
file_path: "src/example.ts",
|
||||
content: "const value = 1;\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
resolveBinary: () => undefined,
|
||||
},
|
||||
);
|
||||
|
||||
// then
|
||||
expect(result.status).toBe("missing");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user