From 5d3215167abc4e1762b53c87cd24249ea2d8beb3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 11 Feb 2026 15:39:15 +0900 Subject: [PATCH] fix: respect user-pinned plugin version, skip auto-update when explicitly pinned When a user pins oh-my-opencode to a specific version (e.g., oh-my-opencode@3.4.0), the auto-update checker now respects that choice and only shows a notification toast instead of overwriting the pinned version with latest. - Skip updatePinnedVersion() when pluginInfo.isPinned is true - Show update-available toast only (notification, no modification) - Added comprehensive tests for pinned/unpinned/autoUpdate scenarios Fixes #1745 --- .../hook/background-update-check.test.ts | 174 ++++++++++++++++++ .../hook/background-update-check.ts | 10 +- 2 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 src/hooks/auto-update-checker/hook/background-update-check.test.ts diff --git a/src/hooks/auto-update-checker/hook/background-update-check.test.ts b/src/hooks/auto-update-checker/hook/background-update-check.test.ts new file mode 100644 index 00000000..8d3009b5 --- /dev/null +++ b/src/hooks/auto-update-checker/hook/background-update-check.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, mock, beforeEach } from "bun:test" + +// Mock modules before importing +const mockFindPluginEntry = mock(() => null as any) +const mockGetCachedVersion = mock(() => null as string | null) +const mockGetLatestVersion = mock(async () => null as string | null) +const mockUpdatePinnedVersion = mock(() => false) +const mockExtractChannel = mock(() => "latest") +const mockInvalidatePackage = mock(() => {}) +const mockRunBunInstall = mock(async () => true) +const mockShowUpdateAvailableToast = mock(async () => {}) +const mockShowAutoUpdatedToast = mock(async () => {}) + +mock.module("../checker", () => ({ + findPluginEntry: mockFindPluginEntry, + getCachedVersion: mockGetCachedVersion, + getLatestVersion: mockGetLatestVersion, + updatePinnedVersion: mockUpdatePinnedVersion, +})) + +mock.module("../version-channel", () => ({ + extractChannel: mockExtractChannel, +})) + +mock.module("../cache", () => ({ + invalidatePackage: mockInvalidatePackage, +})) + +mock.module("../../../cli/config-manager", () => ({ + runBunInstall: mockRunBunInstall, +})) + +mock.module("./update-toasts", () => ({ + showUpdateAvailableToast: mockShowUpdateAvailableToast, + showAutoUpdatedToast: mockShowAutoUpdatedToast, +})) + +mock.module("../../../shared/logger", () => ({ + log: () => {}, +})) + +const { runBackgroundUpdateCheck } = await import("./background-update-check") + +describe("runBackgroundUpdateCheck", () => { + const mockCtx = { directory: "/test" } as any + const mockGetToastMessage = (isUpdate: boolean, version?: string) => + isUpdate ? `Update to ${version}` : "Up to date" + + beforeEach(() => { + mockFindPluginEntry.mockReset() + mockGetCachedVersion.mockReset() + mockGetLatestVersion.mockReset() + mockUpdatePinnedVersion.mockReset() + mockExtractChannel.mockReset() + mockInvalidatePackage.mockReset() + mockRunBunInstall.mockReset() + mockShowUpdateAvailableToast.mockReset() + mockShowAutoUpdatedToast.mockReset() + + mockExtractChannel.mockReturnValue("latest") + mockRunBunInstall.mockResolvedValue(true) + }) + + describe("#given user has pinned a specific version", () => { + beforeEach(() => { + mockFindPluginEntry.mockReturnValue({ + entry: "oh-my-opencode@3.4.0", + isPinned: true, + pinnedVersion: "3.4.0", + configPath: "/test/opencode.json", + }) + mockGetCachedVersion.mockReturnValue("3.4.0") + mockGetLatestVersion.mockResolvedValue("3.5.0") + }) + + it("#then should NOT call updatePinnedVersion", async () => { + await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage) + + expect(mockUpdatePinnedVersion).not.toHaveBeenCalled() + }) + + it("#then should show update-available toast instead", async () => { + await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage) + + expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith( + mockCtx, + "3.5.0", + mockGetToastMessage + ) + }) + + it("#then should NOT run bun install", async () => { + await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage) + + expect(mockRunBunInstall).not.toHaveBeenCalled() + }) + + it("#then should NOT invalidate package cache", async () => { + await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage) + + expect(mockInvalidatePackage).not.toHaveBeenCalled() + }) + }) + + describe("#given user has NOT pinned a version (unpinned)", () => { + beforeEach(() => { + mockFindPluginEntry.mockReturnValue({ + entry: "oh-my-opencode", + isPinned: false, + pinnedVersion: null, + configPath: "/test/opencode.json", + }) + mockGetCachedVersion.mockReturnValue("3.4.0") + mockGetLatestVersion.mockResolvedValue("3.5.0") + }) + + it("#then should proceed with auto-update", async () => { + await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage) + + expect(mockInvalidatePackage).toHaveBeenCalled() + expect(mockRunBunInstall).toHaveBeenCalled() + }) + + it("#then should show auto-updated toast on success", async () => { + mockRunBunInstall.mockResolvedValue(true) + + await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage) + + expect(mockShowAutoUpdatedToast).toHaveBeenCalled() + }) + }) + + describe("#given autoUpdate is false", () => { + beforeEach(() => { + mockFindPluginEntry.mockReturnValue({ + entry: "oh-my-opencode", + isPinned: false, + pinnedVersion: null, + configPath: "/test/opencode.json", + }) + mockGetCachedVersion.mockReturnValue("3.4.0") + mockGetLatestVersion.mockResolvedValue("3.5.0") + }) + + it("#then should only show notification toast", async () => { + await runBackgroundUpdateCheck(mockCtx, false, mockGetToastMessage) + + expect(mockShowUpdateAvailableToast).toHaveBeenCalled() + expect(mockRunBunInstall).not.toHaveBeenCalled() + expect(mockUpdatePinnedVersion).not.toHaveBeenCalled() + }) + }) + + describe("#given already on latest version", () => { + beforeEach(() => { + mockFindPluginEntry.mockReturnValue({ + entry: "oh-my-opencode@3.5.0", + isPinned: true, + pinnedVersion: "3.5.0", + configPath: "/test/opencode.json", + }) + mockGetCachedVersion.mockReturnValue("3.5.0") + mockGetLatestVersion.mockResolvedValue("3.5.0") + }) + + it("#then should not update or show toast", async () => { + await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage) + + expect(mockUpdatePinnedVersion).not.toHaveBeenCalled() + expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled() + expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/hooks/auto-update-checker/hook/background-update-check.ts b/src/hooks/auto-update-checker/hook/background-update-check.ts index 908b5634..d291633d 100644 --- a/src/hooks/auto-update-checker/hook/background-update-check.ts +++ b/src/hooks/auto-update-checker/hook/background-update-check.ts @@ -56,13 +56,9 @@ export async function runBackgroundUpdateCheck( } if (pluginInfo.isPinned) { - const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion) - if (!updated) { - await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) - log("[auto-update-checker] Failed to update pinned version in config") - return - } - log(`[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`) + await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) + log(`[auto-update-checker] User-pinned version detected (${pluginInfo.entry}), skipping auto-update. Notification only.`) + return } invalidatePackage(PACKAGE_NAME)