buninu-browser 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/ai-debug.log +5537 -0
  2. package/lib/audio/playback-buffer.js +27 -0
  3. package/lib/audio/pulse-client.js +18 -8
  4. package/lib/cdp-server.js +6 -0
  5. package/lib/happy-dom/parser.js +5 -0
  6. package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
  7. package/lib/headless-shell.js +3 -0
  8. package/lib/layout/text-layout.js +76 -40
  9. package/lib/media/video-keyframe.js +29 -0
  10. package/lib/paint/display-list.js +20 -5
  11. package/lib/render-tree/index.js +2 -0
  12. package/lib/renderer/bindings.js +11 -2
  13. package/lib/renderer/compositor.js +16 -1
  14. package/lib/renderer/custom-element-reactions.js +18 -0
  15. package/lib/renderer/history.js +42 -0
  16. package/lib/renderer/host.js +8 -1
  17. package/lib/renderer/image-loading.js +27 -0
  18. package/lib/renderer/keyboard-input.js +12 -0
  19. package/lib/renderer/main.js +3 -1
  20. package/lib/renderer/media-playback-events.js +13 -0
  21. package/lib/renderer/page-realm.js +9 -50
  22. package/lib/renderer/page-renderer.js +206 -79
  23. package/lib/renderer/piano-input.js +22 -0
  24. package/lib/renderer/pointer-move.js +29 -0
  25. package/lib/renderer/video-presentation.js +126 -0
  26. package/lib/style/computed-style.js +24 -7
  27. package/package.json +1 -1
  28. package/test/compositor.test.js +38 -0
  29. package/test/computed-style.test.js +21 -0
  30. package/test/display-list.test.js +14 -0
  31. package/test/download.test.js +8 -0
  32. package/test/headless-shell.test.js +22 -0
  33. package/test/media-playback-events.test.js +22 -0
  34. package/test/page-scripts.test.js +159 -1
  35. package/test/piano-input.test.js +44 -0
  36. package/test/playback-buffer.test.js +31 -0
  37. package/test/pulse-client.test.js +17 -2
  38. package/test/text-layout.test.js +62 -0
  39. package/test/video-presentation.test.js +67 -0
@@ -0,0 +1,126 @@
1
+ import { seekVideoKeyframe } from "../media/video-keyframe.js";
2
+
3
+ // Runs in the compositor Worker, never in the page's JavaScript turn. Both
4
+ // queue duration and decoded pixel memory are bounded. No audio extrapolation.
5
+ export class VideoPresentation {
6
+ constructor(engine, changed, report) {
7
+ this.engine = engine;
8
+ this.changed = changed;
9
+ this.report = report;
10
+ this.epoch = 0;
11
+ this.timer = null;
12
+ this.clocks = new Map();
13
+ }
14
+ async open(config) {
15
+ this.stop();
16
+ const epoch = this.epoch;
17
+ this.config = config;
18
+ config.started = performance.now();
19
+ try {
20
+ const decoder = await this.engine.decode(config.bytes, config.options);
21
+ if (epoch !== this.epoch) { decoder.close(); return; }
22
+ this.decoder = decoder;
23
+ this.nextTime = config.time > 0 ? seekVideoKeyframe(decoder, config.time) : 0;
24
+ this.width = Math.max(1, Math.min(480, decoder.width));
25
+ this.height = Math.max(1, Math.round(this.width * decoder.height / Math.max(1, decoder.width)));
26
+ if (this.height > 480) {
27
+ this.width = Math.max(1, Math.round(this.width * 480 / this.height));
28
+ this.height = 480;
29
+ }
30
+ this.limit = Math.max(2, Math.min(30, Math.floor(16 * 1024 * 1024 / (this.width * this.height * 4))));
31
+ this.step();
32
+ } catch (error) {
33
+ if (epoch === this.epoch) this.report({ id: config.id, error: String(error) });
34
+ }
35
+ }
36
+ stop(id) {
37
+ if (id !== undefined && this.config?.id !== id) return;
38
+ this.epoch++;
39
+ clearTimeout(this.timer);
40
+ this.decoder?.close();
41
+ this.decoder = null;
42
+ this.queue = [];
43
+ this.frame = null;
44
+ this.eof = false;
45
+ this.reportedEnd = false;
46
+ this.config = null;
47
+ }
48
+ timing(id, seconds) {
49
+ if (!Number.isFinite(seconds) || seconds < 0) return;
50
+ this.clocks.set(id, seconds);
51
+ // Audio stream identifiers increase monotonically within this renderer.
52
+ if (this.clocks.size > 32) this.clocks.delete(this.clocks.keys().next().value);
53
+ }
54
+ bind(id, audioId, audioStart, time) {
55
+ if (this.config?.id !== id) return;
56
+ Object.assign(this.config, { audioId, audioStart, time });
57
+ }
58
+ time() {
59
+ const c = this.config;
60
+ if (c.audioId != null) return c.audioStart + (this.clocks.get(c.audioId) ?? 0);
61
+ // Until its audio stream exists, a known audio track holds the image.
62
+ if (c.hasAudio) return c.time;
63
+ return c.time + (performance.now() - c.started) / 1000;
64
+ }
65
+ step() {
66
+ if (!this.decoder || !this.config) return;
67
+ const time = this.time();
68
+ const deadline = performance.now() + 8;
69
+ try {
70
+ while (!this.eof && this.queue.length < this.limit && this.nextTime <= time + 0.75) {
71
+ const pixels = this.decoder.nextFrame(this.width, this.height);
72
+ if (!pixels) { this.eof = true; break; }
73
+ const frame = { pixels, time: this.nextTime, width: this.width, height: this.height };
74
+ this.nextTime += 1 / (this.decoder.fps || 30);
75
+ // A reopened decoder starts at an earlier keyframe. Decode through
76
+ // that prefix without flashing it over the last displayed picture.
77
+ if (frame.time + 1 / (this.decoder.fps || 30) > this.config.time) this.queue.push(frame);
78
+ if (performance.now() >= deadline) break;
79
+ }
80
+ } catch {
81
+ // Growing snapshots can throw at their current end. The main thread
82
+ // owns network/refill policy and decides whether this is EOF or an error.
83
+ this.eof = true;
84
+ }
85
+ let latest = null;
86
+ while (this.queue.length && this.queue[0].time <= time + 1e-6) latest = this.queue.shift();
87
+ if (latest) {
88
+ this.frame = latest;
89
+ if (this.config.shared) {
90
+ const meta = new Int32Array(this.config.shared, 0, 4);
91
+ Atomics.add(meta, 0, 1);
92
+ new Uint8Array(this.config.shared, 16, latest.pixels.length).set(latest.pixels);
93
+ Atomics.store(meta, 1, latest.width);
94
+ Atomics.store(meta, 2, latest.height);
95
+ Atomics.add(meta, 0, 1);
96
+ }
97
+ this.changed();
98
+ this.report({ id: this.config.id, time: latest.time });
99
+ }
100
+ if (this.eof && !this.queue.length && time >= this.nextTime && !this.reportedEnd) {
101
+ this.reportedEnd = true;
102
+ this.report({ id: this.config.id, end: this.nextTime });
103
+ }
104
+ this.timer = setTimeout(() => this.step(), 10);
105
+ }
106
+ }
107
+
108
+ // Black/white raster passes capture the video's contribution after clipping,
109
+ // opacity, subtitles and controls. This preserves foreground pixels instead of
110
+ // pasting a rectangular video over the page's UI.
111
+ export function compositeVideo(tile, frame) {
112
+ const layer = tile.videoLayer;
113
+ if (!layer || !frame) return tile;
114
+ const pixels = new Uint8Array(layer.base);
115
+ const { x, y, width, height } = layer.rect;
116
+ for (let py = Math.max(0, Math.floor(y)); py < Math.min(tile.pixelHeight, Math.ceil(y + height)); py++) {
117
+ const sy = Math.max(0, Math.min(frame.height - 1, Math.floor((py - y) / height * frame.height)));
118
+ for (let px = Math.max(0, Math.floor(x)); px < Math.min(tile.pixelWidth, Math.ceil(x + width)); px++) {
119
+ const sx = Math.max(0, Math.min(frame.width - 1, Math.floor((px - x) / width * frame.width)));
120
+ const dst = (py * tile.pixelWidth + px) * 4, src = (sy * frame.width + sx) * 4;
121
+ for (let c = 0; c < 3; c++) pixels[dst + c] = Math.min(255,
122
+ layer.base[dst + c] + Math.round(frame.pixels[src + c] * layer.mask[dst + c] / 255));
123
+ }
124
+ }
125
+ return { ...tile, pixels };
126
+ }
@@ -32,6 +32,7 @@ const LIST_STYLE_TYPES = new Set([
32
32
  "lower-alpha", "upper-alpha", "lower-latin", "upper-latin", "lower-roman", "upper-roman",
33
33
  ]);
34
34
  const DISPLAYS = new Set([
35
+ "-webkit-box", "-webkit-inline-box",
35
36
  "none", "block", "flex", "grid", "inline", "inline-block", "inline-flex", "inline-grid", "list-item",
36
37
  "table", "inline-table", "table-row-group", "table-header-group", "table-footer-group", "table-row",
37
38
  "table-cell", "table-caption", "table-column", "table-column-group",
@@ -61,6 +62,8 @@ const WHITE_SPACES = new Set(["normal", "nowrap", "pre", "pre-wrap"]);
61
62
  const CSS_WIDE_KEYWORDS = new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
62
63
  // Computed-style fields a shorthand (or a property not named after its field) covers.
63
64
  const PROPERTY_FIELDS = Object.freeze({
65
+ "-webkit-line-clamp": ["webkitLineClamp"],
66
+ "-webkit-box-orient": ["webkitBoxOrient"],
64
67
  overflow: ["overflowX", "overflowY", "overflowScrollX", "overflowScrollY"],
65
68
  "overflow-x": ["overflowX", "overflowScrollX"],
66
69
  "overflow-y": ["overflowY", "overflowScrollY"],
@@ -111,6 +114,8 @@ const INITIAL = Object.freeze({
111
114
  lineHeightFactor: null,
112
115
  textAlign: "start",
113
116
  whiteSpace: "normal",
117
+ webkitLineClamp: null,
118
+ webkitBoxOrient: "horizontal",
114
119
  listStyleType: "disc",
115
120
  margin: Object.freeze([0, 0, 0, 0]),
116
121
  padding: Object.freeze([0, 0, 0, 0]),
@@ -1090,6 +1095,13 @@ function setProperty(style, name, value, parentStyle = null) {
1090
1095
  break;
1091
1096
  }
1092
1097
  case "display": if (DISPLAYS.has(value)) style.display = value; break;
1098
+ case "-webkit-line-clamp":
1099
+ if (value === "none") style.webkitLineClamp = null;
1100
+ else if (/^[1-9]\d*$/.test(value)) style.webkitLineClamp = Number(value);
1101
+ break;
1102
+ case "-webkit-box-orient":
1103
+ if (["vertical", "horizontal"].includes(value)) style.webkitBoxOrient = value;
1104
+ break;
1093
1105
  case "text-decoration-line": {
1094
1106
  const lines = parseDecorationLines(value);
1095
1107
  if (lines) style.textDecorationLine = lines;
@@ -1236,8 +1248,11 @@ function setProperty(style, name, value, parentStyle = null) {
1236
1248
  break;
1237
1249
  case "font-size": {
1238
1250
  const parentSize = parentStyle?.fontSize ?? INITIAL.fontSize;
1251
+ const specified = computedLength(value, parentSize, true);
1252
+ const relativeSize = specified?.unit === "%" ? parentSize * specified.value / 100
1253
+ : specified?.unit === "math" && specified.kind === "calc" ? specified.px + parentSize * specified.percent / 100 : specified;
1239
1254
  const length = FONT_SIZE_KEYWORDS[value] ?? (value === "larger" ? parentSize * 1.2 : value === "smaller" ? parentSize / 1.2
1240
- : computedLength(value, parentSize));
1255
+ : relativeSize);
1241
1256
  if (length > 0) style.fontSize = length;
1242
1257
  break;
1243
1258
  }
@@ -1370,7 +1385,7 @@ function setProperty(style, name, value, parentStyle = null) {
1370
1385
  break;
1371
1386
  }
1372
1387
  case "padding": {
1373
- const box = boxLengths(value, false, style.fontSize, false);
1388
+ const box = boxLengths(value, false, style.fontSize, false, true);
1374
1389
  if (box) style.padding = box;
1375
1390
  break;
1376
1391
  }
@@ -1384,8 +1399,9 @@ function setProperty(style, name, value, parentStyle = null) {
1384
1399
  case "padding-left": {
1385
1400
  const [box, side] = name.split("-");
1386
1401
  const isMargin = box === "margin";
1387
- const length = isMargin && value === "auto" ? "auto" : computedLength(value, style.fontSize);
1388
- if (length === "auto" || (Number.isFinite(length) && (isMargin || length >= 0))) {
1402
+ const length = isMargin && value === "auto" ? "auto" : computedLength(value, style.fontSize, !isMargin);
1403
+ if (length === "auto" || (!isMargin && isLength(length) && typeof length === "object"
1404
+ && (!isPercentage(length) || length.value >= 0)) || (Number.isFinite(length) && (isMargin || length >= 0))) {
1389
1405
  style[box] = style[box].with(BOX_SIDES.indexOf(side), length);
1390
1406
  }
1391
1407
  break;
@@ -2465,7 +2481,7 @@ function splitTopLevel(source, separator) {
2465
2481
  const character = source[index];
2466
2482
  if (character === "(") depth++;
2467
2483
  else if (character === ")") depth--;
2468
- else if (character === separator && depth === 0) {
2484
+ else if ((character === separator || separator === " " && /\s/.test(character)) && depth === 0) {
2469
2485
  parts.push(source.slice(start, index).trim());
2470
2486
  start = index + 1;
2471
2487
  }
@@ -2475,10 +2491,11 @@ function splitTopLevel(source, separator) {
2475
2491
  }
2476
2492
 
2477
2493
  function boxLengths(value, allowNegative, emSize, allowAuto, allowPercentage = false) {
2478
- const parts = value.trim().split(/\s+/).map((part) =>
2494
+ const parts = splitTopLevel(value.trim(), " ").filter(Boolean).map((part) =>
2479
2495
  allowAuto && part === "auto" ? "auto" : computedLength(part, emSize, allowPercentage));
2480
2496
  if (parts.length < 1 || parts.length > 4 || parts.some((part) =>
2481
- part !== "auto" && !isPercentage(part) && (!Number.isFinite(part) || (!allowNegative && part < 0)))) return null;
2497
+ part !== "auto" && !(allowPercentage && isLength(part) && typeof part === "object"
2498
+ && (allowNegative || !isPercentage(part) || part.value >= 0)) && (!Number.isFinite(part) || (!allowNegative && part < 0)))) return null;
2482
2499
  const [top, right = top, bottom = top, left = right] = parts.length === 3
2483
2500
  ? [parts[0], parts[1], parts[2], parts[1]]
2484
2501
  : parts.length === 2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu-browser",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Buninu Browser is a browser engine written in JavaScript and WebAssembly for Bun.",
5
5
  "keywords": [
6
6
  "buninu",
@@ -62,6 +62,44 @@ test("the compositor answers screenshots and scrolling while the main thread is
62
62
  }, 30_000);
63
63
 
64
64
  let server;
65
+ test("video continues presenting while page JavaScript blocks, then freezes on pause", async () => {
66
+ const host = new RendererHost({ timeout: 20_000,
67
+ fetch: async () => new Response(Bun.file(new URL("./fixtures/long-video.mp4", import.meta.url)),
68
+ { headers: { "content-type": "video/mp4" } }) });
69
+ try {
70
+ await host.start();
71
+ await host.call("resize", 160, 120, 1);
72
+ await host.call("loadDocument", { url: "https://page.test/", contentType: "text/html",
73
+ source: '<body style="margin:0"><video id="v" width="160" height="120" src="/movie.mp4"></video>' });
74
+ await host.call("whenLoaded");
75
+ await host.call("evaluate", "document.getElementById('v').play()");
76
+ await Bun.sleep(400);
77
+ const busy = host.call("evaluate", "(() => { const end=Date.now()+2000; while(Date.now()<end); return true; })()");
78
+ await Bun.sleep(200);
79
+ const first = await host.composite("screenshot");
80
+ await Bun.sleep(600);
81
+ const second = await host.composite("screenshot");
82
+ expect(Bun.hash(second)).not.toBe(Bun.hash(first));
83
+ expect(await busy).toBe(true);
84
+ await host.call("evaluate", "document.getElementById('v').pause()");
85
+ const paused = await host.composite("screenshot");
86
+ await Bun.sleep(350);
87
+ expect(await host.composite("screenshot")).toBe(paused);
88
+ await host.call("evaluate", "document.getElementById('v').currentTime=5");
89
+ await Bun.sleep(250);
90
+ const sought = await host.composite("screenshot");
91
+ expect(Bun.hash(sought)).not.toBe(Bun.hash(paused));
92
+ await host.call("evaluate", "document.getElementById('v').play()");
93
+ await Bun.sleep(400);
94
+ const resumed = await host.composite("screenshot");
95
+ await Bun.sleep(400);
96
+ expect(Bun.hash(await host.composite("screenshot"))).not.toBe(Bun.hash(resumed));
97
+ await host.call("loadDocument", { url: "https://page.test/next", contentType: "text/html", source: '<body style="background:red">next</body>' });
98
+ const next = await host.composite("screenshot");
99
+ await Bun.sleep(300);
100
+ expect(await host.composite("screenshot")).toBe(next);
101
+ } finally { host.close(); }
102
+ }, 30_000);
65
103
  let releaseStyles;
66
104
  let releaseScript;
67
105
  beforeAll(() => {
@@ -27,6 +27,27 @@ test("computes the initial UA and inherited style subset", () => {
27
27
  window.happyDOM.abort();
28
28
  });
29
29
 
30
+ test(":empty includes text nodes, but ignores comments and empty text nodes", () => {
31
+ const { document, window } = parseHTMLDocument('<span id="text">0</span><span id="space"> </span><span id="empty"><!--comment--></span>');
32
+ const styles = new StyleEngine().compute(document, ['span:empty { display:none }']);
33
+ expect(document.querySelector('#text').matches(':empty')).toBe(false);
34
+ expect(styles.get(document.querySelector('#text')).display).toBe('inline');
35
+ expect(styles.get(document.querySelector('#space')).display).toBe('inline');
36
+ expect(styles.get(document.querySelector('#empty')).display).toBe('none');
37
+ window.happyDOM.abort();
38
+ });
39
+
40
+ test("percentage root fonts set the basis for rem and descendant percentage fonts", () => {
41
+ const { document, window } = parseHTMLDocument(`<html style="font-size:62.5%"><body>
42
+ <div id="relative" style="font-size:1.4rem"><span id="child" style="font-size:150%">text</span></div>
43
+ </body></html>`);
44
+ const styles = new StyleEngine().compute(document);
45
+ expect(styles.get(document.documentElement).fontSize).toBe(10);
46
+ expect(styles.get(document.getElementById('relative')).fontSize).toBe(14);
47
+ expect(styles.get(document.getElementById('child')).fontSize).toBe(21);
48
+ window.happyDOM.abort();
49
+ });
50
+
30
51
  test("inline declarations validate values and preserve important priority", () => {
31
52
  const { document, window } = parseHTMLDocument(`<div id="box" style="
32
53
  display: block; display: none !important; display: inline;
@@ -20,6 +20,20 @@ function displayListFor(html, css, resources = new WeakMap()) {
20
20
  return { document, window, tree, displayList: buildDisplayList(layout, tree) };
21
21
  }
22
22
 
23
+ test("native video controls are opt-in and do not cover a site's own player UI", () => {
24
+ const { window, displayList } = displayListFor('<video></video><video controls></video>', 'video { width:100px; height:60px }');
25
+ expect(displayList.items.filter(item => item.op === 'drawVideo').map(item => item.controls)).toEqual([false, true]);
26
+ window.happyDOM.abort();
27
+ });
28
+
29
+ test("rounded overflow containers clip descendant media, including transformed clips", () => {
30
+ const { window, displayList } = displayListFor('<div><video></video></div>',
31
+ 'div { width:100px;height:60px;overflow:hidden;border-radius:12px;transform:scale(2);transform-origin:0 0 } video {width:100px;height:60px}');
32
+ const video = displayList.items.find(item => item.op === 'drawVideo');
33
+ expect(video.roundedClips).toEqual([{rect:{x:0,y:0,width:200,height:120},radii:{x:24,y:24}}]);
34
+ window.happyDOM.abort();
35
+ });
36
+
23
37
  test("paints box decorations in tree order before inline content", () => {
24
38
  const { window, displayList } = displayListFor(
25
39
  `<div class="card"><p>hi <code>x</code></p></div>`,
@@ -19,6 +19,8 @@ beforeAll(() => {
19
19
  + '<a id="attachment" href="/attachment">attachment</a>'
20
20
  + '<a id="data" href="data:text/plain,hello%20data" download="data.txt">data</a>'
21
21
  + '<a id="blob" download="blob.txt">blob</a>'
22
+ + '<a id="toc" href="#contents">contents</a><h2 id="contents">Contents</h2>'
23
+ + '<script>document.getElementById("toc").addEventListener("click", event => { event.preventDefault(); location.href = event.currentTarget.href; });</script>'
22
24
  + '<script>document.getElementById("blob").href = URL.createObjectURL(new Blob(["blob bytes"]));</script>',
23
25
  { headers: { "content-type": "text/html" } });
24
26
  if (path === "/plain") return new Response("original bytes", { headers: { "content-type": "text/plain" } });
@@ -74,6 +76,12 @@ test("clicked downloads save files and leave the current page in place", async (
74
76
  await click("#attachment");
75
77
  expect(readFileSync(join(dir, "photo.jpg"), "utf8")).toBe("photo bytes");
76
78
  expect(browser.context.resourceContent()?.url).toBe(`${origin}/links`);
79
+ expect(await browser.context.evaluate("[location.href, document.URL, document.baseURI, document.getElementById('toc').href]"))
80
+ .toEqual([`${origin}/links`, `${origin}/links`, `${origin}/links`, `${origin}/links#contents`]);
81
+ await click("#toc");
82
+ expect(await browser.context.evaluate("[location.href, document.URL]"))
83
+ .toEqual([`${origin}/links#contents`, `${origin}/links#contents`]);
84
+ expect(events.filter((item) => item.event === "willBegin")).toHaveLength(4);
77
85
  await click("#data");
78
86
  expect(readFileSync(join(dir, "data.txt"), "utf8")).toBe("hello data");
79
87
  await browser.context.evaluate("document.getElementById('blob').click()");
@@ -126,6 +126,8 @@ test("top-level shell exposes the parsed document through Bun.WebView", async ()
126
126
  await view.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", x: demoKey.x + demoKey.width / 2,
127
127
  y: demoKey.y + demoKey.height - 15, button: "left", clickCount: 1 });
128
128
  expect(await view.evaluate("document.activeElement.id")).toBe("buninu-piano-input");
129
+ await view.cdp("Input.insertText", { text: "jkl JKL!" });
130
+ expect(await view.evaluate("document.activeElement.value")).toBe("");
129
131
  await view.resize(480, 240);
130
132
  await view.navigate(`http://127.0.0.1:${fixture.port}/`);
131
133
 
@@ -198,12 +200,32 @@ test("top-level shell exposes the parsed document through Bun.WebView", async ()
198
200
  await view.cdp("Input.dispatchMouseEvent", { type: "mousePressed", x: pianoRect.x + 10, y: pianoRect.y + 10, button: "left", clickCount: 1 });
199
201
  await view.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", x: pianoRect.x + 10, y: pianoRect.y + 10, button: "left", clickCount: 1 });
200
202
  await view.cdp("Input.insertText", { text: "j" });
203
+ await view.cdp("Input.insertText", { text: "jk lJK!中" });
204
+ for (const key of ["j", "k", " "]) {
205
+ await view.cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", key, autoRepeat: true });
206
+ await view.cdp("Input.dispatchKeyEvent", { type: "keyUp", key });
207
+ }
201
208
  await view.cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", key: "k", code: "KeyK" });
202
209
  await view.cdp("Input.dispatchKeyEvent", { type: "keyUp", key: "k", code: "KeyK" });
210
+ for (const type of ["rawKeyDown", "char", "keyUp"]) {
211
+ for (const key of ["j", "k", "l"]) {
212
+ await view.cdp("Input.dispatchKeyEvent", { type, key, code: `Key${key.toUpperCase()}`, text: type === "char" ? key : undefined });
213
+ }
214
+ }
203
215
  expect(await view.evaluate("document.getElementById('buninu-piano-input').value")).toBe("");
204
216
  await view.navigate(`http://127.0.0.1:${fixture.port}/`);
205
217
 
206
218
  expect(await view.evaluate("scrollY")).toBe(0);
219
+ await view.evaluate(`window.spaceKeys=[];document.addEventListener('keydown',event=>{
220
+ if(event.keyCode===32){event.preventDefault();spaceKeys.push([event.keyCode,event.which]);}
221
+ });`);
222
+ for(let i=0;i<6;i++) {
223
+ for(const type of ['rawKeyDown','char','keyUp']) {
224
+ await view.cdp('Input.dispatchKeyEvent', {type,key:' ',code:'Space',windowsVirtualKeyCode:32,text:type==='char'?' ':undefined});
225
+ }
226
+ }
227
+ expect(await view.evaluate('spaceKeys')).toEqual(Array.from({length:6},()=>[32,32]));
228
+ expect(await view.evaluate('scrollY')).toBe(0);
207
229
  await view.cdp("Input.dispatchMouseEvent", {
208
230
  type: "mouseWheel",
209
231
  x: 100,
@@ -0,0 +1,22 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { playbackEvent } from '../lib/renderer/media-playback-events.js';
3
+
4
+ test('playback notifications distinguish unpaused, advancing and starved states', () => {
5
+ const state = {playing:true}, events=[];
6
+ const dispatch=(_state,type)=>events.push(type);
7
+ for(const phase of ['play','playing','playing','waiting','waiting','playing','playing','pause','play','playing']) {
8
+ playbackEvent(state,phase,dispatch);
9
+ }
10
+ expect(events).toEqual(['play','playing','waiting','canplay','playing','pause','play','playing']);
11
+ });
12
+
13
+ test('pausing in canplay does not report playback resumed', () => {
14
+ const state={playing:true}, events=[];
15
+ const dispatch=(_state,type)=>{
16
+ events.push(type);
17
+ if(type==='canplay') {state.playing=false;playbackEvent(state,'pause',dispatch);}
18
+ };
19
+ playbackEvent(state,'waiting',dispatch);
20
+ playbackEvent(state,'playing',dispatch);
21
+ expect(events).toEqual(['waiting','canplay','pause']);
22
+ });
@@ -226,6 +226,69 @@ test("direct eval preserves receivers of lexical calls and tagged templates", as
226
226
  ]);
227
227
  });
228
228
 
229
+ test("pointer movement delivers trusted mouse events to page controls", async () => {
230
+ await load(`<div id="control" style="width:100px;height:100px;background:red"></div><script>
231
+ window.moves = [];
232
+ for (const type of ['mouseenter','mousemove','mouseleave']) {
233
+ document.querySelector('#control').addEventListener(type, e => moves.push([e.type,e.isTrusted]));
234
+ }
235
+ </script>`);
236
+ await renderer.call('move', 30, 30);
237
+ await renderer.call('move', 150, 150);
238
+ expect(await evaluate('moves')).toEqual([['mouseenter',true],['mousemove',true],['mouseleave',true]]);
239
+ });
240
+
241
+ test("assigning element.style forwards to cssText without replacing its identity", async () => {
242
+ await load(`<div id="target"></div><script>
243
+ const target = document.querySelector('#target'), original = target.style;
244
+ target.style = 'width: 73px; color: red';
245
+ window.styleResult = [target.style === original, target.style.width, target.style.color];
246
+ target.style = '';
247
+ styleResult.push(target.style.cssText);
248
+ </script>`);
249
+ expect(JSON.parse(await evaluate("JSON.stringify(styleResult)"))).toEqual([true, '73px', 'red', '']);
250
+ });
251
+
252
+ test("History methods borrowed from an iframe operate on the receiving history", async () => {
253
+ await load(`<iframe></iframe><script>
254
+ const frame = document.querySelector('iframe').contentWindow;
255
+ frame.History.prototype.replaceState.call(history, { step: 1 }, '', '/watch?v=example');
256
+ frame.History.prototype.pushState.call(history, { step: 2 }, '', '#comments');
257
+ window.historyResult = [location.href, history.state.step, history.length, frame.location.href];
258
+ try { frame.History.prototype.replaceState.call(history, {}, '', 'https://other.test/'); }
259
+ catch (e) { window.historyOriginError = e.name; }
260
+ </script>`);
261
+ expect(JSON.parse(await evaluate("JSON.stringify(historyResult)"))).toEqual([
262
+ 'https://page.test/watch?v=example#comments', 2, 2, 'about:blank',
263
+ ]);
264
+ expect(await evaluate("historyOriginError")).toBe('SecurityError');
265
+ });
266
+
267
+ test("custom elements receive observed attribute reactions and can enable deferred initialization", async () => {
268
+ await load(`<script>
269
+ window.reactions = [];
270
+ window.observedReads = 0;
271
+ class DeferredPanel extends HTMLElement {
272
+ static get observedAttributes() { observedReads++; return ['disable-upgrade']; }
273
+ attributeChangedCallback(name, before, after) {
274
+ reactions.push([name, before, after, this === document.querySelector('deferred-panel')]);
275
+ if (after === null) this.textContent = 'ready';
276
+ }
277
+ }
278
+ customElements.define('deferred-panel', DeferredPanel);
279
+ const panel = document.createElement('deferred-panel');
280
+ document.body.appendChild(panel);
281
+ panel.setAttribute('disable-upgrade', '');
282
+ panel.setAttribute('unobserved', 'value');
283
+ panel.removeAttribute('disable-upgrade');
284
+ </script>`);
285
+ expect(JSON.parse(await evaluate("JSON.stringify(reactions)"))).toEqual([
286
+ ['disable-upgrade', null, '', true], ['disable-upgrade', '', null, true],
287
+ ]);
288
+ expect(await evaluate("observedReads")).toBe(1);
289
+ expect(await evaluate("document.querySelector('deferred-panel').textContent")).toBe('ready');
290
+ });
291
+
229
292
  test("documents and shadow roots have adoptedStyleSheets, which page patches of Element.prototype may extend", async () => {
230
293
  await load(`<div id="host"></div><script>
231
294
  // As a popover polyfill does: every new shadow root adopts its sheet.
@@ -354,6 +417,24 @@ test("form.elements is a live named collection of form controls", async () => {
354
417
  expect(await evaluate("document.getElementById('form').elements.length")).toBe(3);
355
418
  });
356
419
 
420
+ test("uncommitted links do not change the base used by subsequent fragment links", async () => {
421
+ for (const href of ["/archive.zip", "/LICENSE"]) {
422
+ for (const scripted of [false, true]) {
423
+ await load(`<a id="away" href="${href}">away</a><a id="toc" href="#contents">TOC</a>
424
+ <h2 id="contents">Contents</h2>`);
425
+ const before = await evaluate("[document.URL, document.baseURI, location.href, document.getElementById('toc').href]");
426
+ if (scripted) await evaluate("document.getElementById('away').click()");
427
+ else {
428
+ const point = await evaluate("(() => { const r=document.getElementById('away').getBoundingClientRect(); return [r.x+r.width/2,r.y+r.height/2]; })()");
429
+ expect(await renderer.call("click", ...point)).toBe(`https://page.test${href}`);
430
+ }
431
+ expect(await evaluate("[document.URL, document.baseURI, location.href, document.getElementById('toc').href]")).toEqual(before);
432
+ await evaluate("document.getElementById('toc').click()");
433
+ expect(await evaluate("[location.href, document.URL, document.baseURI]")).toEqual(Array(3).fill("https://page.test/#contents"));
434
+ }
435
+ }
436
+ });
437
+
357
438
  test("constructable CSSStyleSheet supports feature checks and basic rule mutation", async () => {
358
439
  await load(`<p>cssom</p>`);
359
440
  expect(await evaluate(`(async () => {
@@ -391,6 +472,23 @@ test("document.elementFromPoint finds the topmost element painted there", async
391
472
  document.elementFromPoint(300, 30)?.id, document.elementFromPoint(-1, 5)]`)).toEqual(["text", "over", "under", null]);
392
473
  });
393
474
 
475
+ test("rounded overflow clips a child's pixels rather than only the parent's background", async () => {
476
+ await load('<div style="width:40px;height:40px;border-radius:20px;overflow:hidden"><div style="width:40px;height:40px;background:red"></div></div>');
477
+ const clipped = await renderer.call("screenshot");
478
+ const { default: init } = await import('../usr/lib/canvaskit/canvaskit.js');
479
+ const kit = await init({ locateFile: name => new URL(`../usr/lib/canvaskit/${name}`, import.meta.url).pathname });
480
+ const image = kit.MakeImageFromEncoded(Buffer.from(clipped, 'base64'));
481
+ try {
482
+ const pixel = (x, y) => Array.from(image.readPixels(x, y, {
483
+ width:1, height:1, colorType:kit.ColorType.RGBA_8888, alphaType:kit.AlphaType.Unpremul, colorSpace:kit.ColorSpace.SRGB,
484
+ })).slice(0, 3);
485
+ expect(pixel(0, 0)).toEqual([255, 255, 255]);
486
+ expect(pixel(20, 20)).toEqual([255, 0, 0]);
487
+ } finally { image.delete(); }
488
+ await load('<div style="width:40px;height:40px;background:red"></div>');
489
+ expect(await renderer.call("screenshot")).not.toBe(clipped);
490
+ });
491
+
394
492
  test("scripts change the DOM and the next screenshot shows it", async () => {
395
493
  await load(`<div id="box" style="height:20px"></div>
396
494
  <script>document.getElementById('box').style.background = 'rgb(255, 0, 0)';
@@ -577,6 +675,40 @@ test("CSS.escape produces selectors for leading digits and punctuation", async (
577
675
  expect(await evaluate("CSS.escape('1:a')")).toBe("\\31 \\:a");
578
676
  });
579
677
 
678
+ test("legacy keyboard shortcuts cancel space scrolling on every press", async () => {
679
+ await load(`<div tabindex="0" id="player" style="height:100px"></div><div style="height:2000px"></div>
680
+ <script>var keys=[],player=document.getElementById('player');player.focus();player.addEventListener('keydown',e=>{
681
+ keys.push([e.key,e.code,e.keyCode,e.which,e.isTrusted]);if(e.keyCode===32)e.preventDefault();
682
+ });</script>`);
683
+ for (let i=0;i<6;i++) await renderer.call('press',' ',{code:'Space',windowsVirtualKeyCode:32});
684
+ expect(await evaluate('keys')).toEqual(Array.from({length:6},()=>[' ','Space',32,32,true]));
685
+ expect(await renderer.call('scrollOffset')).toEqual({x:0,y:0});
686
+ await evaluate('player.blur()');
687
+ await renderer.call('press',' ',{code:'Space'});
688
+ expect((await renderer.call('scrollOffset')).y).toBeGreaterThan(0);
689
+ });
690
+
691
+ test("site media controls can repeatedly toggle using playing/pause events and legacy space keys", async () => {
692
+ await load(`<div id="player" tabindex="0" style="height:100px"><video id="v" src="/long-video.mp4" style="width:100px;height:80px"></video></div><div style="height:2000px"></div>
693
+ <script>var state='paused',events=[],player=document.getElementById('player'),v=document.getElementById('v');
694
+ for(const type of ['play','playing','pause'])v.addEventListener(type,()=>{events.push(type);if(type!=='play')state=type;});
695
+ function toggle(){if(state==='playing')v.pause();else v.play();}
696
+ player.addEventListener('click',toggle);
697
+ player.addEventListener('keydown',e=>{if(e.keyCode===32){e.preventDefault();toggle();}});
698
+ player.focus();</script>`);
699
+ for (const input of ['click','press']) {
700
+ for(let i=0;i<6;i++) {
701
+ if(input==='click')await renderer.call('click',40,40);
702
+ else await renderer.call('press',' ',{code:'Space'});
703
+ const expected=i%2===0?'playing':'pause';
704
+ expect(await waitFor(`state===${JSON.stringify(expected)}`,5000)).toBe(true);
705
+ expect(await evaluate('v.paused')).toBe(i%2===1);
706
+ }
707
+ }
708
+ expect(await renderer.call('scrollOffset')).toEqual({x:0,y:0});
709
+ expect(await evaluate('events.slice(0,3)')).toEqual(['play','playing','pause']);
710
+ });
711
+
580
712
  test("a video plays frames and pauses when clicked", async () => {
581
713
  await load(`<video src="/long-video.mp4" controls width="160" height="120"></video>`);
582
714
  const bounds = await evaluate("document.querySelector('video').getBoundingClientRect().toJSON()");
@@ -1707,7 +1839,8 @@ test("initial MSE playback waits briefly for a separate audio segment", async ()
1707
1839
  await load(`<video id="v" width="160" height="120"></video>`);
1708
1840
  await evaluate(`(() => {
1709
1841
  const video = document.getElementById("v");
1710
- video.addEventListener("play", () => { window.startedAt = performance.now(); });
1842
+ // play is the unpause request; playing is when buffered media can advance.
1843
+ video.addEventListener("playing", () => { window.startedAt = performance.now(); }, { once:true });
1711
1844
  const source = new MediaSource();
1712
1845
  video.src = URL.createObjectURL(source);
1713
1846
  source.addEventListener("sourceopen", async () => {
@@ -1861,6 +1994,31 @@ test("a click inside an iframe goes to the element under it in the frame's docum
1861
1994
  expect(await evaluate(`document.getElementById("frame").contentWindow.clicked`)).toEqual([[20, 20]]);
1862
1995
  });
1863
1996
 
1997
+ test("dynamic linked stylesheets fire load after their rules become available", async () => {
1998
+ await load(`<div id="target"></div><script>
1999
+ window.sheetLoaded = false;
2000
+ const link = document.createElement('link');
2001
+ link.rel = 'stylesheet';
2002
+ link.href = '/slow.css';
2003
+ link.onload = () => { window.sheetLoaded = getComputedStyle(document.body).color; };
2004
+ document.head.appendChild(link);
2005
+ </script>`);
2006
+ for (let i = 0; i < 60 && await evaluate('sheetLoaded') === false; i++) await Bun.sleep(25);
2007
+ expect(await evaluate('sheetLoaded')).toBe('rgba(255, 0, 0, 1)');
2008
+ });
2009
+
2010
+ test("decoded dynamic images report natural dimensions and fire load", async () => {
2011
+ await load(`<script>
2012
+ window.imageLoaded = false;
2013
+ const img = document.createElement('img');
2014
+ img.onload = () => { window.imageLoaded = [img.complete, img.naturalWidth, img.naturalHeight]; };
2015
+ img.src = '/red-2x3.png';
2016
+ document.body.appendChild(img);
2017
+ </script>`);
2018
+ for (let i = 0; i < 60 && await evaluate('imageLoaded') === false; i++) await Bun.sleep(25);
2019
+ expect(await evaluate('imageLoaded')).toEqual([true, 2, 3]);
2020
+ });
2021
+
1864
2022
  test("images a script inserts and measures at once still load", async () => {
1865
2023
  await load(`<div id="host"></div><script>
1866
2024
  const img = document.createElement("img");