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.
- package/ai-debug.log +5537 -0
- package/lib/audio/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +18 -8
- package/lib/cdp-server.js +6 -0
- package/lib/happy-dom/parser.js +5 -0
- package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
- package/lib/headless-shell.js +3 -0
- package/lib/layout/text-layout.js +76 -40
- package/lib/media/video-keyframe.js +29 -0
- package/lib/paint/display-list.js +20 -5
- package/lib/render-tree/index.js +2 -0
- package/lib/renderer/bindings.js +11 -2
- package/lib/renderer/compositor.js +16 -1
- package/lib/renderer/custom-element-reactions.js +18 -0
- package/lib/renderer/history.js +42 -0
- package/lib/renderer/host.js +8 -1
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/main.js +3 -1
- package/lib/renderer/media-playback-events.js +13 -0
- package/lib/renderer/page-realm.js +9 -50
- package/lib/renderer/page-renderer.js +206 -79
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- package/lib/renderer/video-presentation.js +126 -0
- package/lib/style/computed-style.js +24 -7
- package/package.json +1 -1
- package/test/compositor.test.js +38 -0
- package/test/computed-style.test.js +21 -0
- package/test/display-list.test.js +14 -0
- package/test/download.test.js +8 -0
- package/test/headless-shell.test.js +22 -0
- package/test/media-playback-events.test.js +22 -0
- package/test/page-scripts.test.js +159 -1
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -0
- package/test/pulse-client.test.js +17 -2
- package/test/text-layout.test.js +62 -0
- 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
|
-
:
|
|
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" || (
|
|
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().
|
|
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" && !
|
|
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
package/test/compositor.test.js
CHANGED
|
@@ -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>`,
|
package/test/download.test.js
CHANGED
|
@@ -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
|
-
|
|
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");
|