buninu-browser 0.5.0 → 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 (146) 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/inert-link.js +26 -0
  7. package/lib/happy-dom/mutation-observer.js +157 -0
  8. package/lib/happy-dom/node-iterator-removal.js +36 -0
  9. package/lib/happy-dom/node-replacement.js +60 -0
  10. package/lib/happy-dom/parser.js +6 -1
  11. package/lib/headless-shell.js +49 -9
  12. package/lib/layout/repaint.js +89 -0
  13. package/lib/layout/text-layout.js +4 -2
  14. package/lib/media/mp4-keyframes.js +144 -0
  15. package/lib/media/video-keyframe.js +29 -1
  16. package/lib/media/webm-snapshots.js +282 -0
  17. package/lib/network/cookie-jar.js +18 -2
  18. package/lib/paint/display-list.js +4 -3
  19. package/lib/paint/frame.js +12 -5
  20. package/lib/render-tree/index.js +3 -0
  21. package/lib/renderer/animation-frames.js +76 -0
  22. package/lib/renderer/bindings.js +107 -3
  23. package/lib/renderer/compositor-channel.js +95 -10
  24. package/lib/renderer/compositor.js +54 -18
  25. package/lib/renderer/document-cookies.js +87 -0
  26. package/lib/renderer/font-loading.js +60 -16
  27. package/lib/renderer/host.js +48 -19
  28. package/lib/renderer/idle-callbacks.js +109 -0
  29. package/lib/renderer/main.js +5 -1
  30. package/lib/renderer/media-source.js +4 -0
  31. package/lib/renderer/page-realm.js +159 -49
  32. package/lib/renderer/page-renderer.js +577 -201
  33. package/lib/renderer/resource-elements.js +88 -0
  34. package/lib/renderer/script-rewrite.js +18 -1
  35. package/lib/renderer/seccomp.js +6 -4
  36. package/lib/renderer/timers.js +71 -0
  37. package/lib/renderer/video-channel.js +94 -0
  38. package/lib/renderer/video-mask.js +115 -0
  39. package/lib/renderer/video-presentation.js +304 -39
  40. package/lib/renderer/video-worker.js +46 -0
  41. package/lib/renderer/workers.js +7 -17
  42. package/lib/style/computed-style.js +257 -61
  43. package/package.json +1 -1
  44. package/test/animation-frames.test.js +67 -0
  45. package/test/canvas-style-snapshot.test.js +18 -0
  46. package/test/cdp-stream.test.js +96 -3
  47. package/test/compare-node-iterator.js +42 -0
  48. package/test/compositor-channel-ownership.test.js +194 -0
  49. package/test/compositor.test.js +27 -1
  50. package/test/computed-style-binding.test.js +49 -0
  51. package/test/computed-style-connectivity.test.js +13 -0
  52. package/test/computed-style.test.js +86 -0
  53. package/test/cookie-sync.test.js +82 -0
  54. package/test/document-base-cache.test.js +103 -0
  55. package/test/document-cookies.test.js +117 -0
  56. package/test/duplicate-style-keys.test.js +11 -0
  57. package/test/fixtures/animation-frames.js +56 -0
  58. package/test/fixtures/canvas-style-snapshot.js +81 -0
  59. package/test/fixtures/computed-style-connectivity.js +101 -0
  60. package/test/fixtures/computed-style-lazy.js +135 -0
  61. package/test/fixtures/duplicate-style-keys.js +97 -0
  62. package/test/fixtures/fake-renderer.js +12 -0
  63. package/test/fixtures/font-definition-invalidation.js +97 -0
  64. package/test/fixtures/font-metrics-invalidation.js +87 -0
  65. package/test/fixtures/fractional-text-scale.js +38 -0
  66. package/test/fixtures/full-style-identity.js +36 -0
  67. package/test/fixtures/hidden-style-engine.js +260 -0
  68. package/test/fixtures/hidden-style-reentry.js +232 -0
  69. package/test/fixtures/hidden-style-renderer.js +203 -0
  70. package/test/fixtures/layout-repaint.js +69 -0
  71. package/test/fixtures/media-presentation-continuation.js +141 -0
  72. package/test/fixtures/media-presentation-refill.js +113 -0
  73. package/test/fixtures/media-probe.js +148 -0
  74. package/test/fixtures/media-refill-cancel.js +79 -0
  75. package/test/fixtures/media-repaint.js +48 -0
  76. package/test/fixtures/media-resource-reselection.js +94 -0
  77. package/test/fixtures/media-source-attachment.js +136 -0
  78. package/test/fixtures/media-source-no-decoder.js +17 -0
  79. package/test/fixtures/media-track-refill.js +132 -0
  80. package/test/fixtures/media-webm-seek.js +51 -0
  81. package/test/fixtures/mutation-observer.js +201 -0
  82. package/test/fixtures/node-iterator-cases.js +59 -0
  83. package/test/fixtures/node-iterator-chromium.json +315 -0
  84. package/test/fixtures/observer-invalidation.js +105 -0
  85. package/test/fixtures/prepaint-scheduling.js +73 -0
  86. package/test/fixtures/resource-discovery.js +32 -0
  87. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  88. package/test/fixtures/style-propagation.js +26 -0
  89. package/test/fixtures/svg-node-reuse.js +83 -0
  90. package/test/fixtures/video-black-replay.js +140 -0
  91. package/test/fixtures/video-controls-invalidation.js +71 -0
  92. package/test/fixtures/viewport-bounds.js +148 -0
  93. package/test/font-loading.test.js +77 -0
  94. package/test/font-metrics-invalidation.test.js +60 -0
  95. package/test/fractional-text-scale.test.js +6 -0
  96. package/test/full-style-identity.test.js +191 -0
  97. package/test/hidden-style-lazy.test.js +31 -0
  98. package/test/hidden-style-reentry.test.js +20 -0
  99. package/test/idle-callback.test.js +89 -0
  100. package/test/idle-scheduler.test.js +148 -0
  101. package/test/inert-link.test.js +37 -0
  102. package/test/inline-style-cascade.test.js +63 -0
  103. package/test/layout-repaint-renderer.test.js +9 -0
  104. package/test/layout-repaint.test.js +222 -0
  105. package/test/media-presentation-continuation.test.js +14 -0
  106. package/test/media-presentation-refill.test.js +8 -0
  107. package/test/media-probe.test.js +16 -0
  108. package/test/media-repaint.test.js +8 -0
  109. package/test/media-source-attachment.test.js +34 -0
  110. package/test/media-track-refill.test.js +26 -0
  111. package/test/media-webm-seek.test.js +11 -0
  112. package/test/mp4-keyframes.test.js +36 -0
  113. package/test/mutation-observer.test.js +91 -0
  114. package/test/node-iterator.test.js +81 -0
  115. package/test/observer-invalidation.test.js +12 -0
  116. package/test/page-scripts.test.js +151 -1
  117. package/test/page-timers.test.js +67 -0
  118. package/test/paint-frame.test.js +18 -1
  119. package/test/pcm.test.js +63 -0
  120. package/test/prepaint-scheduling.test.js +13 -0
  121. package/test/pulse-client.test.js +165 -0
  122. package/test/raw-capture.test.js +34 -5
  123. package/test/renderer-host.test.js +51 -0
  124. package/test/resource-discovery.test.js +12 -0
  125. package/test/resource-elements.test.js +234 -0
  126. package/test/root-self-style.test.js +104 -0
  127. package/test/script-rewrite.test.js +35 -0
  128. package/test/seccomp.test.js +45 -0
  129. package/test/spare-renderer-scheduling.test.js +8 -0
  130. package/test/style-propagation.test.js +225 -0
  131. package/test/style-sheets.test.js +19 -0
  132. package/test/svg.test.js +45 -0
  133. package/test/text-layout.test.js +15 -0
  134. package/test/timers.test.js +58 -0
  135. package/test/video-black-replay.test.js +34 -0
  136. package/test/video-capture.test.js +170 -0
  137. package/test/video-channel.test.js +253 -0
  138. package/test/video-continuation.test.js +57 -0
  139. package/test/video-controls-invalidation.test.js +12 -0
  140. package/test/video-keyframe.test.js +104 -4
  141. package/test/video-mask.test.js +94 -0
  142. package/test/video-presentation.test.js +239 -1
  143. package/test/video-timing-presentation.test.js +115 -0
  144. package/test/viewport-bounds.test.js +13 -0
  145. package/test/webm-snapshots.test.js +372 -0
  146. package/test/window-resize.test.js +29 -0
@@ -1,6 +1,7 @@
1
- import {test,expect} from 'bun:test';
1
+ import {test,expect,spyOn} from 'bun:test';
2
2
  import {readFileSync,existsSync,unlinkSync} from 'node:fs';
3
3
  import {dirname} from 'node:path';
4
+ import {Server} from 'node:net';
4
5
  import {RendererHost} from '../lib/renderer/host.js';
5
6
  import {PacketReader,encodePacket,listenCompositor,connectCompositor} from '../lib/renderer/compositor-channel.js';
6
7
  import {RawFrames} from '../lib/paint/raw-frames.js';
@@ -85,17 +86,45 @@ test('normal CDP routes raw requests to the controller without changing standard
85
86
  expect([crop.readUInt32BE(16),crop.readUInt32BE(20)]).toEqual([7,4]);
86
87
  await cdp.send('Page.screencastFrameAck',{sessionId:event.params.sessionId});
87
88
  await cdp.send('Page.stopScreencast');
89
+ cdp.events.length=0;
90
+ const zero={x:0,y:0,width:0,height:0,scale:1};
91
+ await cdp.send('Page.startScreencast',{format:'png',clip:zero});
92
+ let notice;for(let i=0;i<100;i++){notice=cdp.events.find(e=>e.method==='Page.screencastFrame');if(notice)break;await Bun.sleep(20);}
93
+ expect(notice).toBeDefined();
94
+ const placeholder=Buffer.from(notice.params.data,'base64');
95
+ expect([placeholder.readUInt32BE(16),placeholder.readUInt32BE(20)]).toEqual([1,1]);
96
+ await cdp.send('Page.stopScreencast');
97
+ await expect(cdp.send('Page.captureScreenshot',{format:'png',clip:zero})).rejects.toThrow('Invalid screenshot clip');
88
98
  const png=await cdp.send('Page.captureScreenshot',{format:'png'});
89
99
  expect([...Buffer.from(png.data,'base64').subarray(0,4)]).toEqual([137,80,78,71]);
90
100
  }finally{cdp.close();server.stop(true);browser.close();}
91
101
  expect(existsSync(directory)).toBe(false);
92
102
  },20000);
93
103
 
94
- test('closing during listener startup rejects promptly and a new start remains usable',async()=>{
95
- const host=new RendererHost();
96
- const pending=host.start();host.close();
104
+ test('closing during listener startup releases the listener and only a new host remains usable',async()=>{
105
+ const host=new RendererHost(),listeners=[],closed=[];
106
+ const original=Server.prototype.listen;
107
+ const listening=spyOn(Server.prototype,'listen').mockImplementation(function(...args){
108
+ listeners.push(this);closed.push(new Promise(resolve=>this.once('close',resolve)));
109
+ return original.apply(this,args);
110
+ });
111
+ let pending;
112
+ try{pending=host.start();host.close();}finally{listening.mockRestore();}
97
113
  await expect(pending).rejects.toThrow('closed');
98
- try{await host.start();expect(host.pid).toBeGreaterThan(0);}finally{host.close();}
114
+ expect(listeners).toHaveLength(1);
115
+ // Cancellation rejects before listen() resolves. Its later continuation
116
+ // must close that newly-created listener instead of leaving a server alive.
117
+ await Promise.all(closed);
118
+ expect(listeners[0].listening).toBe(false);
119
+ await expect(host.start()).rejects.toThrow('Renderer is closed');
120
+ expect(host.pid).toBeNull();
121
+ const fresh=new RendererHost();
122
+ try{
123
+ await fresh.start();expect(fresh.pid).toBeGreaterThan(0);
124
+ await fresh.call('loadDocument',{url:'about:blank',source:'<body>fresh host</body>',contentType:'text/html'});
125
+ const png=await fresh.composite('screenshot',{format:'png'});
126
+ expect([...Buffer.from(png,'base64').subarray(0,4)]).toEqual([137,80,78,71]);
127
+ }finally{fresh.close();}
99
128
  },15000);
100
129
 
101
130
  for(const compositor of [true,false])test(`fractional CDP clips preserve exact physical dimensions and raw pixels (compositor=${compositor})`,async()=>{
@@ -24,6 +24,57 @@ test("runs calls in a separate process with an empty environment", async () => {
24
24
  expect(result.argument.hello).toEqual(new Uint8Array([1, 2]));
25
25
  });
26
26
 
27
+ test("slow startup outlives the method deadline without replacing the child", async () => {
28
+ const renderer = host({ timeout: 20, args: ['--ready-delay=200'] });
29
+ const ready = renderer.start();
30
+ const pid = renderer.pid;
31
+ await ready;
32
+ expect(renderer.pid).toBe(pid);
33
+ expect(renderer.pid).toBeGreaterThan(0);
34
+ });
35
+
36
+ test("disabled watchdog lets slow methods finish and still permits explicit close", async () => {
37
+ const renderer = host({ timeout: 0 });
38
+ expect(await renderer.call('slow')).toBe('completed');
39
+ const hanging = renderer.call('hang').catch(error => error.message);
40
+ await Bun.sleep(20);
41
+ renderer.close();
42
+ expect(await hanging).toBe('Renderer closed');
43
+ });
44
+
45
+ test("closing during slow startup rejects the wait", async () => {
46
+ const renderer = host({ args: ['--ready-delay=60000'] });
47
+ const ready = renderer.start();
48
+ const result = ready.then(() => 'unexpected ready', error => error.message);
49
+ renderer.close();
50
+ expect(await result).toBe('Renderer closed during startup');
51
+ expect(renderer.pid).toBeNull();
52
+ });
53
+
54
+ test("slow document loading is not killed by the interactive method deadline", async () => {
55
+ const renderer = host({ timeout: 20 });
56
+ await renderer.start();
57
+ const pid = renderer.pid;
58
+ expect(await renderer.call('loadDocument', { url: 'https://example.test/', delay: 200 })).toBe('loaded');
59
+ expect(await renderer.call('showFragment', 'https://example.test/#section')).toBe('positioned');
60
+ expect(renderer.pid).toBe(pid);
61
+ });
62
+
63
+ test("closing cancels a pending document load", async () => {
64
+ const renderer = host();
65
+ await renderer.start();
66
+ const loading = renderer.call('loadDocument', { url: 'https://example.test/', delay: 60000 });
67
+ const result = loading.then(() => 'unexpected completion', error => error.message);
68
+ await Bun.sleep(20);
69
+ renderer.close();
70
+ expect(await result).toBe('Renderer closed');
71
+ });
72
+
73
+ test("exit before ready rejects startup without waiting for a deadline", async () => {
74
+ const renderer = host({ args: ['--exit-before-ready'] });
75
+ await expect(renderer.start()).rejects.toThrow('renderer exited during startup');
76
+ });
77
+
27
78
  test("kills a renderer that misses its deadline and restarts it on the next call", async () => {
28
79
  const restarts = [];
29
80
  const renderer = host({
@@ -0,0 +1,12 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ test("geometry reads reuse discovery while newly inserted media stays fresh", async () => {
4
+ const child = Bun.spawn([process.execPath, new URL("./fixtures/resource-discovery.js", import.meta.url).pathname], {
5
+ stdout: "pipe", stderr: "pipe",
6
+ });
7
+ const [out, error, code] = await Promise.all([
8
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
9
+ ]);
10
+ expect({ code, error }).toEqual({ code: 0, error: "" });
11
+ expect(out).toContain("resource discovery integration passed");
12
+ }, 30_000);
@@ -0,0 +1,234 @@
1
+ import { expect, test } from "bun:test";
2
+ import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
3
+ import { createResourceElements } from "../lib/renderer/resource-elements.js";
4
+
5
+ async function fixture(run) {
6
+ const { window, document } = parseHTMLDocument(`<!doctype html><head>
7
+ <style>body {color:red}</style><link rel="preload" href="one.css"></head>
8
+ <body><img src="one.png"><video></video><iframe></iframe></body>`, "https://resource.test/");
9
+ const query = document.querySelectorAll.bind(document), queries = [];
10
+ document.querySelectorAll = selector => { queries.push(selector); return query(selector); };
11
+ const cache = createResourceElements(document);
12
+ try { await run({ window, document, cache, queries }); }
13
+ finally { cache.dispose(); await window.happyDOM.close(); }
14
+ }
15
+
16
+ test("metadata changes retain discovery while resource attributes and sheet text remain live", async () => {
17
+ await fixture(async ({ document, cache, queries }) => {
18
+ const before = Object.fromEntries(["media", "images", "sheets", "frames"].map(kind => [kind, cache.get(kind)]));
19
+ const [image] = before.images, [style] = before.sheets;
20
+ queries.length = 0;
21
+ for (let i = 0; i < 20; i++) {
22
+ document.body.setAttribute("data-counter", String(i));
23
+ image.setAttribute("aria-label", String(i));
24
+ image.setAttribute("class", `item-${i}`);
25
+ for (const kind of Object.keys(before)) expect(cache.get(kind)).toBe(before[kind]);
26
+ }
27
+ image.setAttribute("src", "two.png");
28
+ image.setAttribute("crossorigin", "anonymous");
29
+ style.setAttribute("media", "print");
30
+ style.setAttribute("type", "text/plain");
31
+ style.firstChild.data = "body {color:blue}";
32
+ expect(cache.get("images")[0].getAttribute("src")).toBe("two.png");
33
+ expect(cache.get("images")[0].getAttribute("crossorigin")).toBe("anonymous");
34
+ expect(cache.get("sheets")[0].textContent).toBe("body {color:blue}");
35
+ expect(cache.get("sheets")[0].getAttribute("media")).toBe("print");
36
+ expect(cache.get("sheets")[0].getAttribute("type")).toBe("text/plain");
37
+ expect(queries).toEqual([]);
38
+ });
39
+ });
40
+
41
+ test("rel membership is refreshed synchronously without rescanning other resource kinds", async () => {
42
+ await fixture(async ({ document, cache, queries }) => {
43
+ const imageList = cache.get("images"), sheets = cache.get("sheets");
44
+ const link = document.getElementsByTagName("link")[0];
45
+ queries.length = 0;
46
+ link.setAttribute("rel", "alternate stylesheet");
47
+ expect(cache.get("sheets")).toEqual([sheets[0], link]);
48
+ expect(cache.get("images")).toBe(imageList);
49
+ link.setAttribute("href", "two.css");
50
+ link.setAttribute("disabled", "");
51
+ expect(cache.get("sheets")[1].getAttribute("href")).toBe("two.css");
52
+ expect(cache.get("sheets")[1].hasAttribute("disabled")).toBe(true);
53
+ link.removeAttribute("rel");
54
+ expect(cache.get("sheets")).toEqual(sheets);
55
+ expect(queries).toEqual(["style, link[rel~='stylesheet']", "style, link[rel~='stylesheet']"]);
56
+ });
57
+ });
58
+
59
+ test("text and comment replacement preserves element discovery but stylesheet contents stay live", async () => {
60
+ await fixture(async ({ document, cache, queries }) => {
61
+ const counter = document.createElement("span");
62
+ document.body.append(counter);
63
+ const kinds = ["media", "images", "sheets", "frames"];
64
+ const lists = Object.fromEntries(kinds.map(kind => [kind, cache.get(kind)]));
65
+ const style = lists.sheets[0];
66
+ queries.length = 0;
67
+ for (let index = 0; index < 20; index++) {
68
+ counter.textContent = String(index);
69
+ const comment = document.createComment("counter");
70
+ counter.append(comment);
71
+ comment.remove();
72
+ style.textContent = `body { z-index: ${index} }`;
73
+ for (const kind of kinds) expect(cache.get(kind)).toBe(lists[kind]);
74
+ expect(cache.get("sheets")[0].textContent).toBe(`body { z-index: ${index} }`);
75
+ }
76
+ counter.textContent = "after delivery";
77
+ await Bun.sleep(10);
78
+ for (const kind of kinds) expect(cache.get(kind)).toBe(lists[kind]);
79
+ expect(queries).toEqual([]);
80
+ // An element removed through textContent still invalidates membership.
81
+ counter.append(lists.images[0]);
82
+ cache.get("images");
83
+ counter.textContent = "removed image";
84
+ expect(cache.get("images")).toEqual([]);
85
+ });
86
+ });
87
+
88
+ test("insert, remove and reattach are discovered before and after observer delivery", async () => {
89
+ await fixture(async ({ document, cache }) => {
90
+ const old = cache.get("images")[0];
91
+ const subtree = document.createElement("section");
92
+ subtree.innerHTML = '<img src="new.png"><audio></audio><style>p{color:red}</style><iframe></iframe>';
93
+ document.body.append(subtree);
94
+ expect(cache.get("images")).toEqual([old, subtree.firstChild]);
95
+ expect(cache.get("media").map(element => element.tagName)).toEqual(["VIDEO", "AUDIO"]);
96
+ expect(cache.get("frames")).toHaveLength(2);
97
+ expect(cache.get("sheets")).toHaveLength(2);
98
+ subtree.remove();
99
+ expect(cache.get("images")).toEqual([old]);
100
+ document.body.append(subtree);
101
+ await Bun.sleep(10); // exercise the observer callback, not just takeRecords()
102
+ expect(cache.get("frames")).toHaveLength(2);
103
+ old.remove();
104
+ expect(cache.get("images")).toEqual([subtree.firstChild]);
105
+ document.body.append(old);
106
+ expect(cache.get("images")).toEqual([subtree.firstChild, old]);
107
+ });
108
+ });
109
+
110
+ test("navigation/disposal releases old discovery and base URLs are read from live elements", async () => {
111
+ await fixture(async ({ document, cache }) => {
112
+ const image = cache.get("images")[0];
113
+ const base = document.createElement("base");
114
+ base.setAttribute("href", "/first/"); document.head.prepend(base);
115
+ expect(cache.get("images")[0]).toBe(image);
116
+ expect(new URL(image.getAttribute("src"), image.baseURI).href).toBe("https://resource.test/first/one.png");
117
+ base.setAttribute("href", "/second/");
118
+ expect(new URL(cache.get("images")[0].getAttribute("src"), image.baseURI).href).toBe("https://resource.test/second/one.png");
119
+ cache.dispose();
120
+ expect(() => cache.get("images")).toThrow("disposed");
121
+ const fresh = createResourceElements(document);
122
+ try {
123
+ image.remove();
124
+ expect(fresh.get("images")).toEqual([]);
125
+ } finally { fresh.dispose(); }
126
+ });
127
+ });
128
+
129
+ test("ordinary subtree edits retain resource lists and image edits only rescan images", async () => {
130
+ for (const delivered of [false, true]) await fixture(async ({ document, cache, queries }) => {
131
+ const container = document.createElement("div");
132
+ document.body.append(container);
133
+ const before = Object.fromEntries(["media", "images", "sheets", "frames"].map(kind => [kind, cache.get(kind)]));
134
+ queries.length = 0;
135
+ for (let index = 0; index < 3; index++) {
136
+ container.innerHTML = `<section><span>counter ${index}</span></section>`;
137
+ if (delivered) await Bun.sleep(10);
138
+ for (const kind of Object.keys(before)) expect(cache.get(kind)).toBe(before[kind]);
139
+ }
140
+ expect(queries).toEqual([]);
141
+ // Several connected insertion records overlap the same subtree.
142
+ let parent = container;
143
+ for (let index = 0; index < 100; index++) {
144
+ const child = document.createElement("div");
145
+ parent.append(child); parent = child;
146
+ }
147
+ const image = document.createElement("img");
148
+ parent.append(image);
149
+ if (delivered) await Bun.sleep(10);
150
+ expect(cache.get("images")).toEqual([...before.images, image]);
151
+ for (const kind of ["media", "sheets", "frames"]) expect(cache.get(kind)).toBe(before[kind]);
152
+ expect(queries).toEqual(["img"]);
153
+ });
154
+ });
155
+
156
+ test("removed subtrees still invalidate after their resource descendants are detached", async () => {
157
+ for (const delivered of [false, true]) await fixture(async ({ document, cache }) => {
158
+ const kinds = ["media", "images", "sheets", "frames"];
159
+ const original = Object.fromEntries(kinds.map(kind => [kind, [...cache.get(kind)]]));
160
+ const subtree = document.createElement("section");
161
+ subtree.innerHTML = '<div><img><audio></audio><style>p{color:blue}</style><iframe></iframe></div>';
162
+ document.body.append(subtree);
163
+ for (const kind of kinds) expect(cache.get(kind)).toHaveLength(original[kind].length + 1);
164
+ subtree.remove();
165
+ subtree.firstChild.replaceChildren();
166
+ subtree.replaceChildren();
167
+ if (delivered) await Bun.sleep(10);
168
+ for (const kind of kinds) expect(cache.get(kind)).toEqual(original[kind]);
169
+ });
170
+ });
171
+
172
+ test("detached rel edits, reordering and moves into shadow roots refresh old membership", async () => {
173
+ for (const delivered of [false, true]) await fixture(async ({ document, cache }) => {
174
+ const subtree = document.createElement("section");
175
+ subtree.innerHTML = '<link rel="alternate stylesheet"><img id="second">';
176
+ document.body.append(subtree);
177
+ const sheets = cache.get("sheets"), images = cache.get("images");
178
+ const link = subtree.firstChild, second = subtree.lastChild;
179
+ subtree.remove();
180
+ link.setAttribute("rel", "preload");
181
+ document.body.prepend(subtree);
182
+ if (delivered) await Bun.sleep(10);
183
+ expect(cache.get("sheets")).toEqual(sheets.filter(element => element !== link));
184
+ expect(cache.get("images")).toEqual([second, images[0]]);
185
+ const host = document.createElement("div");
186
+ document.body.append(host);
187
+ host.attachShadow({ mode: "open" }).append(second);
188
+ if (delivered) await Bun.sleep(10);
189
+ expect(cache.get("images")).toEqual([images[0]]);
190
+ document.body.append(second);
191
+ expect(cache.get("images")).toEqual([images[0], second]);
192
+ });
193
+ });
194
+
195
+ test("adopting resource subtrees updates discovery in both documents", async () => {
196
+ await fixture(async ({ document, cache }) => {
197
+ const other = parseHTMLDocument("<body></body>");
198
+ const next = createResourceElements(other.document);
199
+ try {
200
+ const image = cache.get("images")[0];
201
+ expect(next.get("images")).toEqual([]);
202
+ const container = document.createElement("section");
203
+ document.body.append(container); container.append(image);
204
+ cache.get("images");
205
+ other.document.body.append(container);
206
+ expect(cache.get("images")).toEqual([]);
207
+ expect(next.get("images")).toEqual([image]);
208
+ container.remove(); container.replaceChildren();
209
+ await Bun.sleep(10);
210
+ expect(next.get("images")).toEqual([]);
211
+ } finally { next.dispose(); await other.window.happyDOM.close(); }
212
+ });
213
+ });
214
+
215
+ test("foreign element tag names follow the same matching rules as discovery selectors", async () => {
216
+ await fixture(async ({ document, cache }) => {
217
+ const selectors = { media: "video, audio", images: "img", sheets: "style, link[rel~='stylesheet']", frames: "iframe" };
218
+ for (const kind of Object.keys(selectors)) cache.get(kind);
219
+ for (const namespace of ["http://www.w3.org/2000/svg", "urn:resource-test"]) {
220
+ for (const name of ["img", "IMG", "ImG", "x:img", "STYLE", "StYle", "LINK", "lInK", "aUdIo", "ViDeO", "IfRaMe"]) {
221
+ const node = document.createElementNS(namespace, name);
222
+ node.setAttribute("rel", "stylesheet");
223
+ document.body.append(node);
224
+ for (const [kind, selector] of Object.entries(selectors)) {
225
+ expect(cache.get(kind)).toEqual([...document.querySelectorAll(selector)]);
226
+ }
227
+ node.remove();
228
+ for (const [kind, selector] of Object.entries(selectors)) {
229
+ expect(cache.get(kind)).toEqual([...document.querySelectorAll(selector)]);
230
+ }
231
+ }
232
+ }
233
+ });
234
+ });
@@ -0,0 +1,104 @@
1
+ import {expect, test} from 'bun:test';
2
+ import {parseHTMLDocument} from '../lib/happy-dom/parser.js';
3
+ import {StyleEngine} from '../lib/style/computed-style.js';
4
+
5
+ function fixture(source, sheets = []) {
6
+ const parsed = parseHTMLDocument(source);
7
+ return {...parsed, sheets, engine: new StyleEngine().compute(parsed.document, sheets)};
8
+ }
9
+
10
+ function matchesFull({document, sheets, engine}) {
11
+ const reference = new StyleEngine().compute(document, sheets);
12
+ for (const element of document.querySelectorAll('*')) {
13
+ expect(engine.get(element)).toEqual(reference.get(element));
14
+ expect(engine.customPropertiesOf(element)).toEqual(reference.customPropertiesOf(element));
15
+ for (const pseudo of ['before', 'after']) expect(engine.getPseudo(element, pseudo)).toEqual(reference.getPseudo(element, pseudo));
16
+ }
17
+ }
18
+
19
+ test('an unchanged root-only selector match does not recascade its descendants', () => {
20
+ const f = fixture('<html><body><div><span>content</span></div></body></html>', ['html[data-check] { opacity:1 }']);
21
+ try {
22
+ const root = f.document.documentElement;
23
+ const retained = [...f.document.querySelectorAll('*')].map(element => [element, f.engine.get(element)]);
24
+ root.setAttribute('data-check', 'ready');
25
+ f.engine.compute(f.document, f.sheets, {}, new Set(), new Set([root]));
26
+ expect(f.engine.restyled).toBe(1);
27
+ for (const [element, style] of retained) expect(f.engine.get(element)).toBe(style);
28
+ matchesFull(f);
29
+ } finally { f.window.happyDOM.abort(); }
30
+ });
31
+
32
+ test('an unchanged root still processes simultaneous dirty subtrees and self-only elements', () => {
33
+ const f = fixture('<html><body><div id="subtree"><span>first</span></div><p id="other">other</p></body></html>', ['html[data-check] { opacity:1 }']);
34
+ try {
35
+ const root = f.document.documentElement, subtree = f.document.querySelector('#subtree'), other = f.document.querySelector('#other');
36
+ root.setAttribute('data-check', 'ready');
37
+ subtree.style.color = 'red';
38
+ subtree.insertAdjacentHTML('beforeend', '<span>new</span>');
39
+ other.style.color = 'blue';
40
+ f.engine.compute(f.document, f.sheets, {}, new Set([subtree]), new Set([root, other]));
41
+ expect(f.engine.restyled).toBe(5);
42
+ expect(f.engine.get(subtree.lastElementChild).color).toBe('rgba(255, 0, 0, 1)');
43
+ expect(f.engine.get(other).color).toBe('rgba(0, 0, 255, 1)');
44
+ matchesFull(f);
45
+ } finally { f.window.happyDOM.abort(); }
46
+ });
47
+
48
+ test('root-only font changes update inherited and rem sizes using the initial root font basis', () => {
49
+ const f = fixture('<html style="font-size:2rem"><body><div id="child" style="width:2rem;font-size:150%">text</div></body></html>');
50
+ try {
51
+ const root = f.document.documentElement, child = f.document.querySelector('#child');
52
+ // Rechecking the unchanged root must not multiply 2rem by its prior 32px.
53
+ f.engine.compute(f.document, f.sheets, {}, new Set(), new Set([root]));
54
+ expect(f.engine.restyled).toBe(1);
55
+ expect(f.engine.get(root).fontSize).toBe(32);
56
+ expect(f.engine.get(child)).toMatchObject({width:64, fontSize:48});
57
+ root.style.fontSize = '1.5rem';
58
+ f.engine.compute(f.document, f.sheets, {}, new Set(), new Set([root]));
59
+ expect(f.engine.get(root).fontSize).toBe(24);
60
+ expect(f.engine.get(child)).toMatchObject({width:48, fontSize:36});
61
+ matchesFull(f);
62
+ } finally { f.window.happyDOM.abort(); }
63
+ });
64
+
65
+ test('root custom properties and explicit inheritance still recascade descendants', () => {
66
+ const f = fixture('<html style="--accent:red;width:120px"><body style="width:inherit"><div id="child" style="color:var(--accent);width:inherit">text</div></body></html>');
67
+ try {
68
+ const root = f.document.documentElement, child = f.document.querySelector('#child');
69
+ const before = f.engine.get(root);
70
+ root.style.setProperty('--accent', 'blue');
71
+ child.insertAdjacentHTML('beforeend', '<span>inserted during the root change</span>');
72
+ f.engine.compute(f.document, f.sheets, {}, new Set([child]), new Set([root]));
73
+ expect(f.engine.get(root)).toBe(before);
74
+ expect(f.engine.get(child).color).toBe('rgba(0, 0, 255, 1)');
75
+ expect(f.engine.get(child.lastElementChild).color).toBe('rgba(0, 0, 255, 1)');
76
+ matchesFull(f);
77
+ root.style.width = '200px';
78
+ f.engine.compute(f.document, f.sheets, {}, new Set(), new Set([root]));
79
+ expect(f.engine.get(child).width).toBe(200);
80
+ matchesFull(f);
81
+ } finally { f.window.happyDOM.abort(); }
82
+ });
83
+
84
+ test('a root feature used by descendant selectors keeps the subtree invalidation path', () => {
85
+ const f = fixture('<html><body><div class="target">text</div></body></html>', ['html.active .target { color:red }']);
86
+ try {
87
+ const root = f.document.documentElement;
88
+ root.className = 'active';
89
+ f.engine.compute(f.document, f.sheets, {}, new Set([root]), new Set([root]));
90
+ expect(f.engine.get(f.document.querySelector('.target')).color).toBe('rgba(255, 0, 0, 1)');
91
+ matchesFull(f);
92
+ } finally { f.window.happyDOM.abort(); }
93
+ });
94
+
95
+ test('root pseudo changes remain observable with an unchanged principal style', () => {
96
+ const f = fixture('<html><body><div>text</div></body></html>', ['html[data-check]::before { content:"ready";color:red }']);
97
+ try {
98
+ const root = f.document.documentElement;
99
+ root.setAttribute('data-check', 'ready');
100
+ f.engine.compute(f.document, f.sheets, {}, new Set(), new Set([root]));
101
+ expect(f.engine.getPseudo(root, 'before').content).toBe('ready');
102
+ matchesFull(f);
103
+ } finally { f.window.happyDOM.abort(); }
104
+ });
@@ -15,6 +15,41 @@ test("HTML comment markers in JavaScript values are escaped without changing the
15
15
  expect(Function(`${escaped}; return [a, b.test('-->'), c]`)()).toEqual(["<!-- start -->", true, "<!-- 1 -->"]);
16
16
  });
17
17
 
18
+ test("SES preparation protects import property names while leaving dynamic imports to the loader", () => {
19
+ const source = `class Module {
20
+ static import(value) { return this.prefix + value }
21
+ static prefix = "class:";
22
+ import(value) { return value }
23
+ }
24
+ const object = { prefix: "object:", import(value) { return this.prefix + value } };
25
+ result = [Module . import ("a"), object. /* gap */ import /* gap */ ("b"),
26
+ Module.import.name, Object.hasOwn(Module, "import"), new Module().import(7)];`;
27
+ const code = escapeSesHtmlComments(source);
28
+ const result = Function(`let result; ${code}; return result`)();
29
+ expect(result).toEqual(["class:a", "object:b", "import", true, 7]);
30
+ expect(restoreSourceText(code)).toBe(source);
31
+ const dynamic = `import /* module */ ("./module.js"); import\n("./other.js");`;
32
+ expect(escapeSesHtmlComments(dynamic)).toBe(dynamic);
33
+ });
34
+
35
+ test("SES preparation preserves import.meta alongside module methods and dynamic imports", async () => {
36
+ const source = `const registry = { import(value) { return value + 1 } };
37
+ export const meta = import.meta.url;
38
+ export const commentedMeta = import /* keyword */ .meta.url;
39
+ export const result = registry.import(6);
40
+ export const name = registry.import.name;
41
+ export const dynamic = (await import("data:text/javascript,export default 9")).default;`;
42
+ const code = escapeSesHtmlComments(source, "module");
43
+ expect(code).toContain("import.meta.url");
44
+ expect(code).toContain("import /* keyword */ .meta.url");
45
+ expect(restoreSourceText(code)).toBe(source);
46
+ const url = `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`;
47
+ const module = await import(url);
48
+ expect(module.meta).toContain("data:text/javascript;base64,");
49
+ expect(module.commentedMeta).toBe(module.meta);
50
+ expect([module.result, module.name, module.dynamic]).toEqual([7, "import", 9]);
51
+ });
52
+
18
53
  test("constructor reads go through the helper; writes, delete and optional chains do not", () => {
19
54
  expect(rewrite("var A = Object.getPrototypeOf(async function () {}).constructor;"))
20
55
  .toBe("var A = $buninu$constructor(Object.getPrototypeOf(async function () {}));");
@@ -7,6 +7,51 @@ const RET_ALLOW = 0x7fff0000;
7
7
  const RET_EPERM = 0x00050001;
8
8
  const RET_ENOSYS = 0x00050026;
9
9
 
10
+ // Execute the encoded classic-BPF instructions against a seccomp_data record.
11
+ // This checks actual jump destinations and argument loads, including the
12
+ // architecture we cannot install on the current test machine.
13
+ function filterVerdict(program, arch, syscall, args = []) {
14
+ const code = new DataView(encodeFilter(program)), data = new DataView(new ArrayBuffer(64));
15
+ data.setUint32(0, SYSCALL_NUMBERS[arch][syscall], true);
16
+ data.setUint32(4, arch === "x64" ? 0xc000003e : 0xc00000b7, true);
17
+ args.forEach((value, index) => data.setBigUint64(16 + index * 8, BigInt.asUintN(64, BigInt(value)), true));
18
+ let accumulator = 0, pc = 0;
19
+ for (let steps = 0; steps < program.length; steps++) {
20
+ const offset = pc * 8, op = code.getUint16(offset, true), value = code.getUint32(offset + 4, true);
21
+ const yes = code.getUint8(offset + 2), no = code.getUint8(offset + 3);
22
+ switch (op) {
23
+ case 0x20: accumulator = data.getUint32(value, true); pc++; break;
24
+ case 0x15: pc += 1 + (accumulator === value ? yes : no); break;
25
+ case 0x35: pc += 1 + (accumulator >= value ? yes : no); break;
26
+ case 0x45: pc += 1 + ((accumulator & value) !== 0 ? yes : no); break;
27
+ case RET: return value;
28
+ default: throw new Error(`unsupported BPF opcode ${op}`);
29
+ }
30
+ }
31
+ throw new Error("filter did not return a verdict");
32
+ }
33
+
34
+ test("signal filters allow this process but never let tkill target an arbitrary thread", () => {
35
+ const pid = 1234;
36
+ for (const arch of ["x64", "arm64"]) {
37
+ // Even explicitly including the signal syscalls in the unconditional
38
+ // allowlist must not bypass their target checks.
39
+ for (const allowed of [ALLOWED_SYSCALLS, [...ALLOWED_SYSCALLS, "kill", "tkill", "tgkill"]]) {
40
+ const program = buildFilter(arch, pid, allowed);
41
+ for (const name of ["kill", "tkill", "tgkill"]) {
42
+ expect(filterVerdict(program, arch, name, [pid, 0, 0])).toBe(RET_ALLOW);
43
+ for (const other of [pid + 1, 1, 0, -1]) {
44
+ expect(filterVerdict(program, arch, name, [other, 0, 0])).toBe(RET_EPERM);
45
+ }
46
+ expect(program.filter(([op, , , value]) => op === 0x15 && value === SYSCALL_NUMBERS[arch][name])).toHaveLength(1);
47
+ }
48
+ // Exercise the syscall-number reload after the restricted signal blocks.
49
+ expect(filterVerdict(program, arch, "read")).toBe(RET_ALLOW);
50
+ expect(filterVerdict(program, arch, "openat")).toBe(RET_EPERM);
51
+ }
52
+ }
53
+ });
54
+
10
55
  test("builds an allowlist program that checks the architecture first and denies by default", () => {
11
56
  for (const arch of ["x64", "arm64"]) {
12
57
  const program = buildFilter(arch, 1234);
@@ -0,0 +1,8 @@
1
+ import {test,expect} from 'bun:test';
2
+
3
+ test('spare warmup follows document load with bounded fallback and safe cancellation',async()=>{
4
+ const child=Bun.spawn([process.execPath,new URL('./fixtures/spare-renderer-scheduling.js',import.meta.url).pathname],{stdout:'pipe',stderr:'pipe'});
5
+ const [out,error,code]=await Promise.all([new Response(child.stdout).text(),new Response(child.stderr).text(),child.exited]);
6
+ expect({code,error}).toEqual({code:0,error:''});
7
+ expect(out).toContain('spare scheduling lifecycle, isolation, cancellation and failure checks passed');
8
+ },15000);