Files
kilocode/packages/kilo-vscode/tests/unit/review-comments.test.ts
T
Marius 555dc23593 feat(vscode): render submitted review comments across chat surfaces (#11173)
* feat(vscode): render submitted review comments interactively

* chore: update kilo-vscode visual regression baselines

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-12 16:45:47 +00:00

444 lines
17 KiB
TypeScript

import { describe, it, expect } from "bun:test"
import {
sanitizeReviewComments,
formatReviewCommentsMarkdown,
extractLines,
getDirectory,
getFilename,
type ReviewComment,
} from "../../webview-ui/diff-viewer/review-comments"
import { markdownCommentBlocks } from "../../webview-ui/diff-viewer/markdown-comment-ranges"
import {
buildFileAnnotations,
clearReviewComposer,
createReviewComposer,
reviewAnnotationSpeechKey,
reviewComposerDraft,
reviewComposerEdit,
reviewDraftSpeechKey,
reviewEditSpeechKey,
} from "../../webview-ui/diff-viewer/review-annotations"
import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages"
import { parseReview, partReview, reviewMetadata } from "../../src/shared/review-comments"
function diff(file: string, before: string, after: string): WorktreeFileDiff {
return { file, before, after, additions: 1, deletions: 0 }
}
function comment(overrides: Partial<ReviewComment> & Pick<ReviewComment, "file" | "line">): ReviewComment {
return {
id: `c-${overrides.file}-${overrides.line}`,
side: "additions",
comment: "test comment",
selectedText: "",
...overrides,
}
}
// ── sanitizeReviewComments ────────────────────────────────────────────────
describe("sanitizeReviewComments", () => {
it("returns empty array when no comments", () => {
expect(sanitizeReviewComments([], [diff("a.ts", "", "line1")])).toEqual([])
})
it("returns empty array when no diffs", () => {
expect(sanitizeReviewComments([comment({ file: "a.ts", line: 1 })], [])).toEqual([])
})
it("filters out comments for files not in diffs", () => {
const result = sanitizeReviewComments([comment({ file: "missing.ts", line: 1 })], [diff("a.ts", "", "content")])
expect(result).toEqual([])
})
it("keeps comments with valid line numbers", () => {
const c = comment({ file: "a.ts", line: 1 })
const result = sanitizeReviewComments([c], [diff("a.ts", "", "line1\nline2\nline3")])
expect(result).toEqual([c])
})
it("keeps comment on the last line (boundary)", () => {
const c = comment({ file: "a.ts", line: 3 })
const result = sanitizeReviewComments([c], [diff("a.ts", "", "a\nb\nc")])
expect(result).toEqual([c])
})
it("filters comment on line max+1 (off by one)", () => {
const c = comment({ file: "a.ts", line: 4 })
const result = sanitizeReviewComments([c], [diff("a.ts", "", "a\nb\nc")])
expect(result).toEqual([])
})
it("filters comment on line 0", () => {
const c = comment({ file: "a.ts", line: 0 })
const result = sanitizeReviewComments([c], [diff("a.ts", "", "content")])
expect(result).toEqual([])
})
it("filters comment with negative line", () => {
const c = comment({ file: "a.ts", line: -1 })
const result = sanitizeReviewComments([c], [diff("a.ts", "", "content")])
expect(result).toEqual([])
})
it("uses diff.before for deletions side", () => {
const c = comment({ file: "a.ts", line: 2, side: "deletions" })
// before has 2 lines, after has 0 — comment should be valid on deletions side
const result = sanitizeReviewComments([c], [diff("a.ts", "old1\nold2", "")])
expect(result).toEqual([c])
})
it("uses diff.after for additions side", () => {
const c = comment({ file: "a.ts", line: 2, side: "additions" })
// after has 3 lines — comment on line 2 should be valid
const result = sanitizeReviewComments([c], [diff("a.ts", "", "new1\nnew2\nnew3")])
expect(result).toEqual([c])
})
it("rejects deletions comment when before content is empty", () => {
const c = comment({ file: "a.ts", line: 1, side: "deletions" })
const result = sanitizeReviewComments([c], [diff("a.ts", "", "after")])
expect(result).toEqual([])
})
it("rejects additions comment when after content is empty", () => {
const c = comment({ file: "a.ts", line: 1, side: "additions" })
const result = sanitizeReviewComments([c], [diff("a.ts", "before", "")])
expect(result).toEqual([])
})
it("preserves comments when diff is summarized", () => {
const c = comment({ file: "a.ts", line: 5, side: "additions" })
const d = { ...diff("a.ts", "", ""), summarized: true }
const result = sanitizeReviewComments([c], [d])
expect(result).toEqual([c])
})
it("returns all when all comments are valid", () => {
const comments = [
comment({ file: "a.ts", line: 1 }),
comment({ file: "a.ts", line: 2 }),
comment({ file: "b.ts", line: 1 }),
]
const diffs = [diff("a.ts", "", "x\ny"), diff("b.ts", "", "z")]
const result = sanitizeReviewComments(comments, diffs)
expect(result).toHaveLength(3)
})
it("filters a mix of valid and invalid comments", () => {
const valid = comment({ file: "a.ts", line: 1 })
const invalid = comment({ file: "a.ts", line: 100 })
const missing = comment({ file: "gone.ts", line: 1 })
const result = sanitizeReviewComments([valid, invalid, missing], [diff("a.ts", "", "content")])
expect(result).toEqual([valid])
})
})
// ── formatReviewCommentsMarkdown ────────────────────────────────────────────
describe("formatReviewCommentsMarkdown", () => {
it("returns header only for empty array", () => {
const result = formatReviewCommentsMarkdown([])
expect(result).toBe("## Review Comments")
})
it("formats a single comment without selected text", () => {
const result = formatReviewCommentsMarkdown([
comment({ file: "src/a.ts", line: 5, comment: "Fix this", selectedText: "" }),
])
expect(result).toContain("**src/a.ts** (line 5):")
expect(result).toContain("Fix this")
expect(result).not.toContain("```")
})
it("includes code block for comment with selected text", () => {
const result = formatReviewCommentsMarkdown([
comment({ file: "a.ts", line: 1, comment: "Wrong return", selectedText: "return null" }),
])
expect(result).toContain("```\nreturn null\n```")
expect(result).toContain("Wrong return")
})
it("formats multiple comments in order", () => {
const result = formatReviewCommentsMarkdown([
comment({ file: "a.ts", line: 1, comment: "First" }),
comment({ file: "b.ts", line: 10, comment: "Second", selectedText: "code" }),
])
const firstIdx = result.indexOf("**a.ts** (line 1):")
const secondIdx = result.indexOf("**b.ts** (line 10):")
expect(firstIdx).toBeLessThan(secondIdx)
})
})
describe("review message metadata", () => {
const comments = [comment({ file: "src/a.ts", line: 5, comment: "Fix this", selectedText: "const a = 1" })]
const content = `${formatReviewCommentsMarkdown(comments)}\n\nPlease address this feedback.`
const review = { version: 1 as const, comments }
it("round-trips review comments and extracts the visible body", () => {
expect(partReview(reviewMetadata(review), content)).toEqual({
data: review,
body: "Please address this feedback.",
})
})
it("extracts an empty body from a review-only message", () => {
expect(partReview(reviewMetadata(review), formatReviewCommentsMarkdown(comments))?.body).toBe("")
})
it("rejects malformed review comments", () => {
expect(parseReview({ ...review, comments: [{ ...comments[0], line: 0 }] }, content)).toBeUndefined()
expect(parseReview({ ...review, comments: [{ ...comments[0], side: "context" }] }, content)).toBeUndefined()
expect(parseReview({ ...review, comments: [{ ...comments[0], file: "../secret" }] }, content)).toBeUndefined()
expect(parseReview({ ...review, comments: [{ ...comments[0], file: "/tmp/secret" }] }, content)).toBeUndefined()
})
it("rejects metadata that does not match the hidden text", () => {
expect(parseReview(review, `unrelated hidden text\n\n${content}`)).toBeUndefined()
})
it("rejects oversized aggregate metadata before formatting it", () => {
const oversized = Array.from({ length: 6 }, (_, index) =>
comment({ id: `comment-${index}`, selectedText: "x".repeat(200_000) }),
)
expect(parseReview({ version: 1, comments: oversized }, "irrelevant")).toBeUndefined()
})
})
// ── extractLines ────────────────────────────────────────────────────────────
describe("extractLines", () => {
const content = "alpha\nbeta\ngamma\ndelta"
it("extracts a single line (1-indexed)", () => {
expect(extractLines(content, 1, 1)).toBe("alpha")
expect(extractLines(content, 2, 2)).toBe("beta")
expect(extractLines(content, 4, 4)).toBe("delta")
})
it("extracts a range of lines", () => {
expect(extractLines(content, 2, 3)).toBe("beta\ngamma")
})
it("extracts from first to last", () => {
expect(extractLines(content, 1, 4)).toBe("alpha\nbeta\ngamma\ndelta")
})
it("returns empty string for out-of-range start", () => {
expect(extractLines(content, 10, 10)).toBe("")
})
it("handles empty content", () => {
expect(extractLines("", 1, 1)).toBe("")
})
})
// ── markdownCommentBlocks ───────────────────────────────────────────────────
describe("markdownCommentBlocks", () => {
it("keeps fenced code blocks as one selectable range", () => {
const result = markdownCommentBlocks("# Demo\n\n```ts\nconst value = 1\nconsole.log(value)\n```\n\nAfter")
expect(result).toContainEqual({ type: "block", start: 3, end: 6 })
})
it("maps table comments to rendered rows, not separator lines", () => {
const result = markdownCommentBlocks("| Area | Status |\n|---|---|\n| Tables | Pass |\n| Lists | Pass |")
expect(result).toEqual([
{
type: "table",
start: 1,
end: 4,
rows: [
{ start: 1, end: 1 },
{ start: 3, end: 3 },
{ start: 4, end: 4 },
],
},
])
})
it("maps list comments to whole list items", () => {
const result = markdownCommentBlocks("- First\n continued\n- [x] Second\n- Third")
expect(result).toEqual([
{
type: "list",
start: 1,
end: 4,
items: [
{ start: 1, end: 2 },
{ start: 3, end: 3 },
{ start: 4, end: 4 },
],
},
])
})
it("keeps blockquotes and paragraphs as rendered blocks", () => {
const result = markdownCommentBlocks("> Quote\n> Continued\n\nParagraph line one\nparagraph line two")
expect(result).toEqual([
{ type: "block", start: 1, end: 2 },
{ type: "block", start: 4, end: 5 },
])
})
})
// ── review annotation speech keys ───────────────────────────────────────────
describe("review annotation speech keys", () => {
it("keeps draft keys scoped to the selected review location", () => {
expect(reviewDraftSpeechKey({ file: "src/a.ts", side: "additions", line: 4 })).toBe("draft:src/a.ts:additions:4:4")
expect(reviewDraftSpeechKey({ file: "src/a.ts", side: "deletions", line: 4, endLine: 8 })).toBe(
"draft:src/a.ts:deletions:4:8",
)
})
it("keeps edit keys scoped to the review comment id", () => {
expect(reviewEditSpeechKey("c-123")).toBe("edit:c-123")
})
it("only exposes annotation speech keys for active draft and edit composers", () => {
const current = comment({ file: "src/a.ts", line: 7 })
expect(
reviewAnnotationSpeechKey({
type: "draft",
comment: null,
file: "src/a.ts",
side: "additions",
line: 7,
}),
).toBe("draft:src/a.ts:additions:7:7")
expect(
reviewAnnotationSpeechKey({
type: "comment",
comment: current,
file: current.file,
side: current.side,
line: current.line,
editing: true,
}),
).toBe(`edit:${current.id}`)
expect(
reviewAnnotationSpeechKey({
type: "comment",
comment: current,
file: current.file,
side: current.side,
line: current.line,
}),
).toBeUndefined()
})
})
// ── buildFileAnnotations composer metadata ─────────────────────────────────
describe("buildFileAnnotations composer metadata", () => {
it("preserves unfinished draft text when an annotation is rebuilt", () => {
const draft = { file: "a.ts", side: "additions" as const, line: 2 }
const first = buildFileAnnotations("a.ts", [], null, draft, null, null)
if (!first.draftMeta) throw new Error("expected draft metadata")
first.draftMeta.text = "unfinished draft"
const next = buildFileAnnotations("a.ts", [], null, draft, first.draftMeta, first.editMeta)
expect(next.draftMeta).toBe(first.draftMeta)
expect(next.draftMeta?.text).toBe("unfinished draft")
})
it("creates fresh draft metadata when the anchor changes", () => {
const draft = { file: "a.ts", side: "additions" as const, line: 2 }
const first = buildFileAnnotations("a.ts", [], null, draft, null, null)
const next = buildFileAnnotations("a.ts", [], null, { ...draft, line: 3 }, first.draftMeta, first.editMeta)
expect(next.draftMeta).not.toBe(first.draftMeta)
})
it("preserves unfinished edits when an annotation is rebuilt", () => {
const current = comment({ file: "a.ts", line: 2 })
const first = buildFileAnnotations("a.ts", [current], current.id, null, null, null)
if (!first.editMeta) throw new Error("expected edit metadata")
first.editMeta.text = "unfinished edit"
const next = buildFileAnnotations("a.ts", [current], current.id, null, first.draftMeta, first.editMeta)
expect(next.editMeta).toBe(first.editMeta)
expect(next.editMeta?.text).toBe("unfinished edit")
})
it("creates fresh edit metadata when the edited comment changes", () => {
const firstComment = comment({ file: "a.ts", line: 2 })
const secondComment = comment({ file: "a.ts", line: 3 })
const first = buildFileAnnotations("a.ts", [firstComment], firstComment.id, null, null, null)
const next = buildFileAnnotations("a.ts", [secondComment], secondComment.id, null, first.draftMeta, first.editMeta)
expect(next.editMeta).not.toBe(first.editMeta)
})
it("drops unfinished edit metadata after edit mode ends", () => {
const current = comment({ file: "a.ts", line: 2 })
const first = buildFileAnnotations("a.ts", [current], current.id, null, null, null)
const next = buildFileAnnotations("a.ts", [current], null, null, first.draftMeta, first.editMeta)
expect(next.editMeta).toBeNull()
})
it("hands draft and edit composers between review surfaces", () => {
const current = comment({ file: "a.ts", line: 2 })
const composer = createReviewComposer()
const draft = { file: "a.ts", side: "additions" as const, line: 3 }
const first = buildFileAnnotations("a.ts", [current], current.id, draft, null, null)
if (!first.draftMeta || !first.editMeta) throw new Error("expected composer metadata")
first.draftMeta.text = "unfinished draft"
first.editMeta.text = "unfinished edit"
composer.draft = first.draftMeta
composer.edit = first.editMeta
expect(reviewComposerDraft(composer)).toEqual(draft)
expect(reviewComposerEdit(composer)).toBe(current.id)
expect(composer.draft.text).toBe("unfinished draft")
expect(composer.edit.text).toBe("unfinished edit")
})
it("clears handed-off composers when the review context changes", () => {
const composer = createReviewComposer()
composer.draft = { type: "draft", comment: null, file: "a.ts", side: "additions", line: 2 }
composer.edit = {
type: "comment",
comment: comment({ file: "a.ts", line: 2 }),
file: "a.ts",
side: "additions",
line: 2,
}
clearReviewComposer(composer)
expect(reviewComposerDraft(composer)).toBeNull()
expect(reviewComposerEdit(composer)).toBeNull()
})
})
// ── getDirectory / getFilename ──────────────────────────────────────────────
describe("getDirectory", () => {
it("returns empty string for root-level file", () => {
expect(getDirectory("file.ts")).toBe("")
})
it("returns directory path with trailing slash", () => {
expect(getDirectory("src/file.ts")).toBe("src/")
})
it("handles deeply nested paths", () => {
expect(getDirectory("a/b/c/d.ts")).toBe("a/b/c/")
})
})
describe("getFilename", () => {
it("returns the full name for root-level file", () => {
expect(getFilename("file.ts")).toBe("file.ts")
})
it("returns just the filename from a path", () => {
expect(getFilename("src/components/Button.tsx")).toBe("Button.tsx")
})
})