From f2bfba16a37c2ba1b1b13f92124579f748f2dbd5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 19:12:06 +0900 Subject: [PATCH] test(omo-codex): batch 36 (4 files) --- .../test/codex-hook-newline.test.ts | 52 +++ .../comment-checker/test/codex-hook.test.ts | 368 ++++++++++++++++++ .../test/package-smoke.test.ts | 93 +++++ .../comment-checker/test/runner.test.ts | 66 ++++ 4 files changed, 579 insertions(+) create mode 100644 packages/omo-codex/plugin/components/comment-checker/test/codex-hook-newline.test.ts create mode 100644 packages/omo-codex/plugin/components/comment-checker/test/codex-hook.test.ts create mode 100644 packages/omo-codex/plugin/components/comment-checker/test/package-smoke.test.ts create mode 100644 packages/omo-codex/plugin/components/comment-checker/test/runner.test.ts diff --git a/packages/omo-codex/plugin/components/comment-checker/test/codex-hook-newline.test.ts b/packages/omo-codex/plugin/components/comment-checker/test/codex-hook-newline.test.ts new file mode 100644 index 000000000..b6dbfabf1 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/test/codex-hook-newline.test.ts @@ -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"); + }); +}); diff --git a/packages/omo-codex/plugin/components/comment-checker/test/codex-hook.test.ts b/packages/omo-codex/plugin/components/comment-checker/test/codex-hook.test.ts new file mode 100644 index 000000000..83b74c3f1 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/test/codex-hook.test.ts @@ -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 { + 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 { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/comment-checker/test/package-smoke.test.ts b/packages/omo-codex/plugin/components/comment-checker/test/package-smoke.test.ts new file mode 100644 index 000000000..668bae0ff --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/test/package-smoke.test.ts @@ -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; + readonly dependencies?: Record; + readonly optionalDependencies: Record; +}; + +type HookCommand = { + readonly command: string; +}; + +type HookEntry = { + readonly hooks: readonly HookCommand[]; +}; + +type HooksJson = { + readonly hooks: Record; +}; + +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 { + return isRecord(value) && Object.values(value).every((item) => typeof item === "string"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/comment-checker/test/runner.test.ts b/packages/omo-codex/plugin/components/comment-checker/test/runner.test.ts new file mode 100644 index 000000000..39a375b14 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/test/runner.test.ts @@ -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"); + }); +});