single-file-cli 2.6.1 → 2.6.3

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.
Files changed (43) hide show
  1. package/.github/workflows/ci.yml +24 -0
  2. package/build-dev.sh +35 -14
  3. package/build.sh +1 -1
  4. package/deno.json +1 -1
  5. package/eslint.config.mjs +1 -1
  6. package/lib/deno-polyfill.js +6 -4
  7. package/lib/single-file-archive.js +4 -4
  8. package/lib/single-file-bundle.js +1 -1
  9. package/lib/version.js +1 -1
  10. package/package.json +2 -1
  11. package/test/e2e/automation-detection.test.js +2 -3
  12. package/test/e2e/backend-fetch.test.js +2 -3
  13. package/test/e2e/blocked-url-pattern.test.js +2 -3
  14. package/test/e2e/browser-profile.test.js +2 -3
  15. package/test/e2e/compress-content.test.js +3 -4
  16. package/test/e2e/crawl-save-archive.test.js +3 -4
  17. package/test/e2e/crawl.test.js +2 -3
  18. package/test/e2e/errors-file.test.js +2 -3
  19. package/test/e2e/fidelity.test.js +214 -0
  20. package/test/e2e/frame-gate.test.js +2 -3
  21. package/test/e2e/http-header.test.js +2 -3
  22. package/test/e2e/oopif.test.js +2 -3
  23. package/test/e2e/output-json.test.js +2 -3
  24. package/test/e2e/saved-page-opens.test.js +79 -0
  25. package/test/e2e/service-worker.test.js +2 -3
  26. package/test/e2e/timeouts.test.js +2 -3
  27. package/test/e2e/urls-file.test.js +2 -3
  28. package/test/fidelity/README.md +111 -0
  29. package/test/fidelity/browser.js +162 -0
  30. package/test/fidelity/make-font.js +294 -0
  31. package/test/fidelity/pages/duplicate-stylesheet/index.html +126 -0
  32. package/test/fidelity/pages/fonts/band.ttf +0 -0
  33. package/test/fidelity/pages/fonts/bar.ttf +0 -0
  34. package/test/fidelity/pages/fonts/block.ttf +0 -0
  35. package/test/fidelity/pages/frame-fonts/index.html +53 -0
  36. package/test/fidelity/pages/linked-stylesheet/index.html +30 -0
  37. package/test/fidelity/pages/linked-stylesheet/theme.css +38 -0
  38. package/test/fidelity/pages/synthetic-italic/index.html +59 -0
  39. package/test/fidelity/pages/unresolved-font-property/index.html +75 -0
  40. package/test/fidelity/pages/used-fonts/index.html +96 -0
  41. package/test/fidelity/server.js +70 -0
  42. package/test/target.js +77 -0
  43. package/test/unit/archive-packager.test.js +2 -1
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "2.6.1";
1
+ export const version = "2.6.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "single-file-cli",
3
- "version": "2.6.1",
3
+ "version": "2.6.3",
4
4
  "description": "SingleFile CLI",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
@@ -16,6 +16,7 @@
16
16
  "type": "module",
17
17
  "scripts": {
18
18
  "test": "node --test --test-concurrency=2 \"test/**/*.test.js\"",
19
+ "test:dev": "SINGLE_FILE_TARGET=dev node --test --test-concurrency=2 \"test/**/*.test.js\"",
19
20
  "lint": "eslint ."
20
21
  },
21
22
  "bin": {
@@ -5,12 +5,11 @@ import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { mkdtemp, readFile, rm } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
- import { join, dirname } from "node:path";
9
- import { fileURLToPath } from "node:url";
8
+ import { join } from "node:path";
10
9
  import process from "node:process";
10
+ import { cliDirectory } from "../target.js";
11
11
 
12
12
  const execFileAsync = promisify(execFile);
13
- const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
14
13
 
15
14
  test("pages are not captured as controlled by automation", { timeout: 120000 }, async () => {
16
15
  const server = createServer((_, response) => response
@@ -7,12 +7,11 @@ import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { mkdtemp, rm } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
- import { join, dirname } from "node:path";
11
- import { fileURLToPath } from "node:url";
10
+ import { join } from "node:path";
12
11
  import process from "node:process";
12
+ import { cliDirectory } from "../target.js";
13
13
 
14
14
  const execFileAsync = promisify(execFile);
15
- const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
15
 
17
16
  test("the backend fetch presents the same user agent as the browser", { timeout: 120000 }, async () => {
18
17
  const requests = [];
@@ -7,12 +7,11 @@ import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { mkdtemp, readFile, rm } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
- import { join, dirname } from "node:path";
11
- import { fileURLToPath } from "node:url";
10
+ import { join } from "node:path";
12
11
  import process from "node:process";
12
+ import { cliDirectory } from "../target.js";
13
13
 
14
14
  const execFileAsync = promisify(execFile);
15
- const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
15
 
17
16
  test("a blocked URL is never requested", { timeout: 120000 }, async () => {
18
17
  const BLOCKED_STYLE = "rgb(123,45,67)";
@@ -5,12 +5,11 @@ import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { mkdtemp, mkdir, readdir, readFile, writeFile, rm } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
- import { join, dirname } from "node:path";
9
- import { fileURLToPath } from "node:url";
8
+ import { join } from "node:path";
10
9
  import process from "node:process";
10
+ import { cliDirectory } from "../target.js";
11
11
 
12
12
  const execFileAsync = promisify(execFile);
13
- const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
14
13
 
15
14
  test("a browser profile is copied and left unmodified by a capture", { timeout: 120000 }, async () => {
16
15
  const server = createServer((_, response) => response
@@ -7,13 +7,12 @@ import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { mkdtemp, readFile, rm } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
- import { join, dirname } from "node:path";
11
- import { fileURLToPath } from "node:url";
10
+ import { join } from "node:path";
12
11
  import process from "node:process";
13
- import { configure, ZipReader, Uint8ArrayReader } from "../../lib/single-file-archive.js";
12
+ const { configure, ZipReader, Uint8ArrayReader } = await importLibModule("single-file-archive.js");
13
+ import { cliDirectory, importLibModule } from "../target.js";
14
14
 
15
15
  const execFileAsync = promisify(execFile);
16
- const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
17
16
  const TEST_TIMEOUT = 120000;
18
17
  const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
19
18
  const END_OF_CENTRAL_DIRECTORY_LENGTH = 22;
@@ -7,13 +7,12 @@ import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
- import { join, dirname } from "node:path";
11
- import { fileURLToPath } from "node:url";
10
+ import { join } from "node:path";
12
11
  import process from "node:process";
13
- import { configure, ZipReader, Uint8ArrayReader, TextWriter } from "../../lib/single-file-archive.js";
12
+ const { configure, ZipReader, Uint8ArrayReader, TextWriter } = await importLibModule("single-file-archive.js");
13
+ import { cliDirectory, importLibModule } from "../target.js";
14
14
 
15
15
  const execFileAsync = promisify(execFile);
16
- const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
17
16
  const TEST_TIMEOUT = 120000;
18
17
 
19
18
  const crawlPromises = new Map();
@@ -7,12 +7,11 @@ import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
- import { join, dirname } from "node:path";
11
- import { fileURLToPath } from "node:url";
10
+ import { join } from "node:path";
12
11
  import process from "node:process";
12
+ import { cliDirectory } from "../target.js";
13
13
 
14
14
  const execFileAsync = promisify(execFile);
15
- const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
15
  const TEST_TIMEOUT = 120000;
17
16
 
18
17
  let crawlPromise;
@@ -4,12 +4,11 @@ import { execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
5
  import { mkdtemp, readFile, rm } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
- import { join, dirname } from "node:path";
8
- import { fileURLToPath } from "node:url";
7
+ import { join } from "node:path";
9
8
  import process from "node:process";
9
+ import { cliDirectory } from "../target.js";
10
10
 
11
11
  const execFileAsync = promisify(execFile);
12
- const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
13
12
 
14
13
  test("errors file lines include the error message and the exit code is nonzero", { timeout: 120000 }, async () => {
15
14
  const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
@@ -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
+ }
@@ -7,13 +7,12 @@ import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { mkdtemp, readFile, rm } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
- import { join, dirname } from "node:path";
11
- import { fileURLToPath } from "node:url";
10
+ import { join } from "node:path";
12
11
  import { Buffer } from "node:buffer";
13
12
  import process from "node:process";
13
+ import { cliDirectory } from "../target.js";
14
14
 
15
15
  const execFileAsync = promisify(execFile);
16
- const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
17
16
  const LOAD_MARKER = "MARKER_ADDED_AFTER_LOAD_EVENT";
18
17
  const SLOW_RESOURCE_DELAY = 3500;
19
18
  const PIXEL_PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "base64");
@@ -7,12 +7,11 @@ import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { mkdtemp, readFile, rm } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
- import { join, dirname } from "node:path";
11
- import { fileURLToPath } from "node:url";
10
+ import { join } from "node:path";
12
11
  import process from "node:process";
12
+ import { cliDirectory } from "../target.js";
13
13
 
14
14
  const execFileAsync = promisify(execFile);
15
- const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
15
 
17
16
  test("extra HTTP headers do not make cross-origin resources send a preflight", { timeout: 120000 }, async () => {
18
17
  const STYLE = "rgb(11,22,33)";
@@ -7,12 +7,11 @@ import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { mkdtemp, readFile, rm } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
- import { join, dirname } from "node:path";
11
- import { fileURLToPath } from "node:url";
10
+ import { join } from "node:path";
12
11
  import process from "node:process";
12
+ import { cliDirectory } from "../target.js";
13
13
 
14
14
  const execFileAsync = promisify(execFile);
15
- const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
15
  const FRAME_MARKER = "OUT_OF_PROCESS_FRAME_CONTENT";
17
16
 
18
17
  test("blocked URL patterns and extra HTTP headers apply in cross-origin frames", { timeout: 120000 }, async () => {
@@ -5,13 +5,12 @@ import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { mkdtemp, readFile, rm } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
- import { join, dirname } from "node:path";
9
- import { fileURLToPath } from "node:url";
8
+ import { join } from "node:path";
10
9
  import { Buffer } from "node:buffer";
11
10
  import process from "node:process";
11
+ import { cliDirectory } from "../target.js";
12
12
 
13
13
  const execFileAsync = promisify(execFile);
14
- const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
15
14
  const ZIP_SIGNATURE = "PK\u0003\u0004";
16
15
 
17
16
  test("output-json embeds compressed content as base64", { timeout: 120000 }, async () => {
@@ -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
+ }
@@ -7,12 +7,11 @@ import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { mkdtemp, readFile, rm } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
- import { join, dirname } from "node:path";
11
- import { fileURLToPath } from "node:url";
10
+ import { join } from "node:path";
12
11
  import process from "node:process";
12
+ import { cliDirectory } from "../target.js";
13
13
 
14
14
  const execFileAsync = promisify(execFile);
15
- const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
15
 
17
16
  // the worker fetches a marked URL instead of passing the request through, so a
18
17
  // request reaching the server proves the service worker is the one that made it
@@ -8,12 +8,11 @@ import { promisify } from "node:util";
8
8
  import { mkdtemp, rm } from "node:fs/promises";
9
9
  import { Buffer } from "node:buffer";
10
10
  import { tmpdir } from "node:os";
11
- import { join, dirname } from "node:path";
12
- import { fileURLToPath } from "node:url";
11
+ import { join } from "node:path";
13
12
  import process from "node:process";
13
+ import { cliDirectory } from "../target.js";
14
14
 
15
15
  const execFileAsync = promisify(execFile);
16
- const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
17
16
 
18
17
  test("load timeout is reported when the page never loads", { timeout: 120000 }, async () => {
19
18
  const server = createServer(() => { });
@@ -5,12 +5,11 @@ import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
- import { join, dirname } from "node:path";
9
- import { fileURLToPath } from "node:url";
8
+ import { join } from "node:path";
10
9
  import process from "node:process";
10
+ import { cliDirectory } from "../target.js";
11
11
 
12
12
  const execFileAsync = promisify(execFile);
13
- const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
14
13
 
15
14
  test("duplicate urls in a urls file are captured once", { timeout: 120000 }, async () => {
16
15
  const server = createServer((_, response) => response