test: isolate recovery hook mocks from full suite

This commit is contained in:
YeonGyu-Kim
2026-04-03 17:50:24 +09:00
parent ed06428ba3
commit 9fa1ea38e8
2 changed files with 138 additions and 88 deletions
@@ -0,0 +1,113 @@
import { mock } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import type { OhMyOpenCodeConfig } from "../../config"
import { createAnthropicContextWindowLimitRecoveryHook } from "./recovery-hook"
type ExecuteCompactFn = typeof import("./executor").executeCompact
type GetLastAssistantFn = typeof import("./executor").getLastAssistant
type ParseAnthropicTokenLimitErrorFn = typeof import("./parser").parseAnthropicTokenLimitError
export type MockLastAssistant = {
info: {
summary?: boolean
providerID: string
modelID: string
}
hasContent: boolean
}
export const executeCompactMock = mock<ExecuteCompactFn>(async () => {})
export const getLastAssistantMock = mock<GetLastAssistantFn>(async (): Promise<MockLastAssistant> => ({
info: {
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
},
hasContent: true,
}))
export const parseAnthropicTokenLimitErrorMock = mock<ParseAnthropicTokenLimitErrorFn>(() => ({
currentTokens: 250000,
maxTokens: 200000,
errorType: "token_limit_exceeded",
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
}))
const pluginConfig = {
git_master: {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "",
},
} satisfies OhMyOpenCodeConfig
export function createRecoveryHook() {
return createAnthropicContextWindowLimitRecoveryHook(
createMockContext(),
{
pluginConfig,
dependencies: {
executeCompact: executeCompactMock,
getLastAssistant: getLastAssistantMock,
log: () => {},
parseAnthropicTokenLimitError: parseAnthropicTokenLimitErrorMock,
},
} as never,
)
}
export function createMockContext(): PluginInput {
return {
client: {
session: {
messages: mock(() => Promise.resolve({ data: [] })),
},
tui: {
showToast: mock(() => Promise.resolve()),
},
},
project: {} as never,
directory: "/tmp",
worktree: "/tmp",
serverUrl: new URL("http://localhost"),
$: {} as never,
} as never
}
export function setupDelayedTimeoutMocks(): {
createUntrackedTimeout: () => ReturnType<typeof setTimeout>
restore: () => void
getClearTimeoutCalls: () => Array<ReturnType<typeof setTimeout>>
getScheduledTimeouts: () => Array<ReturnType<typeof setTimeout>>
} {
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
const clearTimeoutCalls: Array<ReturnType<typeof setTimeout>> = []
const scheduledTimeouts: Array<ReturnType<typeof setTimeout>> = []
function createTimeoutHandle(): ReturnType<typeof setTimeout> {
const timeoutID = originalSetTimeout(() => {}, 60_000)
originalClearTimeout(timeoutID)
return timeoutID
}
globalThis.setTimeout = ((_: () => void, _delay?: number) => {
const timeoutID = createTimeoutHandle()
scheduledTimeouts.push(timeoutID)
return timeoutID
}) as typeof setTimeout
globalThis.clearTimeout = ((timeoutID: ReturnType<typeof setTimeout>) => {
clearTimeoutCalls.push(timeoutID)
originalClearTimeout(timeoutID)
}) as typeof clearTimeout
return {
createUntrackedTimeout: createTimeoutHandle,
restore: () => {
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
},
getClearTimeoutCalls: () => clearTimeoutCalls,
getScheduledTimeouts: () => scheduledTimeouts,
}
}
@@ -1,81 +1,11 @@
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import * as originalExecutor from "./executor"
import * as originalParser from "./parser"
import * as originalLogger from "../../shared/logger"
const executeCompactMock = mock(async () => {})
const getLastAssistantMock = mock(async () => ({
info: {
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
},
hasContent: true,
}))
const parseAnthropicTokenLimitErrorMock = mock(() => ({
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
}))
mock.module("./executor", () => ({
executeCompact: executeCompactMock,
getLastAssistant: getLastAssistantMock,
}))
mock.module("./parser", () => ({
parseAnthropicTokenLimitError: parseAnthropicTokenLimitErrorMock,
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
afterAll(() => {
mock.module("./executor", () => originalExecutor)
mock.module("./parser", () => originalParser)
mock.module("../../shared/logger", () => originalLogger)
})
function createMockContext(): PluginInput {
return {
client: {
session: {
messages: mock(() => Promise.resolve({ data: [] })),
},
tui: {
showToast: mock(() => Promise.resolve()),
},
},
directory: "/tmp",
} as PluginInput
}
function setupDelayedTimeoutMocks(): {
restore: () => void
getClearTimeoutCalls: () => Array<ReturnType<typeof setTimeout>>
} {
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
const clearTimeoutCalls: Array<ReturnType<typeof setTimeout>> = []
let timeoutCounter = 0
globalThis.setTimeout = ((_: () => void, _delay?: number) => {
timeoutCounter += 1
return timeoutCounter as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.clearTimeout = ((timeoutID: ReturnType<typeof setTimeout>) => {
clearTimeoutCalls.push(timeoutID)
}) as typeof clearTimeout
return {
restore: () => {
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
},
getClearTimeoutCalls: () => clearTimeoutCalls,
}
}
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import {
createRecoveryHook,
executeCompactMock,
getLastAssistantMock,
parseAnthropicTokenLimitErrorMock,
setupDelayedTimeoutMocks,
} from "./recovery-hook.test-support"
describe("createAnthropicContextWindowLimitRecoveryHook", () => {
beforeEach(() => {
@@ -90,9 +20,12 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
test("cancels pending timer when session.idle handles compaction first", async () => {
//#given
const { restore, getClearTimeoutCalls } = setupDelayedTimeoutMocks()
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
const hook = createAnthropicContextWindowLimitRecoveryHook(createMockContext())
const { restore, getClearTimeoutCalls, getScheduledTimeouts } = setupDelayedTimeoutMocks()
let compactedSessionID: unknown
executeCompactMock.mockImplementationOnce(async (...args: unknown[]) => {
compactedSessionID = args[0]
})
const hook = createRecoveryHook()
try {
//#when
@@ -111,9 +44,9 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
})
//#then
expect(getClearTimeoutCalls()).toEqual([1 as ReturnType<typeof setTimeout>])
expect(getClearTimeoutCalls()).toEqual([getScheduledTimeouts()[0]])
expect(executeCompactMock).toHaveBeenCalledTimes(1)
expect(executeCompactMock.mock.calls[0]?.[0]).toBe("session-race")
expect(compactedSessionID).toBe("session-race")
} finally {
restore()
}
@@ -121,7 +54,11 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
test("does not treat empty summary assistant messages as successful compaction", async () => {
//#given
const { restore, getClearTimeoutCalls } = setupDelayedTimeoutMocks()
const { restore, getClearTimeoutCalls, getScheduledTimeouts } = setupDelayedTimeoutMocks()
let compactedSessionID: unknown
executeCompactMock.mockImplementationOnce(async (...args: unknown[]) => {
compactedSessionID = args[0]
})
getLastAssistantMock.mockResolvedValueOnce({
info: {
summary: true,
@@ -130,8 +67,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
},
hasContent: false,
})
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
const hook = createAnthropicContextWindowLimitRecoveryHook(createMockContext())
const hook = createRecoveryHook()
try {
//#when
@@ -150,11 +86,12 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
})
//#then
expect(getClearTimeoutCalls()).toEqual([1 as ReturnType<typeof setTimeout>])
expect(getClearTimeoutCalls()).toEqual([getScheduledTimeouts()[0]])
expect(executeCompactMock).toHaveBeenCalledTimes(1)
expect(executeCompactMock.mock.calls[0]?.[0]).toBe("session-empty-summary")
expect(compactedSessionID).toBe("session-empty-summary")
} finally {
restore()
}
})
})