// Tests for shoot-page.mjs — finding a quoted sentence in a saved page's text. // // The matcher is pure and is tested against text segments shaped like the // page's text nodes in document order. One test drives a real Chromium over a // fixture page; it launches a browser, so it runs only with SHOOT_PAGE_BROWSER=1. // // Run with: pnpm test:scripts import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { allowedRequest, markCrop, matchInSegments, normaliseWithMap, pageExpression, parseArgs, shootPages, validateItems, } from "./shoot-page.mjs"; // The raw text a match covers, read back off the segments. function covered(segments, m) { const out = []; for (let s = m.start.segment; s <= m.end.segment; s++) { const a = s === m.start.segment ? m.start.offset : 0; const b = s === m.end.segment ? m.end.offset : segments[s].length; out.push(segments[s].slice(a, b)); } return out.join(""); } test("normalising: quotes, spaces, invisibles, ellipsis — with a map back to the raw text", () => { const raw = "\u201CIt\u2019s\u00A0fine\u201D, she\u00ADsaid\u200B\u2026"; const { text, map } = normaliseWithMap(raw); assert.equal(text, "\"It's fine\", shesaid..."); assert.equal(map.length, text.length); // Every normalised character points at the raw one it came from. assert.equal(raw[map[text.indexOf("s", 4)]], "s"); assert.equal(map[text.length - 1], raw.length - 1); assert.equal(map[text.length - 3], raw.length - 1); // Leading whitespace goes; a run of it is one space. assert.equal(normaliseWithMap(" \n\t a \n b").text, "a b"); }); test("a plain quote inside one text node", () => { const segs = ["The committee said the plan was not funded and would be dropped."]; const m = matchInSegments(segs, "the plan was not funded"); assert.equal(m.ok, true); assert.deepEqual(m.start, { segment: 0, offset: 19 }); assert.equal(covered(segs, m), "the plan was not funded"); assert.equal(m.occurrences, 1); }); test("a quote split across and matches across the boundaries", () => { //

She wrote that it was never about the money, at all.

const segs = ["She wrote that it was ", "never", " about ", "the money", ", at all."]; const m = matchInSegments(segs, "it was never about the money"); assert.equal(m.ok, true); assert.deepEqual(m.start, { segment: 0, offset: 15 }); // The end lies in the link's text node, not at the start of the next one. assert.deepEqual(m.end, { segment: 3, offset: 9 }); assert.equal(covered(segs, m), "it was never about the money"); }); test("curly on the page, straight in the quote — and the other way round", () => { const curly = ["He said \u201Cwe\u2019re not going\u201D and left."]; const m1 = matchInSegments(curly, "\"we're not going\""); assert.equal(m1.ok, true); assert.equal(covered(curly, m1), "\u201Cwe\u2019re not going\u201D"); const straight = ["He said \"we're not going\" and left."]; const m2 = matchInSegments(straight, "\u201Cwe\u2019re not going\u201D"); assert.equal(m2.ok, true); assert.equal(covered(straight, m2), "\"we're not going\""); }); test("NBSP, soft hyphens, zero-width characters and source line breaks do not stop a match", () => { const segs = ["an un\u00ADprecedented\u00A0and\n ", "unre\u200Bcoverable", " loss"]; const m = matchInSegments(segs, "an unprecedented and unrecoverable loss"); assert.equal(m.ok, true); assert.equal(covered(segs, m), segs.join("")); }); test("a block break between two text nodes reads as a space, never as nothing", () => { // The caller puts a " " segment where one block ends and another starts. const segs = ["first paragraph.", " ", "Second paragraph."]; assert.equal(matchInSegments(segs, "paragraph. Second").ok, true); assert.equal(matchInSegments(segs, "paragraph.Second").ok, false); }); test("a quote that is not there is a miss with a reason", () => { const m = matchInSegments(["Nothing to see here."], "something else entirely"); assert.deepEqual(m, { ok: false, reason: "quote not found", occurrences: 0 }); assert.equal(matchInSegments(["text"], " ").reason, "empty quote"); }); test("context picks the occurrence, and is itself required to be there", () => { const segs = ["It was late. ", "Later, it was late again, said the second witness."]; const plain = matchInSegments(segs, "it was late"); // Case matters: only the second sentence's lowercase "it was late" matches. assert.equal(plain.ok, true); assert.equal(plain.start.segment, 1); const two = ["It was late. It was late again."]; const first = matchInSegments(two, "It was late"); assert.equal(first.occurrences, 2); assert.equal(first.start.offset, 0); const second = matchInSegments(two, "It was late", "It was late again"); assert.equal(second.ok, true); assert.equal(second.start.offset, 13); assert.equal(matchInSegments(two, "It was late", "not on the page").reason, "context not found"); assert.equal( matchInSegments(two, "late again", "It was late. It").reason, "quote not found inside its context", ); }); test("the page expression carries the matcher with it", () => { const expr = pageExpression({ quote: "a \"b\"", context: null, color: "#ff0", padding: 4, maxHeight: 100 }); assert.match(expr, /function normaliseWithMap/); assert.match(expr, /function matchInSegments/); assert.match(expr, /return browserShoot\(\{"quote":"a \\"b\\""/); // It parses as one expression. assert.doesNotThrow(() => new Function(`return ${expr.replace(/return browserShoot\([^\n]*\);/, "return 0;")}`)); }); test("only file:// under the page's own directory is allowed", () => { const page = "/tmp/saved/article.html"; assert.equal(allowedRequest("file:///tmp/saved/article.html", page), true); assert.equal(allowedRequest("file:///tmp/saved/article_files/style.css", page), true); assert.equal(allowedRequest("file:///tmp/saved/../other/x.css", page), false); assert.equal(allowedRequest("file:///tmp/savedx/x.css", page), false); assert.equal(allowedRequest("file:///etc/passwd", page), false); assert.equal(allowedRequest("https://example.com/x.png", page), false); assert.equal(allowedRequest("data:image/png;base64,AAAA", page), false); }); test("batch items: every problem at once", () => { assert.deepEqual(validateItems({ items: [{ id: "a1", page: "p.html", quote: "q" }] }).length, 1); assert.throws( () => validateItems([ { id: "a1", page: "p.html", quote: "q" }, { id: "a1", page: "p.html", quote: "q" }, { id: "../x", page: "", quote: " " }, ]), (err) => /duplicate id/.test(err.message) && /file-name-safe/.test(err.message) && /page is required/.test(err.message) && /quote is required/.test(err.message), ); assert.throws(() => validateItems({}), /array of items/); }); test("arguments: two modes, never both", () => { assert.equal(parseArgs(["--batch", "i.json", "--out", "d"]).mode, "batch"); const s = parseArgs(["--page", "p.html", "--quote", "q", "--out", "s.png", "--scale", "3", "--js"]); assert.equal(s.mode, "single"); assert.deepEqual(s.opts, { scale: 3, js: true }); assert.throws(() => parseArgs(["--batch", "i.json", "--page", "p", "--out", "d"]), /two modes/); assert.throws(() => parseArgs(["--page", "p.html", "--out", "s.png"]), /--batch/); assert.throws(() => parseArgs(["--page", "p.html", "--quote", "q"]), /--out is required/); assert.throws(() => parseArgs(["--padding", "-1"]), /non-negative/); assert.throws(() => parseArgs(["--bogus"]), /unknown argument/); const m = parseArgs(["--batch", "i.json", "--out", "d", "--crop", "mark", "--context-lines", "2"]); assert.deepEqual(m.opts, { crop: "mark", contextLines: 2 }); assert.throws(() => parseArgs(["--crop", "page"]), /block" or "mark/); assert.throws(() => parseArgs(["--context-lines", "1.5"]), /whole number/); }); test("an item's crop is block or mark", () => { assert.equal(validateItems([{ id: "a", page: "p.html", quote: "q", crop: "mark" }]).length, 1); assert.throws(() => validateItems([{ id: "a", page: "p.html", quote: "q", crop: "lines" }]), /crop must be/); }); // A forum post: one block, 27 px lines (18px/1.5), its content box 674 px wide // from x 52 and 1400 px tall from y 100. A fragment's box is its glyphs (21 px // here, plus the mark's padding), centred in its line. const POST = { lineHeight: 27, content: { left: 52, right: 726, top: 100, bottom: 1500 }, padding: 10, docW: 1280, docH: 3000 }; const lineRect = (k) => ({ top: 100 + 27 * k + 3, bottom: 100 + 27 * k + 24 }); test("the mark crop is the quote's lines and whole context lines, not the block", () => { // A quote over lines 10 and 11, one line of context either side: four lines. const { crop: c, band } = markCrop({ ...POST, rects: [lineRect(10), lineRect(11)], contextLines: 1 }); assert.deepEqual(c, { x: 42, y: 100 + 27 * 9 - 10, width: 674 + 20, height: 4 * 27 + 20 }); // The band is the four lines alone; the padding outside it is masked. assert.deepEqual(band, { top: 100 + 27 * 9, bottom: 100 + 27 * 13 }); // Its edges are line boundaries: (y + padding - content.top) is whole lines. assert.equal((c.y + 10 - 100) % 27, 0); // No context: exactly the quote's lines. assert.equal(markCrop({ ...POST, rects: [lineRect(10)], contextLines: 0 }).crop.height, 27 + 20); // Fragments in any order, several on a line (a link mid-quote), the same crop. const mixed = [lineRect(11), { top: lineRect(10).top, bottom: lineRect(10).bottom }, lineRect(10)]; assert.deepEqual(markCrop({ ...POST, rects: mixed, contextLines: 1 }).crop, c); }); test("the mark crop stays inside the block's content box", () => { // On the first line there is no line above to keep, and nothing above the // block is taken in its place. const top = markCrop({ ...POST, rects: [lineRect(0)], contextLines: 2 }).crop; assert.equal(top.y, 100 - 10); assert.equal(top.height, 3 * 27 + 20); const lastLine = (1500 - 100) / 27 - 1; // not whole: the box ends mid-line const bottom = markCrop({ ...POST, rects: [lineRect(Math.floor(lastLine))], contextLines: 3 }).crop; assert.equal(bottom.y + bottom.height, 1500 + 10); }); // --------------------------------------------------------------------------- // A real browser over a fixture page. Heavy, so opt-in. // --------------------------------------------------------------------------- const FIXTURE = `
Header

The council voted on Tuesday. The plan was not funded and would be dropped.

She wrote that it was never about the money, at all.

He said \u201Cwe\u2019re not going\u201D and left.

a hidden sentence that must not match
${Array.from({ length: 24 }, (_, i) => `Paragraph ${i} says something short.`).join("

")}
The post goes on after the embed.
${Array.from({ length: 80 }, (_, i) => `Line ${i} of the long block.`).join("
")}
`; function pngSize(buf) { // IHDR: width and height are the big-endian words at bytes 16 and 20. assert.equal(buf.toString("ascii", 1, 4), "PNG"); return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; } test("a real page: shots, marks, a miss, and nothing fetched from outside", { skip: process.env.SHOOT_PAGE_BROWSER === "1" ? false : "set SHOOT_PAGE_BROWSER=1 to launch Chromium", }, async () => { const dir = await mkdtemp(path.join(os.tmpdir(), "shoot-page-")); try { const saved = path.join(dir, "saved"); await mkdir(path.join(saved, "article_files"), { recursive: true }); await writeFile(path.join(saved, "article.html"), FIXTURE); await writeFile(path.join(saved, "article_files", "style.css"), "p { color: #222; }"); await writeFile(path.join(dir, "outside.png"), ""); const out = path.join(dir, "out"); const items = [ { id: "plain", page: "saved/article.html", quote: "The plan was not funded" }, { id: "split", page: "saved/article.html", quote: "it was never about the money" }, { id: "curly", page: "saved/article.html", quote: "\"we're not going\"" }, { id: "hidden", page: "saved/article.html", quote: "a hidden sentence" }, { id: "nopage", page: "saved/missing.html", quote: "anything" }, // Across a
, in a block far taller than --max-height. { id: "long", page: "saved/article.html", quote: "Line 40 of the long block. Line 41" }, // A forum post: one block of
-separated paragraphs. The same quote, // shot whole and (an item's own crop) as its line with one either side. { id: "post-block", page: "saved/article.html", quote: "Paragraph 12 says something short" }, { id: "post-mark", page: "saved/article.html", quote: "Paragraph 12 says something short", crop: "mark" }, ].map((it) => ({ ...it, out: path.join(out, `${it.id}.png`) })); const results = await shootPages(items, { baseDir: dir, padding: 10 }); const by = Object.fromEntries(results.map((r) => [r.id, r])); assert.equal(by.plain.ok, true); assert.equal(by.plain.matched, "The plan was not funded"); assert.equal(by.plain.block, "#plain"); assert.equal(by.split.ok, true); assert.equal(by.split.matched, "it was never about the money"); assert.equal(by.split.block, "html > body > p:nth-of-type(2)"); assert.equal(by.curly.ok, true); assert.equal(by.curly.matched, "\u201Cwe\u2019re not going\u201D"); assert.deepEqual([by.hidden.ok, by.hidden.reason], [false, "quote not found"]); assert.deepEqual([by.nopage.ok, by.nopage.reason], [false, "page not found"]); for (const id of ["plain", "split", "curly"]) { const r = by[id]; const size = pngSize(await readFile(r.png)); // deviceScaleFactor 2: the PNG is twice the CSS crop. assert.deepEqual(size, { width: r.crop.width * 2, height: r.crop.height * 2 }); assert.deepEqual(r.pixels, size); assert.equal(r.trimmed, false); // Remote and outside-the-folder requests were refused, the saved CSS was not. assert.ok(r.blocked.some((u) => u.startsWith("https://fonts.example.invalid/")), id); assert.ok(r.blocked.some((u) => u.startsWith("https://example.invalid/tracker")), id); assert.ok(r.blocked.some((u) => u.endsWith("/outside.png")), id); assert.ok(!r.blocked.some((u) => u.endsWith("style.css")), id); } // The tall block is trimmed to max-height plus padding, around the quote. assert.equal(by.long.ok, true); assert.equal(by.long.block, "#long"); assert.equal(by.long.trimmed, true); // (Within a pixel: the crop floors its top edge and ceils its bottom.) assert.ok(Math.abs(by.long.crop.height - (1200 + 2 * 10)) <= 1, String(by.long.crop.height)); // The post: the block crop is the whole post; the mark crop is three lines // (the quote's, and the empty line from the

either side) across // its content box (700 less 2 x 12 padding and 2 x 1 border). for (const id of ["post-block", "post-mark"]) { assert.equal(by[id].ok, true, id); assert.equal(by[id].block, "#post", id); pngSize(await readFile(by[id].png)); } assert.equal(by["post-block"].cropMode, "block"); assert.equal(by["post-mark"].cropMode, "mark"); assert.ok(by["post-block"].crop.height > 1000, String(by["post-block"].crop.height)); const pm = by["post-mark"].crop; assert.ok(Math.abs(pm.height - (3 * 27 + 2 * 10)) <= 1, String(pm.height)); assert.ok(Math.abs(pm.width - (674 + 2 * 10)) <= 1, String(pm.width)); assert.equal(by["post-mark"].trimmed, false); // The CLI: a batch with a miss writes what it shot, lists the miss in the // results, says so on stderr, and exits 1. await writeFile(path.join(dir, "items.json"), JSON.stringify([ { id: "ok1", page: "saved/article.html", quote: "would be dropped" }, { id: "miss1", page: "saved/article.html", quote: "not on this page" }, ])); const cli = await new Promise((resolve) => { execFile(process.execPath, [ fileURLToPath(new URL("./shoot-page.mjs", import.meta.url)), "--batch", path.join(dir, "items.json"), "--out", path.join(dir, "cli"), ], (err, stdout, stderr) => resolve({ code: err ? err.code : 0, stderr })); }); assert.equal(cli.code, 1); assert.match(cli.stderr, /MISSED 1 of 2/); assert.match(cli.stderr, /miss1: quote not found/); const report = JSON.parse(await readFile(path.join(dir, "cli", "results.json"), "utf8")); assert.deepEqual([report.shot, report.missed], [1, 1]); assert.deepEqual(report.items.map((r) => [r.id, r.ok]), [["ok1", true], ["miss1", false]]); assert.equal(report.items[0].png, path.join(dir, "cli", "ok1.png")); pngSize(await readFile(report.items[0].png)); } finally { await rm(dir, { recursive: true, force: true }); } });