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

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");