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/build.sh +1 -1
- package/deno.json +1 -1
- package/lib/deno-polyfill.js +6 -4
- package/lib/single-file-archive.js +5 -5
- package/lib/single-file-bundle.js +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/test/e2e/canvas-round-trip.test.js +93 -0
- package/test/e2e/fidelity.test.js +214 -0
- package/test/e2e/saved-page-opens.test.js +79 -0
- package/test/fidelity/README.md +111 -0
- package/test/fidelity/browser.js +162 -0
- package/test/fidelity/make-font.js +294 -0
- package/test/fidelity/pages/duplicate-stylesheet/index.html +126 -0
- package/test/fidelity/pages/fonts/band.ttf +0 -0
- package/test/fidelity/pages/fonts/bar.ttf +0 -0
- package/test/fidelity/pages/fonts/block.ttf +0 -0
- package/test/fidelity/pages/frame-fonts/index.html +53 -0
- package/test/fidelity/pages/linked-stylesheet/index.html +30 -0
- package/test/fidelity/pages/linked-stylesheet/theme.css +38 -0
- package/test/fidelity/pages/synthetic-italic/index.html +59 -0
- package/test/fidelity/pages/unresolved-font-property/index.html +75 -0
- package/test/fidelity/pages/used-fonts/index.html +96 -0
- package/test/fidelity/server.js +70 -0
- package/test/target.js +49 -4
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "2.6.
|
|
1
|
+
export const version = "2.6.4";
|
package/package.json
CHANGED
|
@@ -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.
|