From de2b073fcee26167866a0910b81aa9a816c2dea8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 11 Mar 2026 17:51:31 +0900 Subject: [PATCH] test(auto-update-checker): type background update bun install mock Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/cli/config-manager/bun-install.test.ts | 186 ++++++++++++------ src/cli/config-manager/bun-install.ts | 11 +- .../hook/background-update-check.test.ts | 17 +- 3 files changed, 153 insertions(+), 61 deletions(-) diff --git a/src/cli/config-manager/bun-install.test.ts b/src/cli/config-manager/bun-install.test.ts index ab803377b..02fd2174f 100644 --- a/src/cli/config-manager/bun-install.test.ts +++ b/src/cli/config-manager/bun-install.test.ts @@ -2,23 +2,33 @@ import * as fs from "node:fs" -import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" +import { afterEach, beforeEach, describe, expect, it, jest, spyOn } from "bun:test" import * as dataPath from "../../shared/data-path" import * as logger from "../../shared/logger" import * as spawnHelpers from "../../shared/spawn-with-windows-hide" +import type { BunInstallResult } from "./bun-install" import { runBunInstallWithDetails } from "./bun-install" -function createProc( - exitCode: number, - output?: { stdout?: string; stderr?: string } -): ReturnType { +type CreateProcOptions = { + exitCode?: number | null + exited?: Promise + kill?: () => void + output?: { + stdout?: string + stderr?: string + } +} + +function createProc(options: CreateProcOptions = {}): ReturnType { + const exitCode = options.exitCode ?? 0 + return { - exited: Promise.resolve(exitCode), + exited: options.exited ?? Promise.resolve(exitCode), exitCode, - stdout: output?.stdout !== undefined ? new Blob([output.stdout]).stream() : undefined, - stderr: output?.stderr !== undefined ? new Blob([output.stderr]).stream() : undefined, - kill: () => {}, + stdout: options.output?.stdout !== undefined ? new Blob([options.output.stdout]).stream() : undefined, + stderr: options.output?.stderr !== undefined ? new Blob([options.output.stderr]).stream() : undefined, + kill: options.kill ?? (() => {}), } satisfies ReturnType } @@ -31,7 +41,7 @@ describe("runBunInstallWithDetails", () => { beforeEach(() => { getOpenCodeCacheDirSpy = spyOn(dataPath, "getOpenCodeCacheDir").mockReturnValue("/tmp/opencode-cache") logSpy = spyOn(logger, "log").mockImplementation(() => {}) - spawnWithWindowsHideSpy = spyOn(spawnHelpers, "spawnWithWindowsHide").mockReturnValue(createProc(0)) + spawnWithWindowsHideSpy = spyOn(spawnHelpers, "spawnWithWindowsHide").mockReturnValue(createProc()) existsSyncSpy = spyOn(fs, "existsSync").mockReturnValue(true) }) @@ -42,57 +52,119 @@ describe("runBunInstallWithDetails", () => { existsSyncSpy.mockRestore() }) - it("runs bun install in the OpenCode cache directory with inherited output by default", async () => { - // given + describe("#given the cache workspace exists", () => { + describe("#when bun install uses inherited output", () => { + it("#then runs bun install in the cache directory", async () => { + // given - // when - const result = await runBunInstallWithDetails() + // when + const result = await runBunInstallWithDetails() - // then - expect(result).toEqual({ success: true }) - expect(getOpenCodeCacheDirSpy).toHaveBeenCalledTimes(1) - expect(spawnWithWindowsHideSpy).toHaveBeenCalledWith(["bun", "install"], { - cwd: "/tmp/opencode-cache", - stdout: "inherit", - stderr: "inherit", - }) - }) - - it("pipes install output when requested", async () => { - // given - - // when - const result = await runBunInstallWithDetails({ outputMode: "pipe" }) - - // then - expect(result).toEqual({ success: true }) - expect(spawnWithWindowsHideSpy).toHaveBeenCalledWith(["bun", "install"], { - cwd: "/tmp/opencode-cache", - stdout: "pipe", - stderr: "pipe", - }) - }) - - it("logs captured output when piped install fails", async () => { - // given - spawnWithWindowsHideSpy.mockReturnValue( - createProc(1, { - stdout: "resolved 10 packages", - stderr: "network error", + // then + expect(result).toEqual({ success: true }) + expect(getOpenCodeCacheDirSpy).toHaveBeenCalledTimes(1) + expect(spawnWithWindowsHideSpy).toHaveBeenCalledWith(["bun", "install"], { + cwd: "/tmp/opencode-cache", + stdout: "inherit", + stderr: "inherit", + }) }) - ) - - // when - const result = await runBunInstallWithDetails({ outputMode: "pipe" }) - - // then - expect(result).toEqual({ - success: false, - error: "bun install failed with exit code 1", }) - expect(logSpy).toHaveBeenCalledWith("[bun-install] Captured output from failed bun install", { - stdout: "resolved 10 packages", - stderr: "network error", + + describe("#when bun install uses piped output", () => { + it("#then passes pipe mode to the spawned process", async () => { + // given + + // when + const result = await runBunInstallWithDetails({ outputMode: "pipe" }) + + // then + expect(result).toEqual({ success: true }) + expect(spawnWithWindowsHideSpy).toHaveBeenCalledWith(["bun", "install"], { + cwd: "/tmp/opencode-cache", + stdout: "pipe", + stderr: "pipe", + }) + }) + }) + + describe("#when piped bun install fails", () => { + it("#then logs captured stdout and stderr", async () => { + // given + spawnWithWindowsHideSpy.mockReturnValue( + createProc({ + exitCode: 1, + output: { + stdout: "resolved 10 packages", + stderr: "network error", + }, + }) + ) + + // when + const result = await runBunInstallWithDetails({ outputMode: "pipe" }) + + // then + expect(result).toEqual({ + success: false, + error: "bun install failed with exit code 1", + }) + expect(logSpy).toHaveBeenCalledWith("[bun-install] Captured output from failed bun install", { + stdout: "resolved 10 packages", + stderr: "network error", + }) + }) + }) + + describe("#when the install times out and proc.exited never resolves", () => { + it("#then returns timedOut true without hanging", async () => { + // given + jest.useFakeTimers() + + let killCallCount = 0 + spawnWithWindowsHideSpy.mockReturnValue( + createProc({ + exitCode: null, + exited: new Promise(() => {}), + kill: () => { + killCallCount += 1 + }, + }) + ) + + try { + // when + const resultPromise = runBunInstallWithDetails({ outputMode: "pipe" }) + jest.advanceTimersByTime(60_000) + jest.runOnlyPendingTimers() + await Promise.resolve() + + const outcome = await Promise.race([ + resultPromise.then((result) => ({ + status: "resolved" as const, + result, + })), + new Promise<{ status: "pending" }>((resolve) => { + queueMicrotask(() => resolve({ status: "pending" })) + }), + ]) + + // then + if (outcome.status === "pending") { + throw new Error("runBunInstallWithDetails did not resolve after timing out") + } + + expect(outcome.result).toEqual({ + success: false, + timedOut: true, + error: 'bun install timed out after 60 seconds. Try running manually: cd "/tmp/opencode-cache" && bun i', + } satisfies BunInstallResult) + expect(killCallCount).toBe(1) + } finally { + jest.clearAllTimers() + jest.useRealTimers() + } + }) }) }) }) diff --git a/src/cli/config-manager/bun-install.ts b/src/cli/config-manager/bun-install.ts index f6ba9e99d..ebf4846dd 100644 --- a/src/cli/config-manager/bun-install.ts +++ b/src/cli/config-manager/bun-install.ts @@ -103,8 +103,15 @@ export async function runBunInstallWithDetails(options?: RunBunInstallOptions): log("[cli/install] Failed to kill timed out bun install process:", err) } - await proc.exited - logCapturedOutputOnFailure(outputMode, await outputPromise) + if (outputMode === "pipe") { + void outputPromise + .then((output) => { + logCapturedOutputOnFailure(outputMode, output) + }) + .catch((err) => { + log("[bun-install] Failed to read captured output after timeout:", err) + }) + } return { success: false, diff --git a/src/hooks/auto-update-checker/hook/background-update-check.test.ts b/src/hooks/auto-update-checker/hook/background-update-check.test.ts index a05451a00..b229d3547 100644 --- a/src/hooks/auto-update-checker/hook/background-update-check.test.ts +++ b/src/hooks/auto-update-checker/hook/background-update-check.test.ts @@ -1,6 +1,12 @@ -import type { PluginInput } from "@opencode-ai/plugin" +/// + +import type { BunInstallResult } from "../../../cli/config-manager" import { beforeEach, describe, expect, it, mock } from "bun:test" +type PluginInput = { + directory: string +} + type PluginEntry = { entry: string isPinned: boolean @@ -31,7 +37,7 @@ const mockSyncCachePackageJsonToIntent = mock((_pluginEntry: PluginEntry) => { const mockInvalidatePackage = mock((_packageName: string) => { operationOrder.push("invalidate") }) -const mockRunBunInstallWithDetails = mock(async () => ({ success: true })) +const mockRunBunInstallWithDetails = mock(async (): Promise => ({ success: true })) const mockShowUpdateAvailableToast = mock( async (_ctx: PluginInput, _latestVersion: string, _getToastMessage: ToastMessageGetter): Promise => {} ) @@ -90,6 +96,13 @@ describe("runBackgroundUpdateCheck", () => { operationOrder.length = 0 + mockSyncCachePackageJsonToIntent.mockImplementation((_pluginEntry: PluginEntry) => { + operationOrder.push("sync") + }) + mockInvalidatePackage.mockImplementation((_packageName: string) => { + operationOrder.push("invalidate") + }) + pluginEntry = createPluginEntry() mockFindPluginEntry.mockReturnValue(pluginEntry) mockGetCachedVersion.mockReturnValue("3.4.0")