test(hooks): update multiple hook test suites

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-04-10 15:53:32 +09:00
parent 4180a0ba0a
commit 8a4eacfd24
14 changed files with 316 additions and 259 deletions
@@ -4,35 +4,31 @@ const replaceEmptyTextPartsAsync = mock(() => Promise.resolve(false))
const injectTextPartAsync = mock(() => Promise.resolve(false))
const findMessagesWithEmptyTextPartsFromSDK = mock(() => Promise.resolve([] as string[]))
async function importFreshMessageBuilder(): Promise<typeof import("./message-builder")> {
mock.module("../../shared/logger", () => ({
log: () => {},
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => true,
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => true,
}))
const emptyTextMockFactory = () => ({
findMessagesWithEmptyTextParts: () => [],
replaceEmptyTextParts: () => false,
replaceEmptyTextPartsAsync,
findMessagesWithEmptyTextPartsFromSDK,
})
mock.module("../session-recovery/storage/empty-text", emptyTextMockFactory)
mock.module("../session-recovery/storage/empty-text.ts", emptyTextMockFactory)
const emptyTextMockFactory = () => ({
findMessagesWithEmptyTextParts: () => [],
replaceEmptyTextParts: () => false,
replaceEmptyTextPartsAsync,
findMessagesWithEmptyTextPartsFromSDK,
})
mock.module("../session-recovery/storage/empty-text", emptyTextMockFactory)
mock.module("../session-recovery/storage/empty-text.ts", emptyTextMockFactory)
const textPartInjectorMockFactory = () => ({
injectTextPart: () => false,
injectTextPartAsync,
})
mock.module("../session-recovery/storage/text-part-injector", textPartInjectorMockFactory)
mock.module("../session-recovery/storage/text-part-injector.ts", textPartInjectorMockFactory)
const textPartInjectorMockFactory = () => ({
injectTextPart: () => false,
injectTextPartAsync,
})
mock.module("../session-recovery/storage/text-part-injector", textPartInjectorMockFactory)
mock.module("../session-recovery/storage/text-part-injector.ts", textPartInjectorMockFactory)
const module = await import(`./message-builder?test=${Date.now()}-${Math.random()}`)
mock.restore()
return module
}
const messageBuilderModulePromise = import("./message-builder")
afterAll(() => {
mock.restore()
@@ -49,7 +45,7 @@ describe("sanitizeEmptyMessagesBeforeSummarize", () => {
})
test("#given sqlite message with tool content and empty text part #when sanitizing #then it fixes the mixed-content message", async () => {
const { sanitizeEmptyMessagesBeforeSummarize, PLACEHOLDER_TEXT } = await importFreshMessageBuilder()
const { sanitizeEmptyMessagesBeforeSummarize, PLACEHOLDER_TEXT } = await messageBuilderModulePromise
const client = {
session: {
messages: mock(() => Promise.resolve({
@@ -76,7 +72,7 @@ describe("sanitizeEmptyMessagesBeforeSummarize", () => {
})
test("#given sqlite message with mixed content and failed replacement #when sanitizing #then it injects the placeholder text part", async () => {
const { sanitizeEmptyMessagesBeforeSummarize, PLACEHOLDER_TEXT } = await importFreshMessageBuilder()
const { sanitizeEmptyMessagesBeforeSummarize, PLACEHOLDER_TEXT } = await messageBuilderModulePromise
const client = {
session: {
messages: mock(() => Promise.resolve({
@@ -1,45 +1,52 @@
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
import { truncateUntilTargetTokens } from "./storage"
import * as storage from "./storage"
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
import type { ToolResultInfo } from "./tool-part-types"
// Mock the entire module
mock.module("./storage", () => {
return {
...storage,
findToolResultsBySize: mock(() => []),
truncateToolResult: mock(() => ({ success: false })),
}
})
type TruncateToolResult = {
success: boolean
toolName?: string
originalSize?: number
}
const findToolResultsBySize = mock<(_: string) => ToolResultInfo[]>(() => [])
const truncateToolResult = mock<(_: string) => TruncateToolResult>(() => ({ success: false }))
mock.module("./tool-result-storage", () => ({
findToolResultsBySize,
truncateToolResult,
}))
async function importFreshStorage(): Promise<typeof import("./storage")> {
return import(`./storage?test=${Date.now()}-${Math.random()}`)
}
afterAll(() => {
mock.module("./storage", () => storage)
mock.restore()
})
describe("truncateUntilTargetTokens", () => {
const sessionID = "test-session"
beforeEach(() => {
// Reset mocks
const { findToolResultsBySize, truncateToolResult } = require("./storage")
findToolResultsBySize.mockReset()
truncateToolResult.mockReset()
findToolResultsBySize.mockReturnValue([])
truncateToolResult.mockReturnValue({ success: false })
})
test("truncates only until target is reached", async () => {
const { findToolResultsBySize, truncateToolResult } = require("./storage")
const { truncateUntilTargetTokens } = await importFreshStorage()
// given: Two tool results, each 1000 chars. Target reduction is 500 chars.
const results = [
{ partPath: "path1", partId: "id1", messageID: "m1", toolName: "tool1", outputSize: 1000 },
{ partPath: "path2", partId: "id2", messageID: "m2", toolName: "tool2", outputSize: 1000 },
]
findToolResultsBySize.mockReturnValue(results)
truncateToolResult.mockImplementation((path: string) => ({
success: true,
toolName: path === "path1" ? "tool1" : "tool2",
originalSize: 1000
originalSize: 1000,
}))
// when: currentTokens=1000, maxTokens=1000, targetRatio=0.5 (target=500, reduce=500)
@@ -55,19 +62,19 @@ describe("truncateUntilTargetTokens", () => {
})
test("truncates all if target not reached", async () => {
const { findToolResultsBySize, truncateToolResult } = require("./storage")
const { truncateUntilTargetTokens } = await importFreshStorage()
// given: Two tool results, each 100 chars. Target reduction is 500 chars.
const results = [
{ partPath: "path1", partId: "id1", messageID: "m1", toolName: "tool1", outputSize: 100 },
{ partPath: "path2", partId: "id2", messageID: "m2", toolName: "tool2", outputSize: 100 },
]
findToolResultsBySize.mockReturnValue(results)
truncateToolResult.mockImplementation((path: string) => ({
success: true,
toolName: path === "path1" ? "tool1" : "tool2",
originalSize: 100
originalSize: 100,
}))
// when: reduce 500 chars
@@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto"
import type { PluginInput } from "@opencode-ai/plugin"
import { createAtlasHook } from "./atlas-hook"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
import { _resetForTesting, registerAgentName, setSessionAgent } from "../../features/claude-code-session-state"
import { _resetForTesting, clearSessionAgent, registerAgentName, setSessionAgent } from "../../features/claude-code-session-state"
// Force process isolation in CI runner (globalThis.setTimeout override conflicts with other atlas tests)
mock.module("../../shared/opencode-storage-detection", () => ({
@@ -401,7 +401,8 @@ describe("atlas background task retry", () => {
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: descendantSessionID } } })
expect(capturedTimers.size).toBe(1)
descendantAgent = "sisyphus-junior"
descendantAgent = "prometheus"
clearSessionAgent(descendantSessionID)
backgroundRunning = false
await firePendingTimers()
+34 -18
View File
@@ -9,19 +9,6 @@ const testDirs: string[] = []
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-session-last-agent-${Date.now()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: (sessionID: string) => {
const directPath = join(TEST_MESSAGE_STORAGE, sessionID)
return require("node:fs").existsSync(directPath) ? directPath : null
},
}))
afterAll(() => { mock.restore() })
afterEach(() => {
while (testDirs.length > 0) {
const directory = testDirs.pop()
@@ -31,11 +18,17 @@ afterEach(() => {
}
})
async function importFreshSessionLastAgentModule(): Promise<typeof import("./session-last-agent")> {
return import(`./session-last-agent?test=${Date.now()}-${Math.random()}`)
}
function createTempMessageDir(sessionID: string): string {
const directory = mkdtempSync(join(tmpdir(), "atlas-session-last-agent-json-"))
testDirs.push(directory)
const messageDir = join(TEST_MESSAGE_STORAGE, sessionID)
rmSync(messageDir, { recursive: true, force: true })
mkdirSync(messageDir, { recursive: true })
testDirs.push(messageDir)
return messageDir
}
@@ -57,10 +50,20 @@ describe("getLastAgentFromSession JSON backend", () => {
time: { created: 50 },
}), "utf-8")
const { getLastAgentFromSession } = await import("./session-last-agent")
const { getLastAgentFromSession } = await importFreshSessionLastAgentModule()
// when
const result = await getLastAgentFromSession(sessionID)
const result = await getLastAgentFromSession(sessionID, undefined, {
isSqliteBackend: () => false,
getMessageDir: (targetSessionID: string) => {
const directPath = join(TEST_MESSAGE_STORAGE, targetSessionID)
return require("node:fs").existsSync(directPath) ? directPath : null
},
isCompactionMessage: (message: { agent?: unknown }) => {
return typeof message.agent === "string" && message.agent.toLowerCase() === "compaction"
},
hasCompactionPartInStorage: () => false,
})
// then
expect(result).toBe("atlas")
@@ -71,6 +74,7 @@ describe("getLastAgentFromSession JSON backend", () => {
const sessionID = "ses_json_compaction_marker"
const messageDir = createTempMessageDir(sessionID)
const compactionMessageID = "msg_test_atlas_compaction_marker"
const regularMessageID = `msg_${sessionID}_regular`
const partDir = join(PART_STORAGE, compactionMessageID)
testDirs.push(partDir)
writeFileSync(join(messageDir, "msg_0001.json"), JSON.stringify({
@@ -84,15 +88,27 @@ describe("getLastAgentFromSession JSON backend", () => {
}), "utf-8")
writeFileSync(join(messageDir, "msg_0002.json"), JSON.stringify({
id: "msg_0002",
id: regularMessageID,
agent: "sisyphus-junior",
time: { created: 100 },
}), "utf-8")
const { getLastAgentFromSession } = await import("./session-last-agent")
const { getLastAgentFromSession } = await importFreshSessionLastAgentModule()
// when
const result = await getLastAgentFromSession(sessionID)
const result = await getLastAgentFromSession(sessionID, undefined, {
isSqliteBackend: () => false,
getMessageDir: (targetSessionID: string) => {
const directPath = join(TEST_MESSAGE_STORAGE, targetSessionID)
return require("node:fs").existsSync(directPath) ? directPath : null
},
isCompactionMessage: (message: { agent?: unknown }) => {
return typeof message.agent === "string" && message.agent.toLowerCase() === "compaction"
},
hasCompactionPartInStorage: (messageID: string | undefined) => {
return messageID === compactionMessageID
},
})
// then
expect(result).toBe("sisyphus-junior")
@@ -1,11 +1,5 @@
export {}
const { describe, expect, mock, test, afterAll } = require("bun:test")
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => true,
}))
afterAll(() => { mock.restore() })
const { describe, expect, test } = require("bun:test")
const { getLastAgentFromSession } = await import("./session-last-agent")
@@ -25,7 +19,9 @@ describe("getLastAgentFromSession SQLite backend ordering", () => {
}
// when
const result = await getLastAgentFromSession("ses_sqlite_last_agent", client as never)
const result = await getLastAgentFromSession("ses_sqlite_last_agent", client as never, {
isSqliteBackend: () => true,
})
// then
expect(result).toBe("sisyphus-junior")
@@ -46,7 +42,9 @@ describe("getLastAgentFromSession SQLite backend ordering", () => {
}
// when
const result = await getLastAgentFromSession("ses_sqlite_last_agent_equal_time", client as never)
const result = await getLastAgentFromSession("ses_sqlite_last_agent_equal_time", client as never, {
isSqliteBackend: () => true,
})
// then
expect(result).toBe("sisyphus-junior")
@@ -70,7 +68,9 @@ describe("getLastAgentFromSession SQLite backend ordering", () => {
}
// when
const result = await getLastAgentFromSession("ses_sqlite_compaction_marker", client as never)
const result = await getLastAgentFromSession("ses_sqlite_compaction_marker", client as never, {
isSqliteBackend: () => true,
})
// then
expect(result).toBe("sisyphus")
@@ -87,7 +87,9 @@ describe("getLastAgentFromSession SQLite backend ordering", () => {
}
// when
const result = await getLastAgentFromSession("ses_sqlite_error", client as never)
const result = await getLastAgentFromSession("ses_sqlite_error", client as never, {
isSqliteBackend: () => true,
})
// then
expect(result).toBeNull()
+18 -13
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, beforeEach, afterEach, spyOn } from "bun:test"
import { describe, expect, it, beforeEach, afterEach, spyOn, mock } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
@@ -13,12 +13,7 @@ import type {
// Import real shared module to avoid mock leaking to other test files
import * as shared from "../../shared"
// Spy on log instead of mocking the entire module
const logMock = spyOn(shared, "log").mockImplementation(() => {})
const { createAutoSlashCommandHook } = await import("./index")
type AutoSlashCommandModule = typeof import("./hook")
function createMockInput(sessionID: string, messageID?: string): AutoSlashCommandHookInput {
return {
@@ -44,16 +39,26 @@ function createMockOutput(text: string): AutoSlashCommandHookOutput {
describe("createAutoSlashCommandHook", () => {
let tempDir = ""
let originalWorkingDirectory = ""
let logCalls: Array<[string, unknown?]>
let createAutoSlashCommandHook: AutoSlashCommandModule["createAutoSlashCommandHook"]
beforeEach(() => {
logMock.mockClear()
beforeEach(async () => {
mock.restore()
logCalls = []
spyOn(shared, "log").mockImplementation((message: string, data?: unknown) => {
logCalls.push([message, data])
})
tempDir = mkdtempSync(join(tmpdir(), "omo-auto-slash-hook-test-"))
originalWorkingDirectory = process.cwd()
const autoSlashCommandModule = await import(`./hook?test=${Date.now()}-${Math.random()}`)
createAutoSlashCommandHook = autoSlashCommandModule.createAutoSlashCommandHook
})
afterEach(() => {
process.chdir(originalWorkingDirectory)
rmSync(tempDir, { recursive: true, force: true })
mock.restore()
})
describe("slash command replacement", () => {
@@ -237,7 +242,7 @@ describe("createAutoSlashCommandHook", () => {
// when hook is called
// then should not throw
await expect(hook["chat.message"](input, output)).resolves.toBeUndefined()
await hook["chat.message"](input, output)
})
it("should handle just slash", async () => {
@@ -374,13 +379,13 @@ describe("createAutoSlashCommandHook", () => {
await hook["command.execute.before"](input, output)
//#then
expect(logMock).toHaveBeenCalledWith(
expect(logCalls).toContainEqual([
"[auto-slash-command] command.execute.before received",
expect.objectContaining({
command: "some-command",
arguments: "arg1 arg2 arg3",
})
)
}),
])
})
})
@@ -7,6 +7,9 @@ import type {
CommandExecuteBeforeOutput,
} from "../types"
import * as shared from "../../../shared"
import * as executorModule from "../executor"
type AutoSlashCommandModule = typeof import("../hook")
const executeSlashCommandMock = mock(
async (parsed: { command: string; args: string; raw: string }) => ({
@@ -15,21 +18,11 @@ const executeSlashCommandMock = mock(
})
)
mock.module("../executor", () => ({
executeSlashCommand: executeSlashCommandMock,
}))
afterAll(async () => {
mock.restore()
// Restore the real executor module so subsequent test files in the same batch
// (e.g. executor-resolution.test.ts) don't get the mocked version
const realExecutor = await import("../executor")
mock.module("../executor", () => realExecutor)
})
const logMock = spyOn(shared, "log").mockImplementation(() => {})
const { createAutoSlashCommandHook } = await import("../hook")
let createAutoSlashCommandHook: AutoSlashCommandModule["createAutoSlashCommandHook"]
function createChatInput(sessionID: string, messageID: string): AutoSlashCommandHookInput {
return {
@@ -60,9 +53,14 @@ function createCommandOutput(text: string): CommandExecuteBeforeOutput {
}
describe("createAutoSlashCommandHook leak prevention", () => {
beforeEach(() => {
beforeEach(async () => {
mock.restore()
executeSlashCommandMock.mockClear()
logMock.mockClear()
spyOn(executorModule, "executeSlashCommand").mockImplementation(executeSlashCommandMock)
spyOn(shared, "log").mockImplementation(() => {})
const autoSlashCommandModule = await import(`../hook?test=${Date.now()}-${Math.random()}`)
createAutoSlashCommandHook = autoSlashCommandModule.createAutoSlashCommandHook
})
describe("#given hook with sessionProcessedCommandExecutions", () => {
+2 -2
View File
@@ -146,8 +146,8 @@ describe("keyword-detector session filtering", () => {
)
// then - search keyword should be filtered out based on mainSessionID comparison
const skipLog = logCalls.find(c => c.msg.includes("Skipping non-ultrawork keywords in non-main session"))
expect(skipLog).toBeDefined()
expect(output.message.variant).toBeUndefined()
expect(output.parts[0]?.text).toBe("search mode 찾아줘")
})
test("should allow ultrawork keywords in non-main session", async () => {
@@ -8,10 +8,11 @@ const mockMigrateLegacyPluginEntry = mock(() => true)
mock.module("./plugin-entry-migrator", () => ({
migrateLegacyPluginEntry: mockMigrateLegacyPluginEntry,
}))
mock.module("./plugin-entry-migrator.ts", () => ({
migrateLegacyPluginEntry: mockMigrateLegacyPluginEntry,
}))
async function importFreshAutoMigrateModule(): Promise<typeof import("./auto-migrate")> {
return import(`./auto-migrate?test=${Date.now()}-${Math.random()}`)
}
const autoMigrateModulePromise = import("./auto-migrate")
describe("autoMigrateLegacyPluginEntry", () => {
let testConfigDir = ""
@@ -35,7 +36,7 @@ describe("autoMigrateLegacyPluginEntry", () => {
JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2) + "\n",
)
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
const { autoMigrateLegacyPluginEntry } = await autoMigrateModulePromise
// when
const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -56,7 +57,7 @@ describe("autoMigrateLegacyPluginEntry", () => {
JSON.stringify({ plugin: ["oh-my-opencode@3.10.0"] }, null, 2) + "\n",
)
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
const { autoMigrateLegacyPluginEntry } = await autoMigrateModulePromise
// when
const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -77,7 +78,7 @@ describe("autoMigrateLegacyPluginEntry", () => {
JSON.stringify({ plugin: ["oh-my-openagent", "oh-my-opencode"] }, null, 2) + "\n",
)
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
const { autoMigrateLegacyPluginEntry } = await autoMigrateModulePromise
// when
const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -92,7 +93,7 @@ describe("autoMigrateLegacyPluginEntry", () => {
describe("#given no config file exists", () => {
it("#then returns migrated false", async () => {
// given - empty dir
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
const { autoMigrateLegacyPluginEntry } = await autoMigrateModulePromise
// when
const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -112,7 +113,7 @@ describe("autoMigrateLegacyPluginEntry", () => {
'{\n // my config\n "plugin": ["oh-my-opencode"]\n}\n',
)
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
const { autoMigrateLegacyPluginEntry } = await autoMigrateModulePromise
// when
const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -138,7 +139,7 @@ describe("autoMigrateLegacyPluginEntry", () => {
`,
)
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
const { autoMigrateLegacyPluginEntry } = await autoMigrateModulePromise
// when
const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -157,7 +158,7 @@ describe("autoMigrateLegacyPluginEntry", () => {
const original = JSON.stringify({ plugin: ["oh-my-openagent"] }, null, 2) + "\n"
writeFileSync(join(testConfigDir, "opencode.json"), original)
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
const { autoMigrateLegacyPluginEntry } = await autoMigrateModulePromise
// when
const result = autoMigrateLegacyPluginEntry(testConfigDir)
+20 -9
View File
@@ -1,26 +1,37 @@
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
import { createRuntimeFallbackHook } from "./index"
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config"
import * as sharedModule from "../../shared"
import * as loggerModule from "../../shared/logger"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
type RuntimeFallbackModule = typeof import("./hook")
describe("runtime-fallback", () => {
let logCalls: Array<{ msg: string; data?: unknown }>
let logSpy: ReturnType<typeof spyOn>
let toastCalls: Array<{ title: string; message: string; variant: string }>
let createRuntimeFallbackHook: RuntimeFallbackModule["createRuntimeFallbackHook"]
beforeEach(() => {
beforeEach(async () => {
mock.restore()
logCalls = []
toastCalls = []
SessionCategoryRegistry.clear()
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
logCalls.push({ msg, data })
})
const cacheBuster = `${Date.now()}-${Math.random()}`
mock.module("../../shared/logger", () => ({
...loggerModule,
log: (msg: string, data?: unknown) => {
logCalls.push({ msg, data })
},
}))
const runtimeFallbackModule: RuntimeFallbackModule = await import(`./hook?test=${cacheBuster}`)
createRuntimeFallbackHook = runtimeFallbackModule.createRuntimeFallbackHook
})
afterEach(() => {
SessionCategoryRegistry.clear()
logSpy?.mockRestore()
mock.restore()
})
function createMockPluginInput(overrides?: {
+21 -7
View File
@@ -1,10 +1,24 @@
import { existsSync, readFileSync, rmSync } from "node:fs"
import { randomUUID } from "node:crypto"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { describe, expect, it, mock } from "bun:test"
import { detectErrorType } from "./index"
import { prependThinkingPart, prependThinkingPartAsync } from "./storage/thinking-prepend"
import { PART_STORAGE } from "../../shared/opencode-storage-paths"
const { describe, expect, it, mock } = require("bun:test")
const TEST_STORAGE_ROOT = join(tmpdir(), `session-recovery-thinking-prepend-${randomUUID()}`)
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
mock.module("../../shared", () => ({
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
MESSAGE_STORAGE: join(TEST_STORAGE_ROOT, "message"),
PART_STORAGE: TEST_PART_STORAGE,
log: () => {},
isSqliteBackend: () => false,
patchPart: async () => true,
normalizeSDKResponse: <TData>(response: { data?: TData }, fallback: TData) => response.data ?? fallback,
}))
const { prependThinkingPart, prependThinkingPartAsync } = await import("./storage/thinking-prepend")
describe("detectErrorType", () => {
describe("thinking_block_order errors", () => {
@@ -295,7 +309,7 @@ type StoredPartRecord = {
}
function cleanupParts(messageID: string): void {
rmSync(join(PART_STORAGE, messageID), { recursive: true, force: true })
rmSync(join(TEST_PART_STORAGE, messageID), { recursive: true, force: true })
}
describe("thinking-prepend", () => {
@@ -322,7 +336,7 @@ describe("thinking-prepend", () => {
})
expect(result).toBe(true)
const writtenPath = join(PART_STORAGE, targetMessageID, `${originalPart.id}.json`)
const writtenPath = join(TEST_PART_STORAGE, targetMessageID, `${originalPart.id}.json`)
expect(existsSync(writtenPath)).toBe(true)
expect(JSON.parse(readFileSync(writtenPath, "utf-8"))).toEqual(originalPart)
@@ -344,7 +358,7 @@ describe("thinking-prepend", () => {
})
expect(result).toBe(false)
expect(existsSync(join(PART_STORAGE, targetMessageID))).toBe(false)
expect(existsSync(join(TEST_PART_STORAGE, targetMessageID))).toBe(false)
cleanupParts(targetMessageID)
})
@@ -386,7 +400,7 @@ describe("thinking-prepend", () => {
})
expect(result).toBe(false)
expect(existsSync(join(PART_STORAGE, targetMessageID))).toBe(false)
expect(existsSync(join(TEST_PART_STORAGE, targetMessageID))).toBe(false)
})
it("patches the original signed thinking part verbatim for sdk-backed recovery", async () => {
@@ -1,13 +1,20 @@
const { describe, it, expect, mock, beforeEach, afterEach, spyOn } = require("bun:test")
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import type { MessageData } from "./types"
import * as storageDetection from "../../shared/opencode-storage-detection"
import * as storage from "./storage"
import { recoverToolResultMissing } from "./recover-tool-result-missing"
let sqliteBackend = false
let storedParts: Array<{ type: string; id?: string; callID?: string; [key: string]: unknown }> = []
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => sqliteBackend,
}))
mock.module("./storage", () => ({
readParts: () => storedParts,
}))
const { recoverToolResultMissing } = await import("./recover-tool-result-missing")
const failedAssistantMsg: MessageData = {
info: { id: "msg_failed", role: "assistant" },
parts: [],
@@ -31,9 +38,6 @@ describe("recoverToolResultMissing", () => {
beforeEach(() => {
sqliteBackend = false
storedParts = []
spyOn(storageDetection, "isSqliteBackend").mockImplementation(() => sqliteBackend)
spyOn(storage, "readParts").mockImplementation(() => storedParts)
})
afterEach(() => {
+85 -68
View File
@@ -1,4 +1,5 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { createAutoUpdateCheckerHook } from "../auto-update-checker/hook"
const mockShowConfigErrorsIfAny = mock(async () => {})
const mockShowModelCacheWarningIfNeeded = mock(async () => {})
@@ -10,60 +11,6 @@ const mockRunBackgroundUpdateCheck = mock(async () => {})
const mockGetCachedVersion = mock(() => "3.6.0")
const mockGetLocalDevVersion = mock<(directory: string) => string | null>(() => null)
const _realConfigErrorsToast = require("../auto-update-checker/hook/config-errors-toast")
const _realModelCacheWarning = require("../auto-update-checker/hook/model-cache-warning")
const _realConnectedProvidersStatus = require("../auto-update-checker/hook/connected-providers-status")
const _realModelCapabilitiesStatus = require("../auto-update-checker/hook/model-capabilities-status")
const _realStartupToasts = require("../auto-update-checker/hook/startup-toasts")
const _realBackgroundUpdateCheck = require("../auto-update-checker/hook/background-update-check")
const _realChecker = require("../auto-update-checker/checker")
const _realLogger = require("../../shared/logger")
afterAll(() => {
mock.module("../auto-update-checker/hook/config-errors-toast", () => _realConfigErrorsToast)
mock.module("../auto-update-checker/hook/model-cache-warning", () => _realModelCacheWarning)
mock.module("../auto-update-checker/hook/connected-providers-status", () => _realConnectedProvidersStatus)
mock.module("../auto-update-checker/hook/model-capabilities-status", () => _realModelCapabilitiesStatus)
mock.module("../auto-update-checker/hook/startup-toasts", () => _realStartupToasts)
mock.module("../auto-update-checker/hook/background-update-check", () => _realBackgroundUpdateCheck)
mock.module("../auto-update-checker/checker", () => _realChecker)
mock.module("../../shared/logger", () => _realLogger)
mock.restore()
})
type HookFactory = typeof import("../auto-update-checker/hook").createAutoUpdateCheckerHook
async function importFreshHookFactory(): Promise<HookFactory> {
mock.module("../auto-update-checker/hook/config-errors-toast", () => ({
showConfigErrorsIfAny: mockShowConfigErrorsIfAny,
}))
mock.module("../auto-update-checker/hook/model-cache-warning", () => ({
showModelCacheWarningIfNeeded: mockShowModelCacheWarningIfNeeded,
}))
mock.module("../auto-update-checker/hook/connected-providers-status", () => ({
updateAndShowConnectedProvidersCacheStatus: mockUpdateAndShowConnectedProvidersCacheStatus,
}))
mock.module("../auto-update-checker/hook/model-capabilities-status", () => ({
refreshModelCapabilitiesOnStartup: mockRefreshModelCapabilitiesOnStartup,
}))
mock.module("../auto-update-checker/hook/startup-toasts", () => ({
showLocalDevToast: mockShowLocalDevToast,
showVersionToast: mockShowVersionToast,
}))
mock.module("../auto-update-checker/hook/background-update-check", () => ({
runBackgroundUpdateCheck: mockRunBackgroundUpdateCheck,
}))
mock.module("../auto-update-checker/checker", () => ({
getCachedVersion: mockGetCachedVersion,
getLocalDevVersion: mockGetLocalDevVersion,
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
const hookModule = await import(`../auto-update-checker/hook?test-${Date.now()}-${Math.random()}`)
return hookModule.createAutoUpdateCheckerHook
}
function createPluginInput() {
return {
directory: "/test",
@@ -80,7 +27,7 @@ async function flushScheduledWork(): Promise<void> {
}
function runSessionCreatedEvent(
hook: ReturnType<HookFactory>,
hook: ReturnType<typeof createAutoUpdateCheckerHook>,
properties?: { info?: { parentID?: string } }
): void {
hook.event({
@@ -114,12 +61,22 @@ describe("createAutoUpdateCheckerHook", () => {
it("skips startup toasts and checks in CLI run mode", async () => {
//#given - CLI run mode enabled
process.env.OPENCODE_CLI_RUN_MODE = "true"
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput(), {
showStartupToast: true,
isSisyphusEnabled: true,
autoUpdate: true,
}, {
getCachedVersion: mockGetCachedVersion,
getLocalDevVersion: mockGetLocalDevVersion,
showConfigErrorsIfAny: mockShowConfigErrorsIfAny,
updateAndShowConnectedProvidersCacheStatus: mockUpdateAndShowConnectedProvidersCacheStatus,
refreshModelCapabilitiesOnStartup: mockRefreshModelCapabilitiesOnStartup,
showModelCacheWarningIfNeeded: mockShowModelCacheWarningIfNeeded,
showLocalDevToast: mockShowLocalDevToast,
showVersionToast: mockShowVersionToast,
runBackgroundUpdateCheck: mockRunBackgroundUpdateCheck,
log: () => {},
})
//#when - session.created event arrives
@@ -138,8 +95,18 @@ describe("createAutoUpdateCheckerHook", () => {
it("runs all startup checks on normal session.created", async () => {
//#given - normal mode and no local dev version
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput())
const hook = createAutoUpdateCheckerHook(createPluginInput(), {}, {
getCachedVersion: mockGetCachedVersion,
getLocalDevVersion: mockGetLocalDevVersion,
showConfigErrorsIfAny: mockShowConfigErrorsIfAny,
updateAndShowConnectedProvidersCacheStatus: mockUpdateAndShowConnectedProvidersCacheStatus,
refreshModelCapabilitiesOnStartup: mockRefreshModelCapabilitiesOnStartup,
showModelCacheWarningIfNeeded: mockShowModelCacheWarningIfNeeded,
showLocalDevToast: mockShowLocalDevToast,
showVersionToast: mockShowVersionToast,
runBackgroundUpdateCheck: mockRunBackgroundUpdateCheck,
log: () => {},
})
//#when - session.created event arrives on primary session
runSessionCreatedEvent(hook)
@@ -156,8 +123,18 @@ describe("createAutoUpdateCheckerHook", () => {
it("ignores subagent sessions (parentID present)", async () => {
//#given - a subagent session with parentID
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput())
const hook = createAutoUpdateCheckerHook(createPluginInput(), {}, {
getCachedVersion: mockGetCachedVersion,
getLocalDevVersion: mockGetLocalDevVersion,
showConfigErrorsIfAny: mockShowConfigErrorsIfAny,
updateAndShowConnectedProvidersCacheStatus: mockUpdateAndShowConnectedProvidersCacheStatus,
refreshModelCapabilitiesOnStartup: mockRefreshModelCapabilitiesOnStartup,
showModelCacheWarningIfNeeded: mockShowModelCacheWarningIfNeeded,
showLocalDevToast: mockShowLocalDevToast,
showVersionToast: mockShowVersionToast,
runBackgroundUpdateCheck: mockRunBackgroundUpdateCheck,
log: () => {},
})
//#when - session.created event contains parentID
runSessionCreatedEvent(hook, { info: { parentID: "parent-123" } })
@@ -175,8 +152,18 @@ describe("createAutoUpdateCheckerHook", () => {
it("runs only once (hasChecked guard)", async () => {
//#given - one hook instance in normal mode
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput())
const hook = createAutoUpdateCheckerHook(createPluginInput(), {}, {
getCachedVersion: mockGetCachedVersion,
getLocalDevVersion: mockGetLocalDevVersion,
showConfigErrorsIfAny: mockShowConfigErrorsIfAny,
updateAndShowConnectedProvidersCacheStatus: mockUpdateAndShowConnectedProvidersCacheStatus,
refreshModelCapabilitiesOnStartup: mockRefreshModelCapabilitiesOnStartup,
showModelCacheWarningIfNeeded: mockShowModelCacheWarningIfNeeded,
showLocalDevToast: mockShowLocalDevToast,
showVersionToast: mockShowVersionToast,
runBackgroundUpdateCheck: mockRunBackgroundUpdateCheck,
log: () => {},
})
//#when - session.created event is fired twice
runSessionCreatedEvent(hook)
@@ -195,8 +182,18 @@ describe("createAutoUpdateCheckerHook", () => {
it("shows localDevToast when local dev version exists", async () => {
//#given - local dev version is present
mockGetLocalDevVersion.mockReturnValue("3.6.0-dev")
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput())
const hook = createAutoUpdateCheckerHook(createPluginInput(), {}, {
getCachedVersion: mockGetCachedVersion,
getLocalDevVersion: mockGetLocalDevVersion,
showConfigErrorsIfAny: mockShowConfigErrorsIfAny,
updateAndShowConnectedProvidersCacheStatus: mockUpdateAndShowConnectedProvidersCacheStatus,
refreshModelCapabilitiesOnStartup: mockRefreshModelCapabilitiesOnStartup,
showModelCacheWarningIfNeeded: mockShowModelCacheWarningIfNeeded,
showLocalDevToast: mockShowLocalDevToast,
showVersionToast: mockShowVersionToast,
runBackgroundUpdateCheck: mockRunBackgroundUpdateCheck,
log: () => {},
})
//#when - session.created event arrives
runSessionCreatedEvent(hook)
@@ -214,8 +211,18 @@ describe("createAutoUpdateCheckerHook", () => {
it("ignores non-session.created events", async () => {
//#given - a hook instance in normal mode
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput())
const hook = createAutoUpdateCheckerHook(createPluginInput(), {}, {
getCachedVersion: mockGetCachedVersion,
getLocalDevVersion: mockGetLocalDevVersion,
showConfigErrorsIfAny: mockShowConfigErrorsIfAny,
updateAndShowConnectedProvidersCacheStatus: mockUpdateAndShowConnectedProvidersCacheStatus,
refreshModelCapabilitiesOnStartup: mockRefreshModelCapabilitiesOnStartup,
showModelCacheWarningIfNeeded: mockShowModelCacheWarningIfNeeded,
showLocalDevToast: mockShowLocalDevToast,
showVersionToast: mockShowVersionToast,
runBackgroundUpdateCheck: mockRunBackgroundUpdateCheck,
log: () => {},
})
//#when - a non-session.created event arrives
hook.event({
@@ -237,9 +244,19 @@ describe("createAutoUpdateCheckerHook", () => {
it("passes correct toast message with sisyphus enabled", async () => {
//#given - sisyphus mode enabled
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput(), {
isSisyphusEnabled: true,
}, {
getCachedVersion: mockGetCachedVersion,
getLocalDevVersion: mockGetLocalDevVersion,
showConfigErrorsIfAny: mockShowConfigErrorsIfAny,
updateAndShowConnectedProvidersCacheStatus: mockUpdateAndShowConnectedProvidersCacheStatus,
refreshModelCapabilitiesOnStartup: mockRefreshModelCapabilitiesOnStartup,
showModelCacheWarningIfNeeded: mockShowModelCacheWarningIfNeeded,
showLocalDevToast: mockShowLocalDevToast,
showVersionToast: mockShowVersionToast,
runBackgroundUpdateCheck: mockRunBackgroundUpdateCheck,
log: () => {},
})
//#when - session.created event arrives
@@ -2,59 +2,40 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import type { PluginEntryInfo } from "../auto-update-checker/checker/plugin-entry"
import { CACHE_DIR } from "../auto-update-checker/constants"
const TEST_CACHE_DIR = join(import.meta.dir, "__test-sync-cache__")
const CACHE_PACKAGES_DIR = CACHE_DIR
const CACHE_PACKAGE_JSON_PATH = join(CACHE_PACKAGES_DIR, "package.json")
const ORIGINAL_CACHE_PACKAGE_JSON = existsSync(CACHE_PACKAGE_JSON_PATH)
? readFileSync(CACHE_PACKAGE_JSON_PATH, "utf-8")
: null
let importCounter = 0
// Capture real modules BEFORE mocking
const _realConstants = require("../auto-update-checker/constants")
const _realLogger = require("../../shared/logger")
const _realNodeFs = require("node:fs")
async function importFreshSyncPackageJsonModule(): Promise<typeof import("../auto-update-checker/checker/sync-package-json")> {
mock.module("../auto-update-checker/constants", () => ({
CACHE_DIR: TEST_CACHE_DIR,
PACKAGE_NAME: "oh-my-opencode",
NPM_REGISTRY_URL: "https://registry.npmjs.org/-/package/oh-my-opencode/dist-tags",
NPM_FETCH_TIMEOUT: 5000,
VERSION_FILE: join(TEST_CACHE_DIR, "version"),
INSTALLED_PACKAGE_JSON: join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
getUserConfigDir: () => "/tmp/opencode-config",
getUserOpencodeConfig: () => "/tmp/opencode-config/opencode.json",
getUserOpencodeConfigJsonc: () => "/tmp/opencode-config/opencode.jsonc",
getWindowsAppdataDir: () => null,
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
const syncPackageJsonModule = await import(`../auto-update-checker/checker/sync-package-json?test=${importCounter++}`)
mock.restore()
return syncPackageJsonModule
return import(`../auto-update-checker/checker/sync-package-json?test=${importCounter++}`)
}
function resetTestCache(currentVersion = "3.10.0"): void {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
}
mkdirSync(TEST_CACHE_DIR, { recursive: true })
mkdirSync(CACHE_PACKAGES_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
CACHE_PACKAGE_JSON_PATH,
JSON.stringify({ dependencies: { "oh-my-opencode": currentVersion, other: "1.0.0" } }, null, 2)
)
}
function cleanupTestCache(): void {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
if (existsSync(CACHE_PACKAGE_JSON_PATH)) {
rmSync(CACHE_PACKAGE_JSON_PATH, { force: true })
}
}
function readCachePackageJsonVersion(): string | undefined {
const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8")
const content = readFileSync(CACHE_PACKAGE_JSON_PATH, "utf-8")
const pkg = JSON.parse(content) as { dependencies?: Record<string, string> }
return pkg.dependencies?.["oh-my-opencode"]
}
@@ -65,6 +46,7 @@ describe("syncCachePackageJsonToIntent", () => {
})
afterEach(() => {
mock.restore()
cleanupTestCache()
})
@@ -170,9 +152,9 @@ describe("syncCachePackageJsonToIntent", () => {
describe("#given plugin not in cache package.json dependencies", () => {
it("#then adds the plugin dependency and preserves existing dependencies", async () => {
cleanupTestCache()
mkdirSync(TEST_CACHE_DIR, { recursive: true })
mkdirSync(CACHE_PACKAGES_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
join(CACHE_PACKAGES_DIR, "package.json"),
JSON.stringify({ dependencies: { other: "1.0.0" } }, null, 2)
)
@@ -190,10 +172,10 @@ describe("syncCachePackageJsonToIntent", () => {
expect(result.synced).toBe(true)
expect(result.error).toBeNull()
const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8")
const pkg = JSON.parse(content) as { dependencies?: Record<string, string> }
expect(pkg.dependencies?.["oh-my-opencode"]).toBe("latest")
expect(pkg.dependencies?.other).toBe("1.0.0")
const content = readFileSync(join(CACHE_PACKAGES_DIR, "package.json"), "utf-8")
const pkg = JSON.parse(content) as { dependencies?: Record<string, string> }
expect(pkg.dependencies?.["oh-my-opencode"]).toBe("latest")
expect(pkg.dependencies?.other).toBe("1.0.0")
})
})
@@ -233,17 +215,17 @@ describe("syncCachePackageJsonToIntent", () => {
expect(result.synced).toBe(true)
expect(result.error).toBeNull()
const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8")
const pkg = JSON.parse(content) as { dependencies?: Record<string, string> }
expect(pkg.dependencies?.["other"]).toBe("1.0.0")
const content = readFileSync(join(CACHE_PACKAGES_DIR, "package.json"), "utf-8")
const pkg = JSON.parse(content) as { dependencies?: Record<string, string> }
expect(pkg.dependencies?.["other"]).toBe("1.0.0")
})
})
describe("#given malformed JSON in cache package.json", () => {
it("#then returns parse_error", async () => {
cleanupTestCache()
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(join(TEST_CACHE_DIR, "package.json"), "{ invalid json }")
mkdirSync(CACHE_PACKAGES_DIR, { recursive: true })
writeFileSync(join(CACHE_PACKAGES_DIR, "package.json"), "{ invalid json }")
const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule()
@@ -264,9 +246,9 @@ describe("syncCachePackageJsonToIntent", () => {
describe("#given write permission denied", () => {
it("#then returns write_error", async () => {
cleanupTestCache()
mkdirSync(TEST_CACHE_DIR, { recursive: true })
mkdirSync(CACHE_PACKAGES_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
join(CACHE_PACKAGES_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": "3.10.0" } }, null, 2)
)
@@ -309,9 +291,9 @@ describe("syncCachePackageJsonToIntent", () => {
describe("#given rename fails after successful write", () => {
it("#then returns write_error and cleans up temp file", async () => {
cleanupTestCache()
mkdirSync(TEST_CACHE_DIR, { recursive: true })
mkdirSync(CACHE_PACKAGES_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
join(CACHE_PACKAGES_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": "3.10.0" } }, null, 2)
)
@@ -360,8 +342,11 @@ describe("syncCachePackageJsonToIntent", () => {
})
afterAll(() => {
mock.module("../auto-update-checker/constants", () => _realConstants)
mock.module("../../shared/logger", () => _realLogger)
mock.module("node:fs", () => _realNodeFs)
if (ORIGINAL_CACHE_PACKAGE_JSON === null) {
cleanupTestCache()
} else {
mkdirSync(CACHE_PACKAGES_DIR, { recursive: true })
writeFileSync(CACHE_PACKAGE_JSON_PATH, ORIGINAL_CACHE_PACKAGE_JSON)
}
mock.restore()
})