mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
301 lines
13 KiB
TypeScript
301 lines
13 KiB
TypeScript
import { describe, expect, it } from "bun:test"
|
|
import { createRoot } from "solid-js"
|
|
import { worktreeReferences } from "../../webview-ui/agent-manager/worktree-references"
|
|
import { createProjectStore } from "../../webview-ui/agent-manager/project/store"
|
|
import {
|
|
buildMentionResults,
|
|
buildWorktreeAttachments,
|
|
filterMentionResults,
|
|
type WorktreeReference,
|
|
} from "../../webview-ui/src/hooks/file-mention-utils"
|
|
import { useFileMention } from "../../webview-ui/src/hooks/useFileMention"
|
|
import type { ExtensionMessage, WebviewMessage, WorktreeState } from "../../webview-ui/src/types/messages"
|
|
|
|
function tree(id: string, values: Partial<WorktreeState> = {}): WorktreeState {
|
|
return {
|
|
id,
|
|
branch: `feature/${id}`,
|
|
path: `/repo/.kilo/worktrees/${id}`,
|
|
parentBranch: "main",
|
|
createdAt: "2026-08-01T00:00:00.000Z",
|
|
...values,
|
|
}
|
|
}
|
|
|
|
function reference(values: Partial<WorktreeReference> = {}): WorktreeReference {
|
|
return {
|
|
id: "wt-reference",
|
|
name: "Authentication",
|
|
branch: "feature/auth",
|
|
path: "/repo/.kilo/worktrees/reference",
|
|
base: "main",
|
|
sessions: [{ id: "ses_auth", title: "Fix login" }],
|
|
disabled: false,
|
|
...values,
|
|
}
|
|
}
|
|
|
|
function content(url: string) {
|
|
return decodeURIComponent(url.slice(url.indexOf(",") + 1))
|
|
}
|
|
|
|
function harness(worktrees: () => WorktreeReference[]) {
|
|
const posted: WebviewMessage[] = []
|
|
const handlers = new Set<(message: ExtensionMessage) => void>()
|
|
return createRoot((dispose) => ({
|
|
dispose,
|
|
posted,
|
|
receive: (message: ExtensionMessage) => handlers.forEach((handler) => handler(message)),
|
|
mention: useFileMention(
|
|
{
|
|
postMessage: (message) => posted.push(message),
|
|
onMessage: (handler) => {
|
|
handlers.add(handler)
|
|
return () => handlers.delete(handler)
|
|
},
|
|
},
|
|
undefined,
|
|
() => true,
|
|
worktrees,
|
|
),
|
|
}))
|
|
}
|
|
|
|
function reply(scope: ReturnType<typeof harness>, paths: string[] = []) {
|
|
const request = scope.posted.findLast((message) => message.type === "requestFileSearch")
|
|
if (request?.type !== "requestFileSearch") throw new Error("Missing file search request")
|
|
scope.receive({ type: "fileSearchResult", requestId: request.requestId, dir: "/repo", paths })
|
|
}
|
|
|
|
describe("Agent Manager worktree references", () => {
|
|
it("uses sidebar names and includes all sessions without selecting a transcript", () => {
|
|
const state = createProjectStore("project")
|
|
state.setWorktrees([tree("named", { label: "Custom name" }), tree("ordered"), tree("empty")])
|
|
state.setManagedSessions([
|
|
{ id: "ses_first", worktreeId: "ordered", createdAt: "" },
|
|
{ id: "ses_second", worktreeId: "ordered", createdAt: "" },
|
|
{ id: "ses_local", worktreeId: null, createdAt: "" },
|
|
])
|
|
state.setTabOrder({ ordered: ["ses_second", "ses_first"] })
|
|
const refs = worktreeReferences(
|
|
state,
|
|
[
|
|
{ id: "ses_first", title: "First task" },
|
|
{ id: "ses_second", title: "Second task" },
|
|
{ id: "ses_local", title: "Local task" },
|
|
],
|
|
"local",
|
|
)
|
|
expect(refs.map((ref) => ref.name)).toEqual(["Custom name", "Second task", "empty"])
|
|
expect(refs[1].sessions).toEqual([
|
|
{ id: "ses_first", title: "First task" },
|
|
{ id: "ses_second", title: "Second task" },
|
|
])
|
|
expect(refs[2].sessions).toEqual([])
|
|
})
|
|
|
|
it("omits current, stale, and busy worktrees from suggestions but retains their reference data", () => {
|
|
const state = createProjectStore("project")
|
|
state.setWorktrees([tree("current"), tree("stale"), tree("deleting"), tree("other")])
|
|
state.setStaleWorktreeIds(new Set(["stale"]))
|
|
state.setBusy(new Map([["deleting", { reason: "deleting" }]]))
|
|
const refs = worktreeReferences(state, [], "current")
|
|
expect(refs).toHaveLength(4)
|
|
const scope = harness(() => refs)
|
|
expect(scope.mention.worktreeCandidates().map((item) => item.path)).toEqual(["/repo/.kilo/worktrees/other"])
|
|
scope.dispose()
|
|
})
|
|
|
|
it("keeps project inventories separate even when worktree IDs match", () => {
|
|
const first = createProjectStore("first")
|
|
const second = createProjectStore("second")
|
|
first.setWorktrees([tree("same", { path: "/first/reference" })])
|
|
second.setWorktrees([tree("same", { path: "/second/reference" })])
|
|
expect(worktreeReferences(first, [], "local").map((ref) => ref.path)).toEqual(["/first/reference"])
|
|
expect(worktreeReferences(second, [], "local").map((ref) => ref.path)).toEqual(["/second/reference"])
|
|
})
|
|
|
|
it("keeps multi-version siblings distinct and supports Windows paths", () => {
|
|
const state = createProjectStore("project")
|
|
state.setWorktrees([
|
|
tree("one", { groupId: "group", label: "Compare", path: "C:\\repo\\one\\" }),
|
|
tree("two", { groupId: "group", label: "Compare", path: "C:\\repo\\two" }),
|
|
tree("empty", { path: "C:\\repo\\empty\\" }),
|
|
])
|
|
const refs = worktreeReferences(state, [], "one")
|
|
expect(refs.map((ref) => ref.name)).toEqual(["Compare", "Compare", "empty"])
|
|
expect(refs.map((ref) => ref.disabled)).toEqual([true, false, false])
|
|
expect(refs[0].path).not.toBe(refs[1].path)
|
|
})
|
|
|
|
it("shows one Worktrees entry instead of individual worktrees in the main menu", () => {
|
|
for (const query of ["", "w", "worktree", "WORKTREES", "branch"]) {
|
|
const results = buildMentionResults(query, [], true, true)
|
|
expect(results.filter((item) => item.type === "worktrees")).toEqual([{ type: "worktrees", value: "worktrees" }])
|
|
expect(filterMentionResults(query, results).filter((item) => item.type === "worktrees")).toHaveLength(1)
|
|
}
|
|
expect(buildMentionResults("unrelated", [], true, true).some((item) => item.type === "worktrees")).toBe(false)
|
|
expect(buildMentionResults("", []).some((item) => item.type === "worktrees")).toBe(false)
|
|
})
|
|
|
|
it("ranks recent visits ahead of activity, then uses newest activity and sidebar order", () => {
|
|
const state = createProjectStore("project")
|
|
state.setWorktrees([tree("old"), tree("active"), tree("new", { createdAt: "2026-08-25T00:00:00.000Z" })])
|
|
state.setManagedSessions([{ id: "ses_active", worktreeId: "active", createdAt: "" }])
|
|
const sessions = [{ id: "ses_active", title: "Recent task", updatedAt: "2026-08-26T00:00:00.000Z" }]
|
|
expect(worktreeReferences(state, sessions, "local").map((ref) => ref.id)).toEqual(["active", "new", "old"])
|
|
expect(worktreeReferences(state, sessions, "local", [tree("old").path]).map((ref) => ref.id)).toEqual([
|
|
"old",
|
|
"active",
|
|
"new",
|
|
])
|
|
expect(
|
|
worktreeReferences(state, sessions, "local", [tree("new").path, tree("old").path]).map((ref) => ref.id),
|
|
).toEqual(["new", "old", "active"])
|
|
state.setWorktrees([tree("first"), tree("second")])
|
|
state.setWorktreeOrder(["second", "first"])
|
|
expect(worktreeReferences(state, [], "local").map((ref) => ref.id)).toEqual(["second", "first"])
|
|
})
|
|
|
|
it("does not let a visit in another project boost a colliding worktree ID", () => {
|
|
const state = createProjectStore("first")
|
|
state.setWorktrees([tree("same", { path: "/first/same" }), tree("other", { path: "/first/other" })])
|
|
const refs = worktreeReferences(state, [], "local", ["/second/same", "/first/other"])
|
|
expect(refs.map((ref) => ref.path)).toEqual(["/first/other", "/first/same"])
|
|
})
|
|
|
|
it("attaches only metadata and preserves exact Unicode and spaced paths", () => {
|
|
const ref = reference({ name: 'Fix "登录"\nnow', path: "/repo/.kilo/worktrees/登录 100%" })
|
|
const text = `Compare with @${ref.path} and report differences.`
|
|
const files = buildWorktreeAttachments(text, [ref])
|
|
expect(files).toHaveLength(1)
|
|
expect(files[0]).toMatchObject({
|
|
mime: "text/plain",
|
|
source: {
|
|
type: "file",
|
|
path: ref.path,
|
|
text: { value: `@${ref.path}`, start: 13, end: 14 + ref.path.length },
|
|
},
|
|
})
|
|
expect(files[0].url.startsWith("data:text/plain;charset=utf-8,")).toBe(true)
|
|
const textfile = content(files[0].url)
|
|
expect(JSON.parse(textfile.slice(textfile.indexOf("{")))).toEqual({
|
|
worktreeID: ref.id,
|
|
name: ref.name,
|
|
directory: ref.path,
|
|
branch: ref.branch,
|
|
baseBranch: ref.base,
|
|
sessions: ref.sessions,
|
|
})
|
|
expect(textfile).toContain("metadata only")
|
|
expect(textfile).toContain("recall")
|
|
expect(files.some((file) => file.url.startsWith("file:") || file.url.startsWith("session:"))).toBe(false)
|
|
})
|
|
|
|
it("matches whole references, removes deleted references, and deduplicates repeated mentions", () => {
|
|
const ref = reference()
|
|
expect(buildWorktreeAttachments(`email@${ref.path}`, [ref])).toEqual([])
|
|
expect(buildWorktreeAttachments(`@${ref.path}-other`, [ref])).toEqual([])
|
|
expect(buildWorktreeAttachments(`@${ref.path} @${ref.path}`, [ref])).toHaveLength(1)
|
|
expect(buildWorktreeAttachments("Compare the changes", [ref])).toEqual([])
|
|
const longer = reference({ id: "longer", path: `${ref.path} backup` })
|
|
expect(buildWorktreeAttachments(`@${longer.path}`, [ref, longer]).map((file) => file.source?.path)).toEqual([
|
|
longer.path,
|
|
])
|
|
})
|
|
|
|
it("does not auto-read worktree directories inside the local workspace", () => {
|
|
const ref = reference({ path: "/repo/.kilo/worktrees/reference branch" })
|
|
const scope = harness(() => [ref])
|
|
scope.mention.onInput("@", 1)
|
|
reply(scope, ["src/a.ts"])
|
|
const text = `Compare @${ref.path} with @src/a.ts`
|
|
scope.mention.seedFromText(text)
|
|
expect(scope.mention.mentionedPaths()).toEqual(new Set([ref.path, "src/a.ts"]))
|
|
const files = scope.mention.parseFileAttachments(text)
|
|
expect(files).toHaveLength(2)
|
|
expect(files.filter((file) => file.url.startsWith("file:"))).toMatchObject([{ source: { path: "src/a.ts" } }])
|
|
expect(files.filter((file) => file.source?.path === ref.path)[0].url.startsWith("data:")).toBe(true)
|
|
scope.dispose()
|
|
})
|
|
|
|
it("does not attach a truncated path when worktree data arrives after draft restoration", () => {
|
|
const ref = reference({ path: "/repo/.kilo/worktrees/reference branch" })
|
|
const state = { refs: [] as WorktreeReference[] }
|
|
const scope = harness(() => state.refs)
|
|
scope.mention.onInput("@", 1)
|
|
reply(scope)
|
|
const text = `Compare @${ref.path}`
|
|
scope.mention.seedFromText(text)
|
|
state.refs = [ref]
|
|
const files = scope.mention.parseFileAttachments(text)
|
|
expect(files).toHaveLength(1)
|
|
expect(files[0].source?.path).toBe(ref.path)
|
|
expect(files[0].url.startsWith("data:")).toBe(true)
|
|
scope.dispose()
|
|
})
|
|
|
|
it("restores references without a prior picker selection and refreshes their metadata", () => {
|
|
const state = { refs: [reference()] }
|
|
const scope = harness(() => state.refs)
|
|
const text = `Compare @${state.refs[0].path}`
|
|
scope.mention.seedFromText(text)
|
|
expect(scope.mention.parseFileAttachments(text)).toHaveLength(1)
|
|
state.refs = [reference({ branch: "renamed", sessions: [{ id: "ses_new" }] })]
|
|
const files = scope.mention.parseFileAttachments(text)
|
|
expect(content(files[0].url)).toContain('"branch": "renamed"')
|
|
expect(content(files[0].url)).toContain("ses_new")
|
|
state.refs = []
|
|
expect(content(scope.mention.parseFileAttachments(text)[0].url)).toContain(reference().path)
|
|
expect(scope.mention.parseFileAttachments("Removed reference")).toEqual([])
|
|
scope.dispose()
|
|
})
|
|
|
|
it("retains reference metadata when a worktree disappears after search results arrive", () => {
|
|
const ref = reference()
|
|
const state = { refs: [] as WorktreeReference[] }
|
|
const scope = harness(() => state.refs)
|
|
scope.mention.onInput("@", 1)
|
|
state.refs = [ref]
|
|
reply(scope)
|
|
expect(scope.mention.worktreeCandidates().map((item) => item.path)).toEqual([ref.path])
|
|
state.refs = []
|
|
const text = `Compare @${ref.path}`
|
|
scope.mention.seedFromText(text)
|
|
const files = scope.mention.parseFileAttachments(text)
|
|
expect(files).toHaveLength(1)
|
|
expect(files[0].url.startsWith("data:")).toBe(true)
|
|
scope.dispose()
|
|
})
|
|
|
|
it("opens the local worktree picker without changing the prompt or requesting past chats", () => {
|
|
const state = { refs: [reference(), reference({ id: "tests", name: "Testing", path: "/repo/tests" })] }
|
|
const scope = harness(() => state.refs)
|
|
scope.mention.onInput("@", 1)
|
|
const count = scope.posted.length
|
|
const input = { value: "@", selectionStart: 1 } as HTMLTextAreaElement
|
|
scope.mention.selectMention({ type: "worktrees", value: "worktrees" }, input, () => {})
|
|
expect(scope.mention.worktreePicker()).toBe(true)
|
|
expect(scope.mention.sessionPicker()).toBe(false)
|
|
expect(scope.mention.worktreeCandidates().map((item) => item.name)).toEqual(["Authentication", "Testing"])
|
|
expect(scope.posted).toHaveLength(count)
|
|
expect(input.value).toBe("@")
|
|
state.refs = [reference({ name: "Renamed" })]
|
|
expect(scope.mention.worktreeCandidates().map((item) => item.name)).toEqual(["Renamed"])
|
|
expect(scope.mention.parseFileAttachments("@worktrees")).toEqual([])
|
|
scope.mention.closeMention()
|
|
expect(scope.mention.worktreePicker()).toBe(false)
|
|
expect(scope.mention.showMention()).toBe(false)
|
|
expect(scope.posted.some((message) => message.type === "requestSessionSearch")).toBe(false)
|
|
scope.dispose()
|
|
})
|
|
|
|
it("keeps the Worktrees entry available when there are no other worktrees", () => {
|
|
const scope = harness(() => [reference({ disabled: true })])
|
|
scope.mention.onInput("@", 1)
|
|
expect(scope.mention.mentionResults().filter((item) => item.type === "worktrees")).toHaveLength(1)
|
|
expect(scope.mention.worktreeCandidates()).toEqual([])
|
|
scope.dispose()
|
|
})
|
|
})
|