diff --git a/src/hooks/anthropic-context-window-limit-recovery/message-builder.test.ts b/src/hooks/anthropic-context-window-limit-recovery/message-builder.test.ts index 9d271c3bb..03112af1d 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/message-builder.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/message-builder.test.ts @@ -4,7 +4,7 @@ const replaceEmptyTextPartsAsync = mock(() => Promise.resolve(false)) const injectTextPartAsync = mock(() => Promise.resolve(false)) const findMessagesWithEmptyTextPartsFromSDK = mock(() => Promise.resolve([] as string[])) -mock.module("../../shared", () => ({ +mock.module("../../shared/normalize-sdk-response", () => ({ normalizeSDKResponse: (response: { data?: unknown[] }) => response.data ?? [], })) diff --git a/src/hooks/auto-update-checker/hook/background-update-check.ts b/src/hooks/auto-update-checker/hook/background-update-check.ts index d2cc97dba..ef999069d 100644 --- a/src/hooks/auto-update-checker/hook/background-update-check.ts +++ b/src/hooks/auto-update-checker/hook/background-update-check.ts @@ -10,6 +10,46 @@ import { extractChannel } from "../version-channel" import { findPluginEntry, getCachedVersion, getLatestVersion, syncCachePackageJsonToIntent } from "../checker" import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts" +type BackgroundUpdateCheckDeps = { + existsSync: typeof existsSync + join: typeof join + runBunInstallWithDetails: typeof runBunInstallWithDetails + log: typeof log + getOpenCodeCacheDir: typeof getOpenCodeCacheDir + getOpenCodeConfigPaths: typeof getOpenCodeConfigPaths + invalidatePackage: typeof invalidatePackage + extractChannel: typeof extractChannel + findPluginEntry: typeof findPluginEntry + getCachedVersion: typeof getCachedVersion + getLatestVersion: typeof getLatestVersion + syncCachePackageJsonToIntent: typeof syncCachePackageJsonToIntent + showUpdateAvailableToast: typeof showUpdateAvailableToast + showAutoUpdatedToast: typeof showAutoUpdatedToast +} + +type BackgroundUpdateCheckRunner = ( + ctx: PluginInput, + autoUpdate: boolean, + getToastMessage: (isUpdate: boolean, latestVersion?: string) => string, +) => Promise + +const defaultDeps: BackgroundUpdateCheckDeps = { + existsSync, + join, + runBunInstallWithDetails, + log, + getOpenCodeCacheDir, + getOpenCodeConfigPaths, + invalidatePackage, + extractChannel, + findPluginEntry, + getCachedVersion, + getLatestVersion, + syncCachePackageJsonToIntent, + showUpdateAvailableToast, + showAutoUpdatedToast, +} + function getPinnedVersionToastMessage(latestVersion: string): string { return `Update available: ${latestVersion} (version pinned, update manually)` } @@ -18,109 +58,112 @@ function getPinnedVersionToastMessage(latestVersion: string): string { * Resolves the active install workspace. * Same logic as doctor check: prefer config-dir if installed, fall back to cache-dir. */ -function resolveActiveInstallWorkspace(): string { - const configPaths = getOpenCodeConfigPaths({ binary: "opencode" }) - const cacheDir = getOpenCodeCacheDir() +function resolveActiveInstallWorkspace(deps: BackgroundUpdateCheckDeps): string { + const configPaths = deps.getOpenCodeConfigPaths({ binary: "opencode" }) + const cacheDir = deps.getOpenCodeCacheDir() - const configInstallPath = join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json") - const cacheInstallPath = join(cacheDir, "node_modules", PACKAGE_NAME, "package.json") + const configInstallPath = deps.join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json") + const cacheInstallPath = deps.join(cacheDir, "node_modules", PACKAGE_NAME, "package.json") // Prefer config-dir if installed there, otherwise fall back to cache-dir - if (existsSync(configInstallPath)) { - log(`[auto-update-checker] Active workspace: config-dir (${configPaths.configDir})`) + if (deps.existsSync(configInstallPath)) { + deps.log(`[auto-update-checker] Active workspace: config-dir (${configPaths.configDir})`) return configPaths.configDir } - if (existsSync(cacheInstallPath)) { - log(`[auto-update-checker] Active workspace: cache-dir (${cacheDir})`) + if (deps.existsSync(cacheInstallPath)) { + deps.log(`[auto-update-checker] Active workspace: cache-dir (${cacheDir})`) return cacheDir } // Default to config-dir if neither exists (matches doctor behavior) - log(`[auto-update-checker] Active workspace: config-dir (default, no install detected)`) + deps.log(`[auto-update-checker] Active workspace: config-dir (default, no install detected)`) return configPaths.configDir } -async function runBunInstallSafe(workspaceDir: string): Promise { +async function runBunInstallSafe(workspaceDir: string, deps: BackgroundUpdateCheckDeps): Promise { try { - const result = await runBunInstallWithDetails({ outputMode: "pipe", workspaceDir }) + const result = await deps.runBunInstallWithDetails({ outputMode: "pipe", workspaceDir }) if (!result.success && result.error) { - log("[auto-update-checker] bun install error:", result.error) + deps.log("[auto-update-checker] bun install error:", result.error) } return result.success } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err) - log("[auto-update-checker] bun install error:", errorMessage) + deps.log("[auto-update-checker] bun install error:", errorMessage) return false } } -export async function runBackgroundUpdateCheck( - ctx: PluginInput, - autoUpdate: boolean, - getToastMessage: (isUpdate: boolean, latestVersion?: string) => string -): Promise { - const pluginInfo = findPluginEntry(ctx.directory) - if (!pluginInfo) { - log("[auto-update-checker] Plugin not found in config") - return +export function createBackgroundUpdateCheckRunner( + overrides: Partial = {}, +): BackgroundUpdateCheckRunner { + const deps = { ...defaultDeps, ...overrides } + + return async function runBackgroundUpdateCheck( + ctx: PluginInput, + autoUpdate: boolean, + getToastMessage: (isUpdate: boolean, latestVersion?: string) => string, + ): Promise { + const pluginInfo = deps.findPluginEntry(ctx.directory) + if (!pluginInfo) { + deps.log("[auto-update-checker] Plugin not found in config") + return + } + + const cachedVersion = deps.getCachedVersion() + const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion + if (!currentVersion) { + deps.log("[auto-update-checker] No version found (cached or pinned)") + return + } + + const channel = deps.extractChannel(pluginInfo.pinnedVersion ?? currentVersion) + const latestVersion = await deps.getLatestVersion(channel) + if (!latestVersion) { + deps.log("[auto-update-checker] Failed to fetch latest version for channel:", channel) + return + } + + if (currentVersion === latestVersion) { + deps.log("[auto-update-checker] Already on latest version for channel:", channel) + return + } + + deps.log(`[auto-update-checker] Update available (${channel}): ${currentVersion} → ${latestVersion}`) + + if (!autoUpdate) { + await deps.showUpdateAvailableToast(ctx, latestVersion, getToastMessage) + deps.log("[auto-update-checker] Auto-update disabled, notification only") + return + } + + if (pluginInfo.isPinned) { + await deps.showUpdateAvailableToast(ctx, latestVersion, () => getPinnedVersionToastMessage(latestVersion)) + deps.log(`[auto-update-checker] User-pinned version detected (${pluginInfo.entry}), skipping auto-update. Notification only.`) + return + } + + const syncResult = deps.syncCachePackageJsonToIntent(pluginInfo) + if (syncResult.error) { + deps.log(`[auto-update-checker] Sync failed with error: ${syncResult.error}`, syncResult.message) + await deps.showUpdateAvailableToast(ctx, latestVersion, getToastMessage) + return + } + + deps.invalidatePackage(PACKAGE_NAME) + const activeWorkspace = resolveActiveInstallWorkspace(deps) + const installSuccess = await runBunInstallSafe(activeWorkspace, deps) + + if (installSuccess) { + await deps.showAutoUpdatedToast(ctx, currentVersion, latestVersion) + deps.log(`[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`) + return + } + + await deps.showUpdateAvailableToast(ctx, latestVersion, getToastMessage) + deps.log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)") } - - const cachedVersion = getCachedVersion() - const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion - if (!currentVersion) { - log("[auto-update-checker] No version found (cached or pinned)") - return - } - - const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion) - const latestVersion = await getLatestVersion(channel) - if (!latestVersion) { - log("[auto-update-checker] Failed to fetch latest version for channel:", channel) - return - } - - if (currentVersion === latestVersion) { - log("[auto-update-checker] Already on latest version for channel:", channel) - return - } - - log(`[auto-update-checker] Update available (${channel}): ${currentVersion} → ${latestVersion}`) - - if (!autoUpdate) { - await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) - log("[auto-update-checker] Auto-update disabled, notification only") - return - } - - if (pluginInfo.isPinned) { - await showUpdateAvailableToast(ctx, latestVersion, () => getPinnedVersionToastMessage(latestVersion)) - log(`[auto-update-checker] User-pinned version detected (${pluginInfo.entry}), skipping auto-update. Notification only.`) - return - } - - // Sync cache package.json to match opencode.json intent before updating - // This handles the case where user switched from pinned version to tag (e.g., 3.10.0 -> @latest) - const syncResult = syncCachePackageJsonToIntent(pluginInfo) - - // Abort on ANY sync error to prevent corrupting a bad state further - if (syncResult.error) { - log(`[auto-update-checker] Sync failed with error: ${syncResult.error}`, syncResult.message) - await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) - return - } - - invalidatePackage(PACKAGE_NAME) - - const activeWorkspace = resolveActiveInstallWorkspace() - const installSuccess = await runBunInstallSafe(activeWorkspace) - - if (installSuccess) { - await showAutoUpdatedToast(ctx, currentVersion, latestVersion) - log(`[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`) - return - } - - await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) - log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)") } + +export const runBackgroundUpdateCheck = createBackgroundUpdateCheckRunner() diff --git a/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts b/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts index a13e888a4..f42a86f81 100644 --- a/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts +++ b/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts @@ -61,7 +61,7 @@ describe("executeHttpHook TLS security", () => { }) it("#when hook uses remote http:// URL #then logs warning before rejection", async () => { - mock.module("../../shared", () => ({ + mock.module("../../shared/logger", () => ({ log: mockLog, })) const { executeHttpHook } = await importFreshExecuteHttpHook() @@ -158,7 +158,7 @@ describe("executeHttpHook TLS security", () => { }) it("#when hook uses plain remote http:// URL #then writes warning log", async () => { - mock.module("../../shared", () => ({ + mock.module("../../shared/logger", () => ({ log: mockLog, })) const { executeHttpHook } = await importFreshExecuteHttpHook() diff --git a/src/hooks/session-recovery/recover-tool-result-missing.test.ts b/src/hooks/session-recovery/recover-tool-result-missing.test.ts index 9a8aaed80..aabc6e797 100644 --- a/src/hooks/session-recovery/recover-tool-result-missing.test.ts +++ b/src/hooks/session-recovery/recover-tool-result-missing.test.ts @@ -9,7 +9,7 @@ mock.module("../../shared/opencode-storage-detection", () => ({ isSqliteBackend: () => sqliteBackend, })) -mock.module("../../shared", () => ({ +mock.module("../../shared/normalize-sdk-response", () => ({ normalizeSDKResponse: (response: { data?: TData }, fallback: TData): TData => response.data ?? fallback, }))