diff --git a/src/cli/doctor/formatter.test.ts b/src/cli/doctor/formatter.test.ts index 5884997af..312ccbedc 100644 --- a/src/cli/doctor/formatter.test.ts +++ b/src/cli/doctor/formatter.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it, mock } from "bun:test" +import { describe, expect, it } from "bun:test" +import { stripAnsi } from "./format-shared" import type { DoctorResult } from "./types" function createDoctorResult(): DoctorResult { @@ -39,78 +40,122 @@ function createDoctorResult(): DoctorResult { } } -describe("formatter", () => { - afterEach(() => { - mock.restore() +function createDoctorResultWithIssues(): DoctorResult { + const base = createDoctorResult() + base.results[1].issues = [ + { title: "Config issue", description: "Bad config", severity: "error" as const, fix: "Fix it" }, + { title: "Tool warning", description: "Missing tool", severity: "warning" as const }, + ] + base.summary.failed = 1 + base.summary.warnings = 1 + return base +} + +describe("formatDoctorOutput", () => { + describe("#given default mode", () => { + it("shows System OK when no issues", async () => { + //#given + const result = createDoctorResult() + const { formatDoctorOutput } = await import(`./formatter?default-ok-${Date.now()}`) + + //#when + const output = stripAnsi(formatDoctorOutput(result, "default")) + + //#then + expect(output).toContain("System OK (opencode 1.0.200 · oh-my-opencode 3.4.0)") + }) + + it("shows issue count and details when issues exist", async () => { + //#given + const result = createDoctorResultWithIssues() + const { formatDoctorOutput } = await import(`./formatter?default-issues-${Date.now()}`) + + //#when + const output = stripAnsi(formatDoctorOutput(result, "default")) + + //#then + expect(output).toContain("issues found:") + expect(output).toContain("1. Config issue") + expect(output).toContain("2. Tool warning") + }) }) - describe("formatDoctorOutput", () => { - it("dispatches to default formatter for default mode", async () => { + describe("#given status mode", () => { + it("renders system version line", async () => { //#given - const formatDefaultMock = mock(() => "default-output") - const formatStatusMock = mock(() => "status-output") - const formatVerboseMock = mock(() => "verbose-output") - mock.module("./format-default", () => ({ formatDefault: formatDefaultMock })) - mock.module("./format-status", () => ({ formatStatus: formatStatusMock })) - mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock })) - const { formatDoctorOutput } = await import(`./formatter?default=${Date.now()}`) + const result = createDoctorResult() + const { formatDoctorOutput } = await import(`./formatter?status-ver-${Date.now()}`) //#when - const output = formatDoctorOutput(createDoctorResult(), "default") + const output = stripAnsi(formatDoctorOutput(result, "status")) //#then - expect(output).toBe("default-output") - expect(formatDefaultMock).toHaveBeenCalledTimes(1) - expect(formatStatusMock).toHaveBeenCalledTimes(0) - expect(formatVerboseMock).toHaveBeenCalledTimes(0) + expect(output).toContain("1.0.200 · 3.4.0 · Bun 1.2.0") }) - it("dispatches to status formatter for status mode", async () => { + it("renders tool and MCP info", async () => { //#given - const formatDefaultMock = mock(() => "default-output") - const formatStatusMock = mock(() => "status-output") - const formatVerboseMock = mock(() => "verbose-output") - mock.module("./format-default", () => ({ formatDefault: formatDefaultMock })) - mock.module("./format-status", () => ({ formatStatus: formatStatusMock })) - mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock })) - const { formatDoctorOutput } = await import(`./formatter?status=${Date.now()}`) + const result = createDoctorResult() + const { formatDoctorOutput } = await import(`./formatter?status-tools-${Date.now()}`) //#when - const output = formatDoctorOutput(createDoctorResult(), "status") + const output = stripAnsi(formatDoctorOutput(result, "status")) //#then - expect(output).toBe("status-output") - expect(formatDefaultMock).toHaveBeenCalledTimes(0) - expect(formatStatusMock).toHaveBeenCalledTimes(1) - expect(formatVerboseMock).toHaveBeenCalledTimes(0) + expect(output).toContain("LSP 2/4") + expect(output).toContain("context7") + }) + }) + + describe("#given verbose mode", () => { + it("includes all section headers", async () => { + //#given + const result = createDoctorResult() + const { formatDoctorOutput } = await import(`./formatter?verbose-headers-${Date.now()}`) + + //#when + const output = stripAnsi(formatDoctorOutput(result, "verbose")) + + //#then + expect(output).toContain("System Information") + expect(output).toContain("Configuration") + expect(output).toContain("Tools") + expect(output).toContain("MCPs") + expect(output).toContain("Summary") }) - it("dispatches to verbose formatter for verbose mode", async () => { + it("shows check summary counts", async () => { //#given - const formatDefaultMock = mock(() => "default-output") - const formatStatusMock = mock(() => "status-output") - const formatVerboseMock = mock(() => "verbose-output") - mock.module("./format-default", () => ({ formatDefault: formatDefaultMock })) - mock.module("./format-status", () => ({ formatStatus: formatStatusMock })) - mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock })) - const { formatDoctorOutput } = await import(`./formatter?verbose=${Date.now()}`) + const result = createDoctorResult() + const { formatDoctorOutput } = await import(`./formatter?verbose-summary-${Date.now()}`) //#when - const output = formatDoctorOutput(createDoctorResult(), "verbose") + const output = stripAnsi(formatDoctorOutput(result, "verbose")) //#then - expect(output).toBe("verbose-output") - expect(formatDefaultMock).toHaveBeenCalledTimes(0) - expect(formatStatusMock).toHaveBeenCalledTimes(0) - expect(formatVerboseMock).toHaveBeenCalledTimes(1) + expect(output).toContain("1 passed") + expect(output).toContain("0 failed") + expect(output).toContain("1 warnings") }) }) describe("formatJsonOutput", () => { - it("returns valid JSON payload", async () => { + it("returns valid JSON", async () => { //#given - const { formatJsonOutput } = await import(`./formatter?json=${Date.now()}`) const result = createDoctorResult() + const { formatJsonOutput } = await import(`./formatter?json-valid-${Date.now()}`) + + //#when + const output = formatJsonOutput(result) + + //#then + expect(() => JSON.parse(output)).not.toThrow() + }) + + it("preserves all result fields", async () => { + //#given + const result = createDoctorResult() + const { formatJsonOutput } = await import(`./formatter?json-fields-${Date.now()}`) //#when const output = formatJsonOutput(result) @@ -119,7 +164,6 @@ describe("formatter", () => { //#then expect(parsed.summary.total).toBe(2) expect(parsed.systemInfo.pluginVersion).toBe("3.4.0") - expect(parsed.tools.ghCli.username).toBe("yeongyu") expect(parsed.exitCode).toBe(0) }) }) diff --git a/src/hooks/auto-update-checker/hook.test.ts b/src/hooks/auto-update-checker/hook.test.ts index 04195b666..33d91b48b 100644 --- a/src/hooks/auto-update-checker/hook.test.ts +++ b/src/hooks/auto-update-checker/hook.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, it, expect, mock } from "bun:test" +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" const mockShowConfigErrorsIfAny = mock(async () => {}) const mockShowModelCacheWarningIfNeeded = mock(async () => {}) @@ -7,7 +7,7 @@ const mockShowLocalDevToast = mock(async () => {}) const mockShowVersionToast = mock(async () => {}) const mockRunBackgroundUpdateCheck = mock(async () => {}) const mockGetCachedVersion = mock(() => "3.6.0") -const mockGetLocalDevVersion = mock(() => "3.6.0") +const mockGetLocalDevVersion = mock<(directory: string) => string | null>(() => null) mock.module("./hook/config-errors-toast", () => ({ showConfigErrorsIfAny: mockShowConfigErrorsIfAny, @@ -40,31 +40,49 @@ mock.module("../../shared/logger", () => ({ log: () => {}, })) -const { createAutoUpdateCheckerHook } = await import("./hook") +type HookFactory = typeof import("./hook").createAutoUpdateCheckerHook + +async function importFreshHookFactory(): Promise { + const hookModule = await import(`./hook?test-${Date.now()}-${Math.random()}`) + return hookModule.createAutoUpdateCheckerHook +} + +function createPluginInput() { + return { + directory: "/test", + client: {} as never, + } as never +} + +beforeEach(() => { + mockShowConfigErrorsIfAny.mockClear() + mockShowModelCacheWarningIfNeeded.mockClear() + mockUpdateAndShowConnectedProvidersCacheStatus.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 - mock.restore() }) 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" - mockShowConfigErrorsIfAny.mockClear() - mockShowModelCacheWarningIfNeeded.mockClear() - mockUpdateAndShowConnectedProvidersCacheStatus.mockClear() - mockShowLocalDevToast.mockClear() - mockShowVersionToast.mockClear() - mockRunBackgroundUpdateCheck.mockClear() + const createAutoUpdateCheckerHook = await importFreshHookFactory() - const hook = createAutoUpdateCheckerHook( - { - directory: "/test", - client: {} as never, - } as never, - { showStartupToast: true, isSisyphusEnabled: true, autoUpdate: true } - ) + const hook = createAutoUpdateCheckerHook(createPluginInput(), { + showStartupToast: true, + isSisyphusEnabled: true, + autoUpdate: true, + }) //#when - session.created event arrives hook.event({ @@ -73,7 +91,7 @@ describe("createAutoUpdateCheckerHook", () => { properties: { info: { parentID: undefined } }, }, }) - await new Promise((resolve) => setTimeout(resolve, 25)) + await new Promise((resolve) => setTimeout(resolve, 50)) //#then - no update checker side effects run expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled() @@ -82,6 +100,144 @@ describe("createAutoUpdateCheckerHook", () => { 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 + hook.event({ + event: { + type: "session.created", + }, + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + + //#then - startup checks, toast, and background check run + expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1) + expect(mockUpdateAndShowConnectedProvidersCacheStatus).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 + hook.event({ + event: { + type: "session.created", + properties: { info: { parentID: "parent-123" } }, + }, + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + + //#then - no startup actions run + expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled() + expect(mockUpdateAndShowConnectedProvidersCacheStatus).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 + hook.event({ + event: { + type: "session.created", + }, + }) + hook.event({ + event: { + type: "session.created", + }, + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + + //#then - side effects execute only once + expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1) + expect(mockUpdateAndShowConnectedProvidersCacheStatus).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 + hook.event({ + event: { + type: "session.created", + }, + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + + //#then - local dev toast is shown and background check is skipped + expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1) + expect(mockUpdateAndShowConnectedProvidersCacheStatus).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 new Promise((resolve) => setTimeout(resolve, 50)) + + //#then - no startup actions run + expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled() + expect(mockUpdateAndShowConnectedProvidersCacheStatus).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 + hook.event({ + event: { + type: "session.created", + }, + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + + //#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/auto-update-checker/hook/background-update-check.test.ts b/src/hooks/auto-update-checker/hook/background-update-check.test.ts index 69d514211..0c7cbbf4d 100644 --- a/src/hooks/auto-update-checker/hook/background-update-check.test.ts +++ b/src/hooks/auto-update-checker/hook/background-update-check.test.ts @@ -1,177 +1,208 @@ -import { describe, it, expect, mock, beforeEach } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" +import { beforeEach, describe, expect, it, mock } 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) +type PluginEntry = { + entry: string + isPinned: boolean + pinnedVersion: string | null + configPath: string +} + +type ToastMessageGetter = (isUpdate: boolean, version?: string) => string + +function createPluginEntry(overrides?: Partial): PluginEntry { + return { + entry: "oh-my-opencode@3.4.0", + isPinned: false, + pinnedVersion: null, + configPath: "/test/opencode.json", + ...overrides, + } +} + +const mockFindPluginEntry = mock((_directory: string): PluginEntry | 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 mockRunBunInstall = mock(async () => true) -const mockShowUpdateAvailableToast = mock(async () => {}) -const mockShowAutoUpdatedToast = mock(async () => {}) +const mockShowUpdateAvailableToast = mock( + async (_ctx: PluginInput, _latestVersion: string, _getToastMessage: ToastMessageGetter): Promise => {} +) +const mockShowAutoUpdatedToast = mock( + async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise => {} +) mock.module("../checker", () => ({ findPluginEntry: mockFindPluginEntry, getCachedVersion: mockGetCachedVersion, getLatestVersion: mockGetLatestVersion, - updatePinnedVersion: mockUpdatePinnedVersion, revertPinnedVersion: mock(() => false), })) - -mock.module("../version-channel", () => ({ - extractChannel: mockExtractChannel, -})) - -mock.module("../cache", () => ({ - invalidatePackage: mockInvalidatePackage, -})) - -mock.module("../../../cli/config-manager", () => ({ - runBunInstall: mockRunBunInstall, -})) - +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: () => {} })) -mock.module("../../../shared/logger", () => ({ - log: () => {}, -})) - -const { runBackgroundUpdateCheck } = await import("./background-update-check?test") +const modulePath = "./background-update-check?test" +const { runBackgroundUpdateCheck } = await import(modulePath) describe("runBackgroundUpdateCheck", () => { - const mockCtx = { directory: "/test" } as any - const mockGetToastMessage = (isUpdate: boolean, version?: string) => + 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() - mockUpdatePinnedVersion.mockReset() mockExtractChannel.mockReset() mockInvalidatePackage.mockReset() mockRunBunInstall.mockReset() mockShowUpdateAvailableToast.mockReset() mockShowAutoUpdatedToast.mockReset() + mockFindPluginEntry.mockReturnValue(createPluginEntry()) + mockGetCachedVersion.mockReturnValue("3.4.0") + mockGetLatestVersion.mockResolvedValue("3.5.0") 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 manual-update toast message", async () => { - await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage) - - expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1) - - const [toastContext, latestVersion, getToastMessage] = mockShowUpdateAvailableToast.mock.calls[0] ?? [] - expect(toastContext).toBe(mockCtx) - expect(latestVersion).toBe("3.5.0") - expect(typeof getToastMessage).toBe("function") - expect(getToastMessage(true, "3.5.0")).toBe("Update available: 3.5.0 (version pinned, update manually)") - }) - - it("#then should NOT run bun install", async () => { - await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage) - + describe("#given no plugin entry found", () => { + it("returns early without showing any toast", async () => { + //#given + mockFindPluginEntry.mockReturnValue(null) + //#when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + //#then + expect(mockFindPluginEntry).toHaveBeenCalledTimes(1) + expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled() + expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled() 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() + describe("#given no version available", () => { + it("returns early when neither cached nor pinned version exists", async () => { + //#given + mockFindPluginEntry.mockReturnValue(createPluginEntry({ entry: "oh-my-opencode" })) + mockGetCachedVersion.mockReturnValue(null) + //#when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + //#then + expect(mockGetCachedVersion).toHaveBeenCalledTimes(1) + expect(mockGetLatestVersion).not.toHaveBeenCalled() expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled() expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled() }) }) + + describe("#given latest version fetch fails", () => { + it("returns early without toasts", async () => { + //#given + mockGetLatestVersion.mockResolvedValue(null) + //#when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + //#then + expect(mockGetLatestVersion).toHaveBeenCalledWith("latest") + expect(mockRunBunInstall).not.toHaveBeenCalled() + expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled() + expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled() + }) + }) + + describe("#given already on latest version", () => { + it("returns early without any action", async () => { + //#given + mockGetCachedVersion.mockReturnValue("3.4.0") + mockGetLatestVersion.mockResolvedValue("3.4.0") + //#when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + //#then + expect(mockGetLatestVersion).toHaveBeenCalledTimes(1) + expect(mockRunBunInstall).not.toHaveBeenCalled() + expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled() + expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled() + }) + }) + + describe("#given update available with autoUpdate disabled", () => { + it("shows update notification but does not install", async () => { + //#given + const autoUpdate = false + //#when + await runBackgroundUpdateCheck(mockCtx, autoUpdate, getToastMessage) + //#then + expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage) + expect(mockRunBunInstall).not.toHaveBeenCalled() + expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled() + }) + }) + + describe("#given user has pinned a specific version", () => { + it("shows pinned-version toast without auto-updating", async () => { + //#given + mockFindPluginEntry.mockReturnValue(createPluginEntry({ isPinned: true, pinnedVersion: "3.4.0" })) + //#when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + //#then + expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1) + expect(mockRunBunInstall).not.toHaveBeenCalled() + expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled() + }) + + it("toast message mentions version pinned", async () => { + //#given + let capturedToastMessage: ToastMessageGetter | undefined + mockFindPluginEntry.mockReturnValue(createPluginEntry({ isPinned: true, pinnedVersion: "3.4.0" })) + mockShowUpdateAvailableToast.mockImplementation( + async (_ctx: PluginInput, _latestVersion: string, toastMessage: ToastMessageGetter) => { + capturedToastMessage = toastMessage + } + ) + //#when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + //#then + expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1) + expect(capturedToastMessage).toBeDefined() + if (!capturedToastMessage) { + throw new Error("toast message callback missing") + } + const message = capturedToastMessage(true, "3.5.0") + expect(message).toContain("version pinned") + expect(message).not.toBe("Update to 3.5.0") + }) + }) + + describe("#given unpinned with auto-update and install succeeds", () => { + it("invalidates cache, installs, and shows auto-updated toast", async () => { + //#given + mockRunBunInstall.mockResolvedValue(true) + //#when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + //#then + expect(mockInvalidatePackage).toHaveBeenCalledTimes(1) + expect(mockRunBunInstall).toHaveBeenCalledTimes(1) + expect(mockShowAutoUpdatedToast).toHaveBeenCalledWith(mockCtx, "3.4.0", "3.5.0") + expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled() + }) + }) + + describe("#given unpinned with auto-update and install fails", () => { + it("falls back to notification-only toast", async () => { + //#given + mockRunBunInstall.mockResolvedValue(false) + //#when + await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) + //#then + expect(mockRunBunInstall).toHaveBeenCalledTimes(1) + expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage) + expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/hooks/directory-agents-injector/injector.test.ts b/src/hooks/directory-agents-injector/injector.test.ts index 60c720ccb..ce9134203 100644 --- a/src/hooks/directory-agents-injector/injector.test.ts +++ b/src/hooks/directory-agents-injector/injector.test.ts @@ -1,161 +1,204 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" +import { randomUUID } from "node:crypto" import { mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" -const findAgentsMdUpMock = mock((_: { startDir: string; rootDir: string }) => [] as string[]) -const resolveFilePathMock = mock((_: string, path: string) => path) -const loadInjectedPathsMock = mock((_: string) => new Set()) -const saveInjectedPathsMock = mock((_: string, __: Set) => {}) +const storageMaps = new Map>() + +mock.module("./constants", () => ({ + AGENTS_INJECTOR_STORAGE: "/tmp/directory-agents-injector-tests", + AGENTS_FILENAME: "AGENTS.md", +})) + +mock.module("./storage", () => ({ + loadInjectedPaths: (sessionID: string) => storageMaps.get(sessionID) ?? new Set(), + saveInjectedPaths: (sessionID: string, paths: Set) => { + storageMaps.set(sessionID, paths) + }, + clearInjectedPaths: (sessionID: string) => { + storageMaps.delete(sessionID) + }, +})) + +const truncator = { + truncate: async (_sessionID: string, content: string) => ({ result: content, truncated: false }), + getUsage: async (_sessionID: string) => null, + truncateSync: (output: string, _maxTokens: number, _preserveHeaderLines?: number) => ({ + result: output, + truncated: false, + }), +} describe("processFilePathForAgentsInjection", () => { let testRoot = "" + let srcDirectory = "" + let componentsDirectory = "" + + const rootAgentsContent = "# ROOT AGENTS\nroot-level directives" + const srcAgentsContent = "# SRC AGENTS\nsrc-level directives" + const componentsAgentsContent = "# COMPONENT AGENTS\ncomponents-level directives" beforeEach(() => { - findAgentsMdUpMock.mockClear() - resolveFilePathMock.mockClear() - loadInjectedPathsMock.mockClear() - saveInjectedPathsMock.mockClear() + storageMaps.clear() - testRoot = join( - tmpdir(), - `directory-agents-injector-${Date.now()}-${Math.random().toString(16).slice(2)}` - ) - mkdirSync(testRoot, { recursive: true }) + testRoot = join(tmpdir(), `directory-agents-injector-${randomUUID()}`) + srcDirectory = join(testRoot, "src") + componentsDirectory = join(srcDirectory, "components") + + mkdirSync(componentsDirectory, { recursive: true }) + writeFileSync(join(testRoot, "AGENTS.md"), rootAgentsContent) + writeFileSync(join(srcDirectory, "AGENTS.md"), srcAgentsContent) + writeFileSync(join(componentsDirectory, "AGENTS.md"), componentsAgentsContent) + writeFileSync(join(componentsDirectory, "button.ts"), "export const button = true\n") + writeFileSync(join(srcDirectory, "file.ts"), "export const sourceFile = true\n") + writeFileSync(join(testRoot, "file.ts"), "export const rootFile = true\n") }) afterEach(() => { - mock.restore() rmSync(testRoot, { recursive: true, force: true }) }) - it("does not save when all discovered paths are already cached", async () => { - //#given - const sessionID = "session-1" - const repoRoot = join(testRoot, "repo") - const agentsPath = join(repoRoot, "src", "AGENTS.md") - const cachedDirectory = join(repoRoot, "src") - mkdirSync(join(repoRoot, "src"), { recursive: true }) - writeFileSync(agentsPath, "# AGENTS") - - loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory])) - findAgentsMdUpMock.mockReturnValueOnce([agentsPath]) - - const truncator = { - truncate: mock(async () => ({ result: "trimmed", truncated: false })), - } - - mock.module("./finder", () => ({ - findAgentsMdUp: findAgentsMdUpMock, - resolveFilePath: resolveFilePathMock, - })) - mock.module("./storage", () => ({ - loadInjectedPaths: loadInjectedPathsMock, - saveInjectedPaths: saveInjectedPathsMock, - })) - + it("injects AGENTS.md content from file's parent directory into output", async () => { + // given const { processFilePathForAgentsInjection } = await import("./injector") + const output = { title: "Read result", output: "base output", metadata: {} } - //#when + // when await processFilePathForAgentsInjection({ - ctx: { directory: repoRoot } as never, - truncator: truncator as never, + ctx: { directory: testRoot } as PluginInput, + truncator, sessionCaches: new Map(), - filePath: join(repoRoot, "src", "file.ts"), - sessionID, - output: { title: "Result", output: "", metadata: {} }, + filePath: join(srcDirectory, "file.ts"), + sessionID: "session-parent", + output, }) - //#then - expect(saveInjectedPathsMock).not.toHaveBeenCalled() + // then + expect(output.output).toContain("[Directory Context:") + expect(output.output).toContain(srcAgentsContent) }) - it("saves when a new path is injected", async () => { - //#given - const sessionID = "session-2" - const repoRoot = join(testRoot, "repo") - const agentsPath = join(repoRoot, "src", "AGENTS.md") - const injectedDirectory = join(repoRoot, "src") - mkdirSync(join(repoRoot, "src"), { recursive: true }) - writeFileSync(agentsPath, "# AGENTS") - - loadInjectedPathsMock.mockReturnValueOnce(new Set()) - findAgentsMdUpMock.mockReturnValueOnce([agentsPath]) - - const truncator = { - truncate: mock(async () => ({ result: "trimmed", truncated: false })), - } - - mock.module("./finder", () => ({ - findAgentsMdUp: findAgentsMdUpMock, - resolveFilePath: resolveFilePathMock, - })) - mock.module("./storage", () => ({ - loadInjectedPaths: loadInjectedPathsMock, - saveInjectedPaths: saveInjectedPathsMock, - })) - + it("skips root-level AGENTS.md", async () => { + // given + rmSync(join(srcDirectory, "AGENTS.md"), { force: true }) + rmSync(join(componentsDirectory, "AGENTS.md"), { force: true }) const { processFilePathForAgentsInjection } = await import("./injector") + const output = { title: "Read result", output: "base output", metadata: {} } - //#when + // when await processFilePathForAgentsInjection({ - ctx: { directory: repoRoot } as never, - truncator: truncator as never, + ctx: { directory: testRoot } as PluginInput, + truncator, sessionCaches: new Map(), - filePath: join(repoRoot, "src", "file.ts"), - sessionID, - output: { title: "Result", output: "", metadata: {} }, + filePath: join(testRoot, "file.ts"), + sessionID: "session-root-skip", + output, }) - //#then - expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1) - const saveCall = saveInjectedPathsMock.mock.calls[0] - expect(saveCall[0]).toBe(sessionID) - expect((saveCall[1] as Set).has(injectedDirectory)).toBe(true) + // then + expect(output.output).not.toContain(rootAgentsContent) + expect(output.output).not.toContain("[Directory Context:") }) - it("saves once when cached and new paths are mixed", async () => { - //#given - const sessionID = "session-3" - const repoRoot = join(testRoot, "repo") - const cachedAgentsPath = join(repoRoot, "already-cached", "AGENTS.md") - const newAgentsPath = join(repoRoot, "new-dir", "AGENTS.md") - mkdirSync(join(repoRoot, "already-cached"), { recursive: true }) - mkdirSync(join(repoRoot, "new-dir"), { recursive: true }) - writeFileSync(cachedAgentsPath, "# AGENTS") - writeFileSync(newAgentsPath, "# AGENTS") - - loadInjectedPathsMock.mockReturnValueOnce(new Set([join(repoRoot, "already-cached")])) - findAgentsMdUpMock.mockReturnValueOnce([cachedAgentsPath, newAgentsPath]) - - const truncator = { - truncate: mock(async () => ({ result: "trimmed", truncated: false })), - } - - mock.module("./finder", () => ({ - findAgentsMdUp: findAgentsMdUpMock, - resolveFilePath: resolveFilePathMock, - })) - mock.module("./storage", () => ({ - loadInjectedPaths: loadInjectedPathsMock, - saveInjectedPaths: saveInjectedPathsMock, - })) - + it("injects multiple AGENTS.md when walking up directory tree", async () => { + // given const { processFilePathForAgentsInjection } = await import("./injector") + const output = { title: "Read result", output: "base output", metadata: {} } - //#when + // when await processFilePathForAgentsInjection({ - ctx: { directory: repoRoot } as never, - truncator: truncator as never, + ctx: { directory: testRoot } as PluginInput, + truncator, sessionCaches: new Map(), - filePath: join(repoRoot, "new-dir", "file.ts"), - sessionID, - output: { title: "Result", output: "", metadata: {} }, + filePath: join(componentsDirectory, "button.ts"), + sessionID: "session-multiple", + output, }) - //#then - expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1) - const saveCall = saveInjectedPathsMock.mock.calls[0] - expect((saveCall[1] as Set).has(join(repoRoot, "new-dir"))).toBe(true) + // then + expect(output.output).toContain(srcAgentsContent) + expect(output.output).toContain(componentsAgentsContent) + }) + + it("does not re-inject already cached directories", async () => { + // given + const { processFilePathForAgentsInjection } = await import("./injector") + const sessionCaches = new Map>() + const output = { title: "Read result", output: "base output", metadata: {} } + + // when + await processFilePathForAgentsInjection({ + ctx: { directory: testRoot } as PluginInput, + truncator, + sessionCaches, + filePath: join(componentsDirectory, "button.ts"), + sessionID: "session-cache", + output, + }) + const outputAfterFirstCall = output.output + await processFilePathForAgentsInjection({ + ctx: { directory: testRoot } as PluginInput, + truncator, + sessionCaches, + filePath: join(componentsDirectory, "button.ts"), + sessionID: "session-cache", + output, + }) + + // then + expect(output.output).toBe(outputAfterFirstCall) + expect(output.output.split("[Directory Context:").length - 1).toBe(2) + }) + + it("shows truncation notice when content is truncated", async () => { + // given + const { processFilePathForAgentsInjection } = await import("./injector") + const output = { title: "Read result", output: "base output", metadata: {} } + const truncatedTruncator = { + truncate: async (_sessionID: string, _content: string) => ({ + result: "truncated...", + truncated: true, + }), + getUsage: async (_sessionID: string) => null, + truncateSync: (output: string, _maxTokens: number, _preserveHeaderLines?: number) => ({ + result: output, + truncated: false, + }), + } + + // when + await processFilePathForAgentsInjection({ + ctx: { directory: testRoot } as PluginInput, + truncator: truncatedTruncator, + sessionCaches: new Map(), + filePath: join(srcDirectory, "file.ts"), + sessionID: "session-truncated", + output, + }) + + // then + expect(output.output).toContain("truncated...") + expect(output.output).toContain("[Note: Content was truncated") + }) + + it("does nothing when filePath cannot be resolved", async () => { + // given + const { processFilePathForAgentsInjection } = await import("./injector") + const output = { title: "Read result", output: "base output", metadata: {} } + + // when + await processFilePathForAgentsInjection({ + ctx: { directory: testRoot } as PluginInput, + truncator, + sessionCaches: new Map(), + filePath: "", + sessionID: "session-empty-path", + output, + }) + + // then + expect(output.output).toBe("base output") }) }) diff --git a/src/hooks/directory-readme-injector/injector.test.ts b/src/hooks/directory-readme-injector/injector.test.ts index 932fb77ec..da238efba 100644 --- a/src/hooks/directory-readme-injector/injector.test.ts +++ b/src/hooks/directory-readme-injector/injector.test.ts @@ -1,161 +1,212 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" +import { randomUUID } from "node:crypto" import { mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -const findReadmeMdUpMock = mock((_: { startDir: string; rootDir: string }) => [] as string[]) -const resolveFilePathMock = mock((_: string, path: string) => path) -const loadInjectedPathsMock = mock((_: string) => new Set()) -const saveInjectedPathsMock = mock((_: string, __: Set) => {}) +import type { PluginInput } from "@opencode-ai/plugin" + +const storageMaps = new Map>() + +mock.module("./storage", () => ({ + loadInjectedPaths: (sessionID: string) => storageMaps.get(sessionID) ?? new Set(), + saveInjectedPaths: (sessionID: string, paths: Set) => { + storageMaps.set(sessionID, paths) + }, +})) + +function createPluginContext(directory: string): PluginInput { + return { directory } as PluginInput +} + +function countReadmeMarkers(output: string): number { + return output.split("[Project README:").length - 1 +} + +function createTruncator(input?: { truncated?: boolean; result?: string }) { + return { + truncate: async (_sessionID: string, content: string) => ({ + result: input?.result ?? content, + truncated: input?.truncated ?? false, + }), + getUsage: async (_sessionID: string) => null, + truncateSync: (output: string) => ({ result: output, truncated: false }), + } +} describe("processFilePathForReadmeInjection", () => { let testRoot = "" beforeEach(() => { - findReadmeMdUpMock.mockClear() - resolveFilePathMock.mockClear() - loadInjectedPathsMock.mockClear() - saveInjectedPathsMock.mockClear() - - testRoot = join( - tmpdir(), - `directory-readme-injector-${Date.now()}-${Math.random().toString(16).slice(2)}` - ) + testRoot = join(tmpdir(), `directory-readme-injector-${randomUUID()}`) mkdirSync(testRoot, { recursive: true }) + storageMaps.clear() }) afterEach(() => { - mock.restore() rmSync(testRoot, { recursive: true, force: true }) + storageMaps.clear() }) - it("does not save when all discovered paths are already cached", async () => { - //#given - const sessionID = "session-1" - const repoRoot = join(testRoot, "repo") - const readmePath = join(repoRoot, "src", "README.md") - const cachedDirectory = join(repoRoot, "src") - mkdirSync(join(repoRoot, "src"), { recursive: true }) - writeFileSync(readmePath, "# README") - - loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory])) - findReadmeMdUpMock.mockReturnValueOnce([readmePath]) - - const truncator = { - truncate: mock(async () => ({ result: "trimmed", truncated: false })), - } - - mock.module("./finder", () => ({ - findReadmeMdUp: findReadmeMdUpMock, - resolveFilePath: resolveFilePathMock, - })) - mock.module("./storage", () => ({ - loadInjectedPaths: loadInjectedPathsMock, - saveInjectedPaths: saveInjectedPathsMock, - })) + it("injects README.md content from file's parent directory into output", async () => { + // given + const sourceDirectory = join(testRoot, "src") + mkdirSync(sourceDirectory, { recursive: true }) + writeFileSync(join(sourceDirectory, "README.md"), "# Source README\nlocal context") const { processFilePathForReadmeInjection } = await import("./injector") + const output = { title: "Result", output: "base", metadata: {} } + const truncator = createTruncator() - //#when + // when await processFilePathForReadmeInjection({ - ctx: { directory: repoRoot } as never, - truncator: truncator as never, - sessionCaches: new Map(), - filePath: join(repoRoot, "src", "file.ts"), - sessionID, - output: { title: "Result", output: "", metadata: {} }, + ctx: createPluginContext(testRoot), + truncator, + sessionCaches: new Map>(), + filePath: join(sourceDirectory, "file.ts"), + sessionID: "session-parent", + output, }) - //#then - expect(saveInjectedPathsMock).not.toHaveBeenCalled() + // then + expect(output.output).toContain("[Project README:") + expect(output.output).toContain("# Source README") + expect(output.output).toContain("local context") }) - it("saves when a new path is injected", async () => { - //#given - const sessionID = "session-2" - const repoRoot = join(testRoot, "repo") - const readmePath = join(repoRoot, "src", "README.md") - const injectedDirectory = join(repoRoot, "src") - mkdirSync(join(repoRoot, "src"), { recursive: true }) - writeFileSync(readmePath, "# README") - - loadInjectedPathsMock.mockReturnValueOnce(new Set()) - findReadmeMdUpMock.mockReturnValueOnce([readmePath]) - - const truncator = { - truncate: mock(async () => ({ result: "trimmed", truncated: false })), - } - - mock.module("./finder", () => ({ - findReadmeMdUp: findReadmeMdUpMock, - resolveFilePath: resolveFilePathMock, - })) - mock.module("./storage", () => ({ - loadInjectedPaths: loadInjectedPathsMock, - saveInjectedPaths: saveInjectedPathsMock, - })) + it("includes root-level README.md (unlike agents-injector)", async () => { + // given + writeFileSync(join(testRoot, "README.md"), "# Root README\nroot context") const { processFilePathForReadmeInjection } = await import("./injector") + const output = { title: "Result", output: "", metadata: {} } + const truncator = createTruncator() - //#when + // when await processFilePathForReadmeInjection({ - ctx: { directory: repoRoot } as never, - truncator: truncator as never, - sessionCaches: new Map(), - filePath: join(repoRoot, "src", "file.ts"), - sessionID, - output: { title: "Result", output: "", metadata: {} }, + ctx: createPluginContext(testRoot), + truncator, + sessionCaches: new Map>(), + filePath: join(testRoot, "file.ts"), + sessionID: "session-root", + output, }) - //#then - expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1) - const saveCall = saveInjectedPathsMock.mock.calls[0] - expect(saveCall[0]).toBe(sessionID) - expect((saveCall[1] as Set).has(injectedDirectory)).toBe(true) + // then + expect(output.output).toContain("[Project README:") + expect(output.output).toContain("# Root README") + expect(output.output).toContain("root context") }) - it("saves once when cached and new paths are mixed", async () => { - //#given - const sessionID = "session-3" - const repoRoot = join(testRoot, "repo") - const cachedReadmePath = join(repoRoot, "already-cached", "README.md") - const newReadmePath = join(repoRoot, "new-dir", "README.md") - mkdirSync(join(repoRoot, "already-cached"), { recursive: true }) - mkdirSync(join(repoRoot, "new-dir"), { recursive: true }) - writeFileSync(cachedReadmePath, "# README") - writeFileSync(newReadmePath, "# README") - - loadInjectedPathsMock.mockReturnValueOnce(new Set([join(repoRoot, "already-cached")])) - findReadmeMdUpMock.mockReturnValueOnce([cachedReadmePath, newReadmePath]) - - const truncator = { - truncate: mock(async () => ({ result: "trimmed", truncated: false })), - } - - mock.module("./finder", () => ({ - findReadmeMdUp: findReadmeMdUpMock, - resolveFilePath: resolveFilePathMock, - })) - mock.module("./storage", () => ({ - loadInjectedPaths: loadInjectedPathsMock, - saveInjectedPaths: saveInjectedPathsMock, - })) + it("injects multiple README.md when walking up directory tree", async () => { + // given + const sourceDirectory = join(testRoot, "src") + const componentsDirectory = join(sourceDirectory, "components") + mkdirSync(componentsDirectory, { recursive: true }) + writeFileSync(join(testRoot, "README.md"), "# Root README") + writeFileSync(join(sourceDirectory, "README.md"), "# Src README") + writeFileSync(join(componentsDirectory, "README.md"), "# Components README") + writeFileSync(join(componentsDirectory, "button.ts"), "export const button = true") const { processFilePathForReadmeInjection } = await import("./injector") + const output = { title: "Result", output: "", metadata: {} } + const truncator = createTruncator() - //#when + // when await processFilePathForReadmeInjection({ - ctx: { directory: repoRoot } as never, - truncator: truncator as never, - sessionCaches: new Map(), - filePath: join(repoRoot, "new-dir", "file.ts"), - sessionID, - output: { title: "Result", output: "", metadata: {} }, + ctx: createPluginContext(testRoot), + truncator, + sessionCaches: new Map>(), + filePath: join(componentsDirectory, "button.ts"), + sessionID: "session-multi", + output, }) - //#then - expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1) - const saveCall = saveInjectedPathsMock.mock.calls[0] - expect((saveCall[1] as Set).has(join(repoRoot, "new-dir"))).toBe(true) + // then + expect(countReadmeMarkers(output.output)).toBe(3) + expect(output.output).toContain("# Root README") + expect(output.output).toContain("# Src README") + expect(output.output).toContain("# Components README") + }) + + it("does not re-inject already cached directories", async () => { + // given + const sourceDirectory = join(testRoot, "src") + mkdirSync(sourceDirectory, { recursive: true }) + writeFileSync(join(sourceDirectory, "README.md"), "# Source README") + + const { processFilePathForReadmeInjection } = await import("./injector") + const sessionCaches = new Map>() + const sessionID = "session-cache" + const truncator = createTruncator() + const firstOutput = { title: "Result", output: "", metadata: {} } + const secondOutput = { title: "Result", output: "", metadata: {} } + + // when + await processFilePathForReadmeInjection({ + ctx: createPluginContext(testRoot), + truncator, + sessionCaches, + filePath: join(sourceDirectory, "a.ts"), + sessionID, + output: firstOutput, + }) + await processFilePathForReadmeInjection({ + ctx: createPluginContext(testRoot), + truncator, + sessionCaches, + filePath: join(sourceDirectory, "b.ts"), + sessionID, + output: secondOutput, + }) + + // then + expect(countReadmeMarkers(firstOutput.output)).toBe(1) + expect(secondOutput.output).toBe("") + }) + + it("shows truncation notice when content is truncated", async () => { + // given + const sourceDirectory = join(testRoot, "src") + mkdirSync(sourceDirectory, { recursive: true }) + writeFileSync(join(sourceDirectory, "README.md"), "# Truncated README") + + const { processFilePathForReadmeInjection } = await import("./injector") + const output = { title: "Result", output: "", metadata: {} } + const truncator = createTruncator({ result: "trimmed content", truncated: true }) + + // when + await processFilePathForReadmeInjection({ + ctx: createPluginContext(testRoot), + truncator, + sessionCaches: new Map>(), + filePath: join(sourceDirectory, "file.ts"), + sessionID: "session-truncated", + output, + }) + + // then + expect(output.output).toContain("trimmed content") + expect(output.output).toContain("[Note: Content was truncated") + }) + + it("does nothing when filePath cannot be resolved", async () => { + // given + const { processFilePathForReadmeInjection } = await import("./injector") + const output = { title: "Result", output: "unchanged", metadata: {} } + const truncator = createTruncator() + + // when + await processFilePathForReadmeInjection({ + ctx: createPluginContext(testRoot), + truncator, + sessionCaches: new Map>(), + filePath: "", + sessionID: "session-empty-path", + output, + }) + + // then + expect(output.output).toBe("unchanged") }) })