buninu-browser 0.2.1 → 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/AGENTS.md +4 -0
- package/README.md +74 -9
- package/ai-debug.log +5537 -0
- package/bunfig.toml +1 -0
- package/buninu-browser.js +4 -0
- package/index.js +5 -0
- package/lib/audio/piano.js +24 -1
- package/lib/audio/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +137 -19
- package/lib/cdp-server.js +22 -11
- package/lib/happy-dom/parser.js +29 -4
- package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
- package/lib/headless-shell.js +69 -19
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +268 -85
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/media/video-keyframe.js +29 -0
- package/lib/network/network-service.js +161 -14
- package/lib/paint/display-list.js +316 -15
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +19 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +995 -69
- package/lib/renderer/canvas-2d.js +92 -0
- 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 +166 -9
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/main.js +162 -12
- package/lib/renderer/media-playback-events.js +13 -0
- package/lib/renderer/media-source.js +819 -0
- package/lib/renderer/messaging.js +411 -0
- package/lib/renderer/native-functions.js +113 -0
- package/lib/renderer/page-realm.js +1408 -242
- package/lib/renderer/page-renderer.js +1893 -223
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- package/lib/renderer/rejections.js +31 -0
- package/lib/renderer/rewrite-worker.js +22 -0
- package/lib/renderer/script-rewrite.js +805 -57
- package/lib/renderer/trusted-types.js +129 -0
- package/lib/renderer/video-presentation.js +126 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +186 -29
- package/lib/user-agent.js +14 -2
- package/package.json +4 -1
- package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
- package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
- package/patches/happy-dom/README.md +26 -0
- package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
- package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
- package/patches/ses/README.md +4 -0
- package/test/canvas-2d.test.js +55 -0
- package/test/compositor.test.js +41 -1
- package/test/computed-style.test.js +59 -1
- package/test/display-list.test.js +88 -1
- package/test/download.test.js +43 -0
- package/test/fixtures/long-video.mp4 +0 -0
- package/test/fixtures/media-sync.js +57 -0
- package/test/happy-dom-platform-adapters.test.js +25 -0
- package/test/headless-shell.test.js +23 -1
- package/test/hit-test.test.js +25 -1
- package/test/media-playback-events.test.js +22 -0
- package/test/media-source.test.js +156 -0
- package/test/media-sync.test.js +12 -0
- package/test/mobile.test.js +2 -2
- package/test/navigation.test.js +61 -0
- package/test/network-service.test.js +14 -0
- package/test/page-scripts.test.js +1203 -7
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -0
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +84 -0
- package/test/renderer-host.test.js +5 -2
- package/test/screenshot-cli.test.js +25 -1
- package/test/script-rewrite.test.js +111 -6
- package/test/setup.js +5 -0
- package/test/text-layout.test.js +62 -0
- package/test/user-agent.test.js +60 -4
- package/test/video-presentation.test.js +67 -0
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
- package/vendor/ses/dist/ses.mjs +52 -6
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()");
|
|
@@ -151,6 +159,41 @@ test("CDP Page.getResourceContent returns the bytes base64-encoded", async () =>
|
|
|
151
159
|
}
|
|
152
160
|
});
|
|
153
161
|
|
|
162
|
+
test("CDP downloads the committed document after a navigation outside CDP", async () => {
|
|
163
|
+
const { CdpServer } = await import("../lib/cdp-server.js");
|
|
164
|
+
const browser = await createBrowser({ spareRenderer: false });
|
|
165
|
+
const cdp = CdpServer.create(browser.context).listen(0, "127.0.0.1");
|
|
166
|
+
let socket;
|
|
167
|
+
try {
|
|
168
|
+
const target = await (await fetch(`http://127.0.0.1:${cdp.port}/json/new?about:blank`, { method: "PUT" })).json();
|
|
169
|
+
socket = new WebSocket(target.webSocketDebuggerUrl);
|
|
170
|
+
await new Promise(resolve => socket.addEventListener("open", resolve, { once: true }));
|
|
171
|
+
let nextId = 0;
|
|
172
|
+
const call = (method, params = {}) => new Promise(resolve => {
|
|
173
|
+
const id = ++nextId;
|
|
174
|
+
const listener = ({ data }) => {
|
|
175
|
+
const message = JSON.parse(data);
|
|
176
|
+
if (message.id !== id) return;
|
|
177
|
+
socket.removeEventListener("message", listener);
|
|
178
|
+
resolve(message);
|
|
179
|
+
};
|
|
180
|
+
socket.addEventListener("message", listener);
|
|
181
|
+
socket.send(JSON.stringify({ id, method, params }));
|
|
182
|
+
});
|
|
183
|
+
await browser.context.navigate(`http://127.0.0.1:${server.port}/start`);
|
|
184
|
+
const { result: { frameTree } } = await call("Page.getFrameTree");
|
|
185
|
+
expect(frameTree.frame.url).toBe(`http://127.0.0.1:${server.port}/page`);
|
|
186
|
+
expect(frameTree.frame.mimeType).toBe("text/html");
|
|
187
|
+
const result = await call("Page.getResourceContent", { frameId: frameTree.frame.id, url: frameTree.frame.url });
|
|
188
|
+
expect(result.error).toBeUndefined();
|
|
189
|
+
expect(Buffer.from(result.result.content, "base64")).toEqual(Buffer.from(BIG5));
|
|
190
|
+
} finally {
|
|
191
|
+
socket?.close();
|
|
192
|
+
cdp.stop(true);
|
|
193
|
+
browser.close();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
154
197
|
test("CDP download behavior controls the directory and emits completion events", async () => {
|
|
155
198
|
const { CdpServer } = await import("../lib/cdp-server.js");
|
|
156
199
|
const dir = mkdtempSync(join(tmpdir(), "buninu-cdp-download-"));
|
|
Binary file
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Isolated because host scripting installs browser globals in this process.
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
|
|
4
|
+
|
|
5
|
+
const sleep = Bun.sleep;
|
|
6
|
+
const success = () => process.stdout.write("media clock integration passed\n");
|
|
7
|
+
let output, frames = 0, samples = 0;
|
|
8
|
+
const errors = [];
|
|
9
|
+
const renderer = await createPageRenderer({
|
|
10
|
+
fetchResource: async () => ({ ok: true, body: new Uint8Array([1]), contentType: "video/mp4" }),
|
|
11
|
+
reportError: error => errors.push(String(error)),
|
|
12
|
+
videoDecoder: {
|
|
13
|
+
probe: async () => ({ duration: 10, video: { width: 2, height: 2 }, audio: { sampleRate: 1000 } }),
|
|
14
|
+
decode: async () => ({ width: 2, height: 2, fps: 10,
|
|
15
|
+
nextFrame(w, h) { frames++; return new Uint8Array(w * h * 4); }, seek() {}, close() {} }),
|
|
16
|
+
decodeAudio: async () => ({ sampleRate: 1000, channels: 1,
|
|
17
|
+
nextSamples() { return new Float32Array(100); }, close() {} }),
|
|
18
|
+
},
|
|
19
|
+
audio: {
|
|
20
|
+
open(options, listener) { output = listener; return 1; },
|
|
21
|
+
write(id, bytes) { samples += bytes.length / 2; },
|
|
22
|
+
close() { return Promise.resolve(samples * 2); },
|
|
23
|
+
finish() {},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
try {
|
|
27
|
+
renderer.setScripting("host");
|
|
28
|
+
await renderer.loadDocument({ url: "https://media.test/", source: '<video id="v" src="clip.mp4"></video>', contentType: "text/html" });
|
|
29
|
+
await renderer.evaluate("document.getElementById('v').play()");
|
|
30
|
+
output({ event: "request", bytes: 1000 });
|
|
31
|
+
assert.equal(samples, 500);
|
|
32
|
+
await sleep(180);
|
|
33
|
+
assert.equal(await renderer.evaluate("document.getElementById('v').currentTime"), 0,
|
|
34
|
+
"queued samples are not played samples");
|
|
35
|
+
const firstFrames = frames;
|
|
36
|
+
await sleep(180);
|
|
37
|
+
assert.equal(frames, firstFrames, "video cannot outrun an audio device which hasn't started");
|
|
38
|
+
output({ event: "timing", seconds: 0.2, sentSeconds: 0.5, sinkSeconds: 0.3 });
|
|
39
|
+
await sleep(160);
|
|
40
|
+
assert.equal(await renderer.evaluate("document.getElementById('v').currentTime"), 0.2);
|
|
41
|
+
const stalledFrames = frames;
|
|
42
|
+
await sleep(300);
|
|
43
|
+
assert.equal(frames, stalledFrames, "no clock advance through missing feedback/network starvation");
|
|
44
|
+
assert.equal(await renderer.evaluate("document.getElementById('v').currentTime"), 0.2);
|
|
45
|
+
output({ event: "timing", seconds: 0.4, sentSeconds: 0.5, sinkSeconds: 0.1 });
|
|
46
|
+
await sleep(160);
|
|
47
|
+
assert.equal(await renderer.evaluate("document.getElementById('v').currentTime"), 0.4);
|
|
48
|
+
assert.ok(frames > stalledFrames, "frames resume only with measured audio progress");
|
|
49
|
+
await renderer.evaluate("document.getElementById('v').pause()");
|
|
50
|
+
output({ event: "timing", seconds: 0.5, sentSeconds: 0.5, sinkSeconds: 0 });
|
|
51
|
+
assert.equal(await renderer.evaluate("document.getElementById('v').currentTime"), 0.4,
|
|
52
|
+
"late feedback from a closed stream cannot move a paused element");
|
|
53
|
+
assert.deepEqual(errors, []);
|
|
54
|
+
success();
|
|
55
|
+
} finally {
|
|
56
|
+
renderer.dispose();
|
|
57
|
+
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import imageSize from "../lib/happy-dom/image-size-adapter.js";
|
|
3
3
|
import MIMEType from "../lib/happy-dom/mime-type-adapter.js";
|
|
4
|
+
import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
|
|
4
5
|
|
|
5
6
|
const ONE_PIXEL_PNG = Buffer.from(
|
|
6
7
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+3MxZ5wAAAABJRU5ErkJggg==",
|
|
7
8
|
"base64",
|
|
8
9
|
);
|
|
9
10
|
|
|
11
|
+
// A 2x3 PNG.
|
|
12
|
+
const SMALL_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAAA2iEnWAAAAEElEQVR4nGP4z8AARAwoFABE0AX7pM/egAAAAABJRU5ErkJggg==";
|
|
13
|
+
|
|
10
14
|
test("MIME adapter supplies the FileReader surface without whatwg-mimetype", () => {
|
|
11
15
|
expect(String(MIMEType.parse("Text/Plain; charset=UTF-8"))).toBe("text/plain");
|
|
12
16
|
expect(MIMEType.parse("not a mime type")).toBeNull();
|
|
@@ -16,3 +20,24 @@ test("image size adapter uses Bun.Image metadata", async () => {
|
|
|
16
20
|
expect(await imageSize(ONE_PIXEL_PNG)).toEqual({ width: 1, height: 1 });
|
|
17
21
|
});
|
|
18
22
|
|
|
23
|
+
|
|
24
|
+
test("an image whose src changes while its size is read reports only the latest image", async () => {
|
|
25
|
+
const { document, window } = parseHTMLDocument("<img>");
|
|
26
|
+
const image = document.querySelector("img");
|
|
27
|
+
const events = [];
|
|
28
|
+
image.addEventListener("load", () => events.push(`load ${image.naturalWidth}x${image.naturalHeight}`));
|
|
29
|
+
image.addEventListener("error", () => events.push("error"));
|
|
30
|
+
image.setAttribute("src", `data:image/png;base64,${ONE_PIXEL_PNG.toString("base64")}`);
|
|
31
|
+
image.setAttribute("src", `data:image/png;base64,${SMALL_PNG}`);
|
|
32
|
+
// Not complete until its size is known.
|
|
33
|
+
expect(image.complete).toBeFalse();
|
|
34
|
+
await Bun.sleep(100);
|
|
35
|
+
expect(events).toEqual(["load 2x3"]);
|
|
36
|
+
expect(image.complete).toBeTrue();
|
|
37
|
+
// A removed src drops the image being read.
|
|
38
|
+
image.setAttribute("src", `data:image/png;base64,${ONE_PIXEL_PNG.toString("base64")}`);
|
|
39
|
+
image.removeAttribute("src");
|
|
40
|
+
await Bun.sleep(100);
|
|
41
|
+
expect([events.length, image.naturalWidth]).toEqual([1, 0]);
|
|
42
|
+
window.happyDOM.abort();
|
|
43
|
+
});
|
|
@@ -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,
|
|
@@ -351,7 +373,7 @@ test("a client may set up and navigate before the first renderer is up", async (
|
|
|
351
373
|
fetched++;
|
|
352
374
|
return new Response("<!doctype html><title>early</title><p>x</p>", { headers: { "content-type": "text/html" } });
|
|
353
375
|
} });
|
|
354
|
-
const browser = await createBrowser({ spareRenderer: false, waitForRenderer: false });
|
|
376
|
+
const browser = await createBrowser({ spareRenderer: false, waitForRenderer: false, webkit: false });
|
|
355
377
|
let ready = false;
|
|
356
378
|
browser.ready.then(() => { ready = true; });
|
|
357
379
|
try {
|
package/test/hit-test.test.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
|
|
3
|
-
import { elementBounds, hitTest, interactiveRegions } from "../lib/input/hit-test.js";
|
|
3
|
+
import { elementAtPoint, elementBounds, hitTest, interactiveRegions } from "../lib/input/hit-test.js";
|
|
4
|
+
import { buildDisplayList } from "../lib/paint/display-list.js";
|
|
4
5
|
import { layoutText } from "../lib/layout/text-layout.js";
|
|
5
6
|
import { RenderTreeBuilder } from "../lib/render-tree/index.js";
|
|
6
7
|
import { StyleEngine } from "../lib/style/computed-style.js";
|
|
@@ -84,3 +85,26 @@ test("hidden and pointer-events: none boxes let clicks through, but their opted-
|
|
|
84
85
|
expect(regions.map((region) => region.element.getAttribute("href") ?? region.element.tagName)).toEqual(["open", "shown", "plain"]);
|
|
85
86
|
window.happyDOM.abort();
|
|
86
87
|
});
|
|
88
|
+
|
|
89
|
+
test("pointer events target the topmost element painted at a point, whatever its tag", () => {
|
|
90
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0">
|
|
91
|
+
<table><tr><td id="cell" style="width:100px;height:50px"><span id="label">tile</span></td></tr></table>
|
|
92
|
+
<div id="under" style="height:40px"></div>
|
|
93
|
+
<div id="over" style="position:absolute;top:60px;left:0;width:50px;height:20px"></div>
|
|
94
|
+
<div id="ghost" style="position:absolute;top:60px;left:50px;width:50px;height:20px;pointer-events:none"></div>
|
|
95
|
+
</body>`);
|
|
96
|
+
const styles = new StyleEngine().compute(document);
|
|
97
|
+
const tree = new RenderTreeBuilder().build(document, styles);
|
|
98
|
+
const layout = layoutText(tree, { x: 0, y: 0, width: 400, lineHeight: 20, measureText: (text) => text.length * 10, fontMetrics: () => ({ ascent: 16, height: 20 }) });
|
|
99
|
+
const displayList = buildDisplayList(layout, tree);
|
|
100
|
+
const at = (x, y) => elementAtPoint(tree, displayList, x, y, { x: 0, y: 0 })?.id ?? null;
|
|
101
|
+
const label = layout.fragments.flatMap((fragment) => fragment.runs.map((run) => ({ run, fragment })))
|
|
102
|
+
.find(({ run }) => tree.nodesById.get(run.nodeId)?.domNode?.parentElement?.id === "label");
|
|
103
|
+
// Text hits its element; the rest of the cell hits the cell.
|
|
104
|
+
expect(at(label.run.x + 1, label.fragment.y + 1)).toBe("label");
|
|
105
|
+
expect(at(label.run.x + label.run.width + 20, label.fragment.y + 1)).toBe("cell");
|
|
106
|
+
// A positioned box paints over the in-flow one; pointer-events:none passes through.
|
|
107
|
+
expect(at(10, 65)).toBe("over");
|
|
108
|
+
expect(at(60, 65)).toBe("under");
|
|
109
|
+
window.happyDOM.abort();
|
|
110
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { createMediaSourceScope } from "../lib/renderer/media-source.js";
|
|
3
|
+
|
|
4
|
+
function scope(options = {}) {
|
|
5
|
+
const tasks = [];
|
|
6
|
+
const media = createMediaSourceScope({
|
|
7
|
+
origin: "https://page.test",
|
|
8
|
+
schedule: (task) => tasks.push(task),
|
|
9
|
+
DOMException,
|
|
10
|
+
...options,
|
|
11
|
+
});
|
|
12
|
+
const flush = () => {
|
|
13
|
+
while (tasks.length) tasks.shift()();
|
|
14
|
+
};
|
|
15
|
+
return { media, flush };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function openBuffer(context) {
|
|
19
|
+
const source = new context.media.MediaSource();
|
|
20
|
+
source.attach("blob:https://page.test/media");
|
|
21
|
+
context.flush();
|
|
22
|
+
return { source, buffer: source.addSourceBuffer('video/mp4; codecs="avc1.4d401e"') };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function box(type, ...parts) {
|
|
26
|
+
const size = 8 + parts.reduce((sum, part) => sum + part.byteLength, 0);
|
|
27
|
+
const bytes = new Uint8Array(size);
|
|
28
|
+
const view = new DataView(bytes.buffer);
|
|
29
|
+
view.setUint32(0, size);
|
|
30
|
+
for (let index = 0; index < 4; index++) bytes[4 + index] = type.charCodeAt(index);
|
|
31
|
+
let offset = 8;
|
|
32
|
+
for (const part of parts) {
|
|
33
|
+
bytes.set(part, offset);
|
|
34
|
+
offset += part.byteLength;
|
|
35
|
+
}
|
|
36
|
+
return bytes;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fragmentedMp4Pieces() {
|
|
40
|
+
const mdhd = new Uint8Array(20);
|
|
41
|
+
new DataView(mdhd.buffer).setUint32(12, 1000);
|
|
42
|
+
const init = box("moov", box("trak", box("mdia", box("mdhd", mdhd))));
|
|
43
|
+
const segment = (base) => {
|
|
44
|
+
const tfhd = new Uint8Array(12);
|
|
45
|
+
tfhd[3] = 8; // default_sample_duration_present
|
|
46
|
+
new DataView(tfhd.buffer).setUint32(4, 1);
|
|
47
|
+
new DataView(tfhd.buffer).setUint32(8, 1000);
|
|
48
|
+
const tfdt = new Uint8Array(8);
|
|
49
|
+
new DataView(tfdt.buffer).setUint32(4, base);
|
|
50
|
+
const trun = new Uint8Array(8);
|
|
51
|
+
new DataView(trun.buffer).setUint32(4, 5);
|
|
52
|
+
return box("moof", box("traf", box("tfhd", tfhd), box("tfdt", tfdt), box("trun", trun)));
|
|
53
|
+
};
|
|
54
|
+
return [init, segment(0), segment(5000)];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test("SourceBuffer.abort is harmless while idle and cancels a pending append", () => {
|
|
58
|
+
const context = scope();
|
|
59
|
+
const { buffer } = openBuffer(context);
|
|
60
|
+
expect(() => buffer.abort()).not.toThrow();
|
|
61
|
+
|
|
62
|
+
const events = [];
|
|
63
|
+
buffer.addEventListener("abort", () => events.push("abort"));
|
|
64
|
+
buffer.addEventListener("updateend", () => events.push("updateend"));
|
|
65
|
+
buffer.appendBuffer(new Uint8Array([1, 2, 3]));
|
|
66
|
+
buffer.abort();
|
|
67
|
+
context.flush();
|
|
68
|
+
|
|
69
|
+
expect(buffer.updating).toBe(false);
|
|
70
|
+
expect(buffer._pipe.size).toBe(0);
|
|
71
|
+
expect(events).toEqual(["abort", "updateend"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("removing an old buffered prefix releases append quota", () => {
|
|
75
|
+
const context = scope({ appendQuota: 10 });
|
|
76
|
+
const { source, buffer } = openBuffer(context);
|
|
77
|
+
buffer.appendBuffer(box("mdat", new Uint8Array(2)));
|
|
78
|
+
context.flush();
|
|
79
|
+
source.noteBuffered(10);
|
|
80
|
+
|
|
81
|
+
expect(() => buffer.appendBuffer(new Uint8Array(1))).toThrow("SourceBuffer is full");
|
|
82
|
+
buffer.remove(0, 5);
|
|
83
|
+
context.flush();
|
|
84
|
+
expect(buffer.buffered.length).toBe(1);
|
|
85
|
+
expect(buffer.buffered.start(0)).toBe(5);
|
|
86
|
+
expect(buffer.buffered.end(0)).toBe(10);
|
|
87
|
+
expect(() => buffer.appendBuffer(new Uint8Array(5))).not.toThrow();
|
|
88
|
+
context.flush();
|
|
89
|
+
expect(buffer.updating).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("fragmented MP4 buffered end follows appended segments, not presentation duration", () => {
|
|
93
|
+
const context = scope();
|
|
94
|
+
const { source, buffer } = openBuffer(context);
|
|
95
|
+
const [init, first, second] = fragmentedMp4Pieces();
|
|
96
|
+
buffer.appendBuffer(init);
|
|
97
|
+
context.flush();
|
|
98
|
+
// Probing an init segment can expose the full presentation duration before
|
|
99
|
+
// the first media fragment supplies the actual buffered timeline.
|
|
100
|
+
source.noteBuffered(700);
|
|
101
|
+
for (const bytes of [first, second]) {
|
|
102
|
+
buffer.appendBuffer(bytes);
|
|
103
|
+
context.flush();
|
|
104
|
+
}
|
|
105
|
+
expect(buffer.buffered.length).toBe(1);
|
|
106
|
+
expect(buffer.buffered.end(0)).toBe(10);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("WebM buffered end follows Cluster timecodes, not presentation duration", () => {
|
|
110
|
+
const context = scope();
|
|
111
|
+
const source = new context.media.MediaSource();
|
|
112
|
+
source.attach("blob:https://page.test/audio");
|
|
113
|
+
context.flush();
|
|
114
|
+
const buffer = source.addSourceBuffer('audio/webm; codecs="opus"');
|
|
115
|
+
// Cluster timecode 0, one SimpleBlock at +1000 ms, with a dummy payload.
|
|
116
|
+
buffer.appendBuffer(Uint8Array.from([
|
|
117
|
+
0x1f, 0x43, 0xb6, 0x75, 0x8a,
|
|
118
|
+
0xe7, 0x81, 0x00,
|
|
119
|
+
0xa3, 0x85, 0x81, 0x03, 0xe8, 0x00, 0x00,
|
|
120
|
+
]));
|
|
121
|
+
context.flush();
|
|
122
|
+
source.noteBuffered(700);
|
|
123
|
+
expect(buffer.buffered.end(0)).toBeCloseTo(1.02, 5);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a media element reports the intersection of separate audio and video buffers", () => {
|
|
127
|
+
const context = scope();
|
|
128
|
+
const { source, buffer: video } = openBuffer(context);
|
|
129
|
+
const audio = source.addSourceBuffer('audio/webm; codecs="opus"');
|
|
130
|
+
video.noteEnd(5);
|
|
131
|
+
audio.noteEnd(8);
|
|
132
|
+
expect(source.bufferedEnd()).toBe(5);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a media segment appended again is not concatenated twice, while gaps are still filled", () => {
|
|
136
|
+
const context = scope();
|
|
137
|
+
const { buffer } = openBuffer(context);
|
|
138
|
+
const [init, first, second] = fragmentedMp4Pieces();
|
|
139
|
+
let updates = 0;
|
|
140
|
+
buffer.addEventListener("updateend", () => updates++);
|
|
141
|
+
// The second segment arrives first; the first then fills the gap before it.
|
|
142
|
+
for (const bytes of [init, second, first]) {
|
|
143
|
+
buffer.appendBuffer(bytes);
|
|
144
|
+
context.flush();
|
|
145
|
+
}
|
|
146
|
+
const size = buffer._pipe.size;
|
|
147
|
+
expect(size).toBe(init.byteLength + second.byteLength + first.byteLength);
|
|
148
|
+
// A player retrying both segments on a slow network.
|
|
149
|
+
for (const bytes of [first, second]) {
|
|
150
|
+
buffer.appendBuffer(bytes);
|
|
151
|
+
context.flush();
|
|
152
|
+
}
|
|
153
|
+
expect(buffer._pipe.size).toBe(size);
|
|
154
|
+
expect(updates).toBe(5);
|
|
155
|
+
expect(buffer.buffered.end(0)).toBe(10);
|
|
156
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
test("renderer follows output progress, not wall time or submitted audio", async () => {
|
|
4
|
+
const child = Bun.spawn([process.execPath, new URL("./fixtures/media-sync.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("media clock integration passed");
|
|
12
|
+
}, 30_000);
|
package/test/mobile.test.js
CHANGED
|
@@ -52,8 +52,8 @@ test("--mobile sends the mobile user agent, ignores overrides and applies touch
|
|
|
52
52
|
});
|
|
53
53
|
}, 30_000);
|
|
54
54
|
|
|
55
|
-
test("
|
|
56
|
-
const state = await pageState({});
|
|
55
|
+
test("Chromium desktop allows overrides and does not apply touch rules", async () => {
|
|
56
|
+
const state = await pageState({ webkit: false });
|
|
57
57
|
expect(state).toMatchObject({
|
|
58
58
|
requestUserAgent: "Desktop/1.0 (casty override)",
|
|
59
59
|
navigatorUserAgent: "Desktop/1.0 (casty override)",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
2
|
+
import { createBrowser } from "../lib/headless-shell.js";
|
|
3
|
+
|
|
4
|
+
let server;
|
|
5
|
+
let browser;
|
|
6
|
+
const posts = [];
|
|
7
|
+
beforeAll(async () => {
|
|
8
|
+
server = Bun.serve({
|
|
9
|
+
port: 0,
|
|
10
|
+
async fetch(request) {
|
|
11
|
+
const { pathname } = new URL(request.url);
|
|
12
|
+
const html = (body, init = {}) => new Response(`<!doctype html>${body}`, { ...init, headers: { "content-type": "text/html" } });
|
|
13
|
+
if (pathname === "/start") return new Response(null, { status: 302, headers: { location: "/dir/form?step=1#top" } });
|
|
14
|
+
if (pathname === "/dir/form") {
|
|
15
|
+
// An error status with a page, as some challenge pages come.
|
|
16
|
+
return html(`<title>form</title><form id="f" action="submit" method="post">
|
|
17
|
+
<input name="q" value="a b&c"><input type="hidden" name="continue" value="/next"></form>`, { status: 429 });
|
|
18
|
+
}
|
|
19
|
+
if (pathname === "/dir/submit") {
|
|
20
|
+
posts.push({
|
|
21
|
+
method: request.method,
|
|
22
|
+
body: await request.text(),
|
|
23
|
+
type: request.headers.get("content-type"),
|
|
24
|
+
origin: request.headers.get("origin"),
|
|
25
|
+
referer: request.headers.get("referer"),
|
|
26
|
+
});
|
|
27
|
+
return new Response(null, { status: 303, headers: { location: "/done" } });
|
|
28
|
+
}
|
|
29
|
+
if (pathname === "/done") return html(`<title>done ${request.method}</title>`);
|
|
30
|
+
return new Response("missing", { status: 404 });
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
browser = await createBrowser({ spareRenderer: false });
|
|
34
|
+
});
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
browser?.close();
|
|
37
|
+
server?.stop(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a redirected document has the URL it ended on, and its POST form submits there", async () => {
|
|
41
|
+
const { context } = browser;
|
|
42
|
+
const origin = `http://127.0.0.1:${server.port}`;
|
|
43
|
+
await context.navigate(`${origin}/start`);
|
|
44
|
+
// The final URL, with the request's fragment carried over; relative URLs resolve against it.
|
|
45
|
+
expect(await context.evaluate(`[location.href, document.baseURI, document.getElementById("f").action]`))
|
|
46
|
+
.toEqual([`${origin}/dir/form?step=1#top`, `${origin}/dir/form?step=1#top`, `${origin}/dir/submit`]);
|
|
47
|
+
|
|
48
|
+
await context.evaluate(`document.getElementById("f").submit()`);
|
|
49
|
+
const deadline = Date.now() + 10_000;
|
|
50
|
+
while (await context.title() !== "done GET" && Date.now() < deadline) await Bun.sleep(50);
|
|
51
|
+
expect(await context.title()).toBe("done GET");
|
|
52
|
+
expect(posts).toEqual([{
|
|
53
|
+
method: "POST",
|
|
54
|
+
body: "q=a+b%26c&continue=%2Fnext",
|
|
55
|
+
type: "application/x-www-form-urlencoded",
|
|
56
|
+
origin,
|
|
57
|
+
referer: `${origin}/dir/form?step=1`,
|
|
58
|
+
}]);
|
|
59
|
+
// The 303 after it was followed with a GET, and the document is at its URL.
|
|
60
|
+
expect(await context.evaluate("location.href")).toBe(`${origin}/done`);
|
|
61
|
+
});
|
|
@@ -26,6 +26,8 @@ beforeAll(() => {
|
|
|
26
26
|
});
|
|
27
27
|
case "/home":
|
|
28
28
|
return new Response("home");
|
|
29
|
+
case "/moved":
|
|
30
|
+
return new Response("moved here", { status: 301, headers: { location: "/home", ...cors() } });
|
|
29
31
|
case "/app/deep/set":
|
|
30
32
|
return new Response("ok", { headers: [["set-cookie", "scoped=1"]] });
|
|
31
33
|
case "/api/simple":
|
|
@@ -65,6 +67,18 @@ test("follows redirects by hand and stores cookies on every hop", async () => {
|
|
|
65
67
|
expect(response.headers.some(([name]) => name === "set-cookie")).toBeFalse();
|
|
66
68
|
});
|
|
67
69
|
|
|
70
|
+
test("a manual redirect gives the page an opaque-redirect response", async () => {
|
|
71
|
+
seen = [];
|
|
72
|
+
const network = new NetworkService();
|
|
73
|
+
const same = await network.pageFetch({ url: local("/moved"), redirect: "manual" }, local("/"));
|
|
74
|
+
expect(same).toMatchObject({ type: "opaqueredirect", status: 0, url: "", headers: [] });
|
|
75
|
+
expect(same.body.byteLength).toBe(0);
|
|
76
|
+
// Cross-origin: the redirect still passes the CORS check.
|
|
77
|
+
const cross = await network.pageFetch({ url: local(`/moved?allow=${encodeURIComponent(loopback(""))}`), redirect: "manual" }, loopback("/"));
|
|
78
|
+
expect(cross).toMatchObject({ type: "opaqueredirect", status: 0 });
|
|
79
|
+
expect(seen.map((entry) => entry.path)).toEqual(["/moved", "/moved"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
68
82
|
test("applies the default path and SameSite to cross-site requests", async () => {
|
|
69
83
|
seen = [];
|
|
70
84
|
const network = new NetworkService();
|