From 80c8a793ec345aa99ee0356ca8d9d00877f2f5bb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 4 Apr 2026 18:56:42 +0900 Subject: [PATCH] Add renamed isolated mock test directories (zauc-mocks-*) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add zauc-mocks-bg, zauc-mocks-cache, zauc-mocks-hook, zauc-mocks-ws, and zauc-sync-mocks directories - Renamed from _auc-mocks-* to zauc-mocks-* for better organization 🤖 GENERATED WITH ASSISTANCE OF OhMyOpenCode --- .../background-update-check.test.ts | 238 ++++++++++++ src/hooks/zauc-mocks-cache/cache.test.ts | 112 ++++++ src/hooks/zauc-mocks-hook/hook.test.ts | 257 +++++++++++++ .../workspace-resolution.test.ts | 158 ++++++++ .../zauc-sync-mocks/sync-package-json.test.ts | 361 ++++++++++++++++++ 5 files changed, 1126 insertions(+) create mode 100644 src/hooks/zauc-mocks-bg/background-update-check.test.ts create mode 100644 src/hooks/zauc-mocks-cache/cache.test.ts create mode 100644 src/hooks/zauc-mocks-hook/hook.test.ts create mode 100644 src/hooks/zauc-mocks-ws/workspace-resolution.test.ts create mode 100644 src/hooks/zauc-sync-mocks/sync-package-json.test.ts diff --git a/src/hooks/zauc-mocks-bg/background-update-check.test.ts b/src/hooks/zauc-mocks-bg/background-update-check.test.ts new file mode 100644 index 000000000..125018706 --- /dev/null +++ b/src/hooks/zauc-mocks-bg/background-update-check.test.ts @@ -0,0 +1,238 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { beforeEach, describe, expect, it, mock } from "bun:test" + +import { createBackgroundUpdateCheckRunner } from "../auto-update-checker/hook/background-update-check" +import type { PluginEntryInfo } from "../auto-update-checker/checker" +import type { SyncResult } from "../auto-update-checker/checker/sync-package-json" + +type ToastMessageGetter = (isUpdate: boolean, version?: string) => string + +function createPluginEntry(overrides?: Partial): PluginEntryInfo { + return { + entry: "oh-my-opencode@3.4.0", + isPinned: false, + pinnedVersion: null, + configPath: "/test/opencode.json", + ...overrides, + } +} + +const mockFindPluginEntry = mock((_directory: string): PluginEntryInfo | null => createPluginEntry()) +const mockGetCachedVersion = mock((): string | null => "3.4.0") +const mockGetLatestVersion = mock(async (): Promise => "3.5.0") +const mockExtractChannel = mock(() => "latest") +const mockInvalidatePackage = mock(() => {}) +const mockRunBunInstallWithDetails = mock(async () => ({ success: true })) +const mockShowUpdateAvailableToast = mock( + async (_ctx: PluginInput, _latestVersion: string, _getToastMessage: ToastMessageGetter): Promise => {}, +) +const mockShowAutoUpdatedToast = mock( + async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise => {}, +) +const mockLog = mock(() => {}) +const mockSyncCachePackageJsonToIntent = mock((_pluginInfo: PluginEntryInfo): SyncResult => ({ + synced: true, + error: null, +})) + +function createRunner() { + return createBackgroundUpdateCheckRunner({ + existsSync: () => false, + join: (...parts) => parts.join("/"), + runBunInstallWithDetails: mockRunBunInstallWithDetails as never, + log: mockLog as never, + getOpenCodeCacheDir: () => "/cache", + getOpenCodeConfigPaths: () => ({ + configDir: "/config", + configJson: "/config/opencode.json", + configJsonc: "/config/opencode.jsonc", + packageJson: "/config/package.json", + omoConfig: "/config/oh-my-opencode.json", + }), + invalidatePackage: mockInvalidatePackage as never, + extractChannel: mockExtractChannel, + findPluginEntry: mockFindPluginEntry, + getCachedVersion: mockGetCachedVersion, + getLatestVersion: mockGetLatestVersion, + syncCachePackageJsonToIntent: mockSyncCachePackageJsonToIntent, + showUpdateAvailableToast: mockShowUpdateAvailableToast as never, + showAutoUpdatedToast: mockShowAutoUpdatedToast as never, + }) +} + +describe("runBackgroundUpdateCheck", () => { + const mockCtx = { directory: "/test" } as PluginInput + const getToastMessage: ToastMessageGetter = (isUpdate, version) => + isUpdate ? `Update to ${version}` : "Up to date" + + beforeEach(() => { + mockFindPluginEntry.mockReset() + mockGetCachedVersion.mockReset() + mockGetLatestVersion.mockReset() + mockExtractChannel.mockReset() + mockInvalidatePackage.mockReset() + mockRunBunInstallWithDetails.mockReset() + mockShowUpdateAvailableToast.mockReset() + mockShowAutoUpdatedToast.mockReset() + mockLog.mockReset() + mockSyncCachePackageJsonToIntent.mockReset() + + mockFindPluginEntry.mockReturnValue(createPluginEntry()) + mockGetCachedVersion.mockReturnValue("3.4.0") + mockGetLatestVersion.mockResolvedValue("3.5.0") + mockExtractChannel.mockReturnValue("latest") + mockRunBunInstallWithDetails.mockResolvedValue({ success: true }) + mockSyncCachePackageJsonToIntent.mockImplementation((_pluginInfo) => ({ synced: true, error: null })) + }) + + it("#given no plugin entry #when checking in background #then it returns early", async () => { + // #given + const runBackgroundUpdateCheck = createRunner() + mockFindPluginEntry.mockReturnValue(null) + + // #when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + + // #then + expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled() + expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled() + expect(mockRunBunInstallWithDetails).not.toHaveBeenCalled() + }) + + it("#given no current version #when checking in background #then it returns early", async () => { + // #given + const runBackgroundUpdateCheck = createRunner() + mockFindPluginEntry.mockReturnValue(createPluginEntry({ entry: "oh-my-opencode" })) + mockGetCachedVersion.mockReturnValue(null) + + // #when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + + // #then + expect(mockGetLatestVersion).not.toHaveBeenCalled() + expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled() + }) + + it("#given latest version fetch fails #when checking in background #then it returns early", async () => { + // #given + const runBackgroundUpdateCheck = createRunner() + mockGetLatestVersion.mockResolvedValue(null) + + // #when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + + // #then + expect(mockRunBunInstallWithDetails).not.toHaveBeenCalled() + expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled() + }) + + it("#given current version is latest #when checking in background #then it does nothing", async () => { + // #given + const runBackgroundUpdateCheck = createRunner() + mockGetLatestVersion.mockResolvedValue("3.4.0") + + // #when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + + // #then + expect(mockRunBunInstallWithDetails).not.toHaveBeenCalled() + expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled() + }) + + it("#given auto update is disabled #when checking in background #then it shows notification only", async () => { + // #given + const runBackgroundUpdateCheck = createRunner() + + // #when + await runBackgroundUpdateCheck(mockCtx, false, getToastMessage) + + // #then + expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage) + expect(mockRunBunInstallWithDetails).not.toHaveBeenCalled() + }) + + it("#given user pinned a version #when checking in background #then it skips auto update", async () => { + // #given + const runBackgroundUpdateCheck = createRunner() + mockFindPluginEntry.mockReturnValue(createPluginEntry({ isPinned: true, pinnedVersion: "3.4.0" })) + + // #when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + + // #then + expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1) + expect(mockRunBunInstallWithDetails).not.toHaveBeenCalled() + }) + + it("#given unpinned update succeeds #when checking in background #then it syncs invalidates installs and toasts", async () => { + // #given + const runBackgroundUpdateCheck = createRunner() + + // #when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + + // #then + expect(mockSyncCachePackageJsonToIntent).toHaveBeenCalledTimes(1) + expect(mockInvalidatePackage).toHaveBeenCalledTimes(1) + expect(mockRunBunInstallWithDetails).toHaveBeenCalledTimes(1) + expect(mockShowAutoUpdatedToast).toHaveBeenCalledWith(mockCtx, "3.4.0", "3.5.0") + expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled() + }) + + it("#given update succeeds #when checking in background #then it syncs before invalidate and install", async () => { + // #given + const runBackgroundUpdateCheck = createRunner() + const callOrder: string[] = [] + mockSyncCachePackageJsonToIntent.mockImplementation((_pluginInfo) => { + callOrder.push("sync") + return { synced: true, error: null } + }) + mockInvalidatePackage.mockImplementation(() => { + callOrder.push("invalidate") + }) + mockRunBunInstallWithDetails.mockImplementation(async () => { + callOrder.push("install") + return { success: true } + }) + + // #when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + + // #then + expect(callOrder).toEqual(["sync", "invalidate", "install"]) + }) + + it("#given install fails #when checking in background #then it falls back to notification only", async () => { + // #given + const runBackgroundUpdateCheck = createRunner() + mockRunBunInstallWithDetails.mockResolvedValue({ success: false }) + + // #when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + + // #then + expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage) + expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled() + }) + + for (const syncError of ["file_not_found", "plugin_not_in_deps", "parse_error", "write_error"] as const) { + it(`#given sync fails with ${syncError} #when checking in background #then it aborts and shows notification only`, async () => { + // #given + const runBackgroundUpdateCheck = createRunner() + mockSyncCachePackageJsonToIntent.mockReturnValue({ + synced: false, + error: syncError, + message: `sync failed: ${syncError}`, + }) + + // #when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + + // #then + expect(mockInvalidatePackage).not.toHaveBeenCalled() + expect(mockRunBunInstallWithDetails).not.toHaveBeenCalled() + expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage) + expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled() + }) + } +}) diff --git a/src/hooks/zauc-mocks-cache/cache.test.ts b/src/hooks/zauc-mocks-cache/cache.test.ts new file mode 100644 index 000000000..56fae578d --- /dev/null +++ b/src/hooks/zauc-mocks-cache/cache.test.ts @@ -0,0 +1,112 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test" +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +const TEST_CACHE_DIR = join(import.meta.dir, "__test-cache__") +const TEST_OPENCODE_CACHE_DIR = join(TEST_CACHE_DIR, "opencode") +const TEST_USER_CONFIG_DIR = "/tmp/opencode-config" + +let importCounter = 0 + +// Capture real modules BEFORE mocking +const _realConstants = require("../auto-update-checker/constants") +const _realLogger = require("../../shared/logger") + +async function importFreshCacheModule(): Promise { + mock.module("../auto-update-checker/constants", () => ({ + CACHE_DIR: TEST_OPENCODE_CACHE_DIR, + USER_CONFIG_DIR: TEST_USER_CONFIG_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_OPENCODE_CACHE_DIR, "version"), + USER_OPENCODE_CONFIG: join(TEST_USER_CONFIG_DIR, "opencode.json"), + USER_OPENCODE_CONFIG_JSONC: join(TEST_USER_CONFIG_DIR, "opencode.jsonc"), + INSTALLED_PACKAGE_JSON: join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"), + getWindowsAppdataDir: () => null, + })) + + mock.module("../../shared/logger", () => ({ + log: () => {}, + })) + + const cacheModule = await import(`../auto-update-checker/cache?test=${importCounter++}`) + mock.restore() + return cacheModule +} + +function resetTestCache(): void { + if (existsSync(TEST_CACHE_DIR)) { + rmSync(TEST_CACHE_DIR, { recursive: true, force: true }) + } + + mkdirSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode"), { recursive: true }) + writeFileSync( + join(TEST_OPENCODE_CACHE_DIR, "package.json"), + JSON.stringify({ dependencies: { "oh-my-opencode": "latest", other: "1.0.0" } }, null, 2) + ) + writeFileSync( + join(TEST_OPENCODE_CACHE_DIR, "bun.lock"), + JSON.stringify( + { + workspaces: { + "": { + dependencies: { "oh-my-opencode": "latest", other: "1.0.0" }, + }, + }, + packages: { + "oh-my-opencode": {}, + other: {}, + }, + }, + null, + 2 + ) + ) + writeFileSync( + join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"), + '{"name":"oh-my-opencode"}' + ) +} + +describe("invalidatePackage", () => { + beforeEach(() => { + resetTestCache() + }) + + afterEach(() => { + if (existsSync(TEST_CACHE_DIR)) { + rmSync(TEST_CACHE_DIR, { recursive: true, force: true }) + } + }) + + it("invalidates the installed package from the OpenCode cache directory", async () => { + const { invalidatePackage } = await importFreshCacheModule() + + const result = invalidatePackage() + + expect(result).toBe(true) + expect(existsSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode"))).toBe(false) + + const packageJson = JSON.parse(readFileSync(join(TEST_OPENCODE_CACHE_DIR, "package.json"), "utf-8")) as { + dependencies?: Record + } + expect(packageJson.dependencies?.["oh-my-opencode"]).toBe("latest") + expect(packageJson.dependencies?.other).toBe("1.0.0") + + const bunLock = JSON.parse(readFileSync(join(TEST_OPENCODE_CACHE_DIR, "bun.lock"), "utf-8")) as { + workspaces?: { ""?: { dependencies?: Record } } + packages?: Record + } + expect(bunLock.workspaces?.[""]?.dependencies?.["oh-my-opencode"]).toBe("latest") + expect(bunLock.workspaces?.[""]?.dependencies?.other).toBe("1.0.0") + expect(bunLock.packages?.["oh-my-opencode"]).toBeUndefined() + expect(bunLock.packages?.other).toEqual({}) + }) +}) + +afterAll(() => { + mock.module("../auto-update-checker/constants", () => _realConstants) + mock.module("../../shared/logger", () => _realLogger) + mock.restore() +}) diff --git a/src/hooks/zauc-mocks-hook/hook.test.ts b/src/hooks/zauc-mocks-hook/hook.test.ts new file mode 100644 index 000000000..c6fab5c11 --- /dev/null +++ b/src/hooks/zauc-mocks-hook/hook.test.ts @@ -0,0 +1,257 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test" + +const mockShowConfigErrorsIfAny = mock(async () => {}) +const mockShowModelCacheWarningIfNeeded = mock(async () => {}) +const mockUpdateAndShowConnectedProvidersCacheStatus = mock(async () => {}) +const mockRefreshModelCapabilitiesOnStartup = mock(async () => {}) +const mockShowLocalDevToast = mock(async () => {}) +const mockShowVersionToast = mock(async () => {}) +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 { + 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", + client: {} as never, + } as never +} + +async function flushScheduledWork(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, 0) + }) + await Promise.resolve() + await Promise.resolve() +} + +function runSessionCreatedEvent( + hook: ReturnType, + properties?: { info?: { parentID?: string } } +): void { + hook.event({ + event: { + type: "session.created", + properties, + }, + }) +} + +beforeEach(() => { + mockShowConfigErrorsIfAny.mockClear() + mockShowModelCacheWarningIfNeeded.mockClear() + mockUpdateAndShowConnectedProvidersCacheStatus.mockClear() + mockRefreshModelCapabilitiesOnStartup.mockClear() + mockShowLocalDevToast.mockClear() + mockShowVersionToast.mockClear() + mockRunBackgroundUpdateCheck.mockClear() + mockGetCachedVersion.mockClear() + mockGetLocalDevVersion.mockClear() + + mockGetCachedVersion.mockReturnValue("3.6.0") + mockGetLocalDevVersion.mockReturnValue(null) +}) + +afterEach(() => { + delete process.env.OPENCODE_CLI_RUN_MODE +}) + +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, + }) + + //#when - session.created event arrives + runSessionCreatedEvent(hook, { info: { parentID: undefined } }) + await flushScheduledWork() + + //#then - no update checker side effects run + expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled() + expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled() + expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled() + expect(mockRefreshModelCapabilitiesOnStartup).not.toHaveBeenCalled() + expect(mockShowLocalDevToast).not.toHaveBeenCalled() + expect(mockShowVersionToast).not.toHaveBeenCalled() + expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled() + }) + + 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()) + + //#when - session.created event arrives on primary session + runSessionCreatedEvent(hook) + await flushScheduledWork() + + //#then - startup checks, toast, and background check run + expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1) + expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1) + expect(mockRefreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1) + expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1) + expect(mockShowVersionToast).toHaveBeenCalledTimes(1) + expect(mockRunBackgroundUpdateCheck).toHaveBeenCalledTimes(1) + }) + + it("ignores subagent sessions (parentID present)", async () => { + //#given - a subagent session with parentID + const createAutoUpdateCheckerHook = await importFreshHookFactory() + const hook = createAutoUpdateCheckerHook(createPluginInput()) + + //#when - session.created event contains parentID + runSessionCreatedEvent(hook, { info: { parentID: "parent-123" } }) + await flushScheduledWork() + + //#then - no startup actions run + expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled() + expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled() + expect(mockRefreshModelCapabilitiesOnStartup).not.toHaveBeenCalled() + expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled() + expect(mockShowLocalDevToast).not.toHaveBeenCalled() + expect(mockShowVersionToast).not.toHaveBeenCalled() + expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled() + }) + + it("runs only once (hasChecked guard)", async () => { + //#given - one hook instance in normal mode + const createAutoUpdateCheckerHook = await importFreshHookFactory() + const hook = createAutoUpdateCheckerHook(createPluginInput()) + + //#when - session.created event is fired twice + runSessionCreatedEvent(hook) + runSessionCreatedEvent(hook) + await flushScheduledWork() + + //#then - side effects execute only once + expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1) + expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1) + expect(mockRefreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1) + expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1) + expect(mockShowVersionToast).toHaveBeenCalledTimes(1) + expect(mockRunBackgroundUpdateCheck).toHaveBeenCalledTimes(1) + }) + + 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()) + + //#when - session.created event arrives + runSessionCreatedEvent(hook) + await flushScheduledWork() + + //#then - local dev toast is shown and background check is skipped + expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1) + expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1) + expect(mockRefreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1) + expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1) + expect(mockShowLocalDevToast).toHaveBeenCalledTimes(1) + expect(mockShowVersionToast).not.toHaveBeenCalled() + expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled() + }) + + it("ignores non-session.created events", async () => { + //#given - a hook instance in normal mode + const createAutoUpdateCheckerHook = await importFreshHookFactory() + const hook = createAutoUpdateCheckerHook(createPluginInput()) + + //#when - a non-session.created event arrives + hook.event({ + event: { + type: "session.deleted", + }, + }) + await flushScheduledWork() + + //#then - no startup actions run + expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled() + expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled() + expect(mockRefreshModelCapabilitiesOnStartup).not.toHaveBeenCalled() + expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled() + expect(mockShowLocalDevToast).not.toHaveBeenCalled() + expect(mockShowVersionToast).not.toHaveBeenCalled() + expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled() + }) + + it("passes correct toast message with sisyphus enabled", async () => { + //#given - sisyphus mode enabled + const createAutoUpdateCheckerHook = await importFreshHookFactory() + const hook = createAutoUpdateCheckerHook(createPluginInput(), { + isSisyphusEnabled: true, + }) + + //#when - session.created event arrives + runSessionCreatedEvent(hook) + await flushScheduledWork() + + //#then - startup toast includes sisyphus wording + expect(mockShowVersionToast).toHaveBeenCalledTimes(1) + expect(mockShowVersionToast).toHaveBeenCalledWith( + expect.anything(), + "3.6.0", + expect.stringContaining("Sisyphus") + ) + }) +}) diff --git a/src/hooks/zauc-mocks-ws/workspace-resolution.test.ts b/src/hooks/zauc-mocks-ws/workspace-resolution.test.ts new file mode 100644 index 000000000..86c3580ad --- /dev/null +++ b/src/hooks/zauc-mocks-ws/workspace-resolution.test.ts @@ -0,0 +1,158 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +import { createBackgroundUpdateCheckRunner } from "../auto-update-checker/hook/background-update-check" +import type { PluginEntryInfo } from "../auto-update-checker/checker" +import type { SyncResult } from "../auto-update-checker/checker/sync-package-json" + +type ToastMessageGetter = (isUpdate: boolean, version?: string) => string + +function createPluginEntry(overrides?: Partial): PluginEntryInfo { + return { + entry: "oh-my-opencode@3.4.0", + isPinned: false, + pinnedVersion: null, + configPath: "/test/opencode.json", + ...overrides, + } +} + +const TEST_DIR = join(import.meta.dir, "__test-workspace-resolution__") +const TEST_CACHE_DIR = join(TEST_DIR, "cache") +const TEST_CONFIG_DIR = join(TEST_DIR, "config") + +const mockFindPluginEntry = mock((_directory: string): PluginEntryInfo | null => createPluginEntry()) +const mockGetCachedVersion = mock((): string | null => "3.4.0") +const mockGetLatestVersion = mock(async (): Promise => "3.5.0") +const mockExtractChannel = mock(() => "latest") +const mockInvalidatePackage = mock(() => {}) +const mockShowUpdateAvailableToast = mock( + async (_ctx: PluginInput, _latestVersion: string, _getToastMessage: ToastMessageGetter): Promise => {}, +) +const mockShowAutoUpdatedToast = mock( + async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise => {}, +) +const mockSyncCachePackageJsonToIntent = mock((_pluginInfo: PluginEntryInfo): SyncResult => ({ synced: true, error: null })) +const mockRunBunInstallWithDetails = mock(async (_opts?: { outputMode?: string; workspaceDir?: string }) => ({ success: true })) +const mockLog = mock(() => {}) + +function createRunner() { + return createBackgroundUpdateCheckRunner({ + existsSync, + join, + runBunInstallWithDetails: mockRunBunInstallWithDetails as never, + log: mockLog as never, + getOpenCodeCacheDir: () => TEST_CACHE_DIR, + getOpenCodeConfigPaths: () => ({ + configDir: TEST_CONFIG_DIR, + configJson: join(TEST_CONFIG_DIR, "opencode.json"), + configJsonc: join(TEST_CONFIG_DIR, "opencode.jsonc"), + packageJson: join(TEST_CONFIG_DIR, "package.json"), + omoConfig: join(TEST_CONFIG_DIR, "oh-my-opencode.json"), + }), + invalidatePackage: mockInvalidatePackage as never, + extractChannel: mockExtractChannel, + findPluginEntry: mockFindPluginEntry, + getCachedVersion: mockGetCachedVersion, + getLatestVersion: mockGetLatestVersion, + syncCachePackageJsonToIntent: mockSyncCachePackageJsonToIntent, + showUpdateAvailableToast: mockShowUpdateAvailableToast as never, + showAutoUpdatedToast: mockShowAutoUpdatedToast as never, + }) +} + +describe("workspace resolution", () => { + const mockCtx = { directory: "/test" } as PluginInput + const getToastMessage: ToastMessageGetter = (isUpdate, version) => + isUpdate ? `Update to ${version}` : "Up to date" + + beforeEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }) + } + mkdirSync(TEST_DIR, { recursive: true }) + + mockFindPluginEntry.mockReset() + mockGetCachedVersion.mockReset() + mockGetLatestVersion.mockReset() + mockExtractChannel.mockReset() + mockInvalidatePackage.mockReset() + mockRunBunInstallWithDetails.mockReset() + mockShowUpdateAvailableToast.mockReset() + mockShowAutoUpdatedToast.mockReset() + mockSyncCachePackageJsonToIntent.mockReset() + mockLog.mockReset() + + mockFindPluginEntry.mockReturnValue(createPluginEntry()) + mockGetCachedVersion.mockReturnValue("3.4.0") + mockGetLatestVersion.mockResolvedValue("3.5.0") + mockExtractChannel.mockReturnValue("latest") + mockRunBunInstallWithDetails.mockResolvedValue({ success: true }) + mockSyncCachePackageJsonToIntent.mockReturnValue({ synced: true, error: null }) + }) + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }) + } + }) + + it("#given config-dir install exists but cache-dir does not #when updating #then it installs to config-dir", async () => { + // #given + const runBackgroundUpdateCheck = createRunner() + mkdirSync(join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode"), { recursive: true }) + writeFileSync(join(TEST_CONFIG_DIR, "package.json"), JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)) + writeFileSync( + join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode", "package.json"), + JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2), + ) + + // #when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + + // #then + expect(mockRunBunInstallWithDetails.mock.calls[0]?.[0]?.workspaceDir).toBe(TEST_CONFIG_DIR) + }) + + it("#given both config-dir and cache-dir installs exist #when updating #then it prefers config-dir", async () => { + // #given + const runBackgroundUpdateCheck = createRunner() + mkdirSync(join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode"), { recursive: true }) + writeFileSync(join(TEST_CONFIG_DIR, "package.json"), JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)) + writeFileSync( + join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode", "package.json"), + JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2), + ) + mkdirSync(join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode"), { recursive: true }) + writeFileSync(join(TEST_CACHE_DIR, "package.json"), JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)) + writeFileSync( + join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"), + JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2), + ) + + // #when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + + // #then + expect(mockRunBunInstallWithDetails.mock.calls[0]?.[0]?.workspaceDir).toBe(TEST_CONFIG_DIR) + }) + + it("#given only cache-dir install exists #when updating #then it falls back to cache-dir", async () => { + // #given + const runBackgroundUpdateCheck = createRunner() + mkdirSync(join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode"), { recursive: true }) + writeFileSync(join(TEST_CACHE_DIR, "package.json"), JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)) + writeFileSync( + join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"), + JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2), + ) + + // #when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + + // #then + expect(mockRunBunInstallWithDetails.mock.calls[0]?.[0]?.workspaceDir).toBe(TEST_CACHE_DIR) + }) +}) diff --git a/src/hooks/zauc-sync-mocks/sync-package-json.test.ts b/src/hooks/zauc-sync-mocks/sync-package-json.test.ts new file mode 100644 index 000000000..67fc08722 --- /dev/null +++ b/src/hooks/zauc-sync-mocks/sync-package-json.test.ts @@ -0,0 +1,361 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test" +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import type { PluginEntryInfo } from "../auto-update-checker/checker/plugin-entry" + +const TEST_CACHE_DIR = join(import.meta.dir, "__test-sync-cache__") + +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 { + 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"), + USER_CONFIG_DIR: "/tmp/opencode-config", + USER_OPENCODE_CONFIG: "/tmp/opencode-config/opencode.json", + USER_OPENCODE_CONFIG_JSONC: "/tmp/opencode-config/opencode.jsonc", + INSTALLED_PACKAGE_JSON: join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"), + getWindowsAppdataDir: () => null, + })) + + mock.module("../../shared/logger", () => ({ + log: () => {}, + })) + + const syncPackageJsonModule = await import(`../auto-update-checker/checker/sync-package-json?test=${importCounter++}`) + mock.restore() + return syncPackageJsonModule +} + +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 }) + writeFileSync( + join(TEST_CACHE_DIR, "package.json"), + 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 }) + } +} + +function readCachePackageJsonVersion(): string | undefined { + const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8") + const pkg = JSON.parse(content) as { dependencies?: Record } + return pkg.dependencies?.["oh-my-opencode"] +} + +describe("syncCachePackageJsonToIntent", () => { + beforeEach(() => { + resetTestCache() + }) + + afterEach(() => { + cleanupTestCache() + }) + + describe("#given cache package.json with pinned semver version", () => { + describe("#when opencode.json intent is latest tag", () => { + it("#then updates package.json to use latest", async () => { + const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule() + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@latest", + isPinned: false, + pinnedVersion: "latest", + configPath: "/tmp/opencode.json", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + expect(result.synced).toBe(true) + expect(result.error).toBeNull() + expect(readCachePackageJsonVersion()).toBe("latest") + }) + }) + + describe("#when opencode.json intent is next tag", () => { + it("#then updates package.json to use next", async () => { + const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule() + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@next", + isPinned: false, + pinnedVersion: "next", + configPath: "/tmp/opencode.json", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + expect(result.synced).toBe(true) + expect(result.error).toBeNull() + expect(readCachePackageJsonVersion()).toBe("next") + }) + }) + + describe("#when opencode.json has no version (implies latest)", () => { + it("#then updates package.json to use latest", async () => { + const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule() + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode", + isPinned: false, + pinnedVersion: null, + configPath: "/tmp/opencode.json", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + expect(result.synced).toBe(true) + expect(result.error).toBeNull() + expect(readCachePackageJsonVersion()).toBe("latest") + }) + }) + }) + + describe("#given cache package.json already matches intent", () => { + it("#then returns synced false with no error", async () => { + resetTestCache("latest") + const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule() + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@latest", + isPinned: false, + pinnedVersion: "latest", + configPath: "/tmp/opencode.json", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + expect(result.synced).toBe(false) + expect(result.error).toBeNull() + expect(readCachePackageJsonVersion()).toBe("latest") + }) + }) + + describe("#given cache package.json does not exist", () => { + it("#then returns file_not_found error", async () => { + cleanupTestCache() + const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule() + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@latest", + isPinned: false, + pinnedVersion: "latest", + configPath: "/tmp/opencode.json", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + expect(result.synced).toBe(false) + expect(result.error).toBe("file_not_found") + }) + }) + + describe("#given plugin not in cache package.json dependencies", () => { + it("#then returns plugin_not_in_deps error", async () => { + cleanupTestCache() + mkdirSync(TEST_CACHE_DIR, { recursive: true }) + writeFileSync( + join(TEST_CACHE_DIR, "package.json"), + JSON.stringify({ dependencies: { other: "1.0.0" } }, null, 2) + ) + + const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule() + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@latest", + isPinned: false, + pinnedVersion: "latest", + configPath: "/tmp/opencode.json", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + expect(result.synced).toBe(false) + expect(result.error).toBe("plugin_not_in_deps") + }) + }) + + describe("#given user explicitly changed from one semver to another", () => { + it("#then updates package.json to new version", async () => { + resetTestCache("3.9.0") + const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule() + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@3.10.0", + isPinned: true, + pinnedVersion: "3.10.0", + configPath: "/tmp/opencode.json", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + expect(result.synced).toBe(true) + expect(result.error).toBeNull() + expect(readCachePackageJsonVersion()).toBe("3.10.0") + }) + }) + + describe("#given cache package.json with other dependencies", () => { + it("#then other dependencies are preserved when updating plugin version", async () => { + const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule() + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@latest", + isPinned: false, + pinnedVersion: "latest", + configPath: "/tmp/opencode.json", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + 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 } + 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 }") + + const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule() + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@latest", + isPinned: false, + pinnedVersion: "latest", + configPath: "/tmp/opencode.json", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + expect(result.synced).toBe(false) + expect(result.error).toBe("parse_error") + }) + }) + + describe("#given write permission denied", () => { + it("#then returns write_error", async () => { + cleanupTestCache() + mkdirSync(TEST_CACHE_DIR, { recursive: true }) + writeFileSync( + join(TEST_CACHE_DIR, "package.json"), + JSON.stringify({ dependencies: { "oh-my-opencode": "3.10.0" } }, null, 2) + ) + + const fs = await import("node:fs") + const originalWriteFileSync = fs.writeFileSync + const originalRenameSync = fs.renameSync + + mock.module("node:fs", () => ({ + ...fs, + writeFileSync: mock(() => { + throw new Error("EACCES: permission denied") + }), + renameSync: fs.renameSync, + })) + + try { + const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule() + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@latest", + isPinned: false, + pinnedVersion: "latest", + configPath: "/tmp/opencode.json", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + expect(result.synced).toBe(false) + expect(result.error).toBe("write_error") + } finally { + mock.module("node:fs", () => ({ + ...fs, + writeFileSync: originalWriteFileSync, + renameSync: originalRenameSync, + })) + } + }) + }) + + 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 }) + writeFileSync( + join(TEST_CACHE_DIR, "package.json"), + JSON.stringify({ dependencies: { "oh-my-opencode": "3.10.0" } }, null, 2) + ) + + const fs = await import("node:fs") + const originalWriteFileSync = fs.writeFileSync + const originalRenameSync = fs.renameSync + + let tempFilePath: string | null = null + + mock.module("node:fs", () => ({ + ...fs, + writeFileSync: mock((path: string, data: string) => { + tempFilePath = path + return originalWriteFileSync(path, data) + }), + renameSync: mock(() => { + throw new Error("EXDEV: cross-device link not permitted") + }), + })) + + try { + const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule() + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@latest", + isPinned: false, + pinnedVersion: "latest", + configPath: "/tmp/opencode.json", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + expect(result.synced).toBe(false) + expect(result.error).toBe("write_error") + expect(tempFilePath).not.toBeNull() + expect(existsSync(tempFilePath!)).toBe(false) + } finally { + mock.module("node:fs", () => ({ + ...fs, + writeFileSync: originalWriteFileSync, + renameSync: originalRenameSync, + })) + } + }) + }) +}) + +afterAll(() => { + mock.module("../auto-update-checker/constants", () => _realConstants) + mock.module("../../shared/logger", () => _realLogger) + mock.module("node:fs", () => _realNodeFs) + mock.restore() +})