From 7895361f4299648f92c1b2c46543e560f26fbef2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 26 Mar 2026 09:30:34 +0900 Subject: [PATCH] fix(tests): resolve 5 cross-file test isolation failures - model-fallback hook: mock selectFallbackProvider and add _resetForTesting() to test-setup.ts to clear module-level state between files - fallback-retry-handler: add afterAll(mock.restore) and use mockReturnValueOnce to prevent connected-providers mock leaking to subsequent test files - opencode-config-dir: use win32.join for Windows APPDATA path construction so tests pass on macOS (path.join uses POSIX semantics regardless of process.platform override) - system-loaded-version: use resolveSymlink from file-utils instead of realpathSync to handle macOS /var -> /private/var symlink consistently All 4456 tests pass (0 failures) on full bun test suite. --- .../checks/system-loaded-version.test.ts | 5 ++-- .../doctor/checks/system-loaded-version.ts | 11 +++------ .../fallback-retry-handler.test.ts | 10 +++++--- src/hooks/model-fallback/hook.test.ts | 23 +++++++++++++++++++ src/hooks/model-fallback/hook.ts | 10 ++++++++ src/shared/opencode-config-dir.test.ts | 7 +++--- src/shared/opencode-config-dir.ts | 9 +++++--- test-setup.ts | 6 +++-- 8 files changed, 60 insertions(+), 21 deletions(-) diff --git a/src/cli/doctor/checks/system-loaded-version.test.ts b/src/cli/doctor/checks/system-loaded-version.test.ts index b35e5a638..de4c9391f 100644 --- a/src/cli/doctor/checks/system-loaded-version.test.ts +++ b/src/cli/doctor/checks/system-loaded-version.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, it } from "bun:test" -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs" +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { dirname, join } from "node:path" import { PACKAGE_NAME } from "../constants" +import { resolveSymlink } from "../../../shared/file-utils" const systemLoadedVersionModulePath = "./system-loaded-version?system-loaded-version-test" @@ -125,7 +126,7 @@ describe("system loaded version", () => { const loadedVersion = getLoadedPluginVersion() //#then - expect(loadedVersion.cacheDir).toBe(realpathSync(symlinkConfigDir)) + expect(loadedVersion.cacheDir).toBe(resolveSymlink(symlinkConfigDir)) expect(loadedVersion.expectedVersion).toBe("4.5.6") expect(loadedVersion.loadedVersion).toBe("4.5.6") }) diff --git a/src/cli/doctor/checks/system-loaded-version.ts b/src/cli/doctor/checks/system-loaded-version.ts index 7693b2d7a..04e4a87d1 100644 --- a/src/cli/doctor/checks/system-loaded-version.ts +++ b/src/cli/doctor/checks/system-loaded-version.ts @@ -1,7 +1,7 @@ -import { existsSync, readFileSync, realpathSync } from "node:fs" +import { existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" - +import { resolveSymlink } from "../../../shared/file-utils" import { getLatestVersion } from "../../../hooks/auto-update-checker/checker" import { extractChannel } from "../../../hooks/auto-update-checker" import { PACKAGE_NAME } from "../constants" @@ -38,12 +38,7 @@ function resolveOpenCodeCacheDir(): string { function resolveExistingDir(dirPath: string): string { if (!existsSync(dirPath)) return dirPath - - try { - return realpathSync(dirPath) - } catch { - return dirPath - } + return resolveSymlink(dirPath) } function readPackageJson(filePath: string): PackageJsonShape | null { diff --git a/src/features/background-agent/fallback-retry-handler.test.ts b/src/features/background-agent/fallback-retry-handler.test.ts index 03cd2b16f..825f72a56 100644 --- a/src/features/background-agent/fallback-retry-handler.test.ts +++ b/src/features/background-agent/fallback-retry-handler.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, mock, beforeEach } from "bun:test" +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test" mock.module("../../shared", () => ({ log: mock(() => {}), @@ -82,6 +82,10 @@ function createDefaultArgs(taskOverrides: Partial = {}) { } describe("tryFallbackRetry", () => { + afterAll(() => { + mock.restore() + }) + beforeEach(() => { ;(shouldRetryError as any).mockImplementation(() => true) ;(selectFallbackProvider as any).mockImplementation((providers: string[]) => providers[0]) @@ -274,8 +278,8 @@ describe("tryFallbackRetry", () => { describe("#given disconnected fallback providers with connected preferred provider", () => { test("keeps fallback entry and selects connected preferred provider", () => { - ;(readProviderModelsCache as any).mockReturnValue({ connected: ["provider-a"] }) - ;(selectFallbackProvider as any).mockImplementation( + ;(readProviderModelsCache as any).mockReturnValueOnce({ connected: ["provider-a"] }) + ;(selectFallbackProvider as any).mockImplementationOnce( (_providers: string[], preferredProviderID?: string) => preferredProviderID ?? "provider-b", ) diff --git a/src/hooks/model-fallback/hook.test.ts b/src/hooks/model-fallback/hook.test.ts index 92f630906..09757ab3f 100644 --- a/src/hooks/model-fallback/hook.test.ts +++ b/src/hooks/model-fallback/hook.test.ts @@ -3,6 +3,24 @@ const { beforeEach, describe, expect, mock, test } = require("bun:test") const readConnectedProvidersCacheMock = mock(() => null) const readProviderModelsCacheMock = mock(() => null) +const selectFallbackProviderMock = mock((providers: string[], preferredProviderID?: string) => { + const connectedProviders = readConnectedProvidersCacheMock() + if (connectedProviders) { + const connectedSet = new Set(connectedProviders.map((provider: string) => provider.toLowerCase())) + + for (const provider of providers) { + if (connectedSet.has(provider.toLowerCase())) { + return provider + } + } + + if (preferredProviderID && connectedSet.has(preferredProviderID.toLowerCase())) { + return preferredProviderID + } + } + + return providers[0] || preferredProviderID || "opencode" +}) const transformModelForProviderMock = mock((provider: string, model: string) => { if (provider === "github-copilot") { return model @@ -31,6 +49,10 @@ mock.module("../../shared/provider-model-id-transform", () => ({ transformModelForProvider: transformModelForProviderMock, })) +mock.module("../../shared/model-error-classifier", () => ({ + selectFallbackProvider: selectFallbackProviderMock, +})) + import { clearPendingModelFallback, createModelFallbackHook, @@ -44,6 +66,7 @@ describe("model fallback hook", () => { readProviderModelsCacheMock.mockReturnValue(null) readConnectedProvidersCacheMock.mockClear() readProviderModelsCacheMock.mockClear() + selectFallbackProviderMock.mockClear() clearPendingModelFallback("ses_model_fallback_main") clearPendingModelFallback("ses_model_fallback_ghcp") diff --git a/src/hooks/model-fallback/hook.ts b/src/hooks/model-fallback/hook.ts index 045bba2df..cbbcbc935 100644 --- a/src/hooks/model-fallback/hook.ts +++ b/src/hooks/model-fallback/hook.ts @@ -274,3 +274,13 @@ export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplie }, } } + +/** + * Resets all module-global state for testing. + * Clears pending fallbacks, toast keys, and session chains. + */ +export function _resetForTesting(): void { + pendingModelFallbacks.clear() + lastToastKey.clear() + sessionFallbackChains.clear() +} diff --git a/src/shared/opencode-config-dir.test.ts b/src/shared/opencode-config-dir.test.ts index 86d3afc55..5d6cf3ef5 100644 --- a/src/shared/opencode-config-dir.test.ts +++ b/src/shared/opencode-config-dir.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test" import { homedir } from "node:os" -import { join, resolve } from "node:path" +import { join, resolve, win32 } from "node:path" import { getOpenCodeConfigDir, getOpenCodeConfigPaths, @@ -241,9 +241,10 @@ describe("opencode-config-dir", () => { // when getOpenCodeConfigDir is called with binary="opencode-desktop" const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false }) - // then returns %APPDATA%/ai.opencode.desktop - expect(result).toBe(join("C:\\Users\\TestUser\\AppData\\Roaming", TAURI_APP_IDENTIFIER)) + // then returns %APPDATA%/ai.opencode.desktop using Windows path semantics + expect(result).toBe(win32.join("C:\\Users\\TestUser\\AppData\\Roaming", TAURI_APP_IDENTIFIER)) }) + }) describe("dev build detection", () => { diff --git a/src/shared/opencode-config-dir.ts b/src/shared/opencode-config-dir.ts index cf4fc28da..e1dedc401 100644 --- a/src/shared/opencode-config-dir.ts +++ b/src/shared/opencode-config-dir.ts @@ -1,6 +1,6 @@ import { existsSync, realpathSync } from "node:fs" import { homedir } from "node:os" -import { join, resolve } from "node:path" +import { join, resolve, win32 } from "node:path" import type { OpenCodeBinaryType, @@ -31,7 +31,7 @@ function getTauriConfigDir(identifier: string): string { case "win32": { const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming") - return join(appData, identifier) + return win32.join(appData, identifier) } case "linux": @@ -71,7 +71,10 @@ export function getOpenCodeConfigDir(options: OpenCodeConfigDirOptions): string } const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER - const tauriDir = resolveConfigPath(getTauriConfigDir(identifier)) + const tauriDirBase = getTauriConfigDir(identifier) + const tauriDir = process.platform === "win32" + ? (win32.isAbsolute(tauriDirBase) ? win32.normalize(tauriDirBase) : win32.resolve(tauriDirBase)) + : resolveConfigPath(tauriDirBase) if (checkExisting) { const legacyDir = getCliConfigDir() diff --git a/test-setup.ts b/test-setup.ts index 5ac63e4e6..5c6e5aa0d 100644 --- a/test-setup.ts +++ b/test-setup.ts @@ -1,6 +1,8 @@ import { beforeEach } from "bun:test" -import { _resetForTesting } from "./src/features/claude-code-session-state/state" +import { _resetForTesting as resetClaudeSessionState } from "./src/features/claude-code-session-state/state" +import { _resetForTesting as resetModelFallbackState } from "./src/hooks/model-fallback/hook" beforeEach(() => { - _resetForTesting() + resetClaudeSessionState() + resetModelFallbackState() })