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.
Files changed (133) hide show
  1. package/README.md +9 -3
  2. package/lib/audio/pcm.js +11 -0
  3. package/lib/audio/pulse-client.js +7 -2
  4. package/lib/cdp-server.js +31 -7
  5. package/lib/happy-dom/document-base.js +29 -1
  6. package/lib/happy-dom/mutation-observer.js +157 -0
  7. package/lib/happy-dom/parser.js +2 -0
  8. package/lib/headless-shell.js +49 -9
  9. package/lib/layout/repaint.js +89 -0
  10. package/lib/media/mp4-keyframes.js +144 -0
  11. package/lib/media/video-keyframe.js +29 -1
  12. package/lib/media/webm-snapshots.js +282 -0
  13. package/lib/network/cookie-jar.js +18 -2
  14. package/lib/paint/display-list.js +4 -3
  15. package/lib/paint/frame.js +12 -5
  16. package/lib/render-tree/index.js +3 -0
  17. package/lib/renderer/animation-frames.js +76 -0
  18. package/lib/renderer/bindings.js +2 -0
  19. package/lib/renderer/compositor-channel.js +95 -10
  20. package/lib/renderer/compositor.js +54 -18
  21. package/lib/renderer/document-cookies.js +87 -0
  22. package/lib/renderer/font-loading.js +60 -16
  23. package/lib/renderer/host.js +48 -19
  24. package/lib/renderer/idle-callbacks.js +109 -0
  25. package/lib/renderer/main.js +5 -1
  26. package/lib/renderer/media-source.js +4 -0
  27. package/lib/renderer/page-realm.js +159 -49
  28. package/lib/renderer/page-renderer.js +567 -197
  29. package/lib/renderer/resource-elements.js +88 -0
  30. package/lib/renderer/script-rewrite.js +18 -1
  31. package/lib/renderer/seccomp.js +6 -4
  32. package/lib/renderer/timers.js +71 -0
  33. package/lib/renderer/video-channel.js +94 -0
  34. package/lib/renderer/video-mask.js +115 -0
  35. package/lib/renderer/video-presentation.js +304 -39
  36. package/lib/renderer/video-worker.js +46 -0
  37. package/lib/renderer/workers.js +7 -17
  38. package/lib/style/computed-style.js +257 -61
  39. package/package.json +1 -1
  40. package/test/animation-frames.test.js +67 -0
  41. package/test/canvas-style-snapshot.test.js +18 -0
  42. package/test/cdp-stream.test.js +96 -3
  43. package/test/compositor-channel-ownership.test.js +194 -0
  44. package/test/compositor.test.js +27 -1
  45. package/test/computed-style-binding.test.js +49 -0
  46. package/test/computed-style-connectivity.test.js +13 -0
  47. package/test/computed-style.test.js +86 -0
  48. package/test/cookie-sync.test.js +82 -0
  49. package/test/document-base-cache.test.js +103 -0
  50. package/test/document-cookies.test.js +117 -0
  51. package/test/duplicate-style-keys.test.js +11 -0
  52. package/test/fixtures/animation-frames.js +56 -0
  53. package/test/fixtures/canvas-style-snapshot.js +81 -0
  54. package/test/fixtures/computed-style-connectivity.js +101 -0
  55. package/test/fixtures/computed-style-lazy.js +135 -0
  56. package/test/fixtures/duplicate-style-keys.js +97 -0
  57. package/test/fixtures/fake-renderer.js +12 -0
  58. package/test/fixtures/font-definition-invalidation.js +97 -0
  59. package/test/fixtures/font-metrics-invalidation.js +87 -0
  60. package/test/fixtures/full-style-identity.js +36 -0
  61. package/test/fixtures/hidden-style-engine.js +260 -0
  62. package/test/fixtures/hidden-style-reentry.js +232 -0
  63. package/test/fixtures/hidden-style-renderer.js +203 -0
  64. package/test/fixtures/layout-repaint.js +69 -0
  65. package/test/fixtures/media-presentation-continuation.js +141 -0
  66. package/test/fixtures/media-presentation-refill.js +113 -0
  67. package/test/fixtures/media-probe.js +148 -0
  68. package/test/fixtures/media-refill-cancel.js +79 -0
  69. package/test/fixtures/media-repaint.js +48 -0
  70. package/test/fixtures/media-resource-reselection.js +94 -0
  71. package/test/fixtures/media-source-attachment.js +136 -0
  72. package/test/fixtures/media-source-no-decoder.js +17 -0
  73. package/test/fixtures/media-track-refill.js +132 -0
  74. package/test/fixtures/media-webm-seek.js +51 -0
  75. package/test/fixtures/mutation-observer.js +201 -0
  76. package/test/fixtures/observer-invalidation.js +105 -0
  77. package/test/fixtures/prepaint-scheduling.js +73 -0
  78. package/test/fixtures/resource-discovery.js +32 -0
  79. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  80. package/test/fixtures/style-propagation.js +26 -0
  81. package/test/fixtures/svg-node-reuse.js +83 -0
  82. package/test/fixtures/video-black-replay.js +140 -0
  83. package/test/fixtures/video-controls-invalidation.js +71 -0
  84. package/test/fixtures/viewport-bounds.js +148 -0
  85. package/test/font-loading.test.js +77 -0
  86. package/test/font-metrics-invalidation.test.js +60 -0
  87. package/test/full-style-identity.test.js +191 -0
  88. package/test/hidden-style-lazy.test.js +31 -0
  89. package/test/hidden-style-reentry.test.js +20 -0
  90. package/test/idle-callback.test.js +89 -0
  91. package/test/idle-scheduler.test.js +148 -0
  92. package/test/inline-style-cascade.test.js +63 -0
  93. package/test/layout-repaint-renderer.test.js +9 -0
  94. package/test/layout-repaint.test.js +222 -0
  95. package/test/media-presentation-continuation.test.js +14 -0
  96. package/test/media-presentation-refill.test.js +8 -0
  97. package/test/media-probe.test.js +16 -0
  98. package/test/media-repaint.test.js +8 -0
  99. package/test/media-source-attachment.test.js +34 -0
  100. package/test/media-track-refill.test.js +26 -0
  101. package/test/media-webm-seek.test.js +11 -0
  102. package/test/mp4-keyframes.test.js +36 -0
  103. package/test/mutation-observer.test.js +91 -0
  104. package/test/observer-invalidation.test.js +12 -0
  105. package/test/page-scripts.test.js +35 -0
  106. package/test/page-timers.test.js +67 -0
  107. package/test/paint-frame.test.js +18 -1
  108. package/test/pcm.test.js +63 -0
  109. package/test/prepaint-scheduling.test.js +13 -0
  110. package/test/pulse-client.test.js +165 -0
  111. package/test/raw-capture.test.js +34 -5
  112. package/test/renderer-host.test.js +51 -0
  113. package/test/resource-discovery.test.js +12 -0
  114. package/test/resource-elements.test.js +234 -0
  115. package/test/root-self-style.test.js +104 -0
  116. package/test/script-rewrite.test.js +35 -0
  117. package/test/seccomp.test.js +45 -0
  118. package/test/spare-renderer-scheduling.test.js +8 -0
  119. package/test/style-propagation.test.js +225 -0
  120. package/test/style-sheets.test.js +19 -0
  121. package/test/svg.test.js +45 -0
  122. package/test/timers.test.js +58 -0
  123. package/test/video-black-replay.test.js +34 -0
  124. package/test/video-capture.test.js +170 -0
  125. package/test/video-channel.test.js +253 -0
  126. package/test/video-continuation.test.js +57 -0
  127. package/test/video-controls-invalidation.test.js +12 -0
  128. package/test/video-keyframe.test.js +104 -4
  129. package/test/video-mask.test.js +94 -0
  130. package/test/video-presentation.test.js +239 -1
  131. package/test/video-timing-presentation.test.js +115 -0
  132. package/test/viewport-bounds.test.js +13 -0
  133. package/test/webm-snapshots.test.js +372 -0
@@ -0,0 +1,103 @@
1
+ import { expect, test } from "bun:test";
2
+ import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
3
+
4
+ const URL = "https://base.test/page/index.html";
5
+
6
+ /** Runs `body` while counting base[href] lookups per document. */
7
+ function countBaseQueries(body) {
8
+ const { window, document } = parseHTMLDocument("", URL);
9
+ // Each window has its own Document subclass; spy where querySelector is defined.
10
+ let prototype = Object.getPrototypeOf(document);
11
+ while (!Object.hasOwn(prototype, "querySelector")) prototype = Object.getPrototypeOf(prototype);
12
+ const original = prototype.querySelector;
13
+ const counts = new Map();
14
+ prototype.querySelector = function(selector) {
15
+ if (selector === "base[href]") counts.set(this, (counts.get(this) ?? 0) + 1);
16
+ return original.call(this, selector);
17
+ };
18
+ try {
19
+ body(document => counts.get(document) ?? 0);
20
+ } finally {
21
+ prototype.querySelector = original;
22
+ window.happyDOM.abort();
23
+ }
24
+ }
25
+
26
+ test("parsing many URL attributes does not scan for <base> once per attribute", () => {
27
+ const html = `<!doctype html><body>${'<a href="x">x</a><img src="y">'.repeat(2000)}</body>`;
28
+ countBaseQueries(queries => {
29
+ const parsed = parseHTMLDocument(html, URL);
30
+ expect(parsed.document.querySelectorAll("a[href], img[src]").length).toBe(4000);
31
+ expect(parsed.document.querySelector("a").href).toBe("https://base.test/page/x");
32
+ expect(queries(parsed.document)).toBeLessThan(10);
33
+ parsed.window.happyDOM.abort();
34
+ });
35
+ });
36
+
37
+ test("the cached base follows insertion, attribute changes and removal", () => {
38
+ const { window, document } = parseHTMLDocument(`<!doctype html><head></head><body><a id="link" href="next">x</a></body>`, URL);
39
+ const link = document.getElementById("link");
40
+ expect([document.baseURI, link.href]).toEqual([URL, "https://base.test/page/next"]);
41
+
42
+ const base = document.createElement("base");
43
+ base.setAttribute("href", "/assets/");
44
+ document.head.appendChild(base);
45
+ expect([document.baseURI, link.href]).toEqual(["https://base.test/assets/", "https://base.test/assets/next"]);
46
+
47
+ base.setAttribute("href", "/changed/");
48
+ expect(link.href).toBe("https://base.test/changed/next");
49
+
50
+ // An earlier base[href] takes over; a later removal of it falls back to the first one.
51
+ const earlier = document.createElement("base");
52
+ earlier.setAttribute("href", "/earlier/");
53
+ document.head.insertBefore(earlier, base);
54
+ expect(document.baseURI).toBe("https://base.test/earlier/");
55
+ earlier.remove();
56
+ expect(document.baseURI).toBe("https://base.test/changed/");
57
+
58
+ base.removeAttribute("href");
59
+ expect(document.baseURI).toBe(URL);
60
+ base.setAttribute("href", "/again/");
61
+ expect(document.baseURI).toBe("https://base.test/again/");
62
+
63
+ // Removing an ancestor disconnects the base too.
64
+ document.documentElement.removeChild(document.head);
65
+ expect([document.baseURI, link.href]).toEqual([URL, "https://base.test/page/next"]);
66
+ window.happyDOM.abort();
67
+ });
68
+
69
+ test("each document keeps its own base", () => {
70
+ const first = parseHTMLDocument(`<!doctype html><base href="/one/"><a href="x">x</a>`, URL);
71
+ const second = parseHTMLDocument(`<!doctype html><a href="x">x</a>`, URL);
72
+ expect(first.document.baseURI).toBe("https://base.test/one/");
73
+ expect(second.document.baseURI).toBe(URL);
74
+ expect(first.document.querySelector("a").href).toBe("https://base.test/one/x");
75
+ expect(second.document.querySelector("a").href).toBe("https://base.test/page/x");
76
+ first.window.happyDOM.abort();
77
+ second.window.happyDOM.abort();
78
+ });
79
+
80
+ test("a <base> change invalidates only its own document", () => {
81
+ countBaseQueries(queries => {
82
+ const first = parseHTMLDocument(`<!doctype html><base href="/one/"><a href="x">x</a>`, URL);
83
+ const second = parseHTMLDocument(`<!doctype html><a href="x">x</a>`, URL);
84
+ expect([first.document.baseURI, second.document.baseURI]).toEqual(["https://base.test/one/", URL]);
85
+ const before = queries(second.document);
86
+ first.document.querySelector("base").setAttribute("href", "/two/");
87
+ expect(first.document.baseURI).toBe("https://base.test/two/");
88
+ expect(second.document.baseURI).toBe(URL);
89
+ expect(queries(second.document)).toBe(before);
90
+ first.window.happyDOM.abort();
91
+ second.window.happyDOM.abort();
92
+ });
93
+ });
94
+
95
+ test("an adopted <base> leaves its old document and joins the new one", () => {
96
+ const first = parseHTMLDocument(`<!doctype html><head><base href="/one/"></head>`, URL);
97
+ const second = parseHTMLDocument(`<!doctype html><head></head>`, URL);
98
+ expect([first.document.baseURI, second.document.baseURI]).toEqual(["https://base.test/one/", URL]);
99
+ second.document.head.appendChild(second.document.adoptNode(first.document.querySelector("base")));
100
+ expect([first.document.baseURI, second.document.baseURI]).toEqual([URL, "https://base.test/one/"]);
101
+ first.window.happyDOM.abort();
102
+ second.window.happyDOM.abort();
103
+ });
@@ -0,0 +1,117 @@
1
+ import { expect, test } from "bun:test";
2
+ import { CookieJar } from "../lib/network/cookie-jar.js";
3
+ import { DocumentCookies } from "../lib/renderer/document-cookies.js";
4
+
5
+ const url = "https://page.test/app/index.html";
6
+ const pairs = value => value.split("; ").filter(Boolean).sort();
7
+ function fixture() {
8
+ let now = 100000, revision = 0;
9
+ const sent = [], jar = new CookieJar({ now: () => now });
10
+ const mirror = new DocumentCookies((value, url, sequence) => sent.push({ value, url, sequence }), () => now);
11
+ const snapshot = (acknowledged = 0) => ({ revision: ++revision, acknowledged, cookies: jar.scriptSnapshot(url) });
12
+ return { jar, mirror, sent, snapshot, advance: ms => { now += ms; } };
13
+ }
14
+
15
+ test("a delayed snapshot retains newer setters and unrelated server cookies, then acknowledges them", () => {
16
+ const { jar, mirror, sent, snapshot } = fixture();
17
+ jar.store(url, ["key=before; Path=/"]);
18
+ mirror.accept(snapshot());
19
+ mirror.set(url, "key=after; Path=/");
20
+ jar.store(url, ["server=2; Path=/", "secret=hidden; Path=/; HttpOnly"]);
21
+ const delayed = snapshot();
22
+ expect(mirror.accept(delayed)).toBe(true);
23
+ expect(pairs(mirror.get(url))).toEqual(["key=after", "server=2"]);
24
+ expect(JSON.stringify(delayed)).not.toContain("secret");
25
+ jar.store(sent[0].url, [sent[0].value], { fromScript: true });
26
+ const acknowledged = snapshot(sent[0].sequence);
27
+ mirror.accept(acknowledged);
28
+ expect(pairs(mirror.get(url))).toEqual(pairs(jar.documentCookie(url)));
29
+ expect(mirror.accept(delayed)).toBe(false);
30
+ expect(mirror.accept({ ...delayed, revision: acknowledged.revision + 1 })).toBe(false);
31
+ expect(pairs(mirror.get(url))).toEqual(["key=after", "server=2"]);
32
+ // Once acknowledged, a later server overwrite is authoritative.
33
+ jar.store(url, ["key=server; Path=/"]);
34
+ mirror.accept(snapshot(sent[0].sequence));
35
+ expect(pairs(mirror.get(url))).toEqual(["key=server", "server=2"]);
36
+ });
37
+
38
+ test("pending setters and deletions retain cookie Path, Domain, and Secure rules", () => {
39
+ const { jar, mirror, snapshot } = fixture();
40
+ jar.store(url, ["key=root; Path=/", "key=app; Path=/app", "parent=1; Domain=page.test; Path=/"]);
41
+ mirror.accept(snapshot());
42
+ mirror.set(url, "key=new; Path=/app");
43
+ mirror.set(url, "rejected=1; Domain=other.test");
44
+ mirror.accept(snapshot());
45
+ expect(pairs(mirror.get(url))).toEqual(["key=new", "key=root", "parent=1"]);
46
+ expect(pairs(mirror.get("https://page.test/other"))).toEqual(["key=root", "parent=1"]);
47
+ expect(mirror.get("https://sub.page.test/app/")).toBe("parent=1");
48
+ mirror.set(url, "key=; Path=/app; Max-Age=0");
49
+ jar.store(url, ["unrelated=3; Path=/"]);
50
+ mirror.accept(snapshot());
51
+ expect(pairs(mirror.get(url))).toEqual(["key=root", "parent=1", "unrelated=3"]);
52
+ mirror.set("http://page.test/app/", "secure=1; Secure; Path=/");
53
+ expect(mirror.get(url)).not.toContain("secure=");
54
+ });
55
+
56
+ test("replaying an unacknowledged Max-Age cookie does not restart its lifetime", () => {
57
+ const { mirror, snapshot, advance } = fixture();
58
+ mirror.accept(snapshot());
59
+ mirror.set(url, "short=1; Max-Age=2; Path=/");
60
+ advance(1500);
61
+ mirror.accept(snapshot());
62
+ expect(mirror.get(url)).toBe("short=1");
63
+ advance(600);
64
+ mirror.accept(snapshot());
65
+ expect(mirror.get(url)).toBe("");
66
+ });
67
+
68
+ test("snapshot records exclude HttpOnly and unrelated hosts and cannot share mutable jar records", () => {
69
+ const { jar } = fixture();
70
+ jar.store(url, ["public=1; Path=/", "private=secret; Path=/; HttpOnly", "path=1; Path=/elsewhere"]);
71
+ jar.store("https://other.test/", ["other=secret; Path=/"]);
72
+ const records = jar.scriptSnapshot(url);
73
+ expect(records.map(cookie => cookie.name).sort()).toEqual(["path", "public"]);
74
+ records[0].value = "changed";
75
+ expect(jar.documentCookie(url)).toBe("public=1");
76
+ const mirror = new CookieJar();
77
+ mirror.replaceScriptSnapshot(records);
78
+ records[0].value = "changed again";
79
+ expect(mirror.documentCookie(url)).toBe("public=changed");
80
+ });
81
+
82
+ test("legacy string-only embedders retain replacement semantics", () => {
83
+ const mirror = new DocumentCookies();
84
+ mirror.seed(url, "first=1; second=2");
85
+ expect(pairs(mirror.get(url))).toEqual(["first=1", "second=2"]);
86
+ mirror.seed(url, "second=3", true);
87
+ expect(mirror.get(url)).toBe("second=3");
88
+ mirror.seed(url, "", true);
89
+ expect(mirror.get(url)).toBe("");
90
+ mirror.seed(url, "id=app; id=root; other=1", true);
91
+ expect(mirror.get(url)).toBe("id=app; id=root; other=1");
92
+ mirror.set(url, "other=2; Path=/");
93
+ expect(mirror.get(url)).toBe("id=app; id=root; other=2");
94
+ mirror.set(url, "id=; Max-Age=0");
95
+ expect(mirror.get(url)).toBe("other=2");
96
+ });
97
+
98
+ test("unacknowledged legacy setters do not replay after the snapshot protocol is established", () => {
99
+ const { mirror, snapshot } = fixture();
100
+ for (let i = 0; i < 1000; i++) mirror.set(url, `legacy=${i}; Path=/`);
101
+ expect(mirror.get(url)).toBe("legacy=999");
102
+ mirror.accept(snapshot());
103
+ expect(mirror.get(url)).toBe("");
104
+ });
105
+
106
+ test("an HttpOnly overwrite is rejected by the controller without exposing its value", () => {
107
+ const { jar, mirror, sent, snapshot } = fixture();
108
+ jar.store(url, ["private=hidden; HttpOnly; Path=/", "public=visible; Path=/"]);
109
+ mirror.accept(snapshot());
110
+ mirror.set(url, "private=attempted; Path=/");
111
+ jar.store(sent[0].url, [sent[0].value], { fromScript: true });
112
+ const state = snapshot(sent[0].sequence);
113
+ expect(JSON.stringify(state)).not.toContain("hidden");
114
+ mirror.accept(state);
115
+ expect(mirror.get(url)).toBe("public=visible");
116
+ expect(jar.header(url)).toBe("private=hidden; public=visible");
117
+ });
@@ -0,0 +1,11 @@
1
+ import { expect, test } from 'bun:test';
2
+
3
+ for (const mode of ['plain', 'ses']) {
4
+ test(`duplicate class keys preserve candidate matching and ancestor counts (${mode})`, async () => {
5
+ const child = Bun.spawn([process.execPath, new URL('./fixtures/duplicate-style-keys.js', import.meta.url).pathname, mode],
6
+ { stdout: 'pipe', stderr: 'pipe' });
7
+ const [exit, stdout, stderr] = await Promise.all([child.exited,
8
+ new Response(child.stdout).text(), new Response(child.stderr).text()]);
9
+ expect({ exit, stdout, stderr }).toEqual({ exit: 0, stdout: '', stderr: '' });
10
+ }, 10_000);
11
+ }
@@ -0,0 +1,56 @@
1
+ import assert from "node:assert/strict";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ import { createPageRenderer, loadGraphics } from "../../lib/renderer/page-renderer.js";
4
+ import { supportsFrameCheckpoint } from "../../lib/renderer/animation-frames.js";
5
+ await import("../../vendor/ses/dist/ses.mjs");
6
+ await loadGraphics();
7
+ lockdown({ errorTaming: "safe", overrideTaming: "severe", consoleTaming: "unsafe",
8
+ localeTaming: "unsafe", stackFiltering: "concise", reporting: "none" });
9
+ const errors = [], renderer = await createPageRenderer({ reportError: error => errors.push(String(error)) });
10
+ const load = async path => {
11
+ await renderer.loadDocument({ url: "https://frames.test/" + path, contentType: "text/html", source: "<p>Animation frames</p>" });
12
+ await renderer.whenLoaded();
13
+ };
14
+ try {
15
+ renderer.setScripting("ses"); renderer.resize(320, 220, 1); await load("first");
16
+ const result = await renderer.evaluate(`
17
+ new Promise(resolve => {
18
+ const events=[], times=[]; let cancelled, sameBatch;
19
+ const record=(name,time)=>{events.push(name);if(time!==undefined)times.push([name,time]);};
20
+ addEventListener('error',event=>{if(event.message.includes('RAF_PROBE_THROW'))event.preventDefault();});
21
+ requestAnimationFrame(function first(time){
22
+ record('first',time);record(this===window?'this-window':'this-other');
23
+ requestAnimationFrame(time=>record('nested',time));
24
+ Promise.resolve().then(()=>{
25
+ record('promise-one');requestAnimationFrame(time=>record('nested-microtask',time));
26
+ }).then(()=>{record('promise-two');cancelAnimationFrame(cancelled);});
27
+ queueMicrotask(()=>record('queue-microtask'));
28
+ setTimeout(()=>record('timer'),0);
29
+ const end=performance.now()+5;while(performance.now()<end){}
30
+ });
31
+ cancelled=requestAnimationFrame(time=>record('cancelled-from-microtask',time));
32
+ requestAnimationFrame(time=>{record('third',time);cancelAnimationFrame(sameBatch);});
33
+ sameBatch=requestAnimationFrame(time=>record('cancelled-from-callback',time));
34
+ const early=requestAnimationFrame(()=>record('cancelled-early'));cancelAnimationFrame(early);
35
+ requestAnimationFrame(time=>{record('throws',time);throw new Error('RAF_PROBE_THROW');});
36
+ requestAnimationFrame(time=>{record('after-throw',time);requestAnimationFrame(time=>{
37
+ record('finish',time);resolve({events,times,groups:new Set(times.map(entry=>entry[1])).size});
38
+ });});
39
+ })
40
+ `);
41
+ assert.deepEqual(result.events, ["first", "this-window", "promise-one", "queue-microtask", "promise-two", "third", "throws", "after-throw", "timer", "nested", "nested-microtask", "finish"]);
42
+ if (supportsFrameCheckpoint()) {
43
+ assert.equal(result.groups, 2);
44
+ assert.ok(result.times.slice(0, 4).every(entry => entry[1] === result.times[0][1]));
45
+ assert.ok(result.times.slice(4).every(entry => entry[1] === result.times[4][1]));
46
+ assert.ok(result.times[4][1] > result.times[0][1]);
47
+ }
48
+ assert.equal(errors.length, 1); assert.ok(errors[0].includes("RAF_PROBE_THROW")); errors.length = 0;
49
+ await renderer.evaluate(`requestAnimationFrame(() => { throw new Error("old frame"); })`);
50
+ await load("second");
51
+ assert.equal(await renderer.evaluate(`new Promise(resolve => requestAnimationFrame(time => resolve(typeof time)))`), "number");
52
+ await delay(25); assert.deepEqual(errors, []);
53
+ await renderer.evaluate(`requestAnimationFrame(() => { throw new Error("disposed frame"); })`);
54
+ renderer.dispose(); await delay(25); assert.deepEqual(errors, []);
55
+ console.log("animation frame ordering and lifecycle passed");
56
+ } finally { renderer.dispose(); }
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ const root = new URL("../../", import.meta.url).pathname.replace(/\/$/, "");
3
+ const { createPageRenderer } = await import(
4
+ root + "/lib/renderer/page-renderer.js"
5
+ );
6
+ const { LayoutEngine } = await import(root + "/lib/layout/text-layout.js");
7
+ const { StyleEngine } = await import(root + "/lib/style/computed-style.js");
8
+ let layouts = 0,
9
+ computes = 0;
10
+ const layout = LayoutEngine.prototype.layout,
11
+ compute = StyleEngine.prototype.compute;
12
+ LayoutEngine.prototype.layout = function (...args) {
13
+ layouts++;
14
+ return layout.apply(this, args);
15
+ };
16
+ StyleEngine.prototype.compute = function (...args) {
17
+ computes++;
18
+ return compute.apply(this, args);
19
+ };
20
+ const renderer = await createPageRenderer({});
21
+ await import(root + "/vendor/ses/dist/ses.mjs");
22
+ lockdown({
23
+ errorTaming: "safe",
24
+ overrideTaming: "severe",
25
+ consoleTaming: "unsafe",
26
+ localeTaming: "unsafe",
27
+ stackFiltering: "concise",
28
+ reporting: "none",
29
+ });
30
+ try {
31
+ renderer.setScripting("ses");
32
+ await renderer.loadDocument({
33
+ url: "https://canvas.test/",
34
+ contentType: "text/html",
35
+ source:
36
+ '<div id="hidden" style="display:none;font-size:20px"><canvas id="c" width="100" height="50" style="font-size:24px"></canvas></div><canvas id="v" width="100" height="50"></canvas>',
37
+ });
38
+ await renderer.whenLoaded();
39
+ await renderer.evaluate(
40
+ `window.c=document.getElementById('c');window.ctx=c.getContext('2d');window.v=document.getElementById('v');window.vctx=v.getContext('2d')`,
41
+ );
42
+ const measure = await renderer.evaluate(
43
+ `c.style.fontSize='40px';ctx.font='1em sans-serif';ctx.measureText('MMMM').width`,
44
+ );
45
+ const eagerCurrent = await renderer.evaluate(
46
+ `ctx.font='40px sans-serif';ctx.measureText('MMMM').width`,
47
+ );
48
+ assert.equal(measure, eagerCurrent);
49
+ const before = { layouts, computes };
50
+ // Count renderer style/layout work around a batch of canvas operations.
51
+ const result = await renderer.evaluate(
52
+ `(() => {c.style.fontSize='20px';ctx.font='2em sans-serif';const width=ctx.measureText('MMMM').width;v.style.direction='rtl';vctx.direction='inherit';vctx.font='12px sans-serif';vctx.fillText('abc',50,20);for(let i=0;i<30;i++){ctx.measureText('MMMM');vctx.fillText('abc',50,20)}return width;})()`,
53
+ );
54
+ assert.equal(result, eagerCurrent);
55
+ const after = { layouts, computes };
56
+ // evaluate's trailing refresh may produce one layout, never one per draw.
57
+ assert.ok(after.layouts - before.layouts <= 1);
58
+ assert.ok(after.computes - before.computes <= 3);
59
+ const clean = { layouts, computes };
60
+ await renderer.evaluate(
61
+ `for(let i=0;i<30;i++){ctx.measureText('MMMM');vctx.fillText('abc',50,20)}`,
62
+ );
63
+ assert.equal(layouts, clean.layouts);
64
+ assert.equal(computes, clean.computes);
65
+ console.log(
66
+ JSON.stringify({
67
+ pass: true,
68
+ firstPendingMutationMatchesExplicit40px: true,
69
+ before,
70
+ after,
71
+ cleanDraws: {
72
+ layouts: layouts - clean.layouts,
73
+ computes: computes - clean.computes,
74
+ },
75
+ }),
76
+ );
77
+ } finally {
78
+ renderer.dispose();
79
+ LayoutEngine.prototype.layout = layout;
80
+ StyleEngine.prototype.compute = compute;
81
+ }
@@ -0,0 +1,101 @@
1
+ import assert from "node:assert/strict";
2
+ import { LayoutEngine } from "../../lib/layout/text-layout.js";
3
+ import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
4
+
5
+ const mode = process.argv[2] ?? "ses";
6
+ let layouts = 0;
7
+ const layout = LayoutEngine.prototype.layout;
8
+ LayoutEngine.prototype.layout = function (...args) {
9
+ layouts++;
10
+ return layout.apply(this, args);
11
+ };
12
+ const errors = [];
13
+ const renderer = await createPageRenderer({ reportError: error => errors.push(String(error)) });
14
+ if (mode === "ses") {
15
+ await import("../../vendor/ses/dist/ses.mjs");
16
+ lockdown({ errorTaming: "safe", overrideTaming: "severe", consoleTaming: "unsafe",
17
+ localeTaming: "unsafe", stackFiltering: "concise", reporting: "none" });
18
+ }
19
+ try {
20
+ renderer.setScripting(mode);
21
+ await renderer.loadDocument({ url: "https://styles.test/", contentType: "text/html",
22
+ source: "<!doctype html><style>#accessor{-webkit-line-clamp:2}</style><body></body>" });
23
+ await renderer.whenLoaded();
24
+
25
+ // This must be the first CSSOM declaration in a fresh process: accessors
26
+ // cannot depend on a connected declaration having initialized their schema.
27
+ const detached = await renderer.evaluate(`(() => {
28
+ window.node=document.createElement('div');node.id='accessor';
29
+ node.style.cssText='width:77px;height:19px;color:rgb(12,34,56);--tone:first';
30
+ window.savedStyle=getComputedStyle(node);
31
+ window.readStyle=s=>[s.color,s.width,s.getPropertyValue('--tone'),s.length===0,s.item(0)===''];
32
+ window.savedKeys=Object.keys(savedStyle);
33
+ return {values:readStyle(savedStyle),accessors:['color','width','WebkitLineClamp','webkitLineClamp'].every(key=>key in savedStyle),
34
+ aliases:[savedStyle.WebkitLineClamp,savedStyle.webkitLineClamp],absent:savedStyle.getPropertyValue('unknown-property')};
35
+ })()`);
36
+ assert.deepEqual(detached, { values: ["", "", "", true, true], accessors: true,
37
+ aliases: ["", ""], absent: "" });
38
+
39
+ const connected = await renderer.evaluate(`(() => {
40
+ document.body.appendChild(node);
41
+ window.normalNames=Array.from({length:savedStyle.length},(_,i)=>savedStyle.item(i));
42
+ return {values:readStyle(savedStyle),names:normalNames.includes('color')&&normalNames.includes('width'),
43
+ aliases:[savedStyle.WebkitLineClamp,savedStyle.webkitLineClamp],keysStable:savedKeys.join('|')===Object.keys(savedStyle).join('|')};
44
+ })()`);
45
+ assert.deepEqual(connected, { values: ["rgba(12, 34, 56, 1)", "77px", "first", false, false],
46
+ names: true, aliases: ["2", "2"], keysStable: true });
47
+
48
+ const changes = await renderer.evaluate(`(() => {
49
+ node.style.setProperty('--tone','second');node.style.setProperty('--later','new');
50
+ const customChanged=readStyle(savedStyle);
51
+ const fixedNames=normalNames.join('|')===Array.from({length:savedStyle.length},(_,i)=>savedStyle.item(i)).join('|');
52
+ node.remove();const removed=readStyle(savedStyle),fresh=readStyle(getComputedStyle(node));
53
+ node.style.width='88px';node.style.color='rgb(78,90,12)';
54
+ const changedDetached=readStyle(savedStyle);
55
+ document.body.appendChild(node);const reattached=readStyle(savedStyle);
56
+ return {customChanged,fixedNames,removed,fresh,changedDetached,reattached,
57
+ keysStable:savedKeys.join('|')===Object.keys(savedStyle).join('|')};
58
+ })()`);
59
+ assert.deepEqual(changes, {
60
+ customChanged: ["rgba(12, 34, 56, 1)", "77px", "second", false, false], fixedNames: true,
61
+ removed: ["", "", "", true, true], fresh: ["", "", "", true, true],
62
+ changedDetached: ["", "", "", true, true],
63
+ reattached: ["rgba(78, 90, 12, 1)", "88px", "second", false, false], keysStable: true,
64
+ });
65
+
66
+ const hidden = await renderer.evaluate(`(() => {
67
+ const parent=document.createElement('div');parent.style.cssText='display:none;color:rgb(99,88,77);--tone:inherited';
68
+ const child=document.createElement('div');child.style.width='3em';parent.appendChild(child);document.body.appendChild(parent);
69
+ const style=getComputedStyle(child),connected=readStyle(style);
70
+ parent.remove();const detached=readStyle(style);document.body.appendChild(parent);
71
+ return {connected,detached,reattached:readStyle(style)};
72
+ })()`);
73
+ assert.deepEqual(hidden, {
74
+ connected: ["rgba(99, 88, 77, 1)", "48px", "inherited", false, false],
75
+ detached: ["", "", "", true, true],
76
+ reattached: ["rgba(99, 88, 77, 1)", "48px", "inherited", false, false],
77
+ });
78
+
79
+ // A descendant never queried while connected must also avoid stale style
80
+ // retained by an earlier normal style pass after its ancestor is removed.
81
+ await renderer.evaluate(`document.body.insertAdjacentHTML('beforeend',
82
+ '<style>#hidden{display:none;color:red;--tone:old}#hidden .child{width:3em;background:green}</style>'+
83
+ '<div id="hidden"><span class="child" id="child">Hidden</span></div><div id="visible"></div>')`);
84
+ await renderer.screenshot({ format: "rgba" });
85
+ assert.deepEqual(await renderer.evaluate(`(() => {
86
+ const child=document.getElementById('child');child.remove();
87
+ document.getElementById('visible').style.width='19px';const style=getComputedStyle(child);
88
+ return [style.color,style.width,style.backgroundColor,style.getPropertyValue('--tone'),style.length,style.item(0)];
89
+ })()`), ["", "", "", "", 0, ""]);
90
+
91
+ await renderer.evaluate(`window.detached=document.createElement('div');window.ds=getComputedStyle(detached);
92
+ document.body.style.padding='7px'`);
93
+ const before = layouts;
94
+ assert.deepEqual(await renderer.evaluate(`[ds.color,ds.width,ds.length,ds.item(0),ds.getPropertyValue('--tone')]`),
95
+ ["", "", 0, "", ""]);
96
+ assert.equal(layouts, before, "detached reads must not flush unrelated layout");
97
+ assert.deepEqual(errors, []);
98
+ } finally {
99
+ renderer.dispose();
100
+ LayoutEngine.prototype.layout = layout;
101
+ }
@@ -0,0 +1,135 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { LayoutEngine } from "../../lib/layout/text-layout.js";
3
+ import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
4
+ import { StyleEngine } from "../../lib/style/computed-style.js";
5
+
6
+ // Observe expensive field serialization through the actual CSSOM path. The
7
+ // immutable style and all returned values remain unchanged by this proxy.
8
+ let observeSerialization = false, serializations = 0;
9
+ const computed = StyleEngine.prototype.get, observedStyles = new WeakMap();
10
+ StyleEngine.prototype.get = function (element) {
11
+ const style = computed.call(this, element);
12
+ if (!observeSerialization || element?.id !== "box") return style;
13
+ let observed = observedStyles.get(style);
14
+ if (!observed) observedStyles.set(style, observed = new Proxy(style, {
15
+ get(target, property, receiver) {
16
+ if (property === "fontFamily") serializations++;
17
+ return Reflect.get(target, property, receiver);
18
+ },
19
+ }));
20
+ return observed;
21
+ };
22
+
23
+ let layouts = 0;
24
+ const layout = LayoutEngine.prototype.layout;
25
+ LayoutEngine.prototype.layout = function (...args) { layouts++; return layout.apply(this, args); };
26
+ let releaseImage;
27
+ const imageReady = new Promise(resolve => { releaseImage = resolve; });
28
+ const redImage = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAAA2iEnWAAAAEElEQVR4nGP4z8AARAwoFABE0AX7pM/egAAAAABJRU5ErkJggg==", "base64");
29
+ const renderer = await createPageRenderer({ fetchResource: async url => {
30
+ await imageReady;
31
+ return { ok:true,status:200,url,contentType:"image/png",body:redImage };
32
+ } });
33
+ try {
34
+ renderer.setScripting("host");
35
+ await renderer.resize(400, 300, 1);
36
+ await renderer.loadDocument({ url: "https://page.test/", contentType: "text/html", status: 200,
37
+ source: `<!doctype html><style>body{margin:0}#box{width:50%;height:40px;color:red;--tone:red}
38
+ #child{width:50%;height:20px;padding:5%}</style><div id="box"><div id="child">Text</div></div>` });
39
+ await renderer.whenLoaded();
40
+ globalThis.__layoutCalls = () => layouts;
41
+ globalThis.__serializationCalls = () => serializations;
42
+ observeSerialization = true;
43
+ const repeated = await renderer.evaluate(`(() => {
44
+ const element=document.getElementById('box'),styles=[];
45
+ for(let i=0;i<40;i++) styles.push(getComputedStyle(element));
46
+ const values=styles.map(style=>[style.color,style.getPropertyValue('--tone')]);
47
+ const first=__serializationCalls();
48
+ element.style.setProperty('--tone','changed');
49
+ const custom=styles[0].getPropertyValue('--tone'),second=__serializationCalls();
50
+ const keys=Object.keys(styles[0]);
51
+ styles[0].getPropertyValue=()=> 'local override';
52
+ const independent=styles[1].getPropertyValue('--tone');
53
+ return {first,second,custom,independent,distinct:styles[0]!==styles[1],
54
+ keys:keys.includes('fontFamily')&&keys.includes('color'),
55
+ values:values.every(value=>value[0]==='rgba(255, 0, 0, 1)'&&value[1]==='red')};
56
+ })()`);
57
+ assert.deepEqual(repeated,{first:1,second:2,custom:'changed',independent:'changed',distinct:true,keys:true,values:true});
58
+ observeSerialization = false;
59
+ const styles = await renderer.evaluate(`(() => {
60
+ const box=document.getElementById('box'),style=getComputedStyle(box),start=__layoutCalls();
61
+ window.savedStyle=style;
62
+ const values=[];
63
+ for(let i=0;i<20;i++) {
64
+ box.style.width=(100+i)+'px';box.style.color=i%2?'blue':'red';box.style.setProperty('--tone',String(i));
65
+ values.push([style.color,style.display,style.getPropertyValue('--tone')]);
66
+ }
67
+ box.style.display='none';values.push([style.display]);box.style.display='block';
68
+ return {layouts:__layoutCalls()-start,first:values[0],last:values[19],hidden:values[20]};
69
+ })()`);
70
+ assert.deepEqual(styles, { layouts:0,first:["rgba(255, 0, 0, 1)","block","0"],
71
+ last:["rgba(0, 0, 255, 1)","block","19"],hidden:["none"] });
72
+ const padding = await renderer.evaluate(`(() => {
73
+ const box=document.getElementById('box'),child=document.getElementById('child'),start=__layoutCalls();
74
+ box.style.width='310px';box.style.padding='2px 3px 4px 5px';
75
+ const fixed=[savedStyle.padding,savedStyle.paddingTop,savedStyle.getPropertyValue('padding-right'),savedStyle.paddingBottom,savedStyle.paddingLeft];
76
+ box.style.paddingLeft='7px';fixed.push(savedStyle.paddingLeft);
77
+ const before=__layoutCalls()-start;
78
+ const percent=getComputedStyle(child).paddingLeft;
79
+ box.style.padding='0px';
80
+ return {fixed,before,percent,after:__layoutCalls()-start};
81
+ })()`);
82
+ assert.deepEqual(padding, { fixed:["2px 3px 4px 5px","2px","3px","4px","5px","7px"],
83
+ before:0,percent:"15.5px",after:1 });
84
+ const geometry = await renderer.evaluate(`(() => {
85
+ const box=document.getElementById('box'),child=document.getElementById('child'),start=__layoutCalls();
86
+ box.style.width='240px';box.style.height='60px';savedStyle.color;
87
+ const before=__layoutCalls()-start;
88
+ const result=[savedStyle.width,savedStyle.height,savedStyle.getPropertyValue('WIDTH'),getComputedStyle(child).paddingLeft];
89
+ return {before,after:__layoutCalls()-start,result};
90
+ })()`);
91
+ assert.deepEqual(geometry, { before:0,after:1,result:["240px","60px","240px","12px"] });
92
+ // A style-only read cannot hide later structure, stylesheet or viewport changes.
93
+ await renderer.evaluate(`document.getElementById('box').style.width='50%'; savedStyle.color`);
94
+ await renderer.resize(600, 300, 1.75);
95
+ assert.equal(await renderer.evaluate("savedStyle.width"), "300px");
96
+ assert.equal(await renderer.evaluate(`(() => {
97
+ const sheet=document.createElement('style');sheet.textContent='#box{width:180px!important;color:green!important}';document.head.appendChild(sheet);
98
+ return [savedStyle.color,savedStyle.width].join('/');
99
+ })()`), "rgba(0, 128, 0, 1)/180px");
100
+ assert.equal(await renderer.evaluate(`(() => {
101
+ const box=document.getElementById('box');box.style.height='auto';box.replaceChildren();
102
+ const child=document.createElement('div');child.style.height='70px';box.appendChild(child);savedStyle.display;
103
+ return savedStyle.height;
104
+ })()`), "70px");
105
+ assert.deepEqual(await renderer.evaluate(`(() => {
106
+ const node=document.createElement('div'),style=getComputedStyle(node),keys=Object.keys(style);
107
+ node.style.cssText='width:77px;height:19px;color:red';document.body.appendChild(node);
108
+ const connected=[style.width,style.height,style.color];node.remove();node.style.width='88px';
109
+ document.body.appendChild(node);const reconnected=[style.width,style.height,style.color];
110
+ return [connected,reconnected,keys.length===Object.keys(style).length];
111
+ })()`), [["77px","19px","rgba(255, 0, 0, 1)"],["88px","19px","rgba(255, 0, 0, 1)"],true]);
112
+ await renderer.evaluate("document.body.style.backgroundColor='rgb(11,22,33)';savedStyle.color");
113
+ const painted = await renderer.screenshot({format:"rgba"});
114
+ assert.deepEqual([...painted.pixels.slice(-4)], [11,22,33,255]);
115
+ assert.deepEqual(await renderer.evaluate(`(() => {
116
+ const image=document.createElement('img');image.id='delayed';image.src='/delayed.png';document.body.appendChild(image);
117
+ const start=__layoutCalls();window.imageStyle=getComputedStyle(image);
118
+ return [imageStyle.display,__layoutCalls()-start];
119
+ })()`), ["inline",0]);
120
+ releaseImage();
121
+ const deadline = Date.now() + 3000;
122
+ while (!(await renderer.evaluate("document.getElementById('delayed').complete"))) {
123
+ assert.ok(Date.now() < deadline,"delayed image should finish loading");
124
+ await Bun.sleep(5);
125
+ }
126
+ assert.deepEqual(await renderer.evaluate("[imageStyle.width,imageStyle.height]"),["2px","3px"]);
127
+ await renderer.evaluate("document.getElementById('delayed').remove();savedStyle.color");
128
+ assert.deepEqual([...(await renderer.screenshot({format:"rgba"})).pixels.slice(-4)],[11,22,33,255]);
129
+ } finally {
130
+ renderer.dispose();
131
+ delete globalThis.__layoutCalls;
132
+ delete globalThis.__serializationCalls;
133
+ StyleEngine.prototype.get = computed;
134
+ LayoutEngine.prototype.layout = layout;
135
+ }