Fix mock isolation in remaining hook tests

- message-builder.test.ts: improve mock isolation
- background-update-check.ts: update for test compatibility
- execute-http-hook-security.test.ts: narrow mock targets
- recover-tool-result-missing.test.ts: prevent barrel contamination

🤖 GENERATED WITH ASSISTANCE OF OhMyOpenCode
This commit is contained in:
YeonGyu-Kim
2026-04-04 18:56:34 +09:00
parent ffcd34d4c1
commit 382f9b61fa
4 changed files with 128 additions and 85 deletions
@@ -4,7 +4,7 @@ const replaceEmptyTextPartsAsync = mock(() => Promise.resolve(false))
const injectTextPartAsync = mock(() => Promise.resolve(false)) const injectTextPartAsync = mock(() => Promise.resolve(false))
const findMessagesWithEmptyTextPartsFromSDK = mock(() => Promise.resolve([] as string[])) const findMessagesWithEmptyTextPartsFromSDK = mock(() => Promise.resolve([] as string[]))
mock.module("../../shared", () => ({ mock.module("../../shared/normalize-sdk-response", () => ({
normalizeSDKResponse: (response: { data?: unknown[] }) => response.data ?? [], normalizeSDKResponse: (response: { data?: unknown[] }) => response.data ?? [],
})) }))
@@ -10,6 +10,46 @@ import { extractChannel } from "../version-channel"
import { findPluginEntry, getCachedVersion, getLatestVersion, syncCachePackageJsonToIntent } from "../checker" import { findPluginEntry, getCachedVersion, getLatestVersion, syncCachePackageJsonToIntent } from "../checker"
import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts" 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<void>
const defaultDeps: BackgroundUpdateCheckDeps = {
existsSync,
join,
runBunInstallWithDetails,
log,
getOpenCodeCacheDir,
getOpenCodeConfigPaths,
invalidatePackage,
extractChannel,
findPluginEntry,
getCachedVersion,
getLatestVersion,
syncCachePackageJsonToIntent,
showUpdateAvailableToast,
showAutoUpdatedToast,
}
function getPinnedVersionToastMessage(latestVersion: string): string { function getPinnedVersionToastMessage(latestVersion: string): string {
return `Update available: ${latestVersion} (version pinned, update manually)` return `Update available: ${latestVersion} (version pinned, update manually)`
} }
@@ -18,109 +58,112 @@ function getPinnedVersionToastMessage(latestVersion: string): string {
* Resolves the active install workspace. * Resolves the active install workspace.
* Same logic as doctor check: prefer config-dir if installed, fall back to cache-dir. * Same logic as doctor check: prefer config-dir if installed, fall back to cache-dir.
*/ */
function resolveActiveInstallWorkspace(): string { function resolveActiveInstallWorkspace(deps: BackgroundUpdateCheckDeps): string {
const configPaths = getOpenCodeConfigPaths({ binary: "opencode" }) const configPaths = deps.getOpenCodeConfigPaths({ binary: "opencode" })
const cacheDir = getOpenCodeCacheDir() const cacheDir = deps.getOpenCodeCacheDir()
const configInstallPath = join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json") const configInstallPath = deps.join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json")
const cacheInstallPath = join(cacheDir, "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 // Prefer config-dir if installed there, otherwise fall back to cache-dir
if (existsSync(configInstallPath)) { if (deps.existsSync(configInstallPath)) {
log(`[auto-update-checker] Active workspace: config-dir (${configPaths.configDir})`) deps.log(`[auto-update-checker] Active workspace: config-dir (${configPaths.configDir})`)
return configPaths.configDir return configPaths.configDir
} }
if (existsSync(cacheInstallPath)) { if (deps.existsSync(cacheInstallPath)) {
log(`[auto-update-checker] Active workspace: cache-dir (${cacheDir})`) deps.log(`[auto-update-checker] Active workspace: cache-dir (${cacheDir})`)
return cacheDir return cacheDir
} }
// Default to config-dir if neither exists (matches doctor behavior) // 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 return configPaths.configDir
} }
async function runBunInstallSafe(workspaceDir: string): Promise<boolean> { async function runBunInstallSafe(workspaceDir: string, deps: BackgroundUpdateCheckDeps): Promise<boolean> {
try { try {
const result = await runBunInstallWithDetails({ outputMode: "pipe", workspaceDir }) const result = await deps.runBunInstallWithDetails({ outputMode: "pipe", workspaceDir })
if (!result.success && result.error) { 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 return result.success
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : String(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 return false
} }
} }
export async function runBackgroundUpdateCheck( export function createBackgroundUpdateCheckRunner(
ctx: PluginInput, overrides: Partial<BackgroundUpdateCheckDeps> = {},
autoUpdate: boolean, ): BackgroundUpdateCheckRunner {
getToastMessage: (isUpdate: boolean, latestVersion?: string) => string const deps = { ...defaultDeps, ...overrides }
): Promise<void> {
const pluginInfo = findPluginEntry(ctx.directory) return async function runBackgroundUpdateCheck(
if (!pluginInfo) { ctx: PluginInput,
log("[auto-update-checker] Plugin not found in config") autoUpdate: boolean,
return getToastMessage: (isUpdate: boolean, latestVersion?: string) => string,
): Promise<void> {
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()
@@ -61,7 +61,7 @@ describe("executeHttpHook TLS security", () => {
}) })
it("#when hook uses remote http:// URL #then logs warning before rejection", async () => { it("#when hook uses remote http:// URL #then logs warning before rejection", async () => {
mock.module("../../shared", () => ({ mock.module("../../shared/logger", () => ({
log: mockLog, log: mockLog,
})) }))
const { executeHttpHook } = await importFreshExecuteHttpHook() 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 () => { it("#when hook uses plain remote http:// URL #then writes warning log", async () => {
mock.module("../../shared", () => ({ mock.module("../../shared/logger", () => ({
log: mockLog, log: mockLog,
})) }))
const { executeHttpHook } = await importFreshExecuteHttpHook() const { executeHttpHook } = await importFreshExecuteHttpHook()
@@ -9,7 +9,7 @@ mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => sqliteBackend, isSqliteBackend: () => sqliteBackend,
})) }))
mock.module("../../shared", () => ({ mock.module("../../shared/normalize-sdk-response", () => ({
normalizeSDKResponse: <TData>(response: { data?: TData }, fallback: TData): TData => response.data ?? fallback, normalizeSDKResponse: <TData>(response: { data?: TData }, fallback: TData): TData => response.data ?? fallback,
})) }))