From 5f13b823cc9149e78f85f226504aea9c578e9431 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 19:12:11 +0900 Subject: [PATCH] test(cli): batch 66 (5 files) --- src/cli/cli-installer.platform.test.ts | 177 ++++++++++++++++++++ src/cli/install-platform-resolution.test.ts | 120 +++++++++++++ src/cli/lazycodex-feature-flag.ts | 15 ++ src/cli/star-request.ts | 8 + src/cli/tui-install-prompts.test.ts | 164 ++++++++++++++++++ 5 files changed, 484 insertions(+) create mode 100644 src/cli/cli-installer.platform.test.ts create mode 100644 src/cli/install-platform-resolution.test.ts create mode 100644 src/cli/lazycodex-feature-flag.ts create mode 100644 src/cli/star-request.ts create mode 100644 src/cli/tui-install-prompts.test.ts diff --git a/src/cli/cli-installer.platform.test.ts b/src/cli/cli-installer.platform.test.ts new file mode 100644 index 000000000..84dc07a20 --- /dev/null +++ b/src/cli/cli-installer.platform.test.ts @@ -0,0 +1,177 @@ +/// + +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { runCliInstaller } from "./cli-installer" +import * as configManager from "./config-manager" +import * as codexInstaller from "./install-codex" +import type { CodexInstallResult } from "./install-codex" +import type { InstallArgs } from "./types" + +const codexResult: CodexInstallResult = { + marketplaceName: "sisyphuslabs", + installed: [], + configPath: "/tmp/codex-config.toml", + codexHome: "/tmp/codex-home", +} + +function createOpenCodeArgs(platform: "opencode" | "both"): InstallArgs { + return { + tui: false, + platform, + claude: "no", + openai: "no", + gemini: "no", + copilot: "no", + opencodeZen: "no", + zaiCodingPlan: "no", + kimiForCoding: "no", + opencodeGo: "no", + vercelAiGateway: "no", + } +} + +function stubOpenCodeSuccess(): void { + spyOn(configManager, "detectCurrentConfig").mockReturnValue({ + isInstalled: false, + installedVersion: null, + hasClaude: false, + isMax20: false, + hasOpenAI: false, + hasGemini: false, + hasCopilot: false, + hasCodex: false, + hasOpencodeZen: false, + hasZaiCodingPlan: false, + hasKimiForCoding: false, + hasOpencodeGo: false, + hasVercelAiGateway: false, + }) + spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true) + spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0") + spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({ + success: true, + configPath: "/tmp/opencode.jsonc", + }) + spyOn(configManager, "writeOmoConfig").mockReturnValue({ + success: true, + configPath: "/tmp/oh-my-opencode.jsonc", + }) +} + +describe("runCliInstaller platform branching", () => { + const consoleLogMock = mock(() => {}) + const consoleLog = console.log + const originalPublishLazycodex = process.env.OMO_PUBLISH_LAZYCODEX + + beforeEach(() => { + consoleLogMock.mockClear() + console.log = consoleLogMock + process.env.OMO_PUBLISH_LAZYCODEX = "true" + }) + + afterEach(() => { + console.log = consoleLog + if (originalPublishLazycodex === undefined) { + delete process.env.OMO_PUBLISH_LAZYCODEX + } else { + process.env.OMO_PUBLISH_LAZYCODEX = originalPublishLazycodex + } + mock.restore() + }) + + test("runs only OpenCode installation for platform=opencode", async () => { + // given + stubOpenCodeSuccess() + const codexSpy = spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue(codexResult) + const writeSpy = spyOn(configManager, "writeOmoConfig") + + // when + const result = await runCliInstaller(createOpenCodeArgs("opencode"), "3.4.0") + + // then + expect(result).toBe(0) + expect(writeSpy).toHaveBeenCalledTimes(1) + expect(codexSpy).not.toHaveBeenCalled() + }) + + test("runs only Codex installation and skips OpenCode version checks for platform=codex", async () => { + // given + const versionSpy = spyOn(configManager, "getOpenCodeVersion") + const writeSpy = spyOn(configManager, "writeOmoConfig") + const codexSpy = spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue(codexResult) + + // when + const result = await runCliInstaller({ tui: false, platform: "codex" }, "3.4.0") + + // then + expect(result).toBe(0) + expect(versionSpy).not.toHaveBeenCalled() + expect(writeSpy).not.toHaveBeenCalled() + expect(codexSpy).toHaveBeenCalledTimes(1) + }) + + test("passes Codex autonomous selection into Codex installer", async () => { + // given + const codexSpy = spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue(codexResult) + + // when + const result = await runCliInstaller({ tui: false, platform: "codex", codexAutonomous: true }, "3.4.0") + + // then + expect(result).toBe(0) + expect(codexSpy).toHaveBeenCalledWith({ autonomousPermissions: true }) + }) + + test("runs OpenCode and Codex installation for platform=both", async () => { + // given + stubOpenCodeSuccess() + const codexSpy = spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue(codexResult) + const writeSpy = spyOn(configManager, "writeOmoConfig") + + // when + const result = await runCliInstaller(createOpenCodeArgs("both"), "3.4.0") + + // then + expect(result).toBe(0) + expect(writeSpy).toHaveBeenCalledTimes(1) + expect(codexSpy).toHaveBeenCalledTimes(1) + }) + + test("fails when Codex-only installation cannot install Codex", async () => { + // given + spyOn(codexInstaller, "runCodexInstaller").mockRejectedValue(new Error("codex failed")) + + // when + const result = await runCliInstaller({ tui: false, platform: "codex" }, "3.4.0") + + // then + expect(result).toBe(1) + }) + + test("keeps OpenCode success when Codex fails for platform=both", async () => { + // given + stubOpenCodeSuccess() + spyOn(codexInstaller, "runCodexInstaller").mockRejectedValue(new Error("codex failed")) + + // when + const result = await runCliInstaller(createOpenCodeArgs("both"), "3.4.0") + + // then + expect(result).toBe(0) + }) + + test("prints star commands for OpenAgent and LazyCodex", async () => { + // given + stubOpenCodeSuccess() + spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue(codexResult) + + // when + const result = await runCliInstaller(createOpenCodeArgs("both"), "3.4.0") + + // then + const output = consoleLogMock.mock.calls.map((call) => call.join(" ")).join("\n") + expect(result).toBe(0) + expect(output).toContain("/user/starred/code-yeongyu/oh-my-openagent") + expect(output).toContain("/user/starred/code-yeongyu/lazycodex") + }) +}) diff --git a/src/cli/install-platform-resolution.test.ts b/src/cli/install-platform-resolution.test.ts new file mode 100644 index 000000000..1cecf9648 --- /dev/null +++ b/src/cli/install-platform-resolution.test.ts @@ -0,0 +1,120 @@ +/// + +import { describe, expect, test } from "bun:test" +import { resolveInstallArgs } from "./cli-program" + +describe("install platform resolution", () => { + test("leaves omo install without --platform unresolved for config defaults", () => { + // given + const invocationName = "omo" + + // when + const args = resolveInstallArgs({ tui: true }, invocationName) + + // then + expect(args.platform).toBeUndefined() + }) + + test("resolves explicit --platform=codex", () => { + // given + const invocationName = "omo" + + // when + const args = resolveInstallArgs({ tui: true, platform: "codex" }, invocationName) + + // then + expect(args.platform).toBe("codex") + }) + + test("preserves explicit Codex autonomous install flag", () => { + // given + const invocationName = "omo" + + // when + const args = resolveInstallArgs({ tui: true, platform: "codex", codexAutonomous: true }, invocationName) + + // then + expect(args.codexAutonomous).toBe(true) + }) + + test("resolves explicit --platform=both", () => { + // given + const invocationName = "omo" + + // when + const args = resolveInstallArgs({ tui: true, platform: "both" }, invocationName) + + // then + expect(args.platform).toBe("both") + }) + + test("resolves explicit --platform=opencode", () => { + // given + const invocationName = "omo" + + // when + const args = resolveInstallArgs({ tui: true, platform: "opencode" }, invocationName) + + // then + expect(args.platform).toBe("opencode") + }) + + test("leaves lazycodex install unresolved when lazycodex publishing is disabled", () => { + // given + const invocationName = "lazycodex" + + // when + const args = resolveInstallArgs({ tui: true }, invocationName) + + // then + expect(args.platform).toBeUndefined() + }) + + test("defaults lazycodex install to codex platform when lazycodex publishing is enabled", () => { + // given + const invocationName = "lazycodex" + + // when + const args = resolveInstallArgs({ tui: true }, invocationName, { OMO_PUBLISH_LAZYCODEX: "true" }) + + // then + expect(args.platform).toBe("codex") + }) + + test("lets lazycodex install explicitly override to both", () => { + // given + const invocationName = "lazycodex" + + // when + const args = resolveInstallArgs({ tui: true, platform: "both" }, invocationName) + + // then + expect(args.platform).toBe("both") + }) + + test("lets lazycodex install explicitly override to opencode", () => { + // given + const invocationName = "lazycodex" + + // when + const args = resolveInstallArgs({ tui: true, platform: "opencode" }, invocationName) + + // then + expect(args.platform).toBe("opencode") + }) + + test("defines Commander choices so invalid --platform values are rejected", async () => { + // given + const cliProgramSource = await Bun.file(new URL("./cli-program.ts", import.meta.url)).text() + + // when + const installBlock = cliProgramSource.match(/program\s*\n\s*\.command\("install"\)([\s\S]*?)\.action\(/) + + // then + expect(installBlock).not.toBeNull() + expect(installBlock?.[1]).toContain('new Option("--platform "') + expect(installBlock?.[1]).toContain('.choices(["opencode", "codex", "both"])') + expect(installBlock?.[1]).toContain("--codex-autonomous") + expect(installBlock?.[1]).toContain("--no-codex-autonomous") + }) +}) diff --git a/src/cli/lazycodex-feature-flag.ts b/src/cli/lazycodex-feature-flag.ts new file mode 100644 index 000000000..24545b859 --- /dev/null +++ b/src/cli/lazycodex-feature-flag.ts @@ -0,0 +1,15 @@ +import type { InstallPlatform } from "./types" + +export const LAZYCODEX_PUBLISH_FLAG = "OMO_PUBLISH_LAZYCODEX" +export const LAZYCODEX_DISABLED_MESSAGE = + "Codex platform install is disabled. Set OMO_PUBLISH_LAZYCODEX=true to enable LazyCodex publish/install." + +type Environment = Readonly> + +export function isLazycodexPublishingEnabled(env: Environment = process.env): boolean { + return env[LAZYCODEX_PUBLISH_FLAG] === "true" +} + +export function platformRequiresLazycodex(platform: InstallPlatform | undefined): boolean { + return platform === "codex" || platform === "both" +} diff --git a/src/cli/star-request.ts b/src/cli/star-request.ts new file mode 100644 index 000000000..7926a28f5 --- /dev/null +++ b/src/cli/star-request.ts @@ -0,0 +1,8 @@ +export const STAR_REPOSITORIES = [ + "code-yeongyu/oh-my-openagent", + "code-yeongyu/lazycodex", +] as const + +export function formatGitHubStarCommand(repository: string): string { + return `gh api --silent --method PUT /user/starred/${repository} >/dev/null 2>&1 || true` +} diff --git a/src/cli/tui-install-prompts.test.ts b/src/cli/tui-install-prompts.test.ts new file mode 100644 index 000000000..995da83fd --- /dev/null +++ b/src/cli/tui-install-prompts.test.ts @@ -0,0 +1,164 @@ +/// + +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import * as p from "@clack/prompts" +import * as prompts from "./tui-install-prompts" +import type { DetectedConfig, InstallConfig, InstallPlatform } from "./types" + +function createDetectedConfig(): DetectedConfig { + return { + isInstalled: false, + installedVersion: null, + hasClaude: false, + isMax20: false, + hasOpenAI: false, + hasGemini: false, + hasCopilot: false, + hasCodex: false, + hasOpencodeZen: false, + hasZaiCodingPlan: false, + hasKimiForCoding: false, + hasOpencodeGo: false, + hasVercelAiGateway: false, + } +} + +function withTty(): () => void { + const originalIsStdinTty = process.stdin.isTTY + const originalIsStdoutTty = process.stdout.isTTY + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }) + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }) + return () => { + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalIsStdinTty }) + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsStdoutTty }) + } +} + +describe("promptInstallPlatform", () => { + let restoreTty: () => void + + beforeEach(() => { + restoreTty = withTty() + }) + + afterEach(() => { + restoreTty() + mock.restore() + }) + + test("offers OpenCode, Codex, and Both choices when lazycodex publishing is enabled", async () => { + // given + const selectSpy = spyOn(p, "select").mockResolvedValue("opencode") + + // when + const value = await prompts.promptInstallPlatform("opencode", true) + + // then + expect(value).toBe("opencode") + expect(selectSpy).toHaveBeenCalledTimes(1) + expect(selectSpy.mock.calls[0]?.[0]).toMatchObject({ + initialValue: "opencode", + options: [ + { value: "opencode" }, + { value: "codex" }, + { value: "both" }, + ], + }) + }) + + test("hides Codex platform choices when lazycodex publishing is disabled", async () => { + // given + const selectSpy = spyOn(p, "select").mockResolvedValue("opencode") + + // when + const value = await prompts.promptInstallPlatform("codex", false) + + // then + expect(value).toBe("opencode") + expect(selectSpy).toHaveBeenCalledTimes(1) + expect(selectSpy.mock.calls[0]?.[0]).toMatchObject({ + initialValue: "opencode", + options: [{ value: "opencode" }], + }) + }) +}) + +describe("promptInstallConfig platform branching", () => { + let restoreTty: () => void + + beforeEach(() => { + restoreTty = withTty() + }) + + afterEach(() => { + restoreTty() + mock.restore() + }) + + test("skips OpenCode questions when the user selects codex", async () => { + // given + const selectSpy = spyOn(p, "select").mockResolvedValue(true) + + // when + const config = await prompts.promptInstallConfig(createDetectedConfig(), "codex") + + // then + expect(config).toMatchObject({ + platform: "codex", + hasOpenCode: false, + hasCodex: true, + codexAutonomous: true, + } satisfies Partial) + expect(selectSpy).toHaveBeenCalledTimes(1) + expect(selectSpy.mock.calls[0]?.[0]).toMatchObject({ + initialValue: true, + options: [{ value: true }, { value: false }], + }) + }) + + test.each([ + ["opencode", false], + ["both", true], + ] satisfies readonly [InstallPlatform, boolean][])( + "asks OpenCode questions when the user selects %s", + async (platform, hasCodex) => { + // given + const selectSpy = spyOn(p, "select").mockResolvedValue("no") + + // when + const config = await prompts.promptInstallConfig(createDetectedConfig(), platform) + + // then + expect(config).toMatchObject({ platform, hasOpenCode: true, hasCodex } satisfies Partial) + expect(selectSpy).toHaveBeenCalledTimes(hasCodex ? 10 : 9) + }, + ) + + test("uses explicit Codex autonomous override without asking", async () => { + // given + const selectSpy = spyOn(p, "select").mockResolvedValue("no") + + // when + const config = await prompts.promptInstallConfig(createDetectedConfig(), "codex", false) + + // then + expect(config).toMatchObject({ + platform: "codex", + hasCodex: true, + codexAutonomous: false, + } satisfies Partial) + expect(selectSpy).not.toHaveBeenCalled() + }) + + test("does not ask the old Codex adapter question", async () => { + // given + const selectSpy = spyOn(p, "select").mockResolvedValue("no") + + // when + await prompts.promptInstallConfig(createDetectedConfig(), "both") + + // then + const messages = selectSpy.mock.calls.map((call) => call[0].message) + expect(messages).not.toContain("Install Codex harness adapter into ~/.codex?") + }) +})