thunderbird-patch-review
Easy-to-use patch review interface for Thunderbird
Contribute: ~marcc/thunderbird-review-plugin@lists.sr.ht
git clone git://mccd.space/thunderbird-patch-review
| Log | Files | Refs | README | LICENSE |
run.mjs (15025B)
1 // Test runner for the pure extension modules (no Thunderbird required):
2 // node tests/run.mjs
3 import { readFileSync } from "node:fs";
4 import { dirname, join } from "node:path";
5 import { fileURLToPath } from "node:url";
6
7 import {
8 parseSubject,
9 bodyLooksLikeDiff,
10 isPatchMessage,
11 sameSeries,
12 isReplySubject,
13 } from "../extension/modules/patch-detect.js";
14 import { parsePatchEmail } from "../extension/modules/diff-parse.js";
15 import { pairRanges, computeIntraline } from "../extension/modules/intraline.js";
16 import { formatReview } from "../extension/modules/reply-format.js";
17 import { locator, GENERAL } from "../extension/modules/review-store.js";
18 import { dedupByIndex } from "../extension/modules/series.js";
19 import {
20 isSourcehutList,
21 SOURCEHUT_STATUSES,
22 STATUS_HEADER,
23 } from "../extension/modules/sourcehut.js";
24
25 const here = dirname(fileURLToPath(import.meta.url));
26 const fixture = (name) => readFileSync(join(here, "fixtures", name), "utf8");
27
28 let failures = 0;
29 function check(name, cond, extra = "") {
30 if (cond) {
31 console.log(`ok ${name}`);
32 } else {
33 failures++;
34 console.error(`FAIL ${name} ${extra}`);
35 }
36 }
37
38 // --- parseSubject -----------------------------------------------------------
39
40 {
41 const s = parseSubject("[PATCH v2 3/7] net: fix socket leak");
42 check("subject: n/m and version", s && s.n === 3 && s.m === 7 && s.version === 2);
43 check("subject: title", s.title === "net: fix socket leak");
44 check("subject: prefix", s.prefix === "PATCH");
45
46 const rfc = parseSubject("Re: [RFC PATCH net-next 0/5] some series");
47 check("subject: reply + RFC + cover", rfc && rfc.n === 0 && rfc.prefix === "RFC PATCH NET-NEXT");
48
49 check("subject: bare [PATCH]", parseSubject("[PATCH] one-off fix").m === null);
50 check("subject: non-patch", parseSubject("Meeting notes") === null);
51 check("subject: PATCH word outside tag", parseSubject("please PATCH this") === null);
52 }
53
54 // --- sameSeries -------------------------------------------------------------
55
56 {
57 const a = parseSubject("[PATCH v2 1/3] socket: validate file descriptors");
58 const b = parseSubject("[PATCH v2 3/3] assets: add icon");
59 const c = parseSubject("[PATCH v3 1/3] socket: validate file descriptors");
60 const d = parseSubject("[PATCH v2 1/4] other series");
61 check("sameSeries: same v and m", sameSeries(a, b));
62 check("sameSeries: different version", !sameSeries(a, c));
63 check("sameSeries: different size", !sameSeries(a, d));
64 }
65
66 // --- detection --------------------------------------------------------------
67
68 {
69 check("diff detection: fixture", bodyLooksLikeDiff(fixture("patch1.body")));
70 check("diff detection: prose", !bodyLooksLikeDiff("just words\nabout --- things\n"));
71 check("isPatchMessage: tagged", isPatchMessage("[PATCH 1/2] x", fixture("patch1.body")));
72 check("isPatchMessage: untagged diff", isPatchMessage("a fix", fixture("patch1.body")));
73 check("isPatchMessage: cover letter", isPatchMessage("[PATCH 0/2] x", fixture("cover.body")));
74 check("isPatchMessage: plain mail", !isPatchMessage("hello", "how are you?\n"));
75 }
76
77 // --- series dedup ----------------------------------------------------------
78
79 {
80 const mk = (n, date, hasDiff, id = `m${date}`) => ({
81 header: { headerMessageId: id, date },
82 info: n === null ? null : { n },
83 body: "",
84 hasDiff,
85 });
86
87 // A reply that shares the index but carries no diff must lose to the real
88 // patch, even when the reply is newer (simulates a list that stripped Re:).
89 const d = dedupByIndex([
90 mk(1, 1000, true, "patch1"),
91 mk(1, 2000, false, "reply1"), // newer, no diff
92 ]);
93 check("dedup: diff beats newer no-diff for n>0", d.get(1).header.headerMessageId === "patch1");
94
95 // Two real patches (resend) with diffs: keep the newer one.
96 const d2 = dedupByIndex([
97 mk(2, 1000, true, "patch2v1"),
98 mk(2, 2000, true, "patch2v2"),
99 ]);
100 check("dedup: newer diff wins among diffs", d2.get(2).header.headerMessageId === "patch2v2");
101
102 // Cover letter (n=0): no diff on either, prefer the newer cover.
103 const d3 = dedupByIndex([
104 mk(0, 1000, false, "cover1"),
105 mk(0, 2000, false, "cover2"),
106 ]);
107 check("dedup: cover keeps newer when no diff on either", d3.get(0).header.headerMessageId === "cover2");
108
109 // Lone n (null index) keyed by message id does not collide.
110 const d4 = dedupByIndex([mk(null, 1000, true, "solo")]);
111 check("dedup: null-index keyed by id", d4.get("id:solo").header.headerMessageId === "solo");
112 }
113
114 // --- reply detection -------------------------------------------------------
115
116 {
117 check("reply: Re: prefix", isReplySubject("Re: [PATCH v2 3/7] net: fix socket leak"));
118 check("reply: stacked Re:", isReplySubject("Re: Re: [PATCH] fix a bug"));
119 check("reply: Aw/Fwd", isReplySubject("Fwd: [PATCH 0/2] cover") && isReplySubject("aw:[PATCH] x"));
120 check("reply: case-insensitive and spaced", isReplySubject("re : [PATCH] x"));
121 check("reply: bare patch is not a reply", !isReplySubject("[PATCH v2 3/7] net: fix socket leak"));
122 check("reply: cover letter is not a reply", !isReplySubject("[PATCH 0/3] series intro"));
123 // Patches whose title happens to start with the letters "Re" but no colon.
124 check("reply: word 'Request' is not a reply", !isReplySubject("[PATCH] Request handling"));
125 }
126
127 // --- diff parsing -----------------------------------------------------------
128
129 {
130 const p = parsePatchEmail(fixture("patch1.body"));
131 check("parse: one file", p.files.length === 1, `got ${p.files.length}`);
132 check("parse: path", p.files[0].displayPath === "src/socket.c");
133 check("parse: commit message", p.commitMessage.startsWith("Reject negative descriptors"));
134 check("parse: diffstat", p.diffstat.includes("1 file changed"));
135
136 const hunk = p.files[0].hunks[0];
137 check("parse: single hunk", p.files[0].hunks.length === 1);
138 check("parse: hunk counts", hunk.oldCount === 15 && hunk.newCount === 20);
139
140 const firstAdd = hunk.lines.find((l) => l.origin === "add");
141 check("parse: first addition", firstAdd.text === "#include <errno.h>");
142 check("parse: new-line numbering", firstAdd.newLine === 2 && firstAdd.oldLine === null);
143
144 const firstDel = hunk.lines.find((l) => l.origin === "del");
145 check("parse: deletion numbering", firstDel.oldLine === 5 && firstDel.newLine === null);
146
147 // Raw lines must round-trip exactly for quoting in replies.
148 const start = hunk.lines[0].bodyLine;
149 const rawSlice = p.bodyLines.slice(start, start + hunk.lines.length).join("\n");
150 check("parse: raw round-trip", rawSlice === hunk.lines.map((l) => l.raw).join("\n"));
151 }
152
153 {
154 const p = parsePatchEmail(fixture("patch2.body"));
155 check("parse: new file flag", p.files[0].isNew);
156 check("parse: new file path", p.files[0].displayPath === "src/log.c");
157 check("parse: all lines added", p.files[0].hunks[0].lines.every((l) => l.origin === "add"));
158 }
159
160 {
161 const p = parsePatchEmail(fixture("patch3.body"));
162 check("parse: binary flag", p.files[0].isBinary, JSON.stringify(p.files[0]));
163 check("parse: binary has no hunks", p.files[0].hunks.length === 0);
164 }
165
166 {
167 const p = parsePatchEmail(fixture("cover.body"));
168 check("parse: cover has no files", p.files.length === 0);
169 check("parse: cover keeps text", p.commitMessage.length > 0);
170 }
171
172 {
173 // Single-line commit message: the body starts directly at the scissors.
174 // The commit message must be empty — never the diff (the review tab would
175 // otherwise render the whole raw patch above the interactive diff).
176 const body = [
177 "---",
178 " file.txt | 1 +",
179 " 1 file changed, 1 insertion(+)",
180 "",
181 "diff --git a/file.txt b/file.txt",
182 "index e69de29..8baef1b 100644",
183 "--- a/file.txt",
184 "+++ b/file.txt",
185 "@@ -0,0 +1 @@",
186 "+hello",
187 "-- ",
188 "2.45.0",
189 "",
190 ].join("\n");
191 const p = parsePatchEmail(body);
192 check("parse: scissors-first body has empty message", p.commitMessage === "");
193 check("parse: scissors-first body still parses diff", p.files.length === 1);
194 check("parse: scissors-first diffstat", p.diffstat.includes("1 file changed"));
195 }
196
197 {
198 // git format-patch prepends an mbox-style header block to the body; the
199 // UI renders author/subject separately, so the parser must drop that
200 // block and keep only the real commit message.
201 const body = [
202 "From 0123456789abcdef0123456789abcdef01234567 Mon Sep 17 00:00:00 2001",
203 "From: Author Name <author@example.com>",
204 "Date: Thu, 1 Jan 2026 00:00:00 +0000",
205 "Subject: [PATCH] fix a bug",
206 "",
207 "This is the actual commit message.",
208 "",
209 "---",
210 " file.txt | 1 +",
211 " 1 file changed, 1 insertion(+)",
212 "",
213 "diff --git a/file.txt b/file.txt",
214 "index e69de29..8baef1b 100644",
215 "--- a/file.txt",
216 "+++ b/file.txt",
217 "@@ -0,0 +1 @@",
218 "+hello",
219 "-- ",
220 "2.45.0",
221 "",
222 ].join("\n");
223 const p = parsePatchEmail(body);
224 check("parse: format-patch header stripped", p.commitMessage === "This is the actual commit message.", JSON.stringify(p.commitMessage));
225 check("parse: format-patch diff still parsed", p.files.length === 1);
226 }
227
228 // --- sourcehut detection ----------------------------------------------------
229
230 {
231 check(
232 "sourcehut: recipient address form",
233 isSourcehutList(["Marc <marc@coquand.email>", "~marc/patches@lists.sr.ht"])
234 );
235 check(
236 "sourcehut: List-Id form",
237 isSourcehutList(["~marc/patches <~marc/patches.lists.sr.ht>"])
238 );
239 check("sourcehut: case insensitive", isSourcehutList(["dev@LISTS.SR.HT"]));
240 check(
241 "sourcehut: other lists rejected",
242 !isSourcehutList(["netdev.vger.kernel.org", "dev@lists.example.org"])
243 );
244 check("sourcehut: lookalike domain rejected", !isSourcehutList(["x@lists.sr.hteam.com"]));
245 check("sourcehut: empty/missing values", !isSourcehutList([]) && !isSourcehutList([null, ""]));
246 check(
247 "sourcehut: all statuses present",
248 ["PROPOSED", "NEEDS_REVISION", "SUPERSEDED", "APPROVED", "REJECTED", "APPLIED"].every((s) =>
249 SOURCEHUT_STATUSES.includes(s)
250 ) && SOURCEHUT_STATUSES.length === 6
251 );
252 check("sourcehut: header name", STATUS_HEADER === "X-Sourcehut-Patchset-Update");
253 }
254
255 // --- intra-line highlighting ------------------------------------------------
256
257 {
258 const mid = pairRanges("return value;", "return newvalue;");
259 check("intraline: middle change", mid && mid.del[0] === 7 && mid.add[0] === 7);
260 check(
261 "intraline: add span text",
262 "return newvalue;".slice(mid.add[0], mid.add[1]) === "new"
263 );
264 check("intraline: del span empty for insertion", mid.del[0] === mid.del[1]);
265
266 const unrelated = pairRanges("int foo(void)", "#include <x.h>");
267 check("intraline: unrelated lines skipped", unrelated === null);
268
269 const lines = [
270 { origin: "ctx", text: "a", raw: " a" },
271 { origin: "del", text: "\tif (fd < 0)", raw: "-\tif (fd < 0)" },
272 { origin: "del", text: "old line", raw: "-old line" },
273 { origin: "add", text: "\tif (fd < 0) {", raw: "+\tif (fd < 0) {" },
274 { origin: "add", text: "new line", raw: "+new line" },
275 { origin: "ctx", text: "b", raw: " b" },
276 ];
277 const ranges = computeIntraline(lines);
278 check("intraline: context untouched", ranges[0] === null && ranges[5] === null);
279 check("intraline: pure suffix insertion skips del side", ranges[1] === null);
280 check(
281 "intraline: add side highlights suffix",
282 ranges[3] && "\tif (fd < 0) {".slice(ranges[3].start, ranges[3].end) === " {"
283 );
284 check(
285 "intraline: second pair highlights changed word",
286 ranges[2] &&
287 "old line".slice(ranges[2].start, ranges[2].end) === "old" &&
288 ranges[4] &&
289 "new line".slice(ranges[4].start, ranges[4].end) === "new"
290 );
291 }
292
293 {
294 // Real patch: "-\tif (fd < 0)" replaced by "+\tif (fd < 0) {" + more adds.
295 const p = parsePatchEmail(fixture("patch1.body"));
296 const hunk = p.files[0].hunks[0];
297 const ranges = computeIntraline(hunk.lines);
298 const del = hunk.lines.findIndex((l) => l.origin === "del");
299 check("intraline: fixture del pairs with first add", ranges[del] === null);
300 const add = hunk.lines.findIndex((l) => l.origin === "add" && l.text.endsWith("{"));
301 check(
302 "intraline: fixture add span",
303 ranges[add] && hunk.lines[add].text.slice(ranges[add].start, ranges[add].end) === " {"
304 );
305 }
306
307 // --- reply formatting -------------------------------------------------------
308
309 {
310 const p = parsePatchEmail(fixture("patch1.body"));
311 const hunk = p.files[0].hunks[0];
312 const target = hunk.lines.findIndex((l) => l.text === "#include <errno.h>");
313 const later = hunk.lines.findIndex((l) => l.text.includes("errno = EBADF"));
314
315 const comments = {
316 [GENERAL]: { text: "Looks good overall, two nits." },
317 [locator(0, 0, target)]: { text: "Is errno.h needed on all platforms?" },
318 [locator(0, 0, later)]: { text: "EBADF or EINVAL here?" },
319 };
320 const reply = formatReview(p, comments);
321
322 check("reply: general comment first", reply.startsWith("Looks good overall"));
323 check("reply: quotes file header", reply.includes("> diff --git a/src/socket.c b/src/socket.c"));
324 check("reply: quotes hunk header", reply.includes(`> ${hunk.header}`));
325 check("reply: quotes target line", reply.includes("> +#include <errno.h>"));
326 const quoted = reply.indexOf("> +#include <errno.h>");
327 const comment = reply.indexOf("Is errno.h needed");
328 check("reply: comment follows its line", quoted !== -1 && comment > quoted);
329 const second = reply.indexOf("EBADF or EINVAL");
330 check("reply: comment order", second > comment);
331 check(
332 "reply: unquoted comments",
333 !reply.includes("> Is errno.h") && !reply.includes("> EBADF or EINVAL")
334 );
335
336 check("reply: empty without comments", formatReview(p, {}) === "");
337 }
338
339 // --- patchHost experiment wiring --------------------------------------------
340 // The privileged code only runs inside Thunderbird, but the manifest glue
341 // and the script's syntax can be checked here.
342
343 {
344 const root = join(here, "..", "extension");
345 const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8"));
346 const experiment = (manifest.experiment_apis || {}).patchHost;
347 check("experiment: declared in the manifest", Boolean(experiment));
348 check(
349 "experiment: nativeMessaging permission gone",
350 !(manifest.permissions || []).includes("nativeMessaging")
351 );
352
353 const schema = JSON.parse(readFileSync(join(root, experiment.schema), "utf8"));
354 const names = schema[0].functions.map((f) => f.name).sort().join(",");
355 check("experiment: schema functions", names === "apply,describe,openEditor,pickDir,ping");
356 check("experiment: schema functions async", schema[0].functions.every((f) => f.async));
357
358 const source = readFileSync(join(root, experiment.parent.script), "utf8");
359 let parses = true;
360 try {
361 new Function(source);
362 } catch (e) {
363 parses = false;
364 }
365 check("experiment: implementation parses", parses);
366 check("experiment: defines the patchHost class", /\bvar patchHost\s*=/.test(source));
367 }
368
369 // --- summary ----------------------------------------------------------------
370
371 if (failures) {
372 console.error(`\n${failures} failure(s)`);
373 process.exit(1);
374 }
375 console.log("\nall tests passed");