3827b5b913
Plan agent demote now only sets mode to 'subagent' without spreading prometheus config. This ensures plan agent uses OpenCode's default prompt instead of inheriting prometheus prompt.
591 lines
19 KiB
TypeScript
591 lines
19 KiB
TypeScript
import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test"
|
|
import { resolveCategoryConfig, createConfigHandler } from "./config-handler"
|
|
import type { CategoryConfig } from "../config/schema"
|
|
import type { OhMyOpenCodeConfig } from "../config"
|
|
|
|
import * as agents from "../agents"
|
|
import * as sisyphusJunior from "../agents/sisyphus-junior"
|
|
import * as commandLoader from "../features/claude-code-command-loader"
|
|
import * as builtinCommands from "../features/builtin-commands"
|
|
import * as skillLoader from "../features/opencode-skill-loader"
|
|
import * as agentLoader from "../features/claude-code-agent-loader"
|
|
import * as mcpLoader from "../features/claude-code-mcp-loader"
|
|
import * as pluginLoader from "../features/claude-code-plugin-loader"
|
|
import * as mcpModule from "../mcp"
|
|
import * as shared from "../shared"
|
|
import * as configDir from "../shared/opencode-config-dir"
|
|
import * as permissionCompat from "../shared/permission-compat"
|
|
import * as modelResolver from "../shared/model-resolver"
|
|
|
|
beforeEach(() => {
|
|
spyOn(agents, "createBuiltinAgents" as any).mockResolvedValue({
|
|
sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" },
|
|
oracle: { name: "oracle", prompt: "test", mode: "subagent" },
|
|
})
|
|
|
|
spyOn(sisyphusJunior, "createSisyphusJuniorAgentWithOverrides" as any).mockReturnValue({
|
|
name: "sisyphus-junior",
|
|
prompt: "test",
|
|
mode: "subagent",
|
|
})
|
|
|
|
spyOn(commandLoader, "loadUserCommands" as any).mockResolvedValue({})
|
|
spyOn(commandLoader, "loadProjectCommands" as any).mockResolvedValue({})
|
|
spyOn(commandLoader, "loadOpencodeGlobalCommands" as any).mockResolvedValue({})
|
|
spyOn(commandLoader, "loadOpencodeProjectCommands" as any).mockResolvedValue({})
|
|
|
|
spyOn(builtinCommands, "loadBuiltinCommands" as any).mockReturnValue({})
|
|
|
|
spyOn(skillLoader, "loadUserSkills" as any).mockResolvedValue({})
|
|
spyOn(skillLoader, "loadProjectSkills" as any).mockResolvedValue({})
|
|
spyOn(skillLoader, "loadOpencodeGlobalSkills" as any).mockResolvedValue({})
|
|
spyOn(skillLoader, "loadOpencodeProjectSkills" as any).mockResolvedValue({})
|
|
spyOn(skillLoader, "discoverUserClaudeSkills" as any).mockResolvedValue([])
|
|
spyOn(skillLoader, "discoverProjectClaudeSkills" as any).mockResolvedValue([])
|
|
spyOn(skillLoader, "discoverOpencodeGlobalSkills" as any).mockResolvedValue([])
|
|
spyOn(skillLoader, "discoverOpencodeProjectSkills" as any).mockResolvedValue([])
|
|
|
|
spyOn(agentLoader, "loadUserAgents" as any).mockReturnValue({})
|
|
spyOn(agentLoader, "loadProjectAgents" as any).mockReturnValue({})
|
|
|
|
spyOn(mcpLoader, "loadMcpConfigs" as any).mockResolvedValue({ servers: {} })
|
|
|
|
spyOn(pluginLoader, "loadAllPluginComponents" as any).mockResolvedValue({
|
|
commands: {},
|
|
skills: {},
|
|
agents: {},
|
|
mcpServers: {},
|
|
hooksConfigs: [],
|
|
plugins: [],
|
|
errors: [],
|
|
})
|
|
|
|
spyOn(mcpModule, "createBuiltinMcps" as any).mockReturnValue({})
|
|
|
|
spyOn(shared, "log" as any).mockImplementation(() => {})
|
|
spyOn(shared, "fetchAvailableModels" as any).mockResolvedValue(new Set(["anthropic/claude-opus-4-5"]))
|
|
spyOn(shared, "readConnectedProvidersCache" as any).mockReturnValue(null)
|
|
|
|
spyOn(configDir, "getOpenCodeConfigPaths" as any).mockReturnValue({
|
|
global: "/tmp/.config/opencode",
|
|
project: "/tmp/.opencode",
|
|
})
|
|
|
|
spyOn(permissionCompat, "migrateAgentConfig" as any).mockImplementation((config: Record<string, unknown>) => config)
|
|
|
|
spyOn(modelResolver, "resolveModelWithFallback" as any).mockReturnValue({ model: "anthropic/claude-opus-4-5" })
|
|
})
|
|
|
|
afterEach(() => {
|
|
(agents.createBuiltinAgents as any)?.mockRestore?.()
|
|
;(sisyphusJunior.createSisyphusJuniorAgentWithOverrides as any)?.mockRestore?.()
|
|
;(commandLoader.loadUserCommands as any)?.mockRestore?.()
|
|
;(commandLoader.loadProjectCommands as any)?.mockRestore?.()
|
|
;(commandLoader.loadOpencodeGlobalCommands as any)?.mockRestore?.()
|
|
;(commandLoader.loadOpencodeProjectCommands as any)?.mockRestore?.()
|
|
;(builtinCommands.loadBuiltinCommands as any)?.mockRestore?.()
|
|
;(skillLoader.loadUserSkills as any)?.mockRestore?.()
|
|
;(skillLoader.loadProjectSkills as any)?.mockRestore?.()
|
|
;(skillLoader.loadOpencodeGlobalSkills as any)?.mockRestore?.()
|
|
;(skillLoader.loadOpencodeProjectSkills as any)?.mockRestore?.()
|
|
;(skillLoader.discoverUserClaudeSkills as any)?.mockRestore?.()
|
|
;(skillLoader.discoverProjectClaudeSkills as any)?.mockRestore?.()
|
|
;(skillLoader.discoverOpencodeGlobalSkills as any)?.mockRestore?.()
|
|
;(skillLoader.discoverOpencodeProjectSkills as any)?.mockRestore?.()
|
|
;(agentLoader.loadUserAgents as any)?.mockRestore?.()
|
|
;(agentLoader.loadProjectAgents as any)?.mockRestore?.()
|
|
;(mcpLoader.loadMcpConfigs as any)?.mockRestore?.()
|
|
;(pluginLoader.loadAllPluginComponents as any)?.mockRestore?.()
|
|
;(mcpModule.createBuiltinMcps as any)?.mockRestore?.()
|
|
;(shared.log as any)?.mockRestore?.()
|
|
;(shared.fetchAvailableModels as any)?.mockRestore?.()
|
|
;(shared.readConnectedProvidersCache as any)?.mockRestore?.()
|
|
;(configDir.getOpenCodeConfigPaths as any)?.mockRestore?.()
|
|
;(permissionCompat.migrateAgentConfig as any)?.mockRestore?.()
|
|
;(modelResolver.resolveModelWithFallback as any)?.mockRestore?.()
|
|
})
|
|
|
|
describe("Plan agent demote behavior", () => {
|
|
test("orders core agents as sisyphus -> hephaestus -> prometheus -> atlas", async () => {
|
|
// #given
|
|
const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as {
|
|
mockResolvedValue: (value: Record<string, unknown>) => void
|
|
}
|
|
createBuiltinAgentsMock.mockResolvedValue({
|
|
sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" },
|
|
hephaestus: { name: "hephaestus", prompt: "test", mode: "primary" },
|
|
oracle: { name: "oracle", prompt: "test", mode: "subagent" },
|
|
atlas: { name: "atlas", prompt: "test", mode: "primary" },
|
|
})
|
|
const pluginConfig: OhMyOpenCodeConfig = {
|
|
sisyphus_agent: {
|
|
planner_enabled: true,
|
|
},
|
|
}
|
|
const config: Record<string, unknown> = {
|
|
model: "anthropic/claude-opus-4-5",
|
|
agent: {},
|
|
}
|
|
const handler = createConfigHandler({
|
|
ctx: { directory: "/tmp" },
|
|
pluginConfig,
|
|
modelCacheState: {
|
|
anthropicContext1MEnabled: false,
|
|
modelContextLimitsCache: new Map(),
|
|
},
|
|
})
|
|
|
|
// #when
|
|
await handler(config)
|
|
|
|
// #then
|
|
const keys = Object.keys(config.agent as Record<string, unknown>)
|
|
const coreAgents = ["sisyphus", "hephaestus", "prometheus", "atlas"]
|
|
const ordered = keys.filter((key) => coreAgents.includes(key))
|
|
expect(ordered).toEqual(coreAgents)
|
|
})
|
|
|
|
test("plan agent should be demoted to subagent without inheriting prometheus prompt", async () => {
|
|
// #given
|
|
const pluginConfig: OhMyOpenCodeConfig = {
|
|
sisyphus_agent: {
|
|
planner_enabled: true,
|
|
replace_plan: true,
|
|
},
|
|
}
|
|
const config: Record<string, unknown> = {
|
|
model: "anthropic/claude-opus-4-5",
|
|
agent: {
|
|
plan: {
|
|
name: "plan",
|
|
mode: "primary",
|
|
prompt: "original plan prompt",
|
|
},
|
|
},
|
|
}
|
|
const handler = createConfigHandler({
|
|
ctx: { directory: "/tmp" },
|
|
pluginConfig,
|
|
modelCacheState: {
|
|
anthropicContext1MEnabled: false,
|
|
modelContextLimitsCache: new Map(),
|
|
},
|
|
})
|
|
|
|
// #when
|
|
await handler(config)
|
|
|
|
// #then - plan is demoted to subagent but does NOT inherit prometheus prompt
|
|
const agents = config.agent as Record<string, { mode?: string; name?: string; prompt?: string }>
|
|
expect(agents.plan).toBeDefined()
|
|
expect(agents.plan.mode).toBe("subagent")
|
|
expect(agents.plan.prompt).toBeUndefined()
|
|
expect(agents.prometheus?.prompt).toBeDefined()
|
|
})
|
|
|
|
test("plan agent remains unchanged when planner is disabled", async () => {
|
|
// #given
|
|
const pluginConfig: OhMyOpenCodeConfig = {
|
|
sisyphus_agent: {
|
|
planner_enabled: false,
|
|
},
|
|
}
|
|
const config: Record<string, unknown> = {
|
|
model: "anthropic/claude-opus-4-5",
|
|
agent: {
|
|
plan: {
|
|
name: "plan",
|
|
mode: "primary",
|
|
prompt: "original plan prompt",
|
|
},
|
|
},
|
|
}
|
|
const handler = createConfigHandler({
|
|
ctx: { directory: "/tmp" },
|
|
pluginConfig,
|
|
modelCacheState: {
|
|
anthropicContext1MEnabled: false,
|
|
modelContextLimitsCache: new Map(),
|
|
},
|
|
})
|
|
|
|
// #when
|
|
await handler(config)
|
|
|
|
// #then - plan is not touched, prometheus is not created
|
|
const agents = config.agent as Record<string, { mode?: string; name?: string; prompt?: string }>
|
|
expect(agents.prometheus).toBeUndefined()
|
|
expect(agents.plan).toBeDefined()
|
|
expect(agents.plan.mode).toBe("primary")
|
|
expect(agents.plan.prompt).toBe("original plan prompt")
|
|
})
|
|
|
|
test("prometheus should have mode 'all' to be callable via delegate_task", async () => {
|
|
// given
|
|
const pluginConfig: OhMyOpenCodeConfig = {
|
|
sisyphus_agent: {
|
|
planner_enabled: true,
|
|
},
|
|
}
|
|
const config: Record<string, unknown> = {
|
|
model: "anthropic/claude-opus-4-5",
|
|
agent: {},
|
|
}
|
|
const handler = createConfigHandler({
|
|
ctx: { directory: "/tmp" },
|
|
pluginConfig,
|
|
modelCacheState: {
|
|
anthropicContext1MEnabled: false,
|
|
modelContextLimitsCache: new Map(),
|
|
},
|
|
})
|
|
|
|
// when
|
|
await handler(config)
|
|
|
|
// then
|
|
const agents = config.agent as Record<string, { mode?: string }>
|
|
expect(agents.prometheus).toBeDefined()
|
|
expect(agents.prometheus.mode).toBe("all")
|
|
})
|
|
})
|
|
|
|
describe("Agent permission defaults", () => {
|
|
test("hephaestus should allow delegate_task", async () => {
|
|
// #given
|
|
const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as {
|
|
mockResolvedValue: (value: Record<string, unknown>) => void
|
|
}
|
|
createBuiltinAgentsMock.mockResolvedValue({
|
|
sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" },
|
|
hephaestus: { name: "hephaestus", prompt: "test", mode: "primary" },
|
|
oracle: { name: "oracle", prompt: "test", mode: "subagent" },
|
|
})
|
|
const pluginConfig: OhMyOpenCodeConfig = {}
|
|
const config: Record<string, unknown> = {
|
|
model: "anthropic/claude-opus-4-5",
|
|
agent: {},
|
|
}
|
|
const handler = createConfigHandler({
|
|
ctx: { directory: "/tmp" },
|
|
pluginConfig,
|
|
modelCacheState: {
|
|
anthropicContext1MEnabled: false,
|
|
modelContextLimitsCache: new Map(),
|
|
},
|
|
})
|
|
|
|
// #when
|
|
await handler(config)
|
|
|
|
// #then
|
|
const agentConfig = config.agent as Record<string, { permission?: Record<string, string> }>
|
|
expect(agentConfig.hephaestus).toBeDefined()
|
|
expect(agentConfig.hephaestus.permission?.delegate_task).toBe("allow")
|
|
})
|
|
})
|
|
|
|
describe("Prometheus category config resolution", () => {
|
|
test("resolves ultrabrain category config", () => {
|
|
// given
|
|
const categoryName = "ultrabrain"
|
|
|
|
// when
|
|
const config = resolveCategoryConfig(categoryName)
|
|
|
|
// then
|
|
expect(config).toBeDefined()
|
|
expect(config?.model).toBe("openai/gpt-5.2-codex")
|
|
expect(config?.variant).toBe("xhigh")
|
|
})
|
|
|
|
test("resolves visual-engineering category config", () => {
|
|
// given
|
|
const categoryName = "visual-engineering"
|
|
|
|
// when
|
|
const config = resolveCategoryConfig(categoryName)
|
|
|
|
// then
|
|
expect(config).toBeDefined()
|
|
expect(config?.model).toBe("google/gemini-3-pro")
|
|
})
|
|
|
|
test("user categories override default categories", () => {
|
|
// given
|
|
const categoryName = "ultrabrain"
|
|
const userCategories: Record<string, CategoryConfig> = {
|
|
ultrabrain: {
|
|
model: "google/antigravity-claude-opus-4-5-thinking",
|
|
temperature: 0.1,
|
|
},
|
|
}
|
|
|
|
// when
|
|
const config = resolveCategoryConfig(categoryName, userCategories)
|
|
|
|
// then
|
|
expect(config).toBeDefined()
|
|
expect(config?.model).toBe("google/antigravity-claude-opus-4-5-thinking")
|
|
expect(config?.temperature).toBe(0.1)
|
|
})
|
|
|
|
test("returns undefined for unknown category", () => {
|
|
// given
|
|
const categoryName = "nonexistent-category"
|
|
|
|
// when
|
|
const config = resolveCategoryConfig(categoryName)
|
|
|
|
// then
|
|
expect(config).toBeUndefined()
|
|
})
|
|
|
|
test("falls back to default when user category has no entry", () => {
|
|
// given
|
|
const categoryName = "ultrabrain"
|
|
const userCategories: Record<string, CategoryConfig> = {
|
|
"visual-engineering": {
|
|
model: "custom/visual-model",
|
|
},
|
|
}
|
|
|
|
// when
|
|
const config = resolveCategoryConfig(categoryName, userCategories)
|
|
|
|
// then - falls back to DEFAULT_CATEGORIES
|
|
expect(config).toBeDefined()
|
|
expect(config?.model).toBe("openai/gpt-5.2-codex")
|
|
expect(config?.variant).toBe("xhigh")
|
|
})
|
|
|
|
test("preserves all category properties (temperature, top_p, tools, etc.)", () => {
|
|
// given
|
|
const categoryName = "custom-category"
|
|
const userCategories: Record<string, CategoryConfig> = {
|
|
"custom-category": {
|
|
model: "test/model",
|
|
temperature: 0.5,
|
|
top_p: 0.9,
|
|
maxTokens: 32000,
|
|
tools: { tool1: true, tool2: false },
|
|
},
|
|
}
|
|
|
|
// when
|
|
const config = resolveCategoryConfig(categoryName, userCategories)
|
|
|
|
// then
|
|
expect(config).toBeDefined()
|
|
expect(config?.model).toBe("test/model")
|
|
expect(config?.temperature).toBe(0.5)
|
|
expect(config?.top_p).toBe(0.9)
|
|
expect(config?.maxTokens).toBe(32000)
|
|
expect(config?.tools).toEqual({ tool1: true, tool2: false })
|
|
})
|
|
})
|
|
|
|
describe("Prometheus direct override priority over category", () => {
|
|
test("direct reasoningEffort takes priority over category reasoningEffort", async () => {
|
|
// given - category has reasoningEffort=xhigh, direct override says "low"
|
|
const pluginConfig: OhMyOpenCodeConfig = {
|
|
sisyphus_agent: {
|
|
planner_enabled: true,
|
|
},
|
|
categories: {
|
|
"test-planning": {
|
|
model: "openai/gpt-5.2",
|
|
reasoningEffort: "xhigh",
|
|
},
|
|
},
|
|
agents: {
|
|
prometheus: {
|
|
category: "test-planning",
|
|
reasoningEffort: "low",
|
|
},
|
|
},
|
|
}
|
|
const config: Record<string, unknown> = {
|
|
model: "anthropic/claude-opus-4-5",
|
|
agent: {},
|
|
}
|
|
const handler = createConfigHandler({
|
|
ctx: { directory: "/tmp" },
|
|
pluginConfig,
|
|
modelCacheState: {
|
|
anthropicContext1MEnabled: false,
|
|
modelContextLimitsCache: new Map(),
|
|
},
|
|
})
|
|
|
|
// when
|
|
await handler(config)
|
|
|
|
// then - direct override's reasoningEffort wins
|
|
const agents = config.agent as Record<string, { reasoningEffort?: string }>
|
|
expect(agents.prometheus).toBeDefined()
|
|
expect(agents.prometheus.reasoningEffort).toBe("low")
|
|
})
|
|
|
|
test("category reasoningEffort applied when no direct override", async () => {
|
|
// given - category has reasoningEffort but no direct override
|
|
const pluginConfig: OhMyOpenCodeConfig = {
|
|
sisyphus_agent: {
|
|
planner_enabled: true,
|
|
},
|
|
categories: {
|
|
"reasoning-cat": {
|
|
model: "openai/gpt-5.2",
|
|
reasoningEffort: "high",
|
|
},
|
|
},
|
|
agents: {
|
|
prometheus: {
|
|
category: "reasoning-cat",
|
|
},
|
|
},
|
|
}
|
|
const config: Record<string, unknown> = {
|
|
model: "anthropic/claude-opus-4-5",
|
|
agent: {},
|
|
}
|
|
const handler = createConfigHandler({
|
|
ctx: { directory: "/tmp" },
|
|
pluginConfig,
|
|
modelCacheState: {
|
|
anthropicContext1MEnabled: false,
|
|
modelContextLimitsCache: new Map(),
|
|
},
|
|
})
|
|
|
|
// when
|
|
await handler(config)
|
|
|
|
// then - category's reasoningEffort is applied
|
|
const agents = config.agent as Record<string, { reasoningEffort?: string }>
|
|
expect(agents.prometheus).toBeDefined()
|
|
expect(agents.prometheus.reasoningEffort).toBe("high")
|
|
})
|
|
|
|
test("direct temperature takes priority over category temperature", async () => {
|
|
// given
|
|
const pluginConfig: OhMyOpenCodeConfig = {
|
|
sisyphus_agent: {
|
|
planner_enabled: true,
|
|
},
|
|
categories: {
|
|
"temp-cat": {
|
|
model: "openai/gpt-5.2",
|
|
temperature: 0.8,
|
|
},
|
|
},
|
|
agents: {
|
|
prometheus: {
|
|
category: "temp-cat",
|
|
temperature: 0.1,
|
|
},
|
|
},
|
|
}
|
|
const config: Record<string, unknown> = {
|
|
model: "anthropic/claude-opus-4-5",
|
|
agent: {},
|
|
}
|
|
const handler = createConfigHandler({
|
|
ctx: { directory: "/tmp" },
|
|
pluginConfig,
|
|
modelCacheState: {
|
|
anthropicContext1MEnabled: false,
|
|
modelContextLimitsCache: new Map(),
|
|
},
|
|
})
|
|
|
|
// when
|
|
await handler(config)
|
|
|
|
// then - direct temperature wins over category
|
|
const agents = config.agent as Record<string, { temperature?: number }>
|
|
expect(agents.prometheus).toBeDefined()
|
|
expect(agents.prometheus.temperature).toBe(0.1)
|
|
})
|
|
|
|
test("prometheus prompt_append is appended to base prompt", async () => {
|
|
// #given - prometheus override with prompt_append
|
|
const customInstructions = "## Custom Project Rules\nUse max 2 commits."
|
|
const pluginConfig: OhMyOpenCodeConfig = {
|
|
sisyphus_agent: {
|
|
planner_enabled: true,
|
|
},
|
|
agents: {
|
|
prometheus: {
|
|
prompt_append: customInstructions,
|
|
},
|
|
},
|
|
}
|
|
const config: Record<string, unknown> = {
|
|
model: "anthropic/claude-opus-4-5",
|
|
agent: {},
|
|
}
|
|
const handler = createConfigHandler({
|
|
ctx: { directory: "/tmp" },
|
|
pluginConfig,
|
|
modelCacheState: {
|
|
anthropicContext1MEnabled: false,
|
|
modelContextLimitsCache: new Map(),
|
|
},
|
|
})
|
|
|
|
// #when
|
|
await handler(config)
|
|
|
|
// #then - prompt_append is appended to base prompt, not overwriting it
|
|
const agents = config.agent as Record<string, { prompt?: string }>
|
|
expect(agents.prometheus).toBeDefined()
|
|
expect(agents.prometheus.prompt).toContain("Prometheus")
|
|
expect(agents.prometheus.prompt).toContain(customInstructions)
|
|
expect(agents.prometheus.prompt!.endsWith(customInstructions)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe("Deadlock prevention - fetchAvailableModels must not receive client", () => {
|
|
test("fetchAvailableModels should be called with undefined client to prevent deadlock during plugin init", async () => {
|
|
// given - This test ensures we don't regress on issue #1301
|
|
// Passing client to fetchAvailableModels during config handler causes deadlock:
|
|
// - Plugin init waits for server response (client.provider.list())
|
|
// - Server waits for plugin init to complete before handling requests
|
|
const fetchSpy = spyOn(shared, "fetchAvailableModels" as any).mockResolvedValue(new Set<string>())
|
|
|
|
const pluginConfig: OhMyOpenCodeConfig = {
|
|
sisyphus_agent: {
|
|
planner_enabled: true,
|
|
},
|
|
}
|
|
const config: Record<string, unknown> = {
|
|
model: "anthropic/claude-opus-4-5",
|
|
agent: {},
|
|
}
|
|
const mockClient = {
|
|
provider: { list: () => Promise.resolve({ data: { connected: [] } }) },
|
|
model: { list: () => Promise.resolve({ data: [] }) },
|
|
}
|
|
const handler = createConfigHandler({
|
|
ctx: { directory: "/tmp", client: mockClient },
|
|
pluginConfig,
|
|
modelCacheState: {
|
|
anthropicContext1MEnabled: false,
|
|
modelContextLimitsCache: new Map(),
|
|
},
|
|
})
|
|
|
|
// when
|
|
await handler(config)
|
|
|
|
// then - fetchAvailableModels must be called with undefined as first argument (no client)
|
|
// This prevents the deadlock described in issue #1301
|
|
expect(fetchSpy).toHaveBeenCalled()
|
|
const firstCallArgs = fetchSpy.mock.calls[0]
|
|
expect(firstCallArgs[0]).toBeUndefined()
|
|
|
|
fetchSpy.mockRestore?.()
|
|
})
|
|
})
|