364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
|
|
import {
|
|
formatLineRange,
|
|
normalizeDiffString,
|
|
transformFileMutation,
|
|
transformGitDiff,
|
|
commentsToAnnotations,
|
|
extractCodeFromDiff,
|
|
extractCodeFromContent,
|
|
computeLineDiffStats,
|
|
type FileMutation,
|
|
} from "./adapter";
|
|
import type { DiffComment } from "./types";
|
|
|
|
const FILE = "src/foo.ts";
|
|
const SIMPLE_PATCH_BODY = "@@ -1,1 +1,1 @@\n-old\n+new";
|
|
const DIFF_GIT_HEADER = `diff --git a/${FILE} b/${FILE}`;
|
|
const OLD_HEADER = `--- a/${FILE}`;
|
|
const NEW_HEADER = `+++ b/${FILE}`;
|
|
|
|
const baseComment = (overrides: Partial<DiffComment> = {}): DiffComment => ({
|
|
id: "c1",
|
|
sessionId: "s1",
|
|
source: "diff",
|
|
filePath: FILE,
|
|
startLine: 10,
|
|
endLine: 10,
|
|
side: "additions",
|
|
codeContent: "x",
|
|
text: "looks good",
|
|
createdAt: "2026-05-16T00:00:00Z",
|
|
status: "pending",
|
|
...overrides,
|
|
});
|
|
|
|
describe("formatLineRange", () => {
|
|
it("renders a single-line range as Lx", () => {
|
|
expect(formatLineRange(10, 10)).toBe("L10");
|
|
});
|
|
|
|
it("renders a multi-line range as Lx-y", () => {
|
|
expect(formatLineRange(10, 15)).toBe("L10-15");
|
|
});
|
|
});
|
|
|
|
describe("normalizeDiffString", () => {
|
|
it("returns empty string when diff is empty", () => {
|
|
expect(normalizeDiffString("", FILE)).toBe("");
|
|
});
|
|
|
|
it("re-emits canonical headers and trailing newline even when input already starts with diff --git", () => {
|
|
const diff = `diff --git a/src/foo.ts b/src/foo.ts
|
|
--- a/src/foo.ts
|
|
+++ b/src/foo.ts
|
|
@@ -1,1 +1,1 @@
|
|
-old
|
|
+new`;
|
|
// We always re-emit headers + ensure trailing newline so @pierre/diffs
|
|
// never sees malformed input regardless of upstream formatting.
|
|
expect(normalizeDiffString(` ${diff} `, FILE)).toBe(diff + "\n");
|
|
});
|
|
|
|
it("prepends only the diff --git header when --- and +++ are present", () => {
|
|
const diff = `--- a/src/foo.ts
|
|
+++ b/src/foo.ts
|
|
@@ -1,1 +1,1 @@
|
|
-old
|
|
+new`;
|
|
const result = normalizeDiffString(diff, FILE);
|
|
expect(result.startsWith(`${DIFF_GIT_HEADER}\n`)).toBe(true);
|
|
expect(result).toContain(OLD_HEADER);
|
|
expect(result).toContain(NEW_HEADER);
|
|
});
|
|
|
|
it("prepends the full header trio when no headers are present", () => {
|
|
const diff = `@@ -1,1 +1,1 @@
|
|
-old
|
|
+new`;
|
|
const result = normalizeDiffString(diff, FILE);
|
|
const lines = result.split("\n");
|
|
expect(lines[0]).toBe(DIFF_GIT_HEADER);
|
|
expect(lines[1]).toBe(OLD_HEADER);
|
|
expect(lines[2]).toBe(NEW_HEADER);
|
|
expect(lines[3]).toBe("@@ -1,1 +1,1 @@");
|
|
});
|
|
|
|
it("emits --- /dev/null and new-file-mode for an `@@ -0,0 +N,M @@` hunk", () => {
|
|
// A new file from GitHub mock looks like this (no headers, just the hunk).
|
|
// @pierre/diffs 1.1.x throws "deletionLine and additionLine are null" if the
|
|
// diff is fed --- a/file / +++ b/file because it gets classified as a
|
|
// modification with no old content to read. The /dev/null marker keeps the
|
|
// renderer in the "new file" code path.
|
|
const diff = `@@ -0,0 +1,2 @@
|
|
+line one
|
|
+line two`;
|
|
const result = normalizeDiffString(diff, FILE);
|
|
const lines = result.split("\n");
|
|
expect(lines[0]).toBe(DIFF_GIT_HEADER);
|
|
expect(lines[1]).toBe("new file mode 100644");
|
|
expect(lines[2]).toBe("--- /dev/null");
|
|
expect(lines[3]).toBe("+++ b/src/foo.ts");
|
|
});
|
|
|
|
it("detects new file from a `new file mode` header even without an @@ hunk", () => {
|
|
// Header-only new-file patches (mode-only / binary / rename-no-delta)
|
|
// need the same /dev/null treatment — otherwise the renderer treats
|
|
// them as modifications and pairs missing old content with the patch.
|
|
const diff = `new file mode 100644
|
|
index 0000000..e69de29`;
|
|
const result = normalizeDiffString(diff, FILE);
|
|
const lines = result.split("\n");
|
|
expect(lines[0]).toBe(DIFF_GIT_HEADER);
|
|
expect(lines[1]).toBe("new file mode 100644");
|
|
expect(lines[2]).toBe("--- /dev/null");
|
|
expect(lines[3]).toBe(NEW_HEADER);
|
|
});
|
|
|
|
it("strips the trailing header in a header-only rename diff (no @@ body)", () => {
|
|
// 100%-similarity renames have no hunk body, so the *last* header line
|
|
// (`rename to ...`) has no trailing \n after trim. The strip regex
|
|
// needs to see a terminator, which is why we append `\n` before
|
|
// matching — otherwise `rename to ...` leaks into `body` and breaks
|
|
// the reconstructed patch.
|
|
const diff = `diff --git a/old.ts b/${FILE}
|
|
similarity index 100%
|
|
rename from old.ts
|
|
rename to ${FILE}`;
|
|
const result = normalizeDiffString(diff, FILE);
|
|
expect(result).not.toContain("rename to");
|
|
expect(result).not.toContain("similarity index");
|
|
expect(result).not.toContain("a/old.ts");
|
|
});
|
|
|
|
it("strips rename headers so the canonical --- / +++ pair survives", () => {
|
|
// `git diff --find-renames` produces `similarity index`, `rename from`,
|
|
// `rename to` lines that our older regex left in place — the duplicated
|
|
// header pair confused @pierre/diffs.
|
|
const diff = `diff --git a/old.ts b/${FILE}
|
|
similarity index 95%
|
|
rename from old.ts
|
|
rename to ${FILE}
|
|
index abcdef0..1234567 100644
|
|
--- a/old.ts
|
|
+++ b/${FILE}
|
|
@@ -1,1 +1,1 @@
|
|
-old
|
|
+new`;
|
|
const result = normalizeDiffString(diff, FILE);
|
|
// No leftover rename / similarity / index header should remain.
|
|
expect(result).not.toContain("similarity index");
|
|
expect(result).not.toContain("rename from");
|
|
expect(result).not.toContain("rename to");
|
|
expect(result).not.toContain("a/old.ts");
|
|
expect(result).toContain(DIFF_GIT_HEADER);
|
|
expect(result).toContain(OLD_HEADER);
|
|
expect(result).toContain(NEW_HEADER);
|
|
expect(result).toContain("@@ -1,1 +1,1 @@");
|
|
});
|
|
|
|
it("emits +++ /dev/null and deleted-file-mode for an `@@ -N,M +0,0 @@` hunk", () => {
|
|
const diff = `@@ -1,2 +0,0 @@
|
|
-line one
|
|
-line two`;
|
|
const result = normalizeDiffString(diff, FILE);
|
|
const lines = result.split("\n");
|
|
expect(lines[0]).toBe(DIFF_GIT_HEADER);
|
|
expect(lines[1]).toBe("deleted file mode 100644");
|
|
expect(lines[2]).toBe("--- a/src/foo.ts");
|
|
expect(lines[3]).toBe("+++ /dev/null");
|
|
});
|
|
});
|
|
|
|
describe("transformFileMutation", () => {
|
|
it("uses old_content and new_content for replace mutations", () => {
|
|
const mutation: FileMutation = {
|
|
type: "replace",
|
|
old_content: "old text",
|
|
new_content: "new text",
|
|
};
|
|
const result = transformFileMutation(FILE, mutation);
|
|
expect(result).toEqual({
|
|
filePath: FILE,
|
|
oldContent: "old text",
|
|
newContent: "new text",
|
|
diff: undefined,
|
|
additions: 0,
|
|
deletions: 0,
|
|
});
|
|
});
|
|
|
|
it("falls back to `content` when new_content is missing", () => {
|
|
const mutation: FileMutation = { type: "create", content: "hello world" };
|
|
const result = transformFileMutation(FILE, mutation);
|
|
expect(result.newContent).toBe("hello world");
|
|
});
|
|
|
|
it("normalizes an explicit diff string", () => {
|
|
const mutation: FileMutation = {
|
|
type: "patch",
|
|
diff: SIMPLE_PATCH_BODY,
|
|
};
|
|
const result = transformFileMutation(FILE, mutation);
|
|
expect(result.diff).toContain(DIFF_GIT_HEADER);
|
|
expect(result.diff).toContain("--- a/src/foo.ts");
|
|
expect(result.diff).toContain("+++ b/src/foo.ts");
|
|
});
|
|
|
|
it("generates an all-additions diff for create with content but no diff", () => {
|
|
const mutation: FileMutation = {
|
|
type: "create",
|
|
content: "line one\nline two",
|
|
};
|
|
const result = transformFileMutation("src/new.ts", mutation);
|
|
expect(result.diff).toBe(
|
|
[
|
|
"diff --git a/src/new.ts b/src/new.ts",
|
|
"new file mode 100644",
|
|
"--- /dev/null",
|
|
"+++ b/src/new.ts",
|
|
"@@ -0,0 +1,2 @@",
|
|
"+line one",
|
|
"+line two",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
it("returns undefined diff for create without content or diff", () => {
|
|
const mutation: FileMutation = { type: "create" };
|
|
const result = transformFileMutation("src/empty.ts", mutation);
|
|
expect(result.diff).toBeUndefined();
|
|
});
|
|
|
|
it("prefers new_path over the supplied filePath when renaming", () => {
|
|
const mutation: FileMutation = {
|
|
type: "rename",
|
|
new_path: "src/renamed.ts",
|
|
diff: "@@ -1,1 +1,1 @@\n-a\n+b",
|
|
};
|
|
const result = transformFileMutation("src/old.ts", mutation);
|
|
expect(result.filePath).toBe("src/renamed.ts");
|
|
expect(result.diff).toContain("a/src/renamed.ts b/src/renamed.ts");
|
|
});
|
|
|
|
it("defaults empty content fields to empty strings (not undefined)", () => {
|
|
const result = transformFileMutation(FILE, { type: "delete" });
|
|
expect(result.oldContent).toBe("");
|
|
expect(result.newContent).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("transformGitDiff", () => {
|
|
it("normalizes the diff and zeroes additions/deletions", () => {
|
|
const result = transformGitDiff(FILE, "@@ -1,1 +1,1 @@\n-a\n+b", "M");
|
|
expect(result.filePath).toBe(FILE);
|
|
expect(result.oldContent).toBe("");
|
|
expect(result.newContent).toBe("");
|
|
expect(result.diff).toContain(DIFF_GIT_HEADER);
|
|
expect(result.additions).toBe(0);
|
|
expect(result.deletions).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("commentsToAnnotations", () => {
|
|
it("anchors each annotation at endLine and preserves side", () => {
|
|
const comments: DiffComment[] = [
|
|
baseComment({ id: "c1", startLine: 10, endLine: 12, side: "additions" }),
|
|
baseComment({ id: "c2", startLine: 5, endLine: 5, side: "deletions" }),
|
|
];
|
|
const annotations = commentsToAnnotations(comments);
|
|
expect(annotations).toHaveLength(2);
|
|
expect(annotations[0]).toMatchObject({
|
|
side: "additions",
|
|
lineNumber: 12,
|
|
metadata: { isEditing: false },
|
|
});
|
|
expect(annotations[0].metadata.comment.id).toBe("c1");
|
|
expect(annotations[1]).toMatchObject({
|
|
side: "deletions",
|
|
lineNumber: 5,
|
|
});
|
|
});
|
|
|
|
it("returns an empty array for empty input", () => {
|
|
expect(commentsToAnnotations([])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("extractCodeFromDiff", () => {
|
|
const diff = `diff --git a/src/foo.ts b/src/foo.ts
|
|
--- a/src/foo.ts
|
|
+++ b/src/foo.ts
|
|
@@ -10,4 +10,5 @@
|
|
context line
|
|
-removed line
|
|
+added line one
|
|
+added line two
|
|
trailing context`;
|
|
|
|
it("extracts added lines on the additions side", () => {
|
|
// Hunk starts at +10. Context line is +10, then two additions at +11 and +12.
|
|
const result = extractCodeFromDiff(diff, 11, 12, "additions");
|
|
expect(result).toBe("added line one\nadded line two");
|
|
});
|
|
|
|
it("extracts the removed line on the deletions side", () => {
|
|
// Hunk starts at -10. Context line is -10, removed line is -11.
|
|
const result = extractCodeFromDiff(diff, 11, 11, "deletions");
|
|
expect(result).toBe("removed line");
|
|
});
|
|
|
|
it("includes context lines that fall within range on the additions side", () => {
|
|
// +10 is the first context line; both addition lines are +11 and +12.
|
|
const result = extractCodeFromDiff(diff, 10, 12, "additions");
|
|
expect(result).toBe("context line\nadded line one\nadded line two");
|
|
});
|
|
|
|
it("returns an empty string when the range matches nothing", () => {
|
|
expect(extractCodeFromDiff(diff, 100, 200, "additions")).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("extractCodeFromContent", () => {
|
|
it("extracts a 1-indexed inclusive line range", () => {
|
|
const content = "a\nb\nc\nd\ne";
|
|
expect(extractCodeFromContent(content, 2, 4)).toBe("b\nc\nd");
|
|
});
|
|
|
|
it("returns the single line when start equals end", () => {
|
|
const content = "a\nb\nc";
|
|
expect(extractCodeFromContent(content, 2, 2)).toBe("b");
|
|
});
|
|
});
|
|
|
|
describe("computeLineDiffStats", () => {
|
|
it("returns zero stats for identical content", () => {
|
|
expect(computeLineDiffStats("a\nb\nc", "a\nb\nc")).toEqual({
|
|
additions: 0,
|
|
deletions: 0,
|
|
});
|
|
});
|
|
|
|
it("counts an appended line as a single addition", () => {
|
|
expect(computeLineDiffStats("a\nb", "a\nb\nc")).toEqual({
|
|
additions: 1,
|
|
deletions: 0,
|
|
});
|
|
});
|
|
|
|
it("counts a removed trailing line as a single deletion", () => {
|
|
expect(computeLineDiffStats("a\nb\nc", "a\nb")).toEqual({
|
|
additions: 0,
|
|
deletions: 1,
|
|
});
|
|
});
|
|
|
|
it("counts a changed line as one addition and one deletion", () => {
|
|
expect(computeLineDiffStats("a\nb\nc", "a\nX\nc")).toEqual({
|
|
additions: 1,
|
|
deletions: 1,
|
|
});
|
|
});
|
|
});
|