Merge pull request #2041 from code-yeongyu/fix/rewrite-overmocked-tests
refactor(tests): rewrite 5 over-mocked test files to test real behavior
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<HookFactory> {
|
||||
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")
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>): 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<string | null> => "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<void> => {}
|
||||
)
|
||||
const mockShowAutoUpdatedToast = mock(
|
||||
async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise<void> => {}
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>())
|
||||
const saveInjectedPathsMock = mock((_: string, __: Set<string>) => {})
|
||||
const storageMaps = new Map<string, Set<string>>()
|
||||
|
||||
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<string>(),
|
||||
saveInjectedPaths: (sessionID: string, paths: Set<string>) => {
|
||||
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<string>).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<string>).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<string, Set<string>>()
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>())
|
||||
const saveInjectedPathsMock = mock((_: string, __: Set<string>) => {})
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const storageMaps = new Map<string, Set<string>>()
|
||||
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: (sessionID: string) => storageMaps.get(sessionID) ?? new Set<string>(),
|
||||
saveInjectedPaths: (sessionID: string, paths: Set<string>) => {
|
||||
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<string, Set<string>>(),
|
||||
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<string, Set<string>>(),
|
||||
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<string>).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<string, Set<string>>(),
|
||||
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<string>).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<string, Set<string>>()
|
||||
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<string, Set<string>>(),
|
||||
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<string, Set<string>>(),
|
||||
filePath: "",
|
||||
sessionID: "session-empty-path",
|
||||
output,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(output.output).toBe("unchanged")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user