buninu-browser 0.5.1 → 0.6.0
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/README.md +9 -3
- package/lib/audio/pcm.js +11 -0
- package/lib/audio/pulse-client.js +7 -2
- package/lib/cdp-server.js +31 -7
- package/lib/happy-dom/document-base.js +29 -1
- package/lib/happy-dom/mutation-observer.js +157 -0
- package/lib/happy-dom/parser.js +2 -0
- package/lib/headless-shell.js +49 -9
- package/lib/layout/repaint.js +89 -0
- package/lib/media/mp4-keyframes.js +144 -0
- package/lib/media/video-keyframe.js +29 -1
- package/lib/media/webm-snapshots.js +282 -0
- package/lib/network/cookie-jar.js +18 -2
- package/lib/paint/display-list.js +4 -3
- package/lib/paint/frame.js +12 -5
- package/lib/render-tree/index.js +3 -0
- package/lib/renderer/animation-frames.js +76 -0
- package/lib/renderer/bindings.js +2 -0
- package/lib/renderer/compositor-channel.js +95 -10
- package/lib/renderer/compositor.js +54 -18
- package/lib/renderer/document-cookies.js +87 -0
- package/lib/renderer/font-loading.js +60 -16
- package/lib/renderer/host.js +48 -19
- package/lib/renderer/idle-callbacks.js +109 -0
- package/lib/renderer/main.js +5 -1
- package/lib/renderer/media-source.js +4 -0
- package/lib/renderer/page-realm.js +159 -49
- package/lib/renderer/page-renderer.js +567 -197
- package/lib/renderer/resource-elements.js +88 -0
- package/lib/renderer/script-rewrite.js +18 -1
- package/lib/renderer/seccomp.js +6 -4
- package/lib/renderer/timers.js +71 -0
- package/lib/renderer/video-channel.js +94 -0
- package/lib/renderer/video-mask.js +115 -0
- package/lib/renderer/video-presentation.js +304 -39
- package/lib/renderer/video-worker.js +46 -0
- package/lib/renderer/workers.js +7 -17
- package/lib/style/computed-style.js +257 -61
- package/package.json +1 -1
- package/test/animation-frames.test.js +67 -0
- package/test/canvas-style-snapshot.test.js +18 -0
- package/test/cdp-stream.test.js +96 -3
- package/test/compositor-channel-ownership.test.js +194 -0
- package/test/compositor.test.js +27 -1
- package/test/computed-style-binding.test.js +49 -0
- package/test/computed-style-connectivity.test.js +13 -0
- package/test/computed-style.test.js +86 -0
- package/test/cookie-sync.test.js +82 -0
- package/test/document-base-cache.test.js +103 -0
- package/test/document-cookies.test.js +117 -0
- package/test/duplicate-style-keys.test.js +11 -0
- package/test/fixtures/animation-frames.js +56 -0
- package/test/fixtures/canvas-style-snapshot.js +81 -0
- package/test/fixtures/computed-style-connectivity.js +101 -0
- package/test/fixtures/computed-style-lazy.js +135 -0
- package/test/fixtures/duplicate-style-keys.js +97 -0
- package/test/fixtures/fake-renderer.js +12 -0
- package/test/fixtures/font-definition-invalidation.js +97 -0
- package/test/fixtures/font-metrics-invalidation.js +87 -0
- package/test/fixtures/full-style-identity.js +36 -0
- package/test/fixtures/hidden-style-engine.js +260 -0
- package/test/fixtures/hidden-style-reentry.js +232 -0
- package/test/fixtures/hidden-style-renderer.js +203 -0
- package/test/fixtures/layout-repaint.js +69 -0
- package/test/fixtures/media-presentation-continuation.js +141 -0
- package/test/fixtures/media-presentation-refill.js +113 -0
- package/test/fixtures/media-probe.js +148 -0
- package/test/fixtures/media-refill-cancel.js +79 -0
- package/test/fixtures/media-repaint.js +48 -0
- package/test/fixtures/media-resource-reselection.js +94 -0
- package/test/fixtures/media-source-attachment.js +136 -0
- package/test/fixtures/media-source-no-decoder.js +17 -0
- package/test/fixtures/media-track-refill.js +132 -0
- package/test/fixtures/media-webm-seek.js +51 -0
- package/test/fixtures/mutation-observer.js +201 -0
- package/test/fixtures/observer-invalidation.js +105 -0
- package/test/fixtures/prepaint-scheduling.js +73 -0
- package/test/fixtures/resource-discovery.js +32 -0
- package/test/fixtures/spare-renderer-scheduling.js +124 -0
- package/test/fixtures/style-propagation.js +26 -0
- package/test/fixtures/svg-node-reuse.js +83 -0
- package/test/fixtures/video-black-replay.js +140 -0
- package/test/fixtures/video-controls-invalidation.js +71 -0
- package/test/fixtures/viewport-bounds.js +148 -0
- package/test/font-loading.test.js +77 -0
- package/test/font-metrics-invalidation.test.js +60 -0
- package/test/full-style-identity.test.js +191 -0
- package/test/hidden-style-lazy.test.js +31 -0
- package/test/hidden-style-reentry.test.js +20 -0
- package/test/idle-callback.test.js +89 -0
- package/test/idle-scheduler.test.js +148 -0
- package/test/inline-style-cascade.test.js +63 -0
- package/test/layout-repaint-renderer.test.js +9 -0
- package/test/layout-repaint.test.js +222 -0
- package/test/media-presentation-continuation.test.js +14 -0
- package/test/media-presentation-refill.test.js +8 -0
- package/test/media-probe.test.js +16 -0
- package/test/media-repaint.test.js +8 -0
- package/test/media-source-attachment.test.js +34 -0
- package/test/media-track-refill.test.js +26 -0
- package/test/media-webm-seek.test.js +11 -0
- package/test/mp4-keyframes.test.js +36 -0
- package/test/mutation-observer.test.js +91 -0
- package/test/observer-invalidation.test.js +12 -0
- package/test/page-scripts.test.js +35 -0
- package/test/page-timers.test.js +67 -0
- package/test/paint-frame.test.js +18 -1
- package/test/pcm.test.js +63 -0
- package/test/prepaint-scheduling.test.js +13 -0
- package/test/pulse-client.test.js +165 -0
- package/test/raw-capture.test.js +34 -5
- package/test/renderer-host.test.js +51 -0
- package/test/resource-discovery.test.js +12 -0
- package/test/resource-elements.test.js +234 -0
- package/test/root-self-style.test.js +104 -0
- package/test/script-rewrite.test.js +35 -0
- package/test/seccomp.test.js +45 -0
- package/test/spare-renderer-scheduling.test.js +8 -0
- package/test/style-propagation.test.js +225 -0
- package/test/style-sheets.test.js +19 -0
- package/test/svg.test.js +45 -0
- package/test/timers.test.js +58 -0
- package/test/video-black-replay.test.js +34 -0
- package/test/video-capture.test.js +170 -0
- package/test/video-channel.test.js +253 -0
- package/test/video-continuation.test.js +57 -0
- package/test/video-controls-invalidation.test.js +12 -0
- package/test/video-keyframe.test.js +104 -4
- package/test/video-mask.test.js +94 -0
- package/test/video-presentation.test.js +239 -1
- package/test/video-timing-presentation.test.js +115 -0
- package/test/viewport-bounds.test.js +13 -0
- package/test/webm-snapshots.test.js +372 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import '../../vendor/ses/dist/ses.mjs';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseHTMLDocument } from '../../lib/happy-dom/parser.js';
|
|
4
|
+
import { matchSelectors, StyleEngine } from '../../lib/style/computed-style.js';
|
|
5
|
+
|
|
6
|
+
const source = `<body class="scope scope"><section id="outer" class="shared shared shared">
|
|
7
|
+
<div id="branch" class="branch branch shared shared"><span id="deep" class="target target alternate alternate">deep</span></div>
|
|
8
|
+
<span id="after" class="target target">after nested duplicates</span>
|
|
9
|
+
</section><section id="other" class="shared shared"><span id="later" class="target target">later branch</span></section>
|
|
10
|
+
<span id="outside" class="target target alternate">outside</span></body>`;
|
|
11
|
+
const sheets = [`
|
|
12
|
+
.target { color:black; width:1px }
|
|
13
|
+
.shared .target { color:red; width:11px; --tone:blue }
|
|
14
|
+
.branch .target { height:7px }
|
|
15
|
+
:is(.target, .alternate) { padding:2px }
|
|
16
|
+
.target.target { font-weight:700 }
|
|
17
|
+
.shared .target::before { content:"marker"; color:var(--tone) }
|
|
18
|
+
.scope .target { border:1px solid currentColor }
|
|
19
|
+
.missing .target { color:lime !important }
|
|
20
|
+
`];
|
|
21
|
+
const duplicate = parseHTMLDocument(source), canonical = parseHTMLDocument(source);
|
|
22
|
+
const nodes = document => [...document.querySelectorAll('*')];
|
|
23
|
+
const normalizeClasses = () => {
|
|
24
|
+
const left = nodes(duplicate.document), right = nodes(canonical.document);
|
|
25
|
+
for (let index = 0; index < left.length; index++) {
|
|
26
|
+
right[index].setAttribute('class', [...new Set((left[index].getAttribute('class') ?? '').split(/\s+/).filter(Boolean))].join(' '));
|
|
27
|
+
const style = left[index].getAttribute('style');
|
|
28
|
+
if (style === null) right[index].removeAttribute('style');
|
|
29
|
+
else right[index].setAttribute('style', style);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
normalizeClasses();
|
|
33
|
+
const engine = new StyleEngine().compute(duplicate.document, sheets);
|
|
34
|
+
new StyleEngine().compute(canonical.document, sheets); // Warm DOM/style paths before lockdown.
|
|
35
|
+
if (process.argv[2] === 'ses') lockdown({
|
|
36
|
+
errorTaming: 'safe', overrideTaming: 'severe', consoleTaming: 'unsafe',
|
|
37
|
+
localeTaming: 'unsafe', stackFiltering: 'concise', reporting: 'none',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const get = id => duplicate.document.getElementById(id);
|
|
41
|
+
const ids = elements => [...elements].map(element => element.id);
|
|
42
|
+
const selectors = ['.shared .target', '.branch .target', ':is(.target, .alternate)', '.target.target', '.missing .target'];
|
|
43
|
+
function verify() {
|
|
44
|
+
normalizeClasses();
|
|
45
|
+
const reference = new StyleEngine().compute(canonical.document, sheets);
|
|
46
|
+
const left = nodes(duplicate.document), right = nodes(canonical.document);
|
|
47
|
+
for (let index = 0; index < left.length; index++) {
|
|
48
|
+
assert.deepEqual(engine.get(left[index]), reference.get(right[index]));
|
|
49
|
+
assert.deepEqual(engine.customPropertiesOf(left[index]), reference.customPropertiesOf(right[index]));
|
|
50
|
+
for (const pseudo of ['before', 'after']) assert.deepEqual(engine.getPseudo(left[index], pseudo), reference.getPseudo(right[index], pseudo));
|
|
51
|
+
}
|
|
52
|
+
const matched = matchSelectors(duplicate.document, selectors);
|
|
53
|
+
for (const selector of selectors) assert.deepEqual(ids(matched.get(selector)), ids(canonical.document.querySelectorAll(selector)), selector);
|
|
54
|
+
// Exiting the nested shared/branch keys must preserve the outer shared
|
|
55
|
+
// count for the next sibling, then restore it before the outside element.
|
|
56
|
+
for (const id of ['deep', 'after', 'later']) assert.equal(engine.get(get(id)).color, 'rgba(255, 0, 0, 1)');
|
|
57
|
+
assert.equal(engine.get(get('outside')).color, 'rgba(0, 0, 0, 1)');
|
|
58
|
+
assert.deepEqual(ids(matched.get('.shared .target')), ['deep', 'after', 'later']);
|
|
59
|
+
assert.deepEqual(ids(matched.get(':is(.target, .alternate)')), ['deep', 'after', 'later', 'outside']);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
// Traverse push/pop after lockdown as well as during the initial warmup.
|
|
64
|
+
engine.compute(duplicate.document, sheets);
|
|
65
|
+
verify();
|
|
66
|
+
assert.equal(engine.get(get('deep')).height, 7);
|
|
67
|
+
assert.equal(engine.get(get('after')).height, 'auto');
|
|
68
|
+
for (const classes of ['shared shared', 'branch branch shared shared shared']) {
|
|
69
|
+
get('branch').className = classes;
|
|
70
|
+
engine.compute(duplicate.document, sheets, {}, new Set([get('outer'), get('other')]));
|
|
71
|
+
assert.equal(engine.incremental, true);
|
|
72
|
+
verify();
|
|
73
|
+
assert.equal(engine.get(get('deep')).height, classes.includes('branch') ? 7 : 'auto');
|
|
74
|
+
}
|
|
75
|
+
// Long ancestor and subject lists use the guarded deduplication path. Keep
|
|
76
|
+
// a distinct matching token after the repeated keys so it cannot be lost.
|
|
77
|
+
get('outer').className = Array(1000).fill('shared').join(' ');
|
|
78
|
+
get('branch').className = `${Array(1000).fill('shared').join(' ')} branch`;
|
|
79
|
+
get('deep').className = `${Array(1000).fill('target').join(' ')} alternate`;
|
|
80
|
+
get('after').className = Array(17).fill('target').join(' ');
|
|
81
|
+
get('later').className = Array(16).fill('target').join(' ');
|
|
82
|
+
engine.compute(duplicate.document, sheets, {}, new Set([get('outer'), get('other')]));
|
|
83
|
+
verify();
|
|
84
|
+
assert.equal(engine.get(get('deep')).height, 7);
|
|
85
|
+
assert.equal(engine.get(get('after')).height, 'auto');
|
|
86
|
+
// Separate dirty roots build ancestor filters through repeated class keys.
|
|
87
|
+
get('deep').style.width = '23px';
|
|
88
|
+
get('later').style.width = '29px';
|
|
89
|
+
engine.compute(duplicate.document, sheets, {}, new Set([get('deep'), get('later')]));
|
|
90
|
+
assert.equal(engine.restyled, 2);
|
|
91
|
+
verify();
|
|
92
|
+
assert.equal(engine.get(get('deep')).width, 23);
|
|
93
|
+
assert.equal(engine.get(get('later')).width, 29);
|
|
94
|
+
} finally {
|
|
95
|
+
duplicate.window.happyDOM.abort();
|
|
96
|
+
canonical.window.happyDOM.abort();
|
|
97
|
+
}
|
|
@@ -7,6 +7,15 @@ process.on("message", async (message) => {
|
|
|
7
7
|
if (message?.type !== "call") return;
|
|
8
8
|
const [argument] = message.params;
|
|
9
9
|
switch (message.method) {
|
|
10
|
+
case "slow":
|
|
11
|
+
await Bun.sleep(200);
|
|
12
|
+
return process.send({ type: "result", id: message.id, result: "completed" });
|
|
13
|
+
case "showFragment":
|
|
14
|
+
await Bun.sleep(200);
|
|
15
|
+
return process.send({ type: "result", id: message.id, result: "positioned" });
|
|
16
|
+
case "loadDocument":
|
|
17
|
+
await Bun.sleep(argument.delay);
|
|
18
|
+
return process.send({ type: "result", id: message.id, result: "loaded" });
|
|
10
19
|
case "configuration": return process.send({ type: "result", id: message.id, result: { args: process.argv, secret: process.env.BUNINU_TEST_PRIVATE_ENV } });
|
|
11
20
|
case "echo": return process.send({ type: "result", id: message.id, result: { pid: process.pid, argument } });
|
|
12
21
|
case "hang": for (;;) {}
|
|
@@ -24,4 +33,7 @@ process.on("message", async (message) => {
|
|
|
24
33
|
}
|
|
25
34
|
});
|
|
26
35
|
process.on("disconnect", () => process.exit(0));
|
|
36
|
+
const delay = Number(process.argv.find(arg => arg.startsWith('--ready-delay='))?.split('=')[1] ?? 0);
|
|
37
|
+
if (process.argv.includes('--exit-before-ready')) process.exit(2);
|
|
38
|
+
if (delay) await Bun.sleep(delay);
|
|
27
39
|
process.send({ type: "ready" });
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Compare edited font definitions against fresh documents and count CSS work.
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
|
|
4
|
+
import { StyleEngine } from "../../lib/style/computed-style.js";
|
|
5
|
+
|
|
6
|
+
const mono = await Bun.file(new URL("../../usr/share/fonts/DejaVuSansMono.ttf", import.meta.url)).bytes();
|
|
7
|
+
const sans = await Bun.file(new URL("../../usr/share/fonts/Roboto-Regular.ttf", import.meta.url)).bytes();
|
|
8
|
+
const primary = '@font-face{font-family:DefinitionPrimary;src:url(/mono.ttf)}';
|
|
9
|
+
const alias = '@font-face{font-family:DefinitionAlias;src:url(/sans.ttf)}';
|
|
10
|
+
const phases = [
|
|
11
|
+
["loaded", primary + alias],
|
|
12
|
+
["remove primary", alias],
|
|
13
|
+
["rename primary", primary.replace("DefinitionPrimary", "UnusedDefinition") + alias],
|
|
14
|
+
["replace source", primary.replace("/mono.ttf", "/sans.ttf") + alias],
|
|
15
|
+
["restore primary", primary + alias],
|
|
16
|
+
["remove all", ""],
|
|
17
|
+
["restore all", primary + alias],
|
|
18
|
+
];
|
|
19
|
+
const compute = StyleEngine.prototype.compute;
|
|
20
|
+
let computations = null;
|
|
21
|
+
let currentDocument = null;
|
|
22
|
+
StyleEngine.prototype.compute = function (...args) {
|
|
23
|
+
currentDocument = args[0];
|
|
24
|
+
const result = compute.apply(this, args);
|
|
25
|
+
computations?.push({ count: this.restyled, full: !this.incremental });
|
|
26
|
+
return result;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
async function open(definitions, usesMetrics) {
|
|
30
|
+
let requests = 0;
|
|
31
|
+
const renderer = await createPageRenderer({
|
|
32
|
+
fetchResource: async url => ({ ok: false, status: 404, url, body: new Uint8Array() }),
|
|
33
|
+
pageFetch: async ({ url }) => {
|
|
34
|
+
requests++;
|
|
35
|
+
return { status: 200, body: url.endsWith("/mono.ttf") ? mono : sans };
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
renderer.resize(400, 250, 1);
|
|
39
|
+
await renderer.loadDocument({ url: "https://font-definition.test/", contentType: "text/html", source: `<!doctype html>
|
|
40
|
+
<style id=faces>${definitions}</style><style>
|
|
41
|
+
body{margin:0}#auto,#metric{display:inline-block;font:30px DefinitionPrimary,DefinitionAlias,sans-serif}
|
|
42
|
+
#metric{${usesMetrics ? "width:10ch" : "padding:2px"}}
|
|
43
|
+
#preload{font:20px DefinitionAlias,sans-serif}
|
|
44
|
+
</style><span id=auto>iiii</span><br><span id=metric>metric</span><div id=preload>preload alias</div>` });
|
|
45
|
+
await renderer.whenLoaded();
|
|
46
|
+
return { renderer, document: currentDocument, requests: () => requests };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function capture(renderer) {
|
|
50
|
+
const geometry = renderer.inspect("#auto,#metric", ["width"]);
|
|
51
|
+
const image = await renderer.screenshot({ format: "rgba" });
|
|
52
|
+
return { geometry, pixels: Buffer.from(image.pixels) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
for (const usesMetrics of [false, true]) {
|
|
57
|
+
const live = await open(phases[0][1], usesMetrics);
|
|
58
|
+
const snapshots = [];
|
|
59
|
+
try {
|
|
60
|
+
for (const [index, [name, definitions]] of phases.entries()) {
|
|
61
|
+
computations = [];
|
|
62
|
+
if (index) {
|
|
63
|
+
live.document.getElementById("faces").textContent = definitions;
|
|
64
|
+
// Repeated viewport metrics flush mutations without changing any
|
|
65
|
+
// layout input; font definition invalidation must reset the caches.
|
|
66
|
+
live.renderer.resize(400, 250, 1);
|
|
67
|
+
// Font bytes are already available; drain the cached-source promise
|
|
68
|
+
// chain that registers a new alias when a descriptor is replaced.
|
|
69
|
+
await Bun.sleep(0);
|
|
70
|
+
}
|
|
71
|
+
snapshots.push(await capture(live.renderer));
|
|
72
|
+
if (index) assert.equal(computations.some(entry => entry.full), usesMetrics,
|
|
73
|
+
`${name}: only font-relative computed lengths require a full cascade`);
|
|
74
|
+
assert.equal(live.requests(), 2, `${name}: cached sources and unused definitions must not refetch fonts`);
|
|
75
|
+
computations = null;
|
|
76
|
+
}
|
|
77
|
+
} finally { computations = null; live.renderer.dispose(); }
|
|
78
|
+
|
|
79
|
+
for (const [index, [name, definitions]] of phases.entries()) {
|
|
80
|
+
const fresh = await open(definitions, usesMetrics);
|
|
81
|
+
try {
|
|
82
|
+
const expected = await capture(fresh.renderer);
|
|
83
|
+
assert.deepEqual(snapshots[index].geometry, expected.geometry, `${name}: fresh layout geometry`);
|
|
84
|
+
assert.deepEqual(snapshots[index].pixels, expected.pixels, `${name}: fresh layout pixels`);
|
|
85
|
+
} finally { fresh.renderer.dispose(); }
|
|
86
|
+
}
|
|
87
|
+
assert.ok(snapshots[1].geometry[0].box.width < snapshots[0].geometry[0].box.width / 2,
|
|
88
|
+
"removing the primary face must select the narrower loaded fallback");
|
|
89
|
+
assert.deepEqual(snapshots.at(-1), snapshots[0], "restoring definitions must restore geometry and pixels");
|
|
90
|
+
if (usesMetrics) assert.notEqual(snapshots[1].geometry[1].style.width, snapshots[0].geometry[1].style.width,
|
|
91
|
+
"ch lengths must use the replacement font metrics");
|
|
92
|
+
}
|
|
93
|
+
process.stdout.write("font definition invalidation integration passed\n");
|
|
94
|
+
} finally {
|
|
95
|
+
computations = null;
|
|
96
|
+
StyleEngine.prototype.compute = compute;
|
|
97
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Isolate prototype counters and the forced full-cascade reference.
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
|
|
4
|
+
import { StyleEngine } from "../../lib/style/computed-style.js";
|
|
5
|
+
|
|
6
|
+
const font = await Bun.file(new URL("../../usr/share/fonts/DejaVuSansMono.ttf", import.meta.url)).bytes();
|
|
7
|
+
const compute = StyleEngine.prototype.compute;
|
|
8
|
+
const dependency = Object.getOwnPropertyDescriptor(StyleEngine.prototype, "usesFontMetrics");
|
|
9
|
+
let computations, currentDocument;
|
|
10
|
+
StyleEngine.prototype.compute = function (...args) {
|
|
11
|
+
currentDocument = args[0];
|
|
12
|
+
const result = compute.apply(this, args);
|
|
13
|
+
computations?.push({ count: this.restyled, full: !this.incremental });
|
|
14
|
+
return result;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
async function snapshot(extra, forceFull, mutate = false) {
|
|
18
|
+
if (forceFull) Object.defineProperty(StyleEngine.prototype, "usesFontMetrics", { configurable: true, get: () => true });
|
|
19
|
+
else Object.defineProperty(StyleEngine.prototype, "usesFontMetrics", dependency);
|
|
20
|
+
let finish, requests = 0;
|
|
21
|
+
const response = new Promise(resolve => { finish = resolve; });
|
|
22
|
+
const renderer = await createPageRenderer({
|
|
23
|
+
fetchResource: async url => ({ ok: false, status: 404, url, body: new Uint8Array() }),
|
|
24
|
+
pageFetch: () => { requests++; return response; },
|
|
25
|
+
});
|
|
26
|
+
try {
|
|
27
|
+
renderer.resize(400, 300, 1);
|
|
28
|
+
await renderer.loadDocument({ url: "https://font.test/", contentType: "text/html", source: `<!doctype html>
|
|
29
|
+
<style>@font-face{font-family:Delayed;src:url(/font.ttf)}
|
|
30
|
+
@font-face{font-family:Unused;src:url(/never.ttf)}
|
|
31
|
+
body{margin:0;font:16px Delayed,sans-serif;line-height:normal}
|
|
32
|
+
#probe,.atom{display:inline-block;padding:2px}
|
|
33
|
+
.flex{display:flex;width:180px}.grid{display:grid;grid-template-columns:min-content 1fr;width:180px}
|
|
34
|
+
${extra}</style>
|
|
35
|
+
<div id=probe>iiii</div><div id=changed>normal line height</div>
|
|
36
|
+
<div class=flex><span class=atom>iiii Hello</span><span class=atom>more words</span></div>
|
|
37
|
+
<div class=grid><span>iii iii</span><span>word word word</span></div>
|
|
38
|
+
<div style="width:150px">${'wrapping text '.repeat(12)}</div>` });
|
|
39
|
+
const before = renderer.inspect("#probe")[0].box.width;
|
|
40
|
+
if (mutate) {
|
|
41
|
+
// Pending DOM/style invalidation must not be replaced by font-only work.
|
|
42
|
+
const changed = currentDocument.getElementById("changed");
|
|
43
|
+
changed.style.width = "123px";
|
|
44
|
+
changed.style.color = "blue";
|
|
45
|
+
changed.insertAdjacentHTML("beforeend", '<span style="display:block;height:19px">inserted</span>');
|
|
46
|
+
}
|
|
47
|
+
computations = [];
|
|
48
|
+
finish({ status: 200, body: font });
|
|
49
|
+
await renderer.whenLoaded();
|
|
50
|
+
const after = renderer.inspect("#probe")[0].box.width;
|
|
51
|
+
const changed = renderer.inspect("#changed", ["width", "color"])[0];
|
|
52
|
+
const captured = await renderer.screenshot({ format: "rgba" });
|
|
53
|
+
assert.equal(requests, 1, "unused font definitions must not load or reset layout");
|
|
54
|
+
return { before, after, changed, pixels: Buffer.from(captured.pixels), computations: [...computations] };
|
|
55
|
+
} finally { computations = null; renderer.dispose(); }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
for (const [name, extra, usesMetrics] of [
|
|
60
|
+
["ordinary text", "", false],
|
|
61
|
+
["ch", "#probe{width:10ch}", true],
|
|
62
|
+
["custom variable", "body{--size:4ch}#probe{width:calc(var(--size) + 5px)}", true],
|
|
63
|
+
["pseudo", "#probe::before{content:'x';display:inline-block;width:2ch}", true],
|
|
64
|
+
["root font size", "html{font-family:Delayed,sans-serif;font-size:2ch}#probe{width:10rem}", true],
|
|
65
|
+
]) {
|
|
66
|
+
const candidate = await snapshot(extra, false);
|
|
67
|
+
const reference = await snapshot(extra, true);
|
|
68
|
+
assert.deepEqual(candidate.pixels, reference.pixels, name + " pixels must match a fresh full cascade");
|
|
69
|
+
assert.equal(candidate.after, reference.after, name + " geometry must match");
|
|
70
|
+
// Root font-size ch uses the initial/parent font while resolving size,
|
|
71
|
+
// so it is a conservative dependency even if this face cannot affect it.
|
|
72
|
+
if (name !== "root font size") assert.ok(candidate.after > candidate.before, name + " must use the newly loaded face");
|
|
73
|
+
assert.equal(candidate.computations.some(entry => entry.full), usesMetrics, name + " cascade dependency");
|
|
74
|
+
if (!usesMetrics) assert.equal(candidate.computations.reduce((sum, entry) => sum + entry.count, 0), 0,
|
|
75
|
+
"ordinary text changes font metrics without recomputing CSS");
|
|
76
|
+
}
|
|
77
|
+
const candidate = await snapshot("", false, true);
|
|
78
|
+
const reference = await snapshot("", true, true);
|
|
79
|
+
assert.deepEqual(candidate.pixels, reference.pixels, "simultaneous DOM changes must match full cascade");
|
|
80
|
+
assert.equal(candidate.changed.box.width, 123);
|
|
81
|
+
assert.equal(candidate.changed.style.color, "rgba(0, 0, 255, 1)");
|
|
82
|
+
assert.ok(candidate.computations.some(entry => entry.count > 0), "pending DOM changes still restyle");
|
|
83
|
+
process.stdout.write("font metrics invalidation integration passed\n");
|
|
84
|
+
} finally {
|
|
85
|
+
StyleEngine.prototype.compute = compute;
|
|
86
|
+
Object.defineProperty(StyleEngine.prototype, "usesFontMetrics", dependency);
|
|
87
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import '../../vendor/ses/dist/ses.mjs';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseHTMLDocument } from '../../lib/happy-dom/parser.js';
|
|
4
|
+
import { StyleEngine } from '../../lib/style/computed-style.js';
|
|
5
|
+
import { RenderTreeBuilder } from '../../lib/render-tree/index.js';
|
|
6
|
+
import { LayoutEngine } from '../../lib/layout/text-layout.js';
|
|
7
|
+
import { buildDisplayList } from '../../lib/paint/display-list.js';
|
|
8
|
+
|
|
9
|
+
const { document, window } = parseHTMLDocument('<body>' + Array.from({ length:200 }, (_, i) =>
|
|
10
|
+
`<section class="card"><p>Text ${i}</p><span>More ${i}</span></section>`).join('') + '</body>');
|
|
11
|
+
const sheets = ['body {margin:0} .card {width:500px;padding:3px} p {margin:0}'];
|
|
12
|
+
const engine = new StyleEngine().compute(document, sheets), builder = new RenderTreeBuilder();
|
|
13
|
+
let measures = 0;
|
|
14
|
+
const measureText = (text, style) => { measures++; return text.length * style.fontSize / 2; };
|
|
15
|
+
const layout = new LayoutEngine({ measureText }), options = { width:800, viewportHeight:600 };
|
|
16
|
+
const tree = builder.build(document, engine);
|
|
17
|
+
layout.layout(tree, options);
|
|
18
|
+
const before = [...document.querySelectorAll('*')].map(element => [element, engine.get(element)]);
|
|
19
|
+
lockdown({ errorTaming:'safe', overrideTaming:'severe', consoleTaming:'unsafe', localeTaming:'unsafe', stackFiltering:'concise', reporting:'none' });
|
|
20
|
+
try {
|
|
21
|
+
sheets.push('.unmatched {width:200px}');
|
|
22
|
+
engine.compute(document, sheets);
|
|
23
|
+
assert.equal(engine.incremental, false);
|
|
24
|
+
assert.equal(engine.restyled, before.length);
|
|
25
|
+
for (const [element, style] of before) assert.equal(engine.get(element), style);
|
|
26
|
+
const next = builder.build(document, engine);
|
|
27
|
+
assert.equal(next.root, tree.root);
|
|
28
|
+
measures = 0;
|
|
29
|
+
const result = layout.layout(next, options);
|
|
30
|
+
assert.equal(measures, 0);
|
|
31
|
+
const freshStyle = new StyleEngine().compute(document, sheets);
|
|
32
|
+
const freshTree = new RenderTreeBuilder().build(document, freshStyle);
|
|
33
|
+
const fresh = new LayoutEngine({ measureText }).layout(freshTree, options);
|
|
34
|
+
const actual = buildDisplayList(result, next), expected = buildDisplayList(fresh, freshTree);
|
|
35
|
+
for (const key of ['items', 'hits', 'scrollers', 'extent']) assert.deepEqual(actual[key], expected[key]);
|
|
36
|
+
} finally { window.happyDOM.abort(); }
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
const root = new URL("../../", import.meta.url).pathname.replace(/\/$/, "");
|
|
4
|
+
if (process.argv.includes("--ses")) {
|
|
5
|
+
await import(pathToFileURL(root + "/vendor/ses/dist/ses.mjs"));
|
|
6
|
+
lockdown({
|
|
7
|
+
errorTaming: "safe",
|
|
8
|
+
overrideTaming: "severe",
|
|
9
|
+
consoleTaming: "unsafe",
|
|
10
|
+
localeTaming: "unsafe",
|
|
11
|
+
stackFiltering: "concise",
|
|
12
|
+
reporting: "none",
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
const { parseHTMLDocument } = await import(
|
|
16
|
+
pathToFileURL(root + "/lib/happy-dom/parser.js")
|
|
17
|
+
);
|
|
18
|
+
const { StyleEngine, computeElementStyle } = await import(
|
|
19
|
+
pathToFileURL(root + "/lib/style/computed-style.js")
|
|
20
|
+
);
|
|
21
|
+
let assertions = 0,
|
|
22
|
+
states = 0,
|
|
23
|
+
metric = 7,
|
|
24
|
+
measurements = 0;
|
|
25
|
+
const measureText = (text, style) => {
|
|
26
|
+
measurements++;
|
|
27
|
+
return { width: text.length * metric, height: style.fontSize };
|
|
28
|
+
};
|
|
29
|
+
const f = parseHTMLDocument(
|
|
30
|
+
`<html style="font-size:18px"><body><section id="visible" style="color:purple;--tone:green"></section><div id="outer" style="color:red;--tone:blue"><div id="nested" style="display:none;font-size:20px"><i id="nestedchild" class="child"></i></div><div id="chain" style="font-size:1.2em;line-height:1.5"><span id="leaf" class="child" style="width:inherit"><b id="grand" class="child"></b></span></div><svg id="svg"><g id="g"><path id="path" style="fill:currentColor"/></g></svg></div></body></html>`,
|
|
31
|
+
);
|
|
32
|
+
let sheets = [
|
|
33
|
+
`.child{background:var(--tone);margin:2rem;padding:2ch;max-width:20vw}.child::before{content:"before";color:inherit;width:2ch} #outer:has(.active) .child {color:orange} #visible .child {font-weight:bold} @media(min-width:600px){.child{height:4em}}`,
|
|
34
|
+
];
|
|
35
|
+
let viewport = { width: 800, height: 600 },
|
|
36
|
+
lazy = new StyleEngine({ deferHidden: true, measureText }),
|
|
37
|
+
lastRestyled;
|
|
38
|
+
const el = (id) => f.document.getElementById(id);
|
|
39
|
+
function run(label, { dirty = null, self = null } = {}) {
|
|
40
|
+
lazy.compute(f.document, sheets, viewport, dirty, self);
|
|
41
|
+
lastRestyled = lazy.restyled;
|
|
42
|
+
const full = new StyleEngine({ measureText }).compute(
|
|
43
|
+
f.document,
|
|
44
|
+
sheets,
|
|
45
|
+
viewport,
|
|
46
|
+
);
|
|
47
|
+
// Query descendants before their ancestors to exercise path materialization.
|
|
48
|
+
const elements = [...f.document.querySelectorAll("*")].reverse();
|
|
49
|
+
for (const e of elements) {
|
|
50
|
+
assert.deepEqual(lazy.get(e), full.get(e), label + " style " + e.id);
|
|
51
|
+
assertions++;
|
|
52
|
+
assert.deepEqual(
|
|
53
|
+
lazy.customPropertiesOf(e),
|
|
54
|
+
full.customPropertiesOf(e),
|
|
55
|
+
label + " custom " + e.id,
|
|
56
|
+
);
|
|
57
|
+
assertions++;
|
|
58
|
+
for (const p of ["before", "after"]) {
|
|
59
|
+
assert.deepEqual(
|
|
60
|
+
lazy.getPseudo(e, p),
|
|
61
|
+
full.getPseudo(e, p),
|
|
62
|
+
label + " pseudo " + e.id,
|
|
63
|
+
);
|
|
64
|
+
assertions++;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
assert.equal(
|
|
68
|
+
lazy.restyled,
|
|
69
|
+
lastRestyled,
|
|
70
|
+
label + " query does not affect traversal counter",
|
|
71
|
+
);
|
|
72
|
+
assertions++;
|
|
73
|
+
states++;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
run("visible with nested hidden");
|
|
77
|
+
el("outer").style.display = "none";
|
|
78
|
+
el("nested").style.fontSize = "31px";
|
|
79
|
+
run("outer hides stale nested boundary", { dirty: new Set([el("outer")]) });
|
|
80
|
+
el("outer").style.color = "green";
|
|
81
|
+
el("outer").style.setProperty("--tone", "pink");
|
|
82
|
+
run("hidden boundary inherit self", {
|
|
83
|
+
dirty: new Set(),
|
|
84
|
+
self: new Set([el("outer")]),
|
|
85
|
+
});
|
|
86
|
+
el("chain").style.fontSize = "33px";
|
|
87
|
+
el("leaf").style.width = "77px";
|
|
88
|
+
el("leaf").classList.add("active");
|
|
89
|
+
run("hidden inside roots", { dirty: new Set([el("chain"), el("leaf")]) });
|
|
90
|
+
el("nestedchild").style.cssText = "all:inherit;--tone:yellow";
|
|
91
|
+
run("hidden self inheritance", {
|
|
92
|
+
dirty: new Set(),
|
|
93
|
+
self: new Set([el("nestedchild")]),
|
|
94
|
+
});
|
|
95
|
+
sheets = [
|
|
96
|
+
...sheets,
|
|
97
|
+
".child{border:2px solid currentColor} #chain{width:91px}",
|
|
98
|
+
];
|
|
99
|
+
run("added sheet hidden", { dirty: new Set() });
|
|
100
|
+
viewport = { width: 500, height: 700 };
|
|
101
|
+
run("viewport full");
|
|
102
|
+
f.document.documentElement.style.fontSize = "22px";
|
|
103
|
+
run("root rem self", {
|
|
104
|
+
dirty: new Set(),
|
|
105
|
+
self: new Set([f.document.documentElement]),
|
|
106
|
+
});
|
|
107
|
+
metric = 11;
|
|
108
|
+
run("font environment full");
|
|
109
|
+
assert.equal(lazy.usesFontMetrics, true);
|
|
110
|
+
assertions++;
|
|
111
|
+
el("nested").style.display = "block";
|
|
112
|
+
run("nested reveal under hidden ancestor", {
|
|
113
|
+
dirty: new Set([el("nested")]),
|
|
114
|
+
});
|
|
115
|
+
el("outer").style.display = "block";
|
|
116
|
+
run("outer reveal", { dirty: new Set(), self: new Set([el("outer")]) });
|
|
117
|
+
el("visible").append(el("chain"));
|
|
118
|
+
run("move to visible different inherit", { dirty: new Set([el("chain")]) });
|
|
119
|
+
el("outer").style.display = "none";
|
|
120
|
+
run("hide again", { dirty: new Set([el("outer")]) });
|
|
121
|
+
el("outer").append(el("chain"));
|
|
122
|
+
run("move into hidden", { dirty: new Set([el("chain")]) });
|
|
123
|
+
el("chain").insertAdjacentHTML(
|
|
124
|
+
"beforeend",
|
|
125
|
+
'<em id="new" class="child" style="width:inherit"><strong id="newchild" style="font:inherit">new</strong></em>',
|
|
126
|
+
);
|
|
127
|
+
run("insert new hidden subtree", {
|
|
128
|
+
dirty: new Set([el("new"), el("newchild")]),
|
|
129
|
+
});
|
|
130
|
+
el("visible").append(el("nested"));
|
|
131
|
+
run("move stale former boundary visible", { dirty: new Set([el("nested")]) });
|
|
132
|
+
el("outer").classList.add("active");
|
|
133
|
+
run("hidden has ancestor dirty", { dirty: new Set([el("outer")]) });
|
|
134
|
+
f.document.body.style.display = "none";
|
|
135
|
+
run("body none conservative");
|
|
136
|
+
f.document.documentElement.style.display = "none";
|
|
137
|
+
run("html none conservative");
|
|
138
|
+
// Deterministic mutation stress with legal full/subtree invalidation.
|
|
139
|
+
let seed = 13;
|
|
140
|
+
const rnd = (n) => {
|
|
141
|
+
seed = (seed * 1664525 + 1013904223) >>> 0;
|
|
142
|
+
return seed % n;
|
|
143
|
+
};
|
|
144
|
+
const ids = ["outer", "nested", "chain", "leaf", "grand", "new", "newchild"];
|
|
145
|
+
for (let i = 0; i < 120; i++) {
|
|
146
|
+
const e = el(ids[rnd(ids.length)]),
|
|
147
|
+
op = rnd(6);
|
|
148
|
+
if (op === 0) e.style.display = rnd(2) ? "none" : "block";
|
|
149
|
+
if (op === 1) e.style.color = ["red", "blue", "green", "inherit"][rnd(4)];
|
|
150
|
+
if (op === 2)
|
|
151
|
+
e.style.setProperty("--tone", ["pink", "orange", "teal"][rnd(3)]);
|
|
152
|
+
if (op === 3) e.style.fontSize = 12 + rnd(15) + "px";
|
|
153
|
+
if (op === 4) e.style.width = ["inherit", "3rem", "5ch", "42px"][rnd(4)];
|
|
154
|
+
if (op === 5) e.classList.toggle("active");
|
|
155
|
+
run("stress " + i, { dirty: new Set([op === 5 ? f.document.body : e]) });
|
|
156
|
+
}
|
|
157
|
+
// Query only one hidden chain; siblings must stay uncomputed until requested.
|
|
158
|
+
const select = parseHTMLDocument(
|
|
159
|
+
'<div id="hide" style="display:none">' +
|
|
160
|
+
Array.from(
|
|
161
|
+
{ length: 500 },
|
|
162
|
+
(_, i) => `<span id="x${i}" style="width:1ch"></span>`,
|
|
163
|
+
).join("") +
|
|
164
|
+
"</div>",
|
|
165
|
+
);
|
|
166
|
+
try {
|
|
167
|
+
const limited = new StyleEngine({ deferHidden: true, measureText });
|
|
168
|
+
measurements = 0;
|
|
169
|
+
limited.compute(select.document);
|
|
170
|
+
assert.equal(measurements, 0);
|
|
171
|
+
const count = limited.restyled;
|
|
172
|
+
limited.get(select.document.getElementById("x250"));
|
|
173
|
+
assert.equal(measurements, 1);
|
|
174
|
+
assert.equal(limited.restyled, count);
|
|
175
|
+
limited.get(select.document.getElementById("x250"));
|
|
176
|
+
assert.equal(measurements, 1);
|
|
177
|
+
assertions += 4;
|
|
178
|
+
} finally {
|
|
179
|
+
select.window.happyDOM.abort();
|
|
180
|
+
}
|
|
181
|
+
// A new document/adopted subtree must use only its new context and maps.
|
|
182
|
+
const a = parseHTMLDocument(
|
|
183
|
+
'<div style="display:none;color:red;--x:old"><span id="adopted" style="width:3em"></span></div>',
|
|
184
|
+
);
|
|
185
|
+
const b = parseHTMLDocument(
|
|
186
|
+
'<div id="newhost" style="display:none;color:blue;font-size:25px;--x:new"></div>',
|
|
187
|
+
);
|
|
188
|
+
try {
|
|
189
|
+
const engine = new StyleEngine({ deferHidden: true }).compute(a.document),
|
|
190
|
+
node = a.document.getElementById("adopted");
|
|
191
|
+
const old = engine.get(node);
|
|
192
|
+
b.document.getElementById("newhost").append(node);
|
|
193
|
+
engine.compute(b.document);
|
|
194
|
+
assert.equal(engine.get(node).width, 75);
|
|
195
|
+
assert.equal(engine.get(node).color, "rgba(0, 0, 255, 1)");
|
|
196
|
+
assert.equal(engine.customPropertiesOf(node).get("--x"), "new");
|
|
197
|
+
assert.notEqual(engine.get(node), old);
|
|
198
|
+
assert.equal(engine.get(a.document.documentElement).display, "inline");
|
|
199
|
+
assertions += 5;
|
|
200
|
+
} finally {
|
|
201
|
+
a.window.happyDOM.abort();
|
|
202
|
+
b.window.happyDOM.abort();
|
|
203
|
+
}
|
|
204
|
+
// Lazy metric callbacks may compute another engine; their saved environment
|
|
205
|
+
// must not replace this query's viewport/rem context or survive an exception.
|
|
206
|
+
const envDoc = parseHTMLDocument(
|
|
207
|
+
'<html style="font-size:21px"><body><div style="display:none"><section id="partial" style="font-size:1.2em;width:11vw"><span id="metric" style="width:2ch;height:10vw;margin:2rem"></span></section></div></body></html>',
|
|
208
|
+
);
|
|
209
|
+
const otherDoc = parseHTMLDocument('<div style="width:10vw"></div>');
|
|
210
|
+
try {
|
|
211
|
+
const envEngine = new StyleEngine({
|
|
212
|
+
deferHidden: true,
|
|
213
|
+
measureText() {
|
|
214
|
+
new StyleEngine().compute(otherDoc.document, [], { width: 200 });
|
|
215
|
+
return 9;
|
|
216
|
+
},
|
|
217
|
+
}).compute(envDoc.document, [], { width: 800 });
|
|
218
|
+
const metricNode = envDoc.document.getElementById("metric");
|
|
219
|
+
Object.defineProperty(metricNode, "getAttribute", {
|
|
220
|
+
configurable: true,
|
|
221
|
+
value() {
|
|
222
|
+
throw Error("query failure");
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
let thrown = false;
|
|
226
|
+
try {
|
|
227
|
+
envEngine.get(metricNode);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
thrown = error.message === "query failure";
|
|
230
|
+
}
|
|
231
|
+
assert.equal(thrown, true);
|
|
232
|
+
delete metricNode.getAttribute;
|
|
233
|
+
const initial = envDoc.document.createElement("span");
|
|
234
|
+
initial.style.width = "2rem";
|
|
235
|
+
assert.equal(computeElementStyle(initial).width, 32);
|
|
236
|
+
assert.equal(
|
|
237
|
+
envEngine.get(envDoc.document.getElementById("partial")).width,
|
|
238
|
+
88,
|
|
239
|
+
);
|
|
240
|
+
assertions += 2;
|
|
241
|
+
const computed = envEngine.get(envDoc.document.getElementById("metric"));
|
|
242
|
+
assert.equal(computed.width, 18);
|
|
243
|
+
assert.equal(computed.height, 80);
|
|
244
|
+
assert.deepEqual(computed.margin, [42, 42, 42, 42]);
|
|
245
|
+
assertions += 4;
|
|
246
|
+
} finally {
|
|
247
|
+
envDoc.window.happyDOM.abort();
|
|
248
|
+
otherDoc.window.happyDOM.abort();
|
|
249
|
+
}
|
|
250
|
+
console.log(
|
|
251
|
+
JSON.stringify({
|
|
252
|
+
pass: true,
|
|
253
|
+
states,
|
|
254
|
+
assertions,
|
|
255
|
+
mode: process.argv.includes("--ses") ? "ses" : "plain",
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
} finally {
|
|
259
|
+
f.window.happyDOM.abort();
|
|
260
|
+
}
|