buninu-browser 0.2.0 → 0.2.2
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/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/pulse-client.js +120 -12
- package/lib/cdp-server.js +19 -12
- package/lib/happy-dom/parser.js +24 -4
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +120 -28
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +365 -54
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/network/network-service.js +326 -46
- package/lib/paint/display-list.js +322 -21
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +62 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +989 -72
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/host.js +159 -9
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/main.js +160 -12
- 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 +1407 -200
- package/lib/renderer/page-renderer.js +1899 -394
- 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/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +463 -242
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- 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 +3 -1
- package/test/computed-style.test.js +98 -1
- package/test/display-list.test.js +74 -1
- package/test/download.test.js +35 -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 +29 -0
- package/test/hit-test.test.js +25 -1
- package/test/log.test.js +4 -1
- 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 +81 -0
- package/test/page-scripts.test.js +1094 -7
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +69 -0
- package/test/render-tree.test.js +22 -1
- 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/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/setup.js +5 -0
- package/test/text-layout.test.js +69 -1
- package/test/user-agent.test.js +60 -4
- 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
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
|
|
3
3
|
import { RenderTreeBuilder, renderTreeText } from "../lib/render-tree/index.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
absolutizeStyleSheetUrls, computeElementStyle, matchSelectors, parseDeclarations, StyleEngine,
|
|
6
|
+
} from "../lib/style/computed-style.js";
|
|
5
7
|
|
|
6
8
|
test("computes the initial UA and inherited style subset", () => {
|
|
7
9
|
const { document, window } = parseHTMLDocument(`<body>
|
|
@@ -381,6 +383,50 @@ test("cascade layers: unlayered beats layered, later layers win, !important reve
|
|
|
381
383
|
window.happyDOM.abort();
|
|
382
384
|
});
|
|
383
385
|
|
|
386
|
+
test("sheets parsed once keep layer order and cascade when the sheet list changes or repeats", () => {
|
|
387
|
+
const { document, window } = parseHTMLDocument(`<p id="a" class="x">t</p><p id="b" class="y">u</p>`);
|
|
388
|
+
const base = `@layer one, two; @layer two { .x { color: blue } } @layer one { .x { color: red; display: flex } }`;
|
|
389
|
+
const anonymous = `@layer { .y { color: green !important } } .y { display: inline }`;
|
|
390
|
+
const later = `@layer three { .x { display: block } } .y { color: black }`;
|
|
391
|
+
const lists = [
|
|
392
|
+
[base, later],
|
|
393
|
+
[base, later, base], // a repeated sheet: its earlier copy still declares the layers first
|
|
394
|
+
[later, base],
|
|
395
|
+
[anonymous, later, anonymous], // anonymous layers: both copies count under !important
|
|
396
|
+
[later, `@layer two { .x { display: grid } }`, base],
|
|
397
|
+
];
|
|
398
|
+
const engine = new StyleEngine();
|
|
399
|
+
for (const sheets of lists) {
|
|
400
|
+
engine.compute(document, sheets, {}, new Set([document.body]), null);
|
|
401
|
+
const fresh = new StyleEngine().compute(document, sheets.map((sheet) => `${sheet} `));
|
|
402
|
+
for (const id of ["a", "b"]) expect(engine.get(document.getElementById(id))).toEqual(fresh.get(document.getElementById(id)));
|
|
403
|
+
}
|
|
404
|
+
engine.compute(document, [base, later, base]);
|
|
405
|
+
expect(engine.get(document.getElementById("a")).color).toBe("rgba(0, 0, 255, 1)");
|
|
406
|
+
expect(engine.get(document.getElementById("a")).display).toBe("block");
|
|
407
|
+
window.happyDOM.abort();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("matchSelectors finds what querySelectorAll finds, in one walk", () => {
|
|
411
|
+
const { document, window } = parseHTMLDocument(`<main><ul><li class="a">1</li><li id="i">2</li><li class="a b">3</li></ul>
|
|
412
|
+
<p>x</p><p class="a"><span>y</span></p><div><span hidden>z</span></div></main>`);
|
|
413
|
+
const selectors = ["li:first-child", ".a + li", "li ~ .b", "p:has(span)", ":is(ul, div) > *", "#i", "[hidden]", "*", ":root", "main span", "1x", "li:nth-child(2n+1)"];
|
|
414
|
+
// Elements compared as sets of document positions (deep equality of DOM
|
|
415
|
+
// nodes is slow, and querySelectorAll() may list an element twice).
|
|
416
|
+
const all = [...document.querySelectorAll("*")];
|
|
417
|
+
const positions = (elements) => [...new Set(elements)].map((element) => all.indexOf(element)).sort((x, y) => x - y);
|
|
418
|
+
const matched = matchSelectors(document, selectors);
|
|
419
|
+
for (const selector of selectors) {
|
|
420
|
+
let expected = null;
|
|
421
|
+
try { expected = positions(document.querySelectorAll(selector)); } catch {}
|
|
422
|
+
expect(matched.has(selector) ? positions(matched.get(selector)) : null).toEqual(expected);
|
|
423
|
+
}
|
|
424
|
+
// From an element: its descendants and the element itself.
|
|
425
|
+
const ul = document.querySelector("ul");
|
|
426
|
+
expect(positions(matchSelectors(ul, [":is(ul, li)"]).get(":is(ul, li)"))).toEqual(positions([ul, ...ul.children]));
|
|
427
|
+
window.happyDOM.abort();
|
|
428
|
+
});
|
|
429
|
+
|
|
384
430
|
test("the font shorthand resets its parts and competes with font-size in the cascade", () => {
|
|
385
431
|
const { document, window } = parseHTMLDocument(`<h2 id="a" style="font-size:32px;line-height:3">t</h2><h2 id="b">x</h2>
|
|
386
432
|
<p id="c" style="font:italic small-caps bold 12px/1.5 Georgia, serif">u</p><p id="d" style="font-size:large">v</p>`);
|
|
@@ -455,6 +501,22 @@ test("the font shorthand allows spaces around the line-height slash", () => {
|
|
|
455
501
|
}
|
|
456
502
|
});
|
|
457
503
|
|
|
504
|
+
test("incremental restyling retains equal computed-style identities", () => {
|
|
505
|
+
const { document, window } = parseHTMLDocument(`<body><div><span>same</span></div></body>`);
|
|
506
|
+
const div = document.querySelector("div");
|
|
507
|
+
const span = document.querySelector("span");
|
|
508
|
+
const engine = new StyleEngine().compute(document, [".active { color: red }"]);
|
|
509
|
+
const before = [engine.get(div), engine.get(span)];
|
|
510
|
+
// Visiting this subtree is conservative: the class changes, but neither
|
|
511
|
+
// element's final values change.
|
|
512
|
+
div.className = "unmatched";
|
|
513
|
+
engine.compute(document, [".active { color: red }"], {}, new Set([div]));
|
|
514
|
+
expect([engine.get(div), engine.get(span)]).toEqual(before);
|
|
515
|
+
expect(engine.get(div)).toBe(before[0]);
|
|
516
|
+
expect(engine.get(span)).toBe(before[1]);
|
|
517
|
+
window.happyDOM.abort();
|
|
518
|
+
});
|
|
519
|
+
|
|
458
520
|
test("incremental restyles of changed subtrees and added sheets equal a full restyle", () => {
|
|
459
521
|
const { document, window } = parseHTMLDocument(`<body><div id="a" class="x"><p>one</p><p>two</p></div><div id="b"><span>s</span></div></body>`);
|
|
460
522
|
const sheets = [".x p { color: red } .y p { color: blue; font-size: 20px } #b span { font-weight: 700 }"];
|
|
@@ -484,3 +546,38 @@ test("incremental restyles of changed subtrees and added sheets equal a full res
|
|
|
484
546
|
same("added sheet");
|
|
485
547
|
window.happyDOM.abort();
|
|
486
548
|
});
|
|
549
|
+
|
|
550
|
+
test("background shorthand and longhands keep image layers with their position, size and repeat", () => {
|
|
551
|
+
const { document, window } = parseHTMLDocument(`<body>
|
|
552
|
+
<div id="short" style="background: url(icon.png) no-repeat center / 32px 16px, linear-gradient(red, blue) #fff"></div>
|
|
553
|
+
<div id="long" style="background: url('a.png'); background-size: cover; background-position: right 4px top; background-repeat: repeat-x"></div>
|
|
554
|
+
<div id="color" style="background: url(x.png); background: #000"></div>
|
|
555
|
+
<div id="invalid" style="background: url(x.png) bogus-token"></div>
|
|
556
|
+
</body>`);
|
|
557
|
+
const engine = new StyleEngine().compute(document);
|
|
558
|
+
const style = (id) => engine.get(document.getElementById(id));
|
|
559
|
+
expect(style("short")).toMatchObject({
|
|
560
|
+
backgroundImage: "url(icon.png), linear-gradient(red, blue)",
|
|
561
|
+
backgroundPosition: "center, 0% 0%",
|
|
562
|
+
backgroundSize: "32px 16px, auto",
|
|
563
|
+
backgroundRepeat: "no-repeat, repeat",
|
|
564
|
+
backgroundColor: "rgba(255, 255, 255, 1)",
|
|
565
|
+
});
|
|
566
|
+
expect(style("long")).toMatchObject({
|
|
567
|
+
backgroundImage: "url('a.png')", backgroundSize: "cover", backgroundPosition: "right 4px top", backgroundRepeat: "repeat-x",
|
|
568
|
+
});
|
|
569
|
+
// A later shorthand resets the image layers.
|
|
570
|
+
expect(style("color")).toMatchObject({ backgroundImage: null, backgroundColor: "rgba(0, 0, 0, 1)" });
|
|
571
|
+
// An invalid shorthand is dropped as a whole.
|
|
572
|
+
expect(style("invalid")).toMatchObject({ backgroundImage: null, backgroundColor: "rgba(0, 0, 0, 0)" });
|
|
573
|
+
window.happyDOM.abort();
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("a linked style sheet's url()s resolve against the sheet", () => {
|
|
577
|
+
expect(absolutizeStyleSheetUrls(
|
|
578
|
+
`a { background: url(img/a.png) } b { background: url("../b.png") } c { background: url(data:image/png;base64,AA) }
|
|
579
|
+
d { background: url(https://cdn.test/d.png) } e { mask: url(#m) }`,
|
|
580
|
+
"https://site.test/css/main.css",
|
|
581
|
+
)).toBe(`a { background: url(https://site.test/css/img/a.png) } b { background: url("https://site.test/b.png") } c { background: url(data:image/png;base64,AA) }
|
|
582
|
+
d { background: url(https://cdn.test/d.png) } e { mask: url(#m) }`);
|
|
583
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
|
|
3
3
|
import { layoutText } from "../lib/layout/text-layout.js";
|
|
4
|
-
import { buildDisplayList, visibleItems } from "../lib/paint/display-list.js";
|
|
4
|
+
import { buildDisplayList, hitTarget, visibleItems } from "../lib/paint/display-list.js";
|
|
5
5
|
import { RenderTreeBuilder } from "../lib/render-tree/index.js";
|
|
6
6
|
import { StyleEngine } from "../lib/style/computed-style.js";
|
|
7
7
|
|
|
@@ -126,3 +126,76 @@ test("visibility, opacity and overflow/clip clipping apply to descendants", () =
|
|
|
126
126
|
window.happyDOM.abort();
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
|
|
130
|
+
function backgroundList(html, css, resources) {
|
|
131
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0">${html}</body>`);
|
|
132
|
+
const styles = new StyleEngine().compute(document, [css]);
|
|
133
|
+
const tree = new RenderTreeBuilder().build(document, styles);
|
|
134
|
+
const layout = layoutText(tree, {
|
|
135
|
+
x: 0, y: 0, width: 200, lineHeight: 20, measureText: (text) => text.length * 10, fontMetrics: () => ({ ascent: 16, height: 20 }),
|
|
136
|
+
});
|
|
137
|
+
const displayList = buildDisplayList(layout, tree, null, (url) => resources[url] ?? null);
|
|
138
|
+
window.happyDOM.abort();
|
|
139
|
+
return displayList;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
test("background images are sized, positioned and repeated in the padding box, painted in the border box", () => {
|
|
143
|
+
const icon = { width: 48, height: 24, image: {} };
|
|
144
|
+
const items = backgroundList(
|
|
145
|
+
`<div class="a"></div><div class="b"></div><div class="c"></div><div class="missing"></div>`,
|
|
146
|
+
`div { height:60px; width:100px }
|
|
147
|
+
.a { background: url(icon.png) no-repeat center / 32px; border: 2px solid black }
|
|
148
|
+
.b { background-image: url(icon.png); background-size: cover; background-repeat: repeat-x }
|
|
149
|
+
.c { background: url(icon.png) no-repeat right 5px bottom 10px }
|
|
150
|
+
.missing { background: url(none.png) }`,
|
|
151
|
+
{ "icon.png": icon },
|
|
152
|
+
).items.filter((item) => item.op === "drawBackgroundImage");
|
|
153
|
+
// The image that has not arrived paints nothing.
|
|
154
|
+
expect(items).toHaveLength(3);
|
|
155
|
+
const [a, b, c] = items;
|
|
156
|
+
// 32px wide keeps the 2:1 ratio, centered in the 100x60 padding box inside the border.
|
|
157
|
+
expect(a).toMatchObject({ tile: { x: 2 + 34, y: 2 + 22, width: 32, height: 16 }, repeatX: false, repeatY: false });
|
|
158
|
+
expect(a.bounds).toEqual({ x: 36, y: 24, width: 32, height: 16 });
|
|
159
|
+
// cover: scaled to fill both sides; repeat-x spans the box horizontally only.
|
|
160
|
+
expect(b).toMatchObject({ tile: { x: 0, y: 64, width: 120, height: 60 }, repeatX: true, repeatY: false });
|
|
161
|
+
expect(b.bounds).toEqual({ x: 0, y: 64, width: 100, height: 60 });
|
|
162
|
+
// Edge offsets: 5px from the right, 10px from the bottom.
|
|
163
|
+
expect(c.tile).toEqual({ x: 100 - 5 - 48, y: 124 + 60 - 10 - 24, width: 48, height: 24 });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("transform: scale paints and hit-tests a box and its contents about its origin", () => {
|
|
167
|
+
const displayList = backgroundList(
|
|
168
|
+
`<div class="tile"><span>x</span></div>`,
|
|
169
|
+
`.tile { width:100px; height:100px; background:#123; transform: scale(.5); overflow: hidden }`,
|
|
170
|
+
{},
|
|
171
|
+
);
|
|
172
|
+
const fill = displayList.items.find((item) => item.op === "fillRect");
|
|
173
|
+
expect(fill.transform).toEqual({ a: 0.5, d: 0.5, e: 25, f: 25 });
|
|
174
|
+
expect(fill.bounds).toEqual({ x: 25, y: 25, width: 50, height: 50 });
|
|
175
|
+
// Its overflow clip shrinks with it.
|
|
176
|
+
const text = displayList.items.find((item) => item.op === "drawText");
|
|
177
|
+
expect(text.clipRect).toEqual({ x: 25, y: 25, width: 50, height: 50 });
|
|
178
|
+
expect(hitTarget(displayList, 10, 10, { x: 0, y: 0 })).not.toBe(fill.nodeId);
|
|
179
|
+
expect(hitTarget(displayList, 30, 30, { x: 0, y: 0 })).toBe(fill.nodeId);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("a scroll container moves its content by its scroll offset and reports how far the content reaches", () => {
|
|
183
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0"><div id="box"><p>a</p><p>b</p><p>c</p></div></body>`);
|
|
184
|
+
const styles = new StyleEngine().compute(document, [`#box { height:40px; overflow:auto; background:#123 } p { margin:0; height:20px }`]);
|
|
185
|
+
const tree = new RenderTreeBuilder().build(document, styles);
|
|
186
|
+
const layout = layoutText(tree, {
|
|
187
|
+
x: 0, y: 0, width: 200, lineHeight: 20, measureText: (text) => text.length * 10, fontMetrics: () => ({ ascent: 16, height: 20 }),
|
|
188
|
+
});
|
|
189
|
+
const box = document.getElementById("box");
|
|
190
|
+
const displayList = buildDisplayList(layout, tree, null, null, (element) => element === box ? { x: 0, y: 15 } : null);
|
|
191
|
+
window.happyDOM.abort();
|
|
192
|
+
const background = displayList.items.find((item) => item.op === "fillRect");
|
|
193
|
+
// The container itself stays; its text moves up by the offset, clipped to it.
|
|
194
|
+
expect(background.transform).toBeUndefined();
|
|
195
|
+
const second = displayList.items.find((item) => item.op === "drawText" && item.text === "b");
|
|
196
|
+
expect(second.transform).toEqual({ a: 1, d: 1, e: 0, f: -15 });
|
|
197
|
+
expect(second.clipRect).toEqual({ x: 0, y: 0, width: 200, height: 40 });
|
|
198
|
+
const boxId = [...tree.nodesById.values()].find((node) => node.domNode === box).id;
|
|
199
|
+
expect(displayList.scrollers.get(boxId)).toMatchObject({ bottom: 60 });
|
|
200
|
+
expect(hitTarget(displayList, 5, 10, { x: 0, y: 0 })).toBe(second.nodeId);
|
|
201
|
+
});
|
package/test/download.test.js
CHANGED
|
@@ -151,6 +151,41 @@ test("CDP Page.getResourceContent returns the bytes base64-encoded", async () =>
|
|
|
151
151
|
}
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
test("CDP downloads the committed document after a navigation outside CDP", async () => {
|
|
155
|
+
const { CdpServer } = await import("../lib/cdp-server.js");
|
|
156
|
+
const browser = await createBrowser({ spareRenderer: false });
|
|
157
|
+
const cdp = CdpServer.create(browser.context).listen(0, "127.0.0.1");
|
|
158
|
+
let socket;
|
|
159
|
+
try {
|
|
160
|
+
const target = await (await fetch(`http://127.0.0.1:${cdp.port}/json/new?about:blank`, { method: "PUT" })).json();
|
|
161
|
+
socket = new WebSocket(target.webSocketDebuggerUrl);
|
|
162
|
+
await new Promise(resolve => socket.addEventListener("open", resolve, { once: true }));
|
|
163
|
+
let nextId = 0;
|
|
164
|
+
const call = (method, params = {}) => new Promise(resolve => {
|
|
165
|
+
const id = ++nextId;
|
|
166
|
+
const listener = ({ data }) => {
|
|
167
|
+
const message = JSON.parse(data);
|
|
168
|
+
if (message.id !== id) return;
|
|
169
|
+
socket.removeEventListener("message", listener);
|
|
170
|
+
resolve(message);
|
|
171
|
+
};
|
|
172
|
+
socket.addEventListener("message", listener);
|
|
173
|
+
socket.send(JSON.stringify({ id, method, params }));
|
|
174
|
+
});
|
|
175
|
+
await browser.context.navigate(`http://127.0.0.1:${server.port}/start`);
|
|
176
|
+
const { result: { frameTree } } = await call("Page.getFrameTree");
|
|
177
|
+
expect(frameTree.frame.url).toBe(`http://127.0.0.1:${server.port}/page`);
|
|
178
|
+
expect(frameTree.frame.mimeType).toBe("text/html");
|
|
179
|
+
const result = await call("Page.getResourceContent", { frameId: frameTree.frame.id, url: frameTree.frame.url });
|
|
180
|
+
expect(result.error).toBeUndefined();
|
|
181
|
+
expect(Buffer.from(result.result.content, "base64")).toEqual(Buffer.from(BIG5));
|
|
182
|
+
} finally {
|
|
183
|
+
socket?.close();
|
|
184
|
+
cdp.stop(true);
|
|
185
|
+
browser.close();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
154
189
|
test("CDP download behavior controls the directory and emits completion events", async () => {
|
|
155
190
|
const { CdpServer } = await import("../lib/cdp-server.js");
|
|
156
191
|
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
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
+
import { createBrowser } from "../lib/headless-shell.js";
|
|
2
3
|
|
|
3
4
|
async function readDevToolsUrl(stream, timeout = 15_000) {
|
|
4
5
|
const reader = stream.getReader();
|
|
@@ -343,3 +344,31 @@ test("top-level shell exposes the parsed document through Bun.WebView", async ()
|
|
|
343
344
|
await browser.exited;
|
|
344
345
|
}
|
|
345
346
|
}, 30_000);
|
|
347
|
+
|
|
348
|
+
test("a client may set up and navigate before the first renderer is up", async () => {
|
|
349
|
+
let fetched = 0;
|
|
350
|
+
const site = Bun.serve({ port: 0, fetch() {
|
|
351
|
+
fetched++;
|
|
352
|
+
return new Response("<!doctype html><title>early</title><p>x</p>", { headers: { "content-type": "text/html" } });
|
|
353
|
+
} });
|
|
354
|
+
const browser = await createBrowser({ spareRenderer: false, waitForRenderer: false, webkit: false });
|
|
355
|
+
let ready = false;
|
|
356
|
+
browser.ready.then(() => { ready = true; });
|
|
357
|
+
try {
|
|
358
|
+
// Viewport and user agent are recorded at once, not after the renderer starts.
|
|
359
|
+
await browser.context.resize(321, 234, 1);
|
|
360
|
+
await browser.context.setUserAgent("EarlyAgent/1");
|
|
361
|
+
expect(ready).toBe(false);
|
|
362
|
+
// The document is fetched meanwhile; it commits once the renderer is up.
|
|
363
|
+
const navigation = browser.context.navigate(`http://127.0.0.1:${site.port}/`);
|
|
364
|
+
while (!fetched) await Bun.sleep(5);
|
|
365
|
+
expect(ready).toBe(false);
|
|
366
|
+
await navigation;
|
|
367
|
+
expect(fetched).toBe(1);
|
|
368
|
+
expect(await browser.context.title()).toBe("early");
|
|
369
|
+
expect(await browser.context.evaluate("innerWidth + 'x' + innerHeight + ' ' + navigator.userAgent")).toBe("321x234 EarlyAgent/1");
|
|
370
|
+
} finally {
|
|
371
|
+
browser.close();
|
|
372
|
+
site.stop(true);
|
|
373
|
+
}
|
|
374
|
+
}, 30_000);
|
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
|
+
});
|
package/test/log.test.js
CHANGED
|
@@ -22,13 +22,16 @@ test("the CLI writes controller and renderer output to the log file", async () =
|
|
|
22
22
|
});
|
|
23
23
|
const reader = child.stderr.getReader();
|
|
24
24
|
let seen = "";
|
|
25
|
-
|
|
25
|
+
// The endpoint opens first; the sandbox line follows once the first renderer is up.
|
|
26
|
+
while (!seen.includes("renderer sandbox:")) {
|
|
26
27
|
const { value, done } = await reader.read();
|
|
27
28
|
if (done) break;
|
|
28
29
|
seen += new TextDecoder().decode(value);
|
|
29
30
|
}
|
|
30
31
|
child.kill();
|
|
31
32
|
await child.exited;
|
|
33
|
+
expect(seen.indexOf("DevTools listening")).toBeGreaterThan(-1);
|
|
34
|
+
expect(seen.indexOf("DevTools listening")).toBeLessThan(seen.indexOf("renderer sandbox:"));
|
|
32
35
|
const log = await Bun.file(`${dir}/${DEFAULT_LOG_FILE}`).text();
|
|
33
36
|
expect(log).toContain("[log] started pid");
|
|
34
37
|
expect(log).toContain("renderer sandbox: seccomp");
|
|
@@ -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)",
|