diff --git a/src/hooks/auto-update-checker/hook.test.ts b/src/hooks/auto-update-checker/hook.test.ts index b7d8e5232..4a8096bb9 100644 --- a/src/hooks/auto-update-checker/hook.test.ts +++ b/src/hooks/auto-update-checker/hook.test.ts @@ -1,8 +1,13 @@ import type { PluginInput } from "@opencode-ai/plugin" import { describe, expect, mock, test } from "bun:test" +type CreateAutoUpdateCheckerHook = typeof import("./hook").createAutoUpdateCheckerHook +type HookOptions = Parameters[1] +type HookDeps = NonNullable[2]> + let latestVersionCallCount = 0 let scheduleDeferredIdleCheckCallCount = 0 + const flushMicrotasks = async (count: number): Promise => { for (let index = 0; index < count; index += 1) { await Promise.resolve() @@ -29,72 +34,192 @@ mock.module("./hook/deferred-idle-check", () => ({ scheduleDeferredIdleCheck: scheduleDeferredIdleCheckMock, })) -const createHook = async () => { +const createPluginInput = (): PluginInput => ({ + client: {} as PluginInput["client"], + directory: "/tmp/project", + project: {} as PluginInput["project"], + worktree: "/tmp/project", + serverUrl: new URL("https://example.com"), + $: {} as PluginInput["$"], +} satisfies PluginInput) + +const createDeps = (overrides: Partial = {}) => { + const showConfigErrorsIfAny = mock(async () => undefined) + const updateAndShowConnectedProvidersCacheStatus = mock(async () => undefined) + const refreshModelCapabilitiesOnStartup = mock(async () => undefined) + const showModelCacheWarningIfNeeded = mock(async () => undefined) + const showLocalDevToast = mock(async () => undefined) + const showVersionToast = mock(async () => undefined) + const runBackgroundUpdateCheck = mock(async () => { + await latestVersionMock() + }) + + const deps: HookDeps = { + getCachedVersion: () => "3.0.0", + getLocalDevVersion: () => null, + showConfigErrorsIfAny, + updateAndShowConnectedProvidersCacheStatus, + refreshModelCapabilitiesOnStartup, + showModelCacheWarningIfNeeded, + showLocalDevToast, + showVersionToast, + runBackgroundUpdateCheck, + log: () => undefined, + ...overrides, + } + + return { + deps, + mocks: { + showConfigErrorsIfAny, + updateAndShowConnectedProvidersCacheStatus, + refreshModelCapabilitiesOnStartup, + showModelCacheWarningIfNeeded, + showLocalDevToast, + showVersionToast, + runBackgroundUpdateCheck, + }, + } +} + +const createHook = async ( + options: HookOptions = {}, + overrides: Partial = {}, +) => { const module = await import("./hook") - return module.createAutoUpdateCheckerHook( - { - directory: "/tmp/project", - client: { - tui: { - showToast: async () => undefined, - }, + const { deps, mocks } = createDeps(overrides) + + return { + hook: module.createAutoUpdateCheckerHook( + createPluginInput(), + { + showStartupToast: true, + autoUpdate: false, + ...options, }, - } satisfies PluginInput, - { - showStartupToast: false, - autoUpdate: false, - }, - { - getCachedVersion: () => "3.0.0", - getLocalDevVersion: () => null, - showConfigErrorsIfAny: async () => undefined, - updateAndShowConnectedProvidersCacheStatus: async () => undefined, - refreshModelCapabilitiesOnStartup: async () => undefined, - showModelCacheWarningIfNeeded: async () => undefined, - showLocalDevToast: async () => undefined, - showVersionToast: async () => undefined, - runBackgroundUpdateCheck: async () => { - await latestVersionMock() - }, - log: () => undefined, - }, - ) + deps, + ), + mocks, + } +} + +const resetDeferredState = (): void => { + latestVersionCallCount = 0 + scheduleDeferredIdleCheckCallCount = 0 + scheduledCheck = null +} + +const triggerDeferredIdleCheck = async ( + hook: ReturnType, +): Promise => { + hook.event({ event: { type: "session.idle" } }) + scheduledCheck?.() + await flushMicrotasks(8) } describe("auto-update-checker hook", () => { test("defers update check until first session idle", async () => { // given - latestVersionCallCount = 0 - scheduleDeferredIdleCheckCallCount = 0 - scheduledCheck = null - const hook = await createHook() + resetDeferredState() + const { hook, mocks } = await createHook() // when hook.event({ event: { type: "session.created" } }) // then expect(scheduleDeferredIdleCheckCallCount).toBe(0) + expect(mocks.showVersionToast).not.toHaveBeenCalled() + expect(mocks.runBackgroundUpdateCheck).not.toHaveBeenCalled() expect(latestVersionCallCount).toBe(0) + // when + await triggerDeferredIdleCheck(hook) + + // then + expect(scheduleDeferredIdleCheckCallCount).toBe(1) + expect(mocks.showVersionToast).toHaveBeenCalledTimes(1) + expect(mocks.runBackgroundUpdateCheck).toHaveBeenCalledTimes(1) + expect(latestVersionCallCount).toBe(1) + // when hook.event({ event: { type: "session.idle" } }) // then expect(scheduleDeferredIdleCheckCallCount).toBe(1) - expect(latestVersionCallCount).toBe(0) + expect(mocks.runBackgroundUpdateCheck).toHaveBeenCalledTimes(1) + }) + + test("runs all startup checks on normal session.idle", async () => { + // given + resetDeferredState() + const { hook, mocks } = await createHook() // when - await scheduledCheck?.() + await triggerDeferredIdleCheck(hook) + + // then + expect(mocks.showConfigErrorsIfAny).toHaveBeenCalledTimes(1) + expect(mocks.updateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1) + expect(mocks.refreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1) + expect(mocks.showModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1) + expect(mocks.showVersionToast).toHaveBeenCalledTimes(1) + expect(mocks.runBackgroundUpdateCheck).toHaveBeenCalledTimes(1) + }) + + test("runs only once (hasChecked guard)", async () => { + // given + resetDeferredState() + const { hook, mocks } = await createHook() + + // when + hook.event({ event: { type: "session.idle" } }) + hook.event({ event: { type: "session.idle" } }) + scheduledCheck?.() await flushMicrotasks(8) // then - expect(latestVersionCallCount).toBe(1) + expect(scheduleDeferredIdleCheckCallCount).toBe(1) + expect(mocks.showConfigErrorsIfAny).toHaveBeenCalledTimes(1) + expect(mocks.updateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1) + expect(mocks.showModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1) + expect(mocks.showVersionToast).toHaveBeenCalledTimes(1) + expect(mocks.runBackgroundUpdateCheck).toHaveBeenCalledTimes(1) + }) + + test("shows localDevToast when local dev version exists", async () => { + // given + resetDeferredState() + const { hook, mocks } = await createHook({}, { + getLocalDevVersion: () => "3.0.0-dev", + }) // when - hook.event({ event: { type: "session.idle" } }) + await triggerDeferredIdleCheck(hook) // then - expect(scheduleDeferredIdleCheckCallCount).toBe(1) - expect(latestVersionCallCount).toBe(1) + expect(mocks.showConfigErrorsIfAny).toHaveBeenCalledTimes(1) + expect(mocks.updateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1) + expect(mocks.showModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1) + expect(mocks.showLocalDevToast).toHaveBeenCalledTimes(1) + expect(mocks.showVersionToast).not.toHaveBeenCalled() + expect(mocks.runBackgroundUpdateCheck).not.toHaveBeenCalled() + expect(latestVersionCallCount).toBe(0) + }) + + test("passes correct toast message with sisyphus enabled", async () => { + // given + resetDeferredState() + const { hook, mocks } = await createHook({ isSisyphusEnabled: true }) + + // when + await triggerDeferredIdleCheck(hook) + + // then + expect(mocks.showVersionToast).toHaveBeenCalledTimes(1) + expect(mocks.showVersionToast).toHaveBeenCalledWith( + expect.anything(), + "3.0.0", + expect.stringContaining("Sisyphus"), + ) }) })