Skip to content

Commit e92c316

Browse files
authored
test: cover malformed patches and daemon races (#49)
1 parent ff12658 commit e92c316

6 files changed

Lines changed: 144 additions & 4 deletions

File tree

src/core/agent.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ function normalizeAnnotationFile(file: unknown): AgentFileContext {
4545
throw new Error("Annotation ranges must be integer tuples.");
4646
}
4747

48+
if (start < 1 || end < 1) {
49+
throw new Error("Annotation ranges must use positive 1-based line numbers.");
50+
}
51+
52+
if (end < start) {
53+
throw new Error("Annotation ranges must be ordered start..end tuples.");
54+
}
55+
4856
return [start, end] as [number, number];
4957
};
5058

src/core/loaders.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,21 @@ function normalizePatchChangeset(
200200
agentContext: AgentContext | null,
201201
): Changeset {
202202
const normalizedPatchText = stripTerminalControl(patchText.replaceAll("\r\n", "\n"));
203-
const parsedPatches = parsePatchFiles(normalizedPatchText, "patch", true);
203+
204+
let parsedPatches: ReturnType<typeof parsePatchFiles>;
205+
try {
206+
parsedPatches = parsePatchFiles(normalizedPatchText, "patch", true);
207+
} catch {
208+
return {
209+
id: `changeset:${Date.now()}`,
210+
sourceLabel,
211+
title,
212+
summary: normalizedPatchText.trim() || undefined,
213+
agentSummary: agentContext?.summary,
214+
files: [],
215+
};
216+
}
217+
204218
const metadataFiles = parsedPatches.flatMap((entry) => entry.files);
205219
const chunks = splitPatchIntoFileChunks(normalizedPatchText);
206220

test/agent.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,5 +80,39 @@ describe("agent context", () => {
8080
);
8181

8282
await expect(loadAgentContext(invalidRangePath)).rejects.toThrow("Annotation ranges must be integer tuples.");
83+
84+
const negativeRangePath = join(dir, "negative-range.json");
85+
writeFileSync(
86+
negativeRangePath,
87+
JSON.stringify({
88+
version: 1,
89+
files: [
90+
{
91+
path: "src/example.ts",
92+
annotations: [{ summary: "Bad range", newRange: [0, 2] }],
93+
},
94+
],
95+
}),
96+
);
97+
98+
await expect(loadAgentContext(negativeRangePath)).rejects.toThrow(
99+
"Annotation ranges must use positive 1-based line numbers.",
100+
);
101+
102+
const reversedRangePath = join(dir, "reversed-range.json");
103+
writeFileSync(
104+
reversedRangePath,
105+
JSON.stringify({
106+
version: 1,
107+
files: [
108+
{
109+
path: "src/example.ts",
110+
annotations: [{ summary: "Bad range", newRange: [4, 2] }],
111+
},
112+
],
113+
}),
114+
);
115+
116+
await expect(loadAgentContext(reversedRangePath)).rejects.toThrow("Annotation ranges must be ordered start..end tuples.");
83117
});
84118
});

test/loaders.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,23 @@ describe("loadAppBootstrap", () => {
277277
expect(bootstrap.changeset.title).toContain("stash");
278278
});
279279

280+
test("treats malformed inline patch text as an empty review instead of throwing", async () => {
281+
const bootstrap = await loadAppBootstrap({
282+
kind: "patch",
283+
text: [
284+
"\u001b]0;title\u0007not really a patch",
285+
"--- separator only",
286+
"@@ section heading",
287+
"still plain text",
288+
].join("\n"),
289+
options: { mode: "auto" },
290+
});
291+
292+
expect(bootstrap.changeset.files).toHaveLength(0);
293+
expect(bootstrap.changeset.title).toContain("Patch review");
294+
expect(bootstrap.changeset.summary).toContain("not really a patch");
295+
});
296+
280297
test("loads colorized git patch files like the real pager stdin stream", async () => {
281298
const dir = mkdtempSync(join(tmpdir(), "hunk-patch-"));
282299
tempDirs.push(dir);

test/mcp-daemon.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,30 @@ describe("Hunk MCP daemon state", () => {
217217
await expect(pending).rejects.toThrow("disconnected");
218218
});
219219

220+
test("rejects in-flight commands when a session reconnects on a new socket", async () => {
221+
const state = new HunkDaemonState();
222+
const originalSocket = {
223+
send() {},
224+
};
225+
const replacementSocket = {
226+
send() {},
227+
};
228+
229+
state.registerSession(originalSocket, createRegistration(), createSnapshot());
230+
const pending = state.sendComment({
231+
sessionId: "session-1",
232+
filePath: "src/example.ts",
233+
side: "new",
234+
line: 4,
235+
summary: "Review note",
236+
});
237+
238+
state.registerSession(replacementSocket, createRegistration(), createSnapshot({ updatedAt: "2026-03-22T00:00:01.000Z" }));
239+
240+
await expect(pending).rejects.toThrow("reconnected before the command completed");
241+
expect(state.listSessions()).toHaveLength(1);
242+
});
243+
220244
test("rejects commands immediately when the live session socket cannot accept them", async () => {
221245
const state = new HunkDaemonState();
222246
const socket = {

test/pager.test.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,53 @@ describe("general pager detection", () => {
3939
expect(looksLikePatchInput(patch)).toBe(true);
4040
});
4141

42-
test("does not misclassify plain git pager text as a patch", () => {
43-
const branchOutput = ["* main", " feat/persist-view-config", " release/0.1.0"].join("\n");
42+
test("detects common patch shapes across line endings and terminal wrappers", () => {
43+
const patchFixtures = [
44+
[
45+
"diff --git a/src/example.ts b/src/example.ts",
46+
"--- a/src/example.ts",
47+
"+++ b/src/example.ts",
48+
"@@ -1 +1 @@",
49+
"-export const value = 1;",
50+
"+export const value = 2;",
51+
],
52+
[
53+
"--- a/src/example.ts",
54+
"+++ b/src/example.ts",
55+
"@@ -1 +1,2 @@",
56+
"-export const value = 1;",
57+
"+export const value = 2;",
58+
"+export const extra = true;",
59+
],
60+
[
61+
"header",
62+
"@@ -10,0 +11,2 @@",
63+
"+export const inserted = true;",
64+
"+export const added = true;",
65+
],
66+
];
4467

45-
expect(looksLikePatchInput(branchOutput)).toBe(false);
68+
for (const lines of patchFixtures) {
69+
for (const newline of ["\n", "\r\n"]) {
70+
const patch = lines.join(newline);
71+
expect(looksLikePatchInput(patch)).toBe(true);
72+
expect(looksLikePatchInput(`\u001b]0;title\u0007${patch}\u001bPignored\u001b\\`)).toBe(true);
73+
}
74+
}
75+
});
76+
77+
test("does not misclassify partial diff markers or plain git pager text as a patch", () => {
78+
const fixtures = [
79+
["* main", " feat/persist-view-config", " release/0.1.0"].join("\n"),
80+
["--- separator only", "still prose"].join("\n"),
81+
["+++ banner only", "still prose"].join("\n"),
82+
["@@section heading", "still prose"].join("\n"),
83+
["\u001b]0;title\u0007--- looks patchy", "+++but is just text"].join("\n"),
84+
];
85+
86+
for (const fixture of fixtures) {
87+
expect(looksLikePatchInput(fixture)).toBe(false);
88+
}
4689
});
4790
});
4891

0 commit comments

Comments
 (0)