single-file-cli 2.6.2 → 2.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "2.6.2";
1
+ export const version = "2.6.4";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "single-file-cli",
3
- "version": "2.6.2",
3
+ "version": "2.6.4",
4
4
  "description": "SingleFile CLI",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
@@ -0,0 +1,93 @@
1
+ /* global URL */
2
+
3
+ // A canvas drawing is saved as a background-image data URI on the canvas element itself. Save the
4
+ // saved page again and that element is a real, EMPTY canvas — no script ran to draw into it — so
5
+ // toDataURL() returned a blank bitmap and it OVERWROTE the picture the first save had stored.
6
+ // Measured on the MDN Canvas API page and reproduced here: generation 1 held the drawing, and
7
+ // generation 2 held a blank PNG of the same dimensions, 890 against 874 bytes. Nothing but
8
+ // decoding the image would have caught it — a blank PNG is not conspicuously small.
9
+ //
10
+ // The two controls matter as much as the round trip: a canvas that is drawn on top of a CSS
11
+ // background must still store its drawing, and a canvas nothing ever drew into must keep behaving
12
+ // as it always has. The fix skips the capture only when the fresh bitmap is blank AND the element
13
+ // already carries a background image, so both of those paths have to stay untouched.
14
+ //
15
+ // The fix shipped in single-file-core 1.5.119, so these run against the committed lib/ as well.
16
+
17
+ import { test } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { createServer } from "node:http";
20
+ import { execFile } from "node:child_process";
21
+ import { promisify } from "node:util";
22
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { pathToFileURL } from "node:url";
26
+ import process from "node:process";
27
+ import { cliDirectory } from "../target.js";
28
+
29
+ const execFileAsync = promisify(execFile);
30
+ const TEST_TIMEOUT = 180000;
31
+ const RED_DOT = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
32
+ const DRAWING = "<script>const context=document.getElementById(\"probe\").getContext(\"2d\");context.fillStyle=\"#00aa00\";context.fillRect(10,10,150,80);</script>";
33
+ const PAGES = {
34
+ "/drawn": "<html><head><title>drawn</title></head><body><canvas id=\"probe\" width=\"200\" height=\"100\"></canvas>" + DRAWING + "</body></html>",
35
+ "/drawn-with-background": "<html><head><title>drawn with background</title></head><body><canvas id=\"probe\" width=\"200\" height=\"100\" style=\"background-image:url(" + RED_DOT + ")\"></canvas>" + DRAWING + "</body></html>",
36
+ "/blank": "<html><head><title>blank</title></head><body><canvas id=\"probe\" width=\"200\" height=\"100\"></canvas></body></html>"
37
+ };
38
+
39
+ test("re-saving a saved page keeps the canvas drawing", { timeout: TEST_TIMEOUT }, async () => {
40
+ const generations = await capture("/drawn", 3);
41
+ assert.ok(generations[0], "the first save stored no canvas image");
42
+ assert.equal(generations[1], generations[0], "the second save replaced the canvas image");
43
+ assert.equal(generations[2], generations[0], "the third save replaced the canvas image");
44
+ });
45
+
46
+ test("a canvas drawn over a background image still stores its drawing", { timeout: TEST_TIMEOUT }, async () => {
47
+ const [saved] = await capture("/drawn-with-background", 1);
48
+ assert.ok(saved, "the save stored no canvas image");
49
+ assert.notEqual(saved, RED_DOT.split(",")[1], "the save kept the background image instead of the drawing");
50
+ });
51
+
52
+ test("a canvas nothing drew into is still captured when there is no background", { timeout: TEST_TIMEOUT }, async () => {
53
+ const [saved] = await capture("/blank", 1);
54
+ assert.ok(saved, "the save stored no canvas image");
55
+ });
56
+
57
+ // returns the base64 payload of the canvas background-image of each generation, each one captured
58
+ // from the file the previous one produced
59
+ async function capture(pathname, generations) {
60
+ const server = createServer((request, response) => {
61
+ const page = PAGES[new URL(request.url, "http://localhost").pathname];
62
+ if (page) {
63
+ response.writeHead(200, { "content-type": "text/html", "cache-control": "no-store" }).end(page);
64
+ } else {
65
+ response.writeHead(404).end();
66
+ }
67
+ });
68
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
69
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
70
+ try {
71
+ const images = [];
72
+ let source = "http://localhost:" + server.address().port + pathname;
73
+ for (let generation = 0; generation < generations; generation++) {
74
+ const savedPath = join(directory, "generation-" + generation + ".html");
75
+ await runCli([source, savedPath]);
76
+ images.push(getCanvasImage(await readFile(savedPath, "utf-8")));
77
+ source = pathToFileURL(savedPath).href;
78
+ }
79
+ return images;
80
+ } finally {
81
+ await rm(directory, { recursive: true });
82
+ server.close();
83
+ }
84
+ }
85
+
86
+ function getCanvasImage(html) {
87
+ const match = html.match(/background-image:\s*url\(["']?data:image\/png;base64,([^"')]+)["']?\)/);
88
+ return match && match[1];
89
+ }
90
+
91
+ function runCli(args) {
92
+ return execFileAsync(process.execPath, ["single-file-node.js", ...args], { cwd: cliDirectory });
93
+ }
@@ -0,0 +1,214 @@
1
+ /* global TextDecoder */
2
+
3
+ // Every other e2e test reads a save as text or as bytes: a marker is present, an entry is named
4
+ // what it should be, the console is clean. None of them looks at the page. A save can pass all of
5
+ // them and still come back with its type in a fallback family, its grid collapsed, or a frame
6
+ // rendered blank — the markup is there, it just does not draw the same picture.
7
+ //
8
+ // So this suite renders the source page and the saved page and compares the pixels. What it can
9
+ // assert is bounded by one thing: a page that does not render identically to ITSELF cannot be held
10
+ // to rendering identically to its save. Every check therefore measures that first — the same source
11
+ // captured twice — and uses it as the floor. On these fixtures the floor is zero, which is why they
12
+ // are hand-built and static rather than mirrored from the web; the real-page matrix is a different
13
+ // tool, and it reports rather than asserts.
14
+ //
15
+ // The fixtures are not decorative. Each one is a defect that shipped, kept in the shape that made
16
+ // it visible, with a comment in the page saying which.
17
+
18
+ import { test, before, after } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { execFile } from "node:child_process";
21
+ import { promisify } from "node:util";
22
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
23
+ import { tmpdir } from "node:os";
24
+ import { join, dirname } from "node:path";
25
+ import { pathToFileURL, fileURLToPath } from "node:url";
26
+ import process from "node:process";
27
+ import { cliDirectory, importLibModule, useDevBuild } from "../target.js";
28
+ import { openBrowser } from "../fidelity/browser.js";
29
+ import { startServer } from "../fidelity/server.js";
30
+ const { configure, ZipReader, Uint8ArrayReader, TextWriter } = await importLibModule("single-file-archive.js");
31
+
32
+ const execFileAsync = promisify(execFile);
33
+ const TEST_TIMEOUT = 120000;
34
+ const SAVE_TIMEOUT = 90000;
35
+ const PAGES_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "..", "fidelity", "pages");
36
+
37
+ // These checks are the ones that need a browser of their own, driven directly, and they have not
38
+ // been shown to be reliable on a CI runner yet: the first run there wedged on a CDP command that
39
+ // never answered and the job had to be cancelled. So they run where they were built to be used —
40
+ // against a dev build of single-file-core, which is when the code they cover is being changed — and
41
+ // on demand with SINGLE_FILE_FIDELITY=1. Running them against the default target says little
42
+ // anyway: that target is the *published* core, so a check for a fix lands red until it ships.
43
+ //
44
+ // This is a hold, not a decision. They belong in CI, on a job that builds core from source; the
45
+ // command limit added since would now name what did not answer rather than hanging.
46
+ const enabled = useDevBuild || process.env.SINGLE_FILE_FIDELITY === "1";
47
+ const skip = enabled ? false : "set SINGLE_FILE_FIDELITY=1, or use SINGLE_FILE_TARGET=dev, to run the fidelity checks";
48
+ const options = { timeout: TEST_TIMEOUT, skip };
49
+
50
+ let browser;
51
+
52
+ before(async () => enabled && (browser = await openBrowser()), { timeout: TEST_TIMEOUT });
53
+ after(async () => browser && await browser.close(), { timeout: TEST_TIMEOUT });
54
+
55
+ test("an archived page renders exactly like its source", options, async () => {
56
+ const { comparison, noise } = await compareSaveWithSource("duplicate-stylesheet", ["--compress-content"]);
57
+ assertNoWorseThanNoise(comparison, noise);
58
+ });
59
+
60
+ test("a plain saved page renders exactly like its source", options, async () => {
61
+ const { comparison, noise } = await compareSaveWithSource("duplicate-stylesheet", []);
62
+ assertNoWorseThanNoise(comparison, noise);
63
+ });
64
+
65
+ // The pixels say the page still draws the same picture; they cannot say the element that draws it
66
+ // is still the element the page named. An id a script looks up and a class a selector matches are
67
+ // invisible until something goes looking for them, so they are read out of the save directly.
68
+ test("the archived page keeps the attributes of the stylesheets it rewrites", options, async () => {
69
+ const { saved } = await compareSaveWithSource("duplicate-stylesheet", ["--compress-content"]);
70
+ const page = await readArchiveEntry(saved, "index.html");
71
+ assert.match(page, /id=palette/, "the id of the folded stylesheet was dropped");
72
+ assert.match(page, /class=theme/, "the class of the folded stylesheet was dropped");
73
+ assert.match(page, /data-role=tokens/, "the data attribute of the folded stylesheet was dropped");
74
+ // the point of folding them is that the content is stored once. A page that still carries the
75
+ // declarations inline as well is the bug this fixture was built for, and it passes every
76
+ // assertion above
77
+ assert.doesNotMatch(page, /--accent:/, "the folded stylesheet is still stored inline in the page");
78
+ });
79
+
80
+ // the saved file is a self-extracting archive: what a reader sees as the page is an entry inside
81
+ // the zip, and the wrapper around it holds none of the markup being asserted on
82
+ async function readArchiveEntry(data, filename) {
83
+ configure({ useWebWorkers: false });
84
+ const entries = await new ZipReader(new Uint8ArrayReader(data)).getEntries();
85
+ const entry = entries.find(entry => entry.filename == filename);
86
+ assert.ok(entry, "the archive holds no entry named " + filename);
87
+ return entry.getData(new TextWriter());
88
+ }
89
+
90
+ // The mirror of the check above, on the other path. A plain save has nowhere to put an external
91
+ // stylesheet, so the link becomes a style element built from the media and the text — and the id a
92
+ // script looks up, the class a selector matches and the data attribute it reads were all left
93
+ // behind, on every plain save of every page with an external stylesheet.
94
+ test("a plain saved page keeps the attributes of the stylesheet it inlines", options, async () => {
95
+ const { comparison, noise, saved } = await compareSaveWithSource("linked-stylesheet", []);
96
+ assertNoWorseThanNoise(comparison, noise);
97
+ const [openingTag] = new TextDecoder().decode(saved).match(/<style[^>]*>/) || [];
98
+ assert.ok(openingTag, "the saved page holds no style element");
99
+ ["id=theme", "class=site-theme", "data-role=tokens", "title=\"Site theme\""].forEach(attribute =>
100
+ assert.ok(openingTag.includes(attribute), "the style element lost " + attribute + ": " + openingTag));
101
+ // what the link used to fetch its stylesheet means nothing on the element now holding it
102
+ assert.doesNotMatch(openingTag, /\brel=|\bhref=/, "the style element kept an attribute of the link: " + openingTag);
103
+ });
104
+
105
+ test("an archived page with an external stylesheet renders exactly like its source", options, async () => {
106
+ const { comparison, noise } = await compareSaveWithSource("linked-stylesheet", ["--compress-content"]);
107
+ assertNoWorseThanNoise(comparison, noise);
108
+ });
109
+
110
+ // The font minifier keeps a declared face when something on the page draws with it, and the three
111
+ // samples name their family in the three ways it has to read. It has given up on all three at some
112
+ // point: a property declared on a descendant resolved to nothing, the shorthand was unreadable, and
113
+ // a name that could not be resolved switched pruning off for the whole document.
114
+ test("a saved page still draws with the fonts it used", options, async () => {
115
+ const { comparison, noise } = await compareSaveWithSource("used-fonts", []);
116
+ assertNoWorseThanNoise(comparison, noise);
117
+ });
118
+
119
+ // keeping every face renders identically to keeping the right ones, so the half of the contract the
120
+ // pixels cannot see is read out of the save: the faces nothing draws with are gone, and the three
121
+ // the page draws with are still declared
122
+ test("a saved page drops the fonts it did not use", options, async () => {
123
+ const { saved } = await compareSaveWithSource("used-fonts", []);
124
+ assert.deepEqual(getDeclaredFontFamilies(saved).sort(), ["Fidelity Band", "Fidelity Bar", "Fidelity Block"]);
125
+ });
126
+
127
+ // the faces are read out of the @font-face rules rather than looked for anywhere in the page: a
128
+ // family name also appears in the declarations that USE it, and a check that searched the whole
129
+ // text called a pruned face present because the custom property naming it was still there
130
+ function getDeclaredFontFamilies(saved) {
131
+ return new TextDecoder().decode(saved).split("@font-face").slice(1).map(rule => {
132
+ const [, quoted, single, plain] = rule.match(/font-family:\s*(?:"([^"]*)"|'([^']*)'|([^;}]*))/) || [];
133
+ return (quoted || single || plain || "").trim();
134
+ });
135
+ }
136
+
137
+ test("a saved page keeps the fonts declared inside a frame it cannot read", options, async () => {
138
+ const { comparison, noise } = await compareSaveWithSource("frame-fonts", []);
139
+ assertNoWorseThanNoise(comparison, noise);
140
+ });
141
+
142
+ // A family drawn in a style it declares no face for is drawn by the browser slanting or thickening
143
+ // the face it has. It is still the family being used, and narrowing the used list to the faces that
144
+ // match the computed style dropped it from the page that draws it — while the upright sample kept
145
+ // the list non-empty, so nothing else noticed.
146
+ test("a saved page keeps a font drawn in a style it declares no face for", options, async () => {
147
+ const { comparison, noise, saved } = await compareSaveWithSource("synthetic-italic", []);
148
+ assertNoWorseThanNoise(comparison, noise);
149
+ assert.deepEqual(getDeclaredFontFamilies(saved).sort(), ["Fidelity Bar", "Fidelity Block"]);
150
+ });
151
+
152
+ // The two halves of what happens when a family cannot be resolved from the stylesheets. The face
153
+ // the browser drew with has to survive — the rendered list is the only thing left that names it —
154
+ // and the faces nothing drew have to go, which is the half that says the document was pruned at all
155
+ // rather than given up on. Both are needed: keeping everything passes the first check alone.
156
+ test("a saved page keeps a font named through a value it cannot resolve", options, async () => {
157
+ const { comparison, noise } = await compareSaveWithSource("unresolved-font-property", []);
158
+ assertNoWorseThanNoise(comparison, noise);
159
+ });
160
+
161
+ test("a value it cannot resolve does not stop the rest of the page being pruned", options, async () => {
162
+ const { saved } = await compareSaveWithSource("unresolved-font-property", []);
163
+ assert.deepEqual(getDeclaredFontFamilies(saved), ["Fidelity Block"],
164
+ "one unreadable value decided what happens to every face the page declares");
165
+ });
166
+
167
+ function assertNoWorseThanNoise(comparison, noise) {
168
+ assert.ok(comparison.differing <= noise.differing, describe(comparison, noise));
169
+ }
170
+
171
+ // a failure that says "1382914 pixels differ" is a failure nobody can act on: the report names the
172
+ // bands, so the first thing read is where on the page the two renderings parted company
173
+ function describe(comparison, noise) {
174
+ const worst = comparison.bands
175
+ .filter(band => band.differing > 0)
176
+ .sort((first, second) => second.differing - first.differing)
177
+ .slice(0, 3)
178
+ .map(band => `\trows ${band.top}-${band.bottom}: ${band.differing} of ${band.total} pixels`);
179
+ return [
180
+ `the saved page does not render like its source: ${comparison.differing} of ${comparison.total} pixels differ`,
181
+ `\tnoise floor (the same source captured twice): ${noise.differing} pixels`,
182
+ comparison.sizeMatches ? "\tboth renderings are the same size" : `\tthe renderings are NOT the same size (union ${comparison.width}x${comparison.height})`,
183
+ ...worst
184
+ ].join("\n");
185
+ }
186
+
187
+ async function compareSaveWithSource(fixtureName, saveOptions) {
188
+ // the whole pages directory is served, not one fixture: the fonts sit beside the fixtures and
189
+ // are shared, so that regenerating them cannot leave one copy behind and one up to date
190
+ const server = await startServer(PAGES_DIRECTORY);
191
+ const url = server.url + fixtureName + "/";
192
+ const directory = await mkdtemp(join(tmpdir(), "single-file-fidelity-"));
193
+ try {
194
+ // the control is captured first and last is never assumed: the source is rendered twice
195
+ // before anything is saved, and the two shots set the floor the save is measured against
196
+ const source = await browser.capture(url);
197
+ const noise = await browser.compare(source, await browser.capture(url));
198
+ const savedPath = join(directory, "saved.html");
199
+ // the default conflict action is to uniquify, which writes "saved (2).html" and leaves the
200
+ // stale file exactly where the test looks for it
201
+ await rm(savedPath, { force: true });
202
+ // the save gets its own limit: a browser that never answers would otherwise be reported
203
+ // as the check timing out, which says nothing about where it stopped
204
+ await execFileAsync(process.execPath, ["single-file-node.js", url, savedPath, ...saveOptions], { cwd: cliDirectory, timeout: SAVE_TIMEOUT });
205
+ const comparison = await browser.compare(source, await browser.capture(pathToFileURL(savedPath).href));
206
+ // asserted rather than returned: a fixture that asks for something the server does not have
207
+ // is not a result to interpret, it is a fixture to fix
208
+ assert.deepEqual(server.misses, [], "the fixture asked for files the server does not have");
209
+ return { comparison, noise, saved: new Uint8Array(await readFile(savedPath)) };
210
+ } finally {
211
+ await rm(directory, { recursive: true, force: true });
212
+ await server.close();
213
+ }
214
+ }
@@ -0,0 +1,79 @@
1
+ /* global URL */
2
+
3
+ // Every other test in this suite reads a capture as bytes: zip fields, entry names, JSON shape.
4
+ // None of them opened one. That is how single-file-cli 2.6.0 and 2.6.1 shipped producing
5
+ // self-extracting archives that rendered a blank page — the zip was valid, `index.html` was
6
+ // correct, every assertion those tests make stayed true, and the script that unpacks the archive
7
+ // in the browser threw `ReferenceError` before writing anything.
8
+ //
9
+ // So this file asserts the only thing a user actually cares about: the saved file opens, its
10
+ // console is clean, and the page that appears is the page that was saved. It captures a marker,
11
+ // then re-captures the saved file from a file:// URL and looks for that marker in the result —
12
+ // a capture of a blank page cannot contain it.
13
+
14
+ import { test } from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { createServer } from "node:http";
17
+ import { execFile } from "node:child_process";
18
+ import { promisify } from "node:util";
19
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { pathToFileURL } from "node:url";
23
+ import process from "node:process";
24
+ import { cliDirectory } from "../target.js";
25
+
26
+ const execFileAsync = promisify(execFile);
27
+ const TEST_TIMEOUT = 180000;
28
+ const MARKER = "single-file-render-marker-9f2c";
29
+ const PAGE = "<html><head><title>Saved Page</title></head><body><p id=\"marker\">" + MARKER + "</p></body></html>";
30
+
31
+ test("a self-extracting archive opens with no console error and shows the saved page", { timeout: TEST_TIMEOUT }, async () => {
32
+ const { consoleMessages, reopened } = await captureAndReopen(["--compress-content"]);
33
+ assertNoConsoleError(consoleMessages);
34
+ assert.ok(reopened.includes(MARKER), "the reopened archive does not contain the saved content");
35
+ });
36
+
37
+ test("a plain saved page opens with no console error and shows the saved page", { timeout: TEST_TIMEOUT }, async () => {
38
+ const { consoleMessages, reopened } = await captureAndReopen([]);
39
+ assertNoConsoleError(consoleMessages);
40
+ assert.ok(reopened.includes(MARKER), "the reopened page does not contain the saved content");
41
+ });
42
+
43
+ function assertNoConsoleError(consoleMessages) {
44
+ const errors = consoleMessages.filter(message => message.level === "error");
45
+ assert.deepEqual(errors.map(message => message.text), [], "the saved page logged errors when opened");
46
+ }
47
+
48
+ async function captureAndReopen(options) {
49
+ const server = createServer((request, response) => {
50
+ const { pathname } = new URL(request.url, "http://localhost");
51
+ if (pathname === "/") {
52
+ response.writeHead(200, { "content-type": "text/html" }).end(PAGE);
53
+ } else {
54
+ response.writeHead(404).end();
55
+ }
56
+ });
57
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
58
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
59
+ try {
60
+ const savedPath = join(directory, "saved.html");
61
+ await runCli(["http://localhost:" + server.address().port + "/", savedPath, ...options]);
62
+ // re-capturing the saved file runs it in the browser: whatever the page ends up displaying
63
+ // is what gets written out, so a page that failed to render comes back without the marker
64
+ const reopenedPath = join(directory, "reopened.html");
65
+ const consolePath = join(directory, "console.json");
66
+ await runCli([pathToFileURL(savedPath).href, reopenedPath, "--console-messages-file", consolePath]);
67
+ return {
68
+ consoleMessages: JSON.parse(await readFile(consolePath, "utf-8")),
69
+ reopened: await readFile(reopenedPath, "utf-8")
70
+ };
71
+ } finally {
72
+ await rm(directory, { recursive: true });
73
+ server.close();
74
+ }
75
+ }
76
+
77
+ function runCli(args) {
78
+ return execFileAsync(process.execPath, ["single-file-node.js", ...args], { cwd: cliDirectory });
79
+ }
@@ -0,0 +1,111 @@
1
+ # Fidelity harness
2
+
3
+ Renders a page, saves it, renders the save, and compares the pixels. The rest of the e2e suite
4
+ reads a capture as text or as bytes — a marker is present, an entry is named what it should be, the
5
+ console is clean. A save can pass all of that and still come back with its type in a fallback
6
+ family, its grid collapsed or a frame drawn blank.
7
+
8
+ The checks live in [`../e2e/fidelity.test.js`](../e2e/fidelity.test.js) and run with the rest of the
9
+ suite:
10
+
11
+ ```
12
+ ./build-dev.sh && npm run test:dev
13
+ ```
14
+
15
+ `test:dev` is the form that matters here, and it is also what switches these checks on. They are
16
+ skipped on the default target, which runs the committed `lib/` — a build of the *pinned npm release*
17
+ of single-file-core, so a green run there says nothing about a local fix, and a check written for one
18
+ stays red until it ships. To run them against that released build anyway:
19
+
20
+ ```
21
+ SINGLE_FILE_FIDELITY=1 node --test test/e2e/fidelity.test.js
22
+ ```
23
+
24
+ **They do not run in CI yet, and that is a hold rather than a decision.** The first CI run wedged on
25
+ a CDP command that never answered: simple-cdp leaves commands without a limit by default, so nothing
26
+ failed, the connection stayed stuck, every check after the first was never reached, and the job had
27
+ to be cancelled after ten minutes. The limit is set now (`commandMaxTime`), so a repeat would name
28
+ the method that did not answer instead of hanging — but that has not been seen on a runner yet.
29
+ Where they belong is a CI job that builds core from source and runs `test:dev`.
30
+
31
+ ## What it can assert, and why
32
+
33
+ A page that does not render identically to **itself** cannot be held to rendering identically to its
34
+ save. Every check therefore captures the source twice first and uses the difference as its floor.
35
+ On these fixtures the floor is zero, which is why they are hand-built and static rather than
36
+ mirrored from the web — real pages reflow between two shots of the same file, and a suite built on
37
+ them reports noise as regression.
38
+
39
+ The comparison is done in the browser, on the two PNGs: Node has no image decoder, and adding one as
40
+ a dependency to compare pictures taken by a browser that already decodes PNG natively would be
41
+ paying twice for the same capability. Images are compared in horizontal bands so that a failure says
42
+ *where* the two renderings parted company, and a save shorter than its source counts its missing rows
43
+ as differing rather than having them cropped away.
44
+
45
+ Two things pixels cannot see, which are asserted on the saved markup instead:
46
+
47
+ - an `id` a script looks up or a `class` a selector matches, on an element the save replaced;
48
+ - pruning that did not happen. A build that gives up and keeps every font renders exactly like a
49
+ correct one.
50
+
51
+ ## The fixtures
52
+
53
+ Each is a defect that shipped, kept in the shape that made it visible, with a comment in the page
54
+ saying which.
55
+
56
+ | Fixture | What it holds |
57
+ |---|---|
58
+ | `duplicate-stylesheet/` | Two `<style>` elements with identical content. The archive writer folds them into one entry and points links at it; the element they were folded into kept its content inline as well, and the replacement dropped the attributes that identified it. |
59
+ | `linked-stylesheet/` | An external stylesheet linked with an id, a class, a data attribute and a title. A plain save has nowhere to put it, so the link becomes a style element — a new one, built from the media and the text and nothing else. |
60
+ | `used-fonts/` | Five declared faces, three drawn — named as a plain family, through a custom property declared on a descendant, and through the `font` shorthand. The minifier has stopped resolving each of those at some point. |
61
+ | `frame-fonts/` | A sandboxed frame declaring and using a face the page around it never names. Its `contentDocument` is out of reach, so it is re-parsed from its srcdoc and reports nothing about what it draws with. |
62
+ | `synthetic-italic/` | A face drawn in an italic it declares no face for, so the browser slants the upright one. Matching a loaded face against the computed style found nothing and pruned the family off the page that draws it. |
63
+ | `unresolved-font-property/` | A family named inside a property holding a whole font shorthand, declared twice so there is no single value to substitute. The stylesheets cannot name it; the rendered list can. |
64
+
65
+ `pages/fonts/` holds generated fonts in which every printable ASCII character is the same filled
66
+ rectangle: text set in them is a solid bar, so a face that goes missing is not a subtle reflow. The
67
+ three shapes differ, so keeping the *wrong* face is as visible as keeping none.
68
+ [`make-font.js`](make-font.js) writes them; run it after changing it, and commit the result.
69
+
70
+ ## Rules the harness enforces, and what they cost to learn
71
+
72
+ Each of these is a way a run reported good news that was not true.
73
+
74
+ 1. **A run without a noise floor concludes nothing.** Measured every time, never assumed.
75
+ 2. **A whole-image verdict is not usable.** One line of reflow near the top moves every pixel below
76
+ it, and a single number cannot tell a small local difference from a large one.
77
+ 3. **A fixture that fails to load renders identically to itself.** The floor is zero, the save of
78
+ that same failure matches it, and the check passes while testing nothing — it happened here, with
79
+ a directory served as a file giving two beautifully identical 404 pages. The server records every
80
+ miss and the checks refuse to conclude when there is one. Only the favicon is excused: a request
81
+ to the captured site that the site cannot answer is a defect wherever it comes from, and this is
82
+ how the archive writer was caught asking that site for a zip worker three times per save.
83
+ 4. **The output file is removed before each save.** The default conflict action is to uniquify, so a
84
+ second save writes `saved (2).html` and leaves the stale file where the test is looking.
85
+ 5. **A stale dev build reads as "the change has no effect".** [`../target.js`](../target.js) refuses
86
+ to start when `.dev/` is older than the newest source file in single-file-core.
87
+ 6. **Assert on content, not on a number.** A five-megabyte cookie-consent wall looks exactly like a
88
+ successful save.
89
+
90
+ ## Adding a fixture
91
+
92
+ Build the page around the defect, in the shape that made it visible, and say in an HTML comment what
93
+ that was — the fixtures read as a record of what has gone wrong, which is most of their value once
94
+ the bug is a year old.
95
+
96
+ Then confirm the check can fail. Break the fix in single-file-core, rebuild, watch it go red, and
97
+ put it back. A check that passes against deliberately broken code is protecting nothing, and this is
98
+ not a formality here: `frame-fonts` found a defect on its first run that the commit it was written
99
+ to guard did not cover, `unresolved-font-property` had to be reshaped twice before it exercised the
100
+ code it names — a var() in family position resolves through the ordinary path, whatever it is nested
101
+ in — and `synthetic-italic` exists because a fixture that should have been unremarkable came back
102
+ with an empty list of used fonts.
103
+
104
+ Two traps worth knowing before writing one. A face used in a style the fonts do not declare is
105
+ drawn synthesized, which changes what the page reports about it; and a family name appears in the
106
+ declarations that *use* it as well as in the `@font-face` that declares it, so read the declared
107
+ faces out of the `@font-face` rules rather than searching the page for a name.
108
+
109
+ And commit the fix before mutating it. Restoring the tree between mutations is a `git checkout`,
110
+ which takes the uncommitted fix with it — the next mutation then fails to apply against code that
111
+ is already reverted, and reports a pass. It has happened twice here.