This commit is contained in:
Eason Goodale
2025-04-20 03:47:48 -07:00
parent b1cef74d8c
commit 379b023a7f
64 changed files with 6753 additions and 8117 deletions
@@ -0,0 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Check for updates > should outputs the update message when package is outdated 1`] = `
"
╭─────────────────────────────────────────────────────────────╮
│ │
│ Update available! 1.0.0 → 2.0.0. │
│ To update, run: npm install -g @openai/codex to update. │
│ │
╰─────────────────────────────────────────────────────────────╯
"
`;
+30 -1
View File
@@ -1,7 +1,13 @@
import type { SafetyAssessment } from "../src/approvals";
import { canAutoApprove } from "../src/approvals";
import { describe, test, expect } from "vitest";
import { describe, test, expect, vi } from "vitest";
vi.mock("../src/utils/config", () => ({
loadConfig: () => ({
safeCommands: ["npm test", "sl"],
}),
}));
describe("canAutoApprove()", () => {
const env = {
@@ -89,4 +95,27 @@ describe("canAutoApprove()", () => {
expect(check(["cargo", "build"])).toEqual({ type: "ask-user" });
});
test("commands in safeCommands config should be safe", async () => {
expect(check(["npm", "test"])).toEqual({
type: "auto-approve",
reason: "User-defined safe command",
group: "User config",
runInSandbox: false,
});
expect(check(["sl"])).toEqual({
type: "auto-approve",
reason: "User-defined safe command",
group: "User config",
runInSandbox: false,
});
expect(check(["npm", "test", "--watch"])).toEqual({
type: "auto-approve",
reason: "User-defined safe command",
group: "User config",
runInSandbox: false,
});
});
});
+2 -1
View File
@@ -13,7 +13,8 @@ vi.mock("../src/utils/input-utils.js", () => ({
}));
// mock external deps used inside chat input
vi.mock("../../approvals.js", () => ({ isSafeCommand: () => null }));
// Mock approval helper used by TerminalChatInput
vi.mock("../src/approvals.js", () => ({ isSafeCommand: () => null }));
vi.mock("../src/format-command.js", () => ({
// Accept an array of command tokens and join them with spaces for display.
formatCommandForDisplay: (c: Array<string>): string => c.join(" "),
@@ -11,7 +11,7 @@ vi.mock("../src/utils/input-utils.js", () => ({
createInputItem: vi.fn(async () => ({})),
imageFilenameByDataUrl: new Map(),
}));
vi.mock("../../approvals.js", () => ({ isSafeCommand: () => null }));
vi.mock("../src/approvals.js", () => ({ isSafeCommand: () => null }));
vi.mock("../src/format-command.js", () => ({
formatCommandForDisplay: (c: Array<string>): string => c.join(" "),
}));
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
checkForUpdates,
checkOutdated,
getNPMCommandPath,
} from "../src/utils/check-updates.js";
import { execFile } from "node:child_process";
import { join } from "node:path";
import { CONFIG_DIR } from "src/utils/config.js";
import { beforeEach } from "node:test";
vi.mock("which", () => ({
default: vi.fn(() => "/usr/local/bin/npm"),
}));
vi.mock("child_process", () => ({
execFile: vi.fn((_cmd, _args, _opts, callback) => {
const stdout = JSON.stringify({
"@openai/codex": {
current: "1.0.0",
latest: "2.0.0",
},
});
callback?.(null, stdout, "");
return {} as any;
}),
}));
let memfs: Record<string, string> = {};
vi.mock("node:fs/promises", async (importOriginal) => ({
...(await importOriginal()),
readFile: async (path: string) => {
if (memfs[path] === undefined) {
throw new Error("ENOENT");
}
return memfs[path];
},
}));
beforeEach(() => {
memfs = {}; // reset in‑memory store
});
describe("Check for updates", () => {
it("should return the path to npm", async () => {
const npmPath = await getNPMCommandPath();
expect(npmPath).toBeDefined();
});
it("should return undefined if npm is not found", async () => {
vi.mocked(await import("which")).default.mockImplementationOnce(() => {
throw new Error("not found");
});
const npmPath = await getNPMCommandPath();
expect(npmPath).toBeUndefined();
});
it("should return the return value when package is outdated", async () => {
const npmPath = await getNPMCommandPath();
const info = await checkOutdated(npmPath!);
expect(info).toStrictEqual({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
});
});
it("should return undefined when package is not outdated", async () => {
const npmPath = await getNPMCommandPath();
vi.mocked(execFile).mockImplementationOnce(
(_cmd, _args, _opts, callback) => {
// Simulate the case where the package is not outdated, returning an empty object
const stdout = JSON.stringify({});
callback?.(null, stdout, "");
return {} as any;
},
);
const info = await checkOutdated(npmPath!);
expect(info).toBeUndefined();
});
it("should outputs the update message when package is outdated", async () => {
const codexStatePath = join(CONFIG_DIR, "update-check.json");
// Use a fixed early date far in the past to ensure it's always at least 1 day before now
memfs[codexStatePath] = JSON.stringify({
lastUpdateCheck: new Date("2000-01-01T00:00:00Z").toUTCString(),
});
await checkForUpdates();
// Spy on console.log to capture output
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await checkForUpdates();
expect(logSpy).toHaveBeenCalled();
// The last call should be the boxen message
const lastCallArg = logSpy.mock.calls.at(-1)?.[0] ?? "";
// Strip ANSI colors to make snapshot stable across environments
const stripAnsi = (await import("strip-ansi")).default as (
input: string,
) => string;
expect(stripAnsi(lastCallArg)).toMatchSnapshot();
});
it("should not output the update message when package is not outdated", async () => {
const codexStatePath = join(CONFIG_DIR, "update-check.json");
memfs[codexStatePath] = JSON.stringify({
lastUpdateCheck: new Date().toUTCString(),
});
await checkForUpdates();
// Spy on console.log to capture output
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await checkForUpdates();
expect(logSpy).not.toHaveBeenCalled();
});
});
+41
View File
@@ -1,6 +1,7 @@
import type * as fsType from "fs";
import { loadConfig, saveConfig } from "../src/utils/config.js"; // parent import first
import { AutoApprovalMode } from "../src/utils/auto-approval-mode.js";
import { tmpdir } from "os";
import { join } from "path";
import { test, expect, beforeEach, afterEach, vi } from "vitest";
@@ -107,3 +108,43 @@ test("loads user instructions + project doc when codex.md is present", () => {
userInstr + "\n\n--- project-doc ---\n\n" + projectDoc,
);
});
test("loads and saves approvalMode correctly", () => {
// Setup config with approvalMode
memfs[testConfigPath] = JSON.stringify(
{
model: "mymodel",
approvalMode: AutoApprovalMode.AUTO_EDIT,
},
null,
2,
);
memfs[testInstructionsPath] = "test instructions";
// Load config and verify approvalMode
const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
disableProjectDoc: true,
});
// Check approvalMode was loaded correctly
expect(loadedConfig.approvalMode).toBe(AutoApprovalMode.AUTO_EDIT);
// Modify approvalMode and save
const updatedConfig = {
...loadedConfig,
approvalMode: AutoApprovalMode.FULL_AUTO,
};
saveConfig(updatedConfig, testConfigPath, testInstructionsPath);
// Verify saved config contains updated approvalMode
expect(memfs[testConfigPath]).toContain(
`"approvalMode": "${AutoApprovalMode.FULL_AUTO}"`,
);
// Load again and verify updated value
const reloadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
disableProjectDoc: true,
});
expect(reloadedConfig.approvalMode).toBe(AutoApprovalMode.FULL_AUTO);
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from "vitest";
import fs from "fs/promises";
import { createInputItem } from "../src/utils/input-utils.js";
describe("createInputItem", () => {
it("returns only text when no images provided", async () => {
const result = await createInputItem("hello", []);
expect(result).toEqual({
role: "user",
type: "message",
content: [{ type: "input_text", text: "hello" }],
});
});
it("includes image content for existing file", async () => {
const fakeBuffer = Buffer.from("fake image content");
const readSpy = vi
.spyOn(fs, "readFile")
.mockResolvedValue(fakeBuffer as any);
const result = await createInputItem("hello", ["dummy-path"]);
const expectedUrl = `data:application/octet-stream;base64,${fakeBuffer.toString(
"base64",
)}`;
expect(result.role).toBe("user");
expect(result.type).toBe("message");
expect(result.content.length).toBe(2);
const [textItem, imageItem] = result.content;
expect(textItem).toEqual({ type: "input_text", text: "hello" });
expect(imageItem).toEqual({
type: "input_image",
detail: "auto",
image_url: expectedUrl,
});
readSpy.mockRestore();
});
it("falls back to missing image text for non-existent file", async () => {
const filePath = "tests/__fixtures__/does-not-exist.png";
const result = await createInputItem("hello", [filePath]);
expect(result.content.length).toBe(2);
const fallbackItem = result.content[1];
expect(fallbackItem).toEqual({
type: "input_text",
text: "[missing image: does-not-exist.png]",
});
});
});
@@ -0,0 +1,49 @@
// Regression test: Terminals with modifyOtherKeys=1 emit CSI~ sequence for
// Shift+Enter: ESC [ 27 ; mod ; 13 ~. The editor must treat Shift+Enter as
// newline (without submitting) and Ctrl+Enter as submit.
import { renderTui } from "./ui-test-helpers.js";
import MultilineTextEditor from "../src/components/chat/multiline-editor.js";
import * as React from "react";
import { describe, it, expect, vi } from "vitest";
async function type(
stdin: NodeJS.WritableStream,
text: string,
flush: () => Promise<void>,
) {
stdin.write(text);
await flush();
}
describe("MultilineTextEditor – Shift+Enter with modifyOtherKeys=1", () => {
it("inserts newline, does NOT submit", async () => {
const onSubmit = vi.fn();
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
React.createElement(MultilineTextEditor, {
height: 5,
width: 20,
initialText: "",
onSubmit,
}),
);
await flush();
await type(stdin, "abc", flush);
// Shift+Enter => ESC [27;2;13~
await type(stdin, "\u001B[27;2;13~", flush);
await type(stdin, "def", flush);
const frame = lastFrameStripped();
expect(frame).toMatch(/abc/);
expect(frame).toMatch(/def/);
// newline inserted -> at least 2 lines
expect(frame.split("\n").length).toBeGreaterThanOrEqual(2);
expect(onSubmit).not.toHaveBeenCalled();
cleanup();
});
});
+39
View File
@@ -0,0 +1,39 @@
import { test, expect } from "vitest";
import { SLASH_COMMANDS, type SlashCommand } from "../src/utils/slash-commands";
test("SLASH_COMMANDS includes expected commands", () => {
const commands = SLASH_COMMANDS.map((c: SlashCommand) => c.command);
expect(commands).toContain("/clear");
expect(commands).toContain("/compact");
expect(commands).toContain("/history");
expect(commands).toContain("/help");
expect(commands).toContain("/model");
expect(commands).toContain("/approval");
expect(commands).toContain("/clearhistory");
expect(commands).toContain("/diff");
});
test("filters slash commands by prefix", () => {
const prefix = "/c";
const filtered = SLASH_COMMANDS.filter((c: SlashCommand) =>
c.command.startsWith(prefix),
);
const names = filtered.map((c: SlashCommand) => c.command);
expect(names).toEqual(
expect.arrayContaining(["/clear", "/clearhistory", "/compact"]),
);
expect(names).not.toEqual(
expect.arrayContaining(["/history", "/help", "/model", "/approval"]),
);
const emptyPrefixFiltered = SLASH_COMMANDS.filter((c: SlashCommand) =>
c.command.startsWith(""),
);
const emptyPrefixNames = emptyPrefixFiltered.map(
(c: SlashCommand) => c.command,
);
expect(emptyPrefixNames).toEqual(
expect.arrayContaining(SLASH_COMMANDS.map((c: SlashCommand) => c.command)),
);
expect(emptyPrefixNames).toHaveLength(SLASH_COMMANDS.length);
});
@@ -17,12 +17,14 @@ describe("TerminalChatInput compact command", () => {
setItems: () => {},
contextLeftPercent: 10,
openOverlay: () => {},
openDiffOverlay: () => {},
openModelOverlay: () => {},
openApprovalOverlay: () => {},
openHelpOverlay: () => {},
onCompact: () => {},
interruptAgent: () => {},
active: true,
thinkingSeconds: 0,
};
const { lastFrameStripped } = renderTui(<TerminalChatInput {...props} />);
const frame = lastFrameStripped();