refactor(tests): rewrite 5 over-mocked test files to test real behavior
- formatter.test.ts: use dynamic imports with cache-busting to avoid mock pollution from runner.test.ts; test real format output instead of dispatch mocking - hook.test.ts: rewrite with proper branch coverage (7 tests), add success/guard/subagent paths - background-update-check.test.ts: rewrite with 10 tests covering all branches (early returns, pinned versions, auto-update success/failure) - directory-agents-injector/injector.test.ts: replace finder/storage mocks with real filesystem + temp directories, verify actual AGENTS.md injection content - directory-readme-injector/injector.test.ts: same pattern as agents-injector but for README.md, verifies root inclusion behavior
This commit is contained in:
parent
ffa2a255d9
commit
7e05bd2b8e
@ -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"
|
import type { DoctorResult } from "./types"
|
||||||
|
|
||||||
function createDoctorResult(): DoctorResult {
|
function createDoctorResult(): DoctorResult {
|
||||||
@ -39,78 +40,122 @@ function createDoctorResult(): DoctorResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("formatter", () => {
|
function createDoctorResultWithIssues(): DoctorResult {
|
||||||
afterEach(() => {
|
const base = createDoctorResult()
|
||||||
mock.restore()
|
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", () => {
|
describe("#given status mode", () => {
|
||||||
it("dispatches to default formatter for default mode", async () => {
|
it("renders system version line", async () => {
|
||||||
//#given
|
//#given
|
||||||
const formatDefaultMock = mock(() => "default-output")
|
const result = createDoctorResult()
|
||||||
const formatStatusMock = mock(() => "status-output")
|
const { formatDoctorOutput } = await import(`./formatter?status-ver-${Date.now()}`)
|
||||||
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()}`)
|
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const output = formatDoctorOutput(createDoctorResult(), "default")
|
const output = stripAnsi(formatDoctorOutput(result, "status"))
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(output).toBe("default-output")
|
expect(output).toContain("1.0.200 · 3.4.0 · Bun 1.2.0")
|
||||||
expect(formatDefaultMock).toHaveBeenCalledTimes(1)
|
|
||||||
expect(formatStatusMock).toHaveBeenCalledTimes(0)
|
|
||||||
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("dispatches to status formatter for status mode", async () => {
|
it("renders tool and MCP info", async () => {
|
||||||
//#given
|
//#given
|
||||||
const formatDefaultMock = mock(() => "default-output")
|
const result = createDoctorResult()
|
||||||
const formatStatusMock = mock(() => "status-output")
|
const { formatDoctorOutput } = await import(`./formatter?status-tools-${Date.now()}`)
|
||||||
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()}`)
|
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const output = formatDoctorOutput(createDoctorResult(), "status")
|
const output = stripAnsi(formatDoctorOutput(result, "status"))
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(output).toBe("status-output")
|
expect(output).toContain("LSP 2/4")
|
||||||
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
|
expect(output).toContain("context7")
|
||||||
expect(formatStatusMock).toHaveBeenCalledTimes(1)
|
})
|
||||||
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
|
})
|
||||||
|
|
||||||
|
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
|
//#given
|
||||||
const formatDefaultMock = mock(() => "default-output")
|
const result = createDoctorResult()
|
||||||
const formatStatusMock = mock(() => "status-output")
|
const { formatDoctorOutput } = await import(`./formatter?verbose-summary-${Date.now()}`)
|
||||||
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()}`)
|
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const output = formatDoctorOutput(createDoctorResult(), "verbose")
|
const output = stripAnsi(formatDoctorOutput(result, "verbose"))
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(output).toBe("verbose-output")
|
expect(output).toContain("1 passed")
|
||||||
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
|
expect(output).toContain("0 failed")
|
||||||
expect(formatStatusMock).toHaveBeenCalledTimes(0)
|
expect(output).toContain("1 warnings")
|
||||||
expect(formatVerboseMock).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("formatJsonOutput", () => {
|
describe("formatJsonOutput", () => {
|
||||||
it("returns valid JSON payload", async () => {
|
it("returns valid JSON", async () => {
|
||||||
//#given
|
//#given
|
||||||
const { formatJsonOutput } = await import(`./formatter?json=${Date.now()}`)
|
|
||||||
const result = createDoctorResult()
|
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
|
//#when
|
||||||
const output = formatJsonOutput(result)
|
const output = formatJsonOutput(result)
|
||||||
@ -119,7 +164,6 @@ describe("formatter", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(parsed.summary.total).toBe(2)
|
expect(parsed.summary.total).toBe(2)
|
||||||
expect(parsed.systemInfo.pluginVersion).toBe("3.4.0")
|
expect(parsed.systemInfo.pluginVersion).toBe("3.4.0")
|
||||||
expect(parsed.tools.ghCli.username).toBe("yeongyu")
|
|
||||||
expect(parsed.exitCode).toBe(0)
|
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 mockShowConfigErrorsIfAny = mock(async () => {})
|
||||||
const mockShowModelCacheWarningIfNeeded = mock(async () => {})
|
const mockShowModelCacheWarningIfNeeded = mock(async () => {})
|
||||||
@ -7,7 +7,7 @@ const mockShowLocalDevToast = mock(async () => {})
|
|||||||
const mockShowVersionToast = mock(async () => {})
|
const mockShowVersionToast = mock(async () => {})
|
||||||
const mockRunBackgroundUpdateCheck = mock(async () => {})
|
const mockRunBackgroundUpdateCheck = mock(async () => {})
|
||||||
const mockGetCachedVersion = mock(() => "3.6.0")
|
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", () => ({
|
mock.module("./hook/config-errors-toast", () => ({
|
||||||
showConfigErrorsIfAny: mockShowConfigErrorsIfAny,
|
showConfigErrorsIfAny: mockShowConfigErrorsIfAny,
|
||||||
@ -40,31 +40,49 @@ mock.module("../../shared/logger", () => ({
|
|||||||
log: () => {},
|
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(() => {
|
afterEach(() => {
|
||||||
delete process.env.OPENCODE_CLI_RUN_MODE
|
delete process.env.OPENCODE_CLI_RUN_MODE
|
||||||
mock.restore()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("createAutoUpdateCheckerHook", () => {
|
describe("createAutoUpdateCheckerHook", () => {
|
||||||
it("skips startup toasts and checks in CLI run mode", async () => {
|
it("skips startup toasts and checks in CLI run mode", async () => {
|
||||||
//#given - CLI run mode enabled
|
//#given - CLI run mode enabled
|
||||||
process.env.OPENCODE_CLI_RUN_MODE = "true"
|
process.env.OPENCODE_CLI_RUN_MODE = "true"
|
||||||
mockShowConfigErrorsIfAny.mockClear()
|
const createAutoUpdateCheckerHook = await importFreshHookFactory()
|
||||||
mockShowModelCacheWarningIfNeeded.mockClear()
|
|
||||||
mockUpdateAndShowConnectedProvidersCacheStatus.mockClear()
|
|
||||||
mockShowLocalDevToast.mockClear()
|
|
||||||
mockShowVersionToast.mockClear()
|
|
||||||
mockRunBackgroundUpdateCheck.mockClear()
|
|
||||||
|
|
||||||
const hook = createAutoUpdateCheckerHook(
|
const hook = createAutoUpdateCheckerHook(createPluginInput(), {
|
||||||
{
|
showStartupToast: true,
|
||||||
directory: "/test",
|
isSisyphusEnabled: true,
|
||||||
client: {} as never,
|
autoUpdate: true,
|
||||||
} as never,
|
})
|
||||||
{ showStartupToast: true, isSisyphusEnabled: true, autoUpdate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
//#when - session.created event arrives
|
//#when - session.created event arrives
|
||||||
hook.event({
|
hook.event({
|
||||||
@ -73,7 +91,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
|||||||
properties: { info: { parentID: undefined } },
|
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
|
//#then - no update checker side effects run
|
||||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||||
@ -82,6 +100,144 @@ describe("createAutoUpdateCheckerHook", () => {
|
|||||||
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
|
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
|
||||||
expect(mockShowVersionToast).not.toHaveBeenCalled()
|
expect(mockShowVersionToast).not.toHaveBeenCalled()
|
||||||
expect(mockRunBackgroundUpdateCheck).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,219 @@
|
|||||||
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
|
type PluginEntry = {
|
||||||
const mockFindPluginEntry = mock(() => null as any)
|
entry: string
|
||||||
const mockGetCachedVersion = mock(() => null as string | null)
|
isPinned: boolean
|
||||||
const mockGetLatestVersion = mock(async () => null as string | null)
|
pinnedVersion: string | null
|
||||||
const mockUpdatePinnedVersion = mock(() => false)
|
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 mockExtractChannel = mock(() => "latest")
|
||||||
const mockInvalidatePackage = mock(() => {})
|
const mockInvalidatePackage = mock(() => {})
|
||||||
const mockRunBunInstall = mock(async () => true)
|
const mockRunBunInstall = mock(async () => true)
|
||||||
const mockShowUpdateAvailableToast = mock(async () => {})
|
const mockShowUpdateAvailableToast = mock(
|
||||||
const mockShowAutoUpdatedToast = mock(async () => {})
|
async (_ctx: PluginInput, _latestVersion: string, _getToastMessage: ToastMessageGetter): Promise<void> => {}
|
||||||
|
)
|
||||||
|
const mockShowAutoUpdatedToast = mock(
|
||||||
|
async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise<void> => {}
|
||||||
|
)
|
||||||
|
|
||||||
mock.module("../checker", () => ({
|
mock.module("../checker", () => ({
|
||||||
findPluginEntry: mockFindPluginEntry,
|
findPluginEntry: mockFindPluginEntry,
|
||||||
getCachedVersion: mockGetCachedVersion,
|
getCachedVersion: mockGetCachedVersion,
|
||||||
getLatestVersion: mockGetLatestVersion,
|
getLatestVersion: mockGetLatestVersion,
|
||||||
updatePinnedVersion: mockUpdatePinnedVersion,
|
|
||||||
revertPinnedVersion: mock(() => false),
|
revertPinnedVersion: mock(() => false),
|
||||||
}))
|
}))
|
||||||
|
mock.module("../version-channel", () => ({ extractChannel: mockExtractChannel }))
|
||||||
mock.module("../version-channel", () => ({
|
mock.module("../cache", () => ({ invalidatePackage: mockInvalidatePackage }))
|
||||||
extractChannel: mockExtractChannel,
|
mock.module("../../../cli/config-manager", () => ({ runBunInstall: mockRunBunInstall }))
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module("../cache", () => ({
|
|
||||||
invalidatePackage: mockInvalidatePackage,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module("../../../cli/config-manager", () => ({
|
|
||||||
runBunInstall: mockRunBunInstall,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module("./update-toasts", () => ({
|
mock.module("./update-toasts", () => ({
|
||||||
showUpdateAvailableToast: mockShowUpdateAvailableToast,
|
showUpdateAvailableToast: mockShowUpdateAvailableToast,
|
||||||
showAutoUpdatedToast: mockShowAutoUpdatedToast,
|
showAutoUpdatedToast: mockShowAutoUpdatedToast,
|
||||||
}))
|
}))
|
||||||
|
mock.module("../../../shared/logger", () => ({ log: () => {} }))
|
||||||
|
|
||||||
mock.module("../../../shared/logger", () => ({
|
const modulePath = "./background-update-check?test"
|
||||||
log: () => {},
|
const { runBackgroundUpdateCheck } = await import(modulePath)
|
||||||
}))
|
|
||||||
|
|
||||||
const { runBackgroundUpdateCheck } = await import("./background-update-check?test")
|
|
||||||
|
|
||||||
describe("runBackgroundUpdateCheck", () => {
|
describe("runBackgroundUpdateCheck", () => {
|
||||||
const mockCtx = { directory: "/test" } as any
|
const mockCtx = { directory: "/test" } as PluginInput
|
||||||
const mockGetToastMessage = (isUpdate: boolean, version?: string) =>
|
const getToastMessage: ToastMessageGetter = (isUpdate, version) =>
|
||||||
isUpdate ? `Update to ${version}` : "Up to date"
|
isUpdate ? `Update to ${version}` : "Up to date"
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFindPluginEntry.mockReset()
|
mockFindPluginEntry.mockReset()
|
||||||
mockGetCachedVersion.mockReset()
|
mockGetCachedVersion.mockReset()
|
||||||
mockGetLatestVersion.mockReset()
|
mockGetLatestVersion.mockReset()
|
||||||
mockUpdatePinnedVersion.mockReset()
|
|
||||||
mockExtractChannel.mockReset()
|
mockExtractChannel.mockReset()
|
||||||
mockInvalidatePackage.mockReset()
|
mockInvalidatePackage.mockReset()
|
||||||
mockRunBunInstall.mockReset()
|
mockRunBunInstall.mockReset()
|
||||||
mockShowUpdateAvailableToast.mockReset()
|
mockShowUpdateAvailableToast.mockReset()
|
||||||
mockShowAutoUpdatedToast.mockReset()
|
mockShowAutoUpdatedToast.mockReset()
|
||||||
|
|
||||||
|
mockFindPluginEntry.mockReturnValue(createPluginEntry())
|
||||||
|
mockGetCachedVersion.mockReturnValue("3.4.0")
|
||||||
|
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
||||||
mockExtractChannel.mockReturnValue("latest")
|
mockExtractChannel.mockReturnValue("latest")
|
||||||
mockRunBunInstall.mockResolvedValue(true)
|
mockRunBunInstall.mockResolvedValue(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("#given user has pinned a specific version", () => {
|
describe("#given no plugin entry found", () => {
|
||||||
beforeEach(() => {
|
it("returns early without showing any toast", async () => {
|
||||||
mockFindPluginEntry.mockReturnValue({
|
//#given
|
||||||
entry: "oh-my-opencode@3.4.0",
|
mockFindPluginEntry.mockReturnValue(null)
|
||||||
isPinned: true,
|
//#when
|
||||||
pinnedVersion: "3.4.0",
|
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||||
configPath: "/test/opencode.json",
|
//#then
|
||||||
})
|
expect(mockFindPluginEntry).toHaveBeenCalledTimes(1)
|
||||||
mockGetCachedVersion.mockReturnValue("3.4.0")
|
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
|
||||||
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
expect(mockRunBunInstall).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)", () => {
|
describe("#given no version available", () => {
|
||||||
beforeEach(() => {
|
it("returns early when neither cached nor pinned version exists", async () => {
|
||||||
mockFindPluginEntry.mockReturnValue({
|
//#given
|
||||||
entry: "oh-my-opencode",
|
mockFindPluginEntry.mockReturnValue(createPluginEntry({ entry: "oh-my-opencode" }))
|
||||||
isPinned: false,
|
mockGetCachedVersion.mockReturnValue(null)
|
||||||
pinnedVersion: null,
|
//#when
|
||||||
configPath: "/test/opencode.json",
|
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||||
})
|
//#then
|
||||||
mockGetCachedVersion.mockReturnValue("3.4.0")
|
expect(mockGetCachedVersion).toHaveBeenCalledTimes(1)
|
||||||
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
expect(mockGetLatestVersion).not.toHaveBeenCalled()
|
||||||
})
|
|
||||||
|
|
||||||
it("#then should proceed with auto-update", async () => {
|
|
||||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
|
||||||
|
|
||||||
expect(mockInvalidatePackage).toHaveBeenCalled()
|
|
||||||
expect(mockRunBunInstall).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("#then should show auto-updated toast on success", async () => {
|
|
||||||
mockRunBunInstall.mockResolvedValue(true)
|
|
||||||
|
|
||||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
|
||||||
|
|
||||||
expect(mockShowAutoUpdatedToast).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("#given autoUpdate is false", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFindPluginEntry.mockReturnValue({
|
|
||||||
entry: "oh-my-opencode",
|
|
||||||
isPinned: false,
|
|
||||||
pinnedVersion: null,
|
|
||||||
configPath: "/test/opencode.json",
|
|
||||||
})
|
|
||||||
mockGetCachedVersion.mockReturnValue("3.4.0")
|
|
||||||
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("#then should only show notification toast", async () => {
|
|
||||||
await runBackgroundUpdateCheck(mockCtx, false, mockGetToastMessage)
|
|
||||||
|
|
||||||
expect(mockShowUpdateAvailableToast).toHaveBeenCalled()
|
|
||||||
expect(mockRunBunInstall).not.toHaveBeenCalled()
|
|
||||||
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("#given already on latest version", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFindPluginEntry.mockReturnValue({
|
|
||||||
entry: "oh-my-opencode@3.5.0",
|
|
||||||
isPinned: true,
|
|
||||||
pinnedVersion: "3.5.0",
|
|
||||||
configPath: "/test/opencode.json",
|
|
||||||
})
|
|
||||||
mockGetCachedVersion.mockReturnValue("3.5.0")
|
|
||||||
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("#then should not update or show toast", async () => {
|
|
||||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
|
||||||
|
|
||||||
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
|
|
||||||
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
|
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
|
||||||
expect(mockShowAutoUpdatedToast).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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does NOT show update-available toast on success", async () => {
|
||||||
|
//#given
|
||||||
|
mockRunBunInstall.mockResolvedValue(true)
|
||||||
|
//#when
|
||||||
|
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||||
|
//#then
|
||||||
|
expect(mockShowAutoUpdatedToast).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
|
||||||
|
expect(mockRunBunInstall).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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 { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
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 storageMaps = new Map<string, Set<string>>()
|
||||||
const resolveFilePathMock = mock((_: string, path: string) => path)
|
|
||||||
const loadInjectedPathsMock = mock((_: string) => new Set<string>())
|
mock.module("./constants", () => ({
|
||||||
const saveInjectedPathsMock = mock((_: string, __: Set<string>) => {})
|
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", () => {
|
describe("processFilePathForAgentsInjection", () => {
|
||||||
let testRoot = ""
|
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(() => {
|
beforeEach(() => {
|
||||||
findAgentsMdUpMock.mockClear()
|
storageMaps.clear()
|
||||||
resolveFilePathMock.mockClear()
|
|
||||||
loadInjectedPathsMock.mockClear()
|
|
||||||
saveInjectedPathsMock.mockClear()
|
|
||||||
|
|
||||||
testRoot = join(
|
testRoot = join(tmpdir(), `directory-agents-injector-${randomUUID()}`)
|
||||||
tmpdir(),
|
srcDirectory = join(testRoot, "src")
|
||||||
`directory-agents-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
componentsDirectory = join(srcDirectory, "components")
|
||||||
)
|
|
||||||
mkdirSync(testRoot, { recursive: true })
|
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(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
|
||||||
rmSync(testRoot, { recursive: true, force: true })
|
rmSync(testRoot, { recursive: true, force: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
it("does not save when all discovered paths are already cached", async () => {
|
it("injects AGENTS.md content from file's parent directory into output", async () => {
|
||||||
//#given
|
// 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,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||||
|
const output = { title: "Read result", output: "base output", metadata: {} }
|
||||||
|
|
||||||
//#when
|
// when
|
||||||
await processFilePathForAgentsInjection({
|
await processFilePathForAgentsInjection({
|
||||||
ctx: { directory: repoRoot } as never,
|
ctx: { directory: testRoot } as PluginInput,
|
||||||
truncator: truncator as never,
|
truncator,
|
||||||
sessionCaches: new Map(),
|
sessionCaches: new Map(),
|
||||||
filePath: join(repoRoot, "src", "file.ts"),
|
filePath: join(srcDirectory, "file.ts"),
|
||||||
sessionID,
|
sessionID: "session-parent",
|
||||||
output: { title: "Result", output: "", metadata: {} },
|
output,
|
||||||
})
|
})
|
||||||
|
|
||||||
//#then
|
// then
|
||||||
expect(saveInjectedPathsMock).not.toHaveBeenCalled()
|
expect(output.output).toContain("[Directory Context:")
|
||||||
|
expect(output.output).toContain(srcAgentsContent)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("saves when a new path is injected", async () => {
|
it("skips root-level AGENTS.md", async () => {
|
||||||
//#given
|
// given
|
||||||
const sessionID = "session-2"
|
rmSync(join(srcDirectory, "AGENTS.md"), { force: true })
|
||||||
const repoRoot = join(testRoot, "repo")
|
rmSync(join(componentsDirectory, "AGENTS.md"), { force: true })
|
||||||
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,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||||
|
const output = { title: "Read result", output: "base output", metadata: {} }
|
||||||
|
|
||||||
//#when
|
// when
|
||||||
await processFilePathForAgentsInjection({
|
await processFilePathForAgentsInjection({
|
||||||
ctx: { directory: repoRoot } as never,
|
ctx: { directory: testRoot } as PluginInput,
|
||||||
truncator: truncator as never,
|
truncator,
|
||||||
sessionCaches: new Map(),
|
sessionCaches: new Map(),
|
||||||
filePath: join(repoRoot, "src", "file.ts"),
|
filePath: join(testRoot, "file.ts"),
|
||||||
sessionID,
|
sessionID: "session-root-skip",
|
||||||
output: { title: "Result", output: "", metadata: {} },
|
output,
|
||||||
})
|
})
|
||||||
|
|
||||||
//#then
|
// then
|
||||||
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
|
expect(output.output).not.toContain(rootAgentsContent)
|
||||||
const saveCall = saveInjectedPathsMock.mock.calls[0]
|
expect(output.output).not.toContain("[Directory Context:")
|
||||||
expect(saveCall[0]).toBe(sessionID)
|
|
||||||
expect((saveCall[1] as Set<string>).has(injectedDirectory)).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("saves once when cached and new paths are mixed", async () => {
|
it("injects multiple AGENTS.md when walking up directory tree", async () => {
|
||||||
//#given
|
// 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,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||||
|
const output = { title: "Read result", output: "base output", metadata: {} }
|
||||||
|
|
||||||
//#when
|
// when
|
||||||
await processFilePathForAgentsInjection({
|
await processFilePathForAgentsInjection({
|
||||||
ctx: { directory: repoRoot } as never,
|
ctx: { directory: testRoot } as PluginInput,
|
||||||
truncator: truncator as never,
|
truncator,
|
||||||
sessionCaches: new Map(),
|
sessionCaches: new Map(),
|
||||||
filePath: join(repoRoot, "new-dir", "file.ts"),
|
filePath: join(componentsDirectory, "button.ts"),
|
||||||
sessionID,
|
sessionID: "session-multiple",
|
||||||
output: { title: "Result", output: "", metadata: {} },
|
output,
|
||||||
})
|
})
|
||||||
|
|
||||||
//#then
|
// then
|
||||||
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
|
expect(output.output).toContain(srcAgentsContent)
|
||||||
const saveCall = saveInjectedPathsMock.mock.calls[0]
|
expect(output.output).toContain(componentsAgentsContent)
|
||||||
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
|
})
|
||||||
|
|
||||||
|
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 { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
|
||||||
const findReadmeMdUpMock = mock((_: { startDir: string; rootDir: string }) => [] as string[])
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
const resolveFilePathMock = mock((_: string, path: string) => path)
|
|
||||||
const loadInjectedPathsMock = mock((_: string) => new Set<string>())
|
const storageMaps = new Map<string, Set<string>>()
|
||||||
const saveInjectedPathsMock = mock((_: 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", () => {
|
describe("processFilePathForReadmeInjection", () => {
|
||||||
let testRoot = ""
|
let testRoot = ""
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
findReadmeMdUpMock.mockClear()
|
testRoot = join(tmpdir(), `directory-readme-injector-${randomUUID()}`)
|
||||||
resolveFilePathMock.mockClear()
|
|
||||||
loadInjectedPathsMock.mockClear()
|
|
||||||
saveInjectedPathsMock.mockClear()
|
|
||||||
|
|
||||||
testRoot = join(
|
|
||||||
tmpdir(),
|
|
||||||
`directory-readme-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
||||||
)
|
|
||||||
mkdirSync(testRoot, { recursive: true })
|
mkdirSync(testRoot, { recursive: true })
|
||||||
|
storageMaps.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
|
||||||
rmSync(testRoot, { recursive: true, force: true })
|
rmSync(testRoot, { recursive: true, force: true })
|
||||||
|
storageMaps.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("does not save when all discovered paths are already cached", async () => {
|
it("injects README.md content from file's parent directory into output", async () => {
|
||||||
//#given
|
// given
|
||||||
const sessionID = "session-1"
|
const sourceDirectory = join(testRoot, "src")
|
||||||
const repoRoot = join(testRoot, "repo")
|
mkdirSync(sourceDirectory, { recursive: true })
|
||||||
const readmePath = join(repoRoot, "src", "README.md")
|
writeFileSync(join(sourceDirectory, "README.md"), "# Source README\nlocal context")
|
||||||
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,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||||
|
const output = { title: "Result", output: "base", metadata: {} }
|
||||||
|
const truncator = createTruncator()
|
||||||
|
|
||||||
//#when
|
// when
|
||||||
await processFilePathForReadmeInjection({
|
await processFilePathForReadmeInjection({
|
||||||
ctx: { directory: repoRoot } as never,
|
ctx: createPluginContext(testRoot),
|
||||||
truncator: truncator as never,
|
truncator,
|
||||||
sessionCaches: new Map(),
|
sessionCaches: new Map<string, Set<string>>(),
|
||||||
filePath: join(repoRoot, "src", "file.ts"),
|
filePath: join(sourceDirectory, "file.ts"),
|
||||||
sessionID,
|
sessionID: "session-parent",
|
||||||
output: { title: "Result", output: "", metadata: {} },
|
output,
|
||||||
})
|
})
|
||||||
|
|
||||||
//#then
|
// then
|
||||||
expect(saveInjectedPathsMock).not.toHaveBeenCalled()
|
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 () => {
|
it("includes root-level README.md (unlike agents-injector)", async () => {
|
||||||
//#given
|
// given
|
||||||
const sessionID = "session-2"
|
writeFileSync(join(testRoot, "README.md"), "# Root README\nroot context")
|
||||||
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,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||||
|
const output = { title: "Result", output: "", metadata: {} }
|
||||||
|
const truncator = createTruncator()
|
||||||
|
|
||||||
//#when
|
// when
|
||||||
await processFilePathForReadmeInjection({
|
await processFilePathForReadmeInjection({
|
||||||
ctx: { directory: repoRoot } as never,
|
ctx: createPluginContext(testRoot),
|
||||||
truncator: truncator as never,
|
truncator,
|
||||||
sessionCaches: new Map(),
|
sessionCaches: new Map<string, Set<string>>(),
|
||||||
filePath: join(repoRoot, "src", "file.ts"),
|
filePath: join(testRoot, "file.ts"),
|
||||||
sessionID,
|
sessionID: "session-root",
|
||||||
output: { title: "Result", output: "", metadata: {} },
|
output,
|
||||||
})
|
})
|
||||||
|
|
||||||
//#then
|
// then
|
||||||
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
|
expect(output.output).toContain("[Project README:")
|
||||||
const saveCall = saveInjectedPathsMock.mock.calls[0]
|
expect(output.output).toContain("# Root README")
|
||||||
expect(saveCall[0]).toBe(sessionID)
|
expect(output.output).toContain("root context")
|
||||||
expect((saveCall[1] as Set<string>).has(injectedDirectory)).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("saves once when cached and new paths are mixed", async () => {
|
it("injects multiple README.md when walking up directory tree", async () => {
|
||||||
//#given
|
// given
|
||||||
const sessionID = "session-3"
|
const sourceDirectory = join(testRoot, "src")
|
||||||
const repoRoot = join(testRoot, "repo")
|
const componentsDirectory = join(sourceDirectory, "components")
|
||||||
const cachedReadmePath = join(repoRoot, "already-cached", "README.md")
|
mkdirSync(componentsDirectory, { recursive: true })
|
||||||
const newReadmePath = join(repoRoot, "new-dir", "README.md")
|
writeFileSync(join(testRoot, "README.md"), "# Root README")
|
||||||
mkdirSync(join(repoRoot, "already-cached"), { recursive: true })
|
writeFileSync(join(sourceDirectory, "README.md"), "# Src README")
|
||||||
mkdirSync(join(repoRoot, "new-dir"), { recursive: true })
|
writeFileSync(join(componentsDirectory, "README.md"), "# Components README")
|
||||||
writeFileSync(cachedReadmePath, "# README")
|
writeFileSync(join(componentsDirectory, "button.ts"), "export const button = true")
|
||||||
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,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||||
|
const output = { title: "Result", output: "", metadata: {} }
|
||||||
|
const truncator = createTruncator()
|
||||||
|
|
||||||
//#when
|
// when
|
||||||
await processFilePathForReadmeInjection({
|
await processFilePathForReadmeInjection({
|
||||||
ctx: { directory: repoRoot } as never,
|
ctx: createPluginContext(testRoot),
|
||||||
truncator: truncator as never,
|
truncator,
|
||||||
sessionCaches: new Map(),
|
sessionCaches: new Map<string, Set<string>>(),
|
||||||
filePath: join(repoRoot, "new-dir", "file.ts"),
|
filePath: join(componentsDirectory, "button.ts"),
|
||||||
sessionID,
|
sessionID: "session-multi",
|
||||||
output: { title: "Result", output: "", metadata: {} },
|
output,
|
||||||
})
|
})
|
||||||
|
|
||||||
//#then
|
// then
|
||||||
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
|
expect(countReadmeMarkers(output.output)).toBe(3)
|
||||||
const saveCall = saveInjectedPathsMock.mock.calls[0]
|
expect(output.output).toContain("# Root README")
|
||||||
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
|
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")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user