buninu-browser 0.2.1 → 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 +16 -11
- package/lib/happy-dom/parser.js +24 -4
- package/lib/headless-shell.js +66 -19
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +196 -49
- 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 +161 -14
- package/lib/paint/display-list.js +300 -14
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +17 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +985 -68
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/host.js +158 -8
- 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 +1405 -198
- package/lib/renderer/page-renderer.js +1736 -193
- 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 +162 -22
- 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 +38 -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 +1 -1
- package/test/hit-test.test.js +25 -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 +14 -0
- package/test/page-scripts.test.js +1045 -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/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/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
|
@@ -21,6 +21,8 @@ git -C vendor/happy-dom apply --check ../../patches/happy-dom/0004-insert-adjace
|
|
|
21
21
|
git -C vendor/happy-dom apply ../../patches/happy-dom/0004-insert-adjacent-html-in-order.patch
|
|
22
22
|
git -C vendor/happy-dom apply --check ../../patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch
|
|
23
23
|
git -C vendor/happy-dom apply ../../patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch
|
|
24
|
+
git -C vendor/happy-dom apply --check ../../patches/happy-dom/0006-parse-css-escapes-in-selectors.patch
|
|
25
|
+
git -C vendor/happy-dom apply ../../patches/happy-dom/0006-parse-css-escapes-in-selectors.patch
|
|
24
26
|
```
|
|
25
27
|
|
|
26
28
|
For the already-patched vendored tree, verify that the recorded patch can be
|
|
@@ -32,6 +34,7 @@ git -C vendor/happy-dom apply --reverse --check ../../patches/happy-dom/0002-use
|
|
|
32
34
|
git -C vendor/happy-dom apply --reverse --check ../../patches/happy-dom/0003-defer-script-events-to-renderer.patch
|
|
33
35
|
git -C vendor/happy-dom apply --reverse --check ../../patches/happy-dom/0004-insert-adjacent-html-in-order.patch
|
|
34
36
|
git -C vendor/happy-dom apply --reverse --check ../../patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch
|
|
37
|
+
git -C vendor/happy-dom apply --reverse --check ../../patches/happy-dom/0006-parse-css-escapes-in-selectors.patch
|
|
35
38
|
```
|
|
36
39
|
|
|
37
40
|
## Patch manifest
|
|
@@ -43,6 +46,29 @@ git -C vendor/happy-dom apply --reverse --check ../../patches/happy-dom/0005-inv
|
|
|
43
46
|
| `0003-defer-script-events-to-renderer.patch` | `packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts` | Suppress Happy DOM's synthetic load/error event when its own script loader is disabled; the renderer fetches and executes scripts and dispatches the real outcome itself. | `bun test test/page-scripts.test.js` (`page-inserted scripts run; innerHTML scripts do not; on* handlers fire`) |
|
|
44
47
|
| `0004-insert-adjacent-html-in-order.patch` | `packages/happy-dom/src/nodes/element/Element.ts` | `insertAdjacentHTML` inserted the parsed nodes one at a time, which reversed their order at `afterbegin` and `afterend`; insert the parsed fragment as a whole. | `bun test test/page-scripts.test.js` (`insertAdjacentHTML keeps the order of the inserted nodes at every position`) |
|
|
45
48
|
| `0005-invalidate-matches-on-structure-changes.patch` | `packages/happy-dom/src/query-selector/QuerySelector.ts` | `Element.matches()` cached results that were invalidated only by changes to the elements the matcher visited, so structural pseudo-classes and sibling combinators stayed stale after a child list change, and `:has()` after any change in the subtree; register the parent of every visited element, and do not cache `:has()` matches. | `bun test test/happy-dom-matches.test.js` |
|
|
49
|
+
| `0006-parse-css-escapes-in-selectors.patch` | `packages/happy-dom/src/query-selector/SelectorParser.ts` | The group scan treated a comma, space, quote, or parenthesis that belonged to a CSS escape (`\,`, `\31 `, `\"`, `\(`) as a combinator or grouping character, and a type selector stopped at the first backslash, so `\64 iv` and `my\-el` did not parse. Escapes are consumed first (CSS Syntax §4.3.7) and a type selector is unescaped with the existing `cssUnescape` before it is uppercased. Identifiers, classes, and attribute values already accepted a backslash; this patch does not change how those are decoded. | `bun test test/page-scripts.test.js -t "selectors with CSS escapes"` |
|
|
50
|
+
|
|
51
|
+
### `0006` against upstream `SelectorParser.ts`
|
|
52
|
+
|
|
53
|
+
Upstream `v20.14.5` splits a selector with `SELECTOR_GROUP_REGEXP`. Its groups
|
|
54
|
+
are a combinator (` `, `,`, `+`, `>`, `~`) and a grouping character (`(`, `)`,
|
|
55
|
+
`[`, `]`, `"` or `'`). A backslash is not special there, so the character after
|
|
56
|
+
it is what the scan sees. The type-selector alternative is
|
|
57
|
+
`[a-zA-Z0-9\u00A0-\uFFFF-]+`, which also stops at `\`.
|
|
58
|
+
|
|
59
|
+
| Selector | Upstream | With `0006` |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `style\=\"\"` | each `"` changes the quote depth, then the type selector stops at `\`, so the selector is rejected | both escapes stay in one type selector; `cssUnescape` yields `style=""` |
|
|
62
|
+
| `.x\,y > span` | the comma starts another selector in the list | `\,` stays in the class; `>` is still the child combinator |
|
|
63
|
+
| `\64 iv` | the space after the hex escape is a descendant combinator, and `\` is not a type-selector character | `\64 ` is one escape (U+0064), so the type is `div` |
|
|
64
|
+
| `my\-el` | the type selector matches `my` and then fails on `\-el` | the type is `my-el` |
|
|
65
|
+
|
|
66
|
+
The patched group expression matches an escape first
|
|
67
|
+
(`\[0-9a-fA-F]{1,6}\s?` or `\[\s\S]`) and skips it. Combinators and grouping
|
|
68
|
+
characters move to groups 2 and 3. A type selector may contain the same
|
|
69
|
+
escapes and is passed through `cssUnescape`, the helper upstream already uses
|
|
70
|
+
for attribute values, before `toUpperCase()`. Classes and ids still drop a
|
|
71
|
+
backslash with `replace(/\\/g, '')`; hex escapes in those are unchanged.
|
|
46
72
|
|
|
47
73
|
Keep each local Happy DOM change in a separate numbered patch and add it to
|
|
48
74
|
this table with its reason and focused test. When updating Happy DOM, regenerate
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
diff --git a/dist/ses.mjs b/dist/ses.mjs
|
|
2
|
+
index 8bda46b..d7b18ff 100644
|
|
3
|
+
--- a/dist/ses.mjs
|
|
4
|
+
+++ b/dist/ses.mjs
|
|
5
|
+
@@ -9568,7 +9568,7 @@ function buildOptimizer(constants, name) {
|
|
6
|
+
${globalObjectOptimizer}
|
|
7
|
+
${moduleLexicalOptimizer}
|
|
8
|
+
return function() {
|
|
9
|
+
- 'use strict';
|
|
10
|
+
+ ${context.sloppyCode ? '' : "'use strict';"}
|
|
11
|
+
return eval(arguments[0]);
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
@@ -9611,6 +9611,7 @@ const { Fail } = assert;
|
|
15
|
+
moduleLexicals = {},
|
|
16
|
+
globalTransforms = [],
|
|
17
|
+
sloppyGlobalsMode = false,
|
|
18
|
+
+ sloppyCode = false,
|
|
19
|
+
}) => {
|
|
20
|
+
const scopeTerminator = sloppyGlobalsMode
|
|
21
|
+
? createSloppyGlobalsScopeTerminator(globalObject)
|
|
22
|
+
@@ -9623,6 +9624,7 @@ const { Fail } = assert;
|
|
23
|
+
moduleLexicals,
|
|
24
|
+
globalObject,
|
|
25
|
+
scopeTerminator,
|
|
26
|
+
+ sloppyCode,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Defer creating the actual evaluator to first use.
|
|
30
|
+
@@ -9715,6 +9717,14 @@ const nativeSuffix = ') { [native code] }';
|
|
31
|
+
// use this top level state so that multiple calls to `tameFunctionToString` are
|
|
32
|
+
// idempotent, rather than creating redundant indirections.
|
|
33
|
+
let markVirtualizedNativeFunction;
|
|
34
|
+
+let sourceTextTransform;
|
|
35
|
+
+// Taken (and deleted) by the embedder, like the native-function marker.
|
|
36
|
+
+Object.defineProperty(globalThis, Symbol.for('ses.setFunctionSourceTextTransform'), {
|
|
37
|
+
+ value: transform => {
|
|
38
|
+
+ sourceTextTransform = transform;
|
|
39
|
+
+ },
|
|
40
|
+
+ configurable: true,
|
|
41
|
+
+});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Replace `Function.prototype.toString` with one that recognizes
|
|
45
|
+
@@ -9726,7 +9736,10 @@ let markVirtualizedNativeFunction;
|
|
46
|
+
|
|
47
|
+
const tamingMethods = {
|
|
48
|
+
toString() {
|
|
49
|
+
- const str = functionToString(this);
|
|
50
|
+
+ let str = functionToString(this);
|
|
51
|
+
+ // The embedder may map the text of source it rewrote back to the
|
|
52
|
+
+ // text the page wrote (see the registered symbol below).
|
|
53
|
+
+ if (sourceTextTransform !== undefined) str = sourceTextTransform(str);
|
|
54
|
+
if (
|
|
55
|
+
stringEndsWith(str, nativeSuffix) ||
|
|
56
|
+
!weaksetHas(virtualizedNativeFunctions, this)
|
|
57
|
+
@@ -9744,6 +9757,8 @@ let markVirtualizedNativeFunction;
|
|
58
|
+
markVirtualizedNativeFunction = freeze(func =>
|
|
59
|
+
weaksetAdd(virtualizedNativeFunctions, func),
|
|
60
|
+
);
|
|
61
|
+
+ // Function.prototype.toString is itself a built-in function.
|
|
62
|
+
+ markVirtualizedNativeFunction(tamingMethods.toString);
|
|
63
|
+
}
|
|
64
|
+
return markVirtualizedNativeFunction;
|
|
65
|
+
};$h͏_once.tameFunctionToString(tameFunctionToString);
|
|
66
|
+
@@ -12523,12 +12538,15 @@ const { quote: q } = assert;
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
const provideCompartmentEvaluator = (compartmentFields, options) => {
|
|
70
|
+
- const { sloppyGlobalsMode = false, __moduleShimLexicals__ = undefined } =
|
|
71
|
+
- options;
|
|
72
|
+
+ const {
|
|
73
|
+
+ sloppyGlobalsMode = false,
|
|
74
|
+
+ __moduleShimLexicals__ = undefined,
|
|
75
|
+
+ __sloppyCode__ = false,
|
|
76
|
+
+ } = options;
|
|
77
|
+
|
|
78
|
+
let safeEvaluate;
|
|
79
|
+
|
|
80
|
+
- if (__moduleShimLexicals__ === undefined && !sloppyGlobalsMode) {
|
|
81
|
+
+ if (__moduleShimLexicals__ === undefined && !sloppyGlobalsMode && !__sloppyCode__) {
|
|
82
|
+
({ safeEvaluate } = compartmentFields);
|
|
83
|
+
} else {
|
|
84
|
+
// The scope proxy or global lexicals are different from the
|
|
85
|
+
@@ -12560,6 +12578,7 @@ const { quote: q } = assert;
|
|
86
|
+
moduleLexicals,
|
|
87
|
+
globalTransforms,
|
|
88
|
+
sloppyGlobalsMode,
|
|
89
|
+
+ sloppyCode: __sloppyCode__,
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@@ -15138,6 +15157,13 @@ globalThis.repairIntrinsics = options => {
|
|
94
|
+
|
|
95
|
+
const markVirtualizedNativeFunction = tameFunctionToString();
|
|
96
|
+
|
|
97
|
+
+// The embedder takes this once (and deletes it) to present its own
|
|
98
|
+
+// host-implemented Web APIs as built-in functions, as browsers do.
|
|
99
|
+
+Object.defineProperty(globalThis, Symbol.for('ses.markVirtualizedNativeFunction'), {
|
|
100
|
+
+ value: markVirtualizedNativeFunction,
|
|
101
|
+
+ configurable: true,
|
|
102
|
+
+});
|
|
103
|
+
+
|
|
104
|
+
const muteReporter = chooseReporter('none');
|
|
105
|
+
|
|
106
|
+
// @ts-ignore Compartment is definitely on globalThis.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
diff --git a/dist/ses.mjs b/dist/ses.mjs
|
|
2
|
+
index d7b18ff..31d23ad 100644
|
|
3
|
+
--- a/dist/ses.mjs
|
|
4
|
+
+++ b/dist/ses.mjs
|
|
5
|
+
@@ -12537,7 +12537,13 @@ const { quote: q } = assert;
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
- const provideCompartmentEvaluator = (compartmentFields, options) => {
|
|
10
|
+
+ // Evaluators for sloppyGlobalsMode / __sloppyCode__, one per compartment and
|
|
11
|
+
+// combination: building one scans the global object and compiles a new
|
|
12
|
+
+// evaluator function, which made every such evaluate() call cost hundreds of
|
|
13
|
+
+// microseconds.
|
|
14
|
+
+const sloppyEvaluators = new WeakMap();
|
|
15
|
+
+
|
|
16
|
+
+const provideCompartmentEvaluator = (compartmentFields, options) => {
|
|
17
|
+
const {
|
|
18
|
+
sloppyGlobalsMode = false,
|
|
19
|
+
__moduleShimLexicals__ = undefined,
|
|
20
|
+
@@ -12546,6 +12552,13 @@ const { quote: q } = assert;
|
|
21
|
+
|
|
22
|
+
let safeEvaluate;
|
|
23
|
+
|
|
24
|
+
+ const cacheKey = __moduleShimLexicals__ === undefined
|
|
25
|
+
+ ? `${Boolean(sloppyGlobalsMode)}:${Boolean(__sloppyCode__)}` : undefined;
|
|
26
|
+
+ let cached = sloppyEvaluators.get(compartmentFields);
|
|
27
|
+
+ if (cacheKey !== undefined && cached && cached.has(cacheKey)) {
|
|
28
|
+
+ return { safeEvaluate: cached.get(cacheKey) };
|
|
29
|
+
+ }
|
|
30
|
+
+
|
|
31
|
+
if (__moduleShimLexicals__ === undefined && !sloppyGlobalsMode && !__sloppyCode__) {
|
|
32
|
+
({ safeEvaluate } = compartmentFields);
|
|
33
|
+
} else {
|
|
34
|
+
@@ -12580,6 +12593,13 @@ const { quote: q } = assert;
|
|
35
|
+
sloppyGlobalsMode,
|
|
36
|
+
sloppyCode: __sloppyCode__,
|
|
37
|
+
}));
|
|
38
|
+
+ if (cacheKey !== undefined) {
|
|
39
|
+
+ if (!cached) {
|
|
40
|
+
+ cached = new Map();
|
|
41
|
+
+ sloppyEvaluators.set(compartmentFields, cached);
|
|
42
|
+
+ }
|
|
43
|
+
+ cached.set(cacheKey, safeEvaluate);
|
|
44
|
+
+ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { safeEvaluate };
|
package/patches/ses/README.md
CHANGED
|
@@ -6,11 +6,15 @@ relative to `vendor/ses`.
|
|
|
6
6
|
For the already-patched tree, verify the recorded patch without changing files:
|
|
7
7
|
|
|
8
8
|
```sh
|
|
9
|
+
git -C vendor/ses apply --reverse --check ../../patches/ses/0003-cache-sloppy-evaluators.patch
|
|
10
|
+
git -C vendor/ses apply --reverse --check ../../patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch
|
|
9
11
|
git -C vendor/ses apply --reverse --check ../../patches/ses/0001-allow-regexp-own-tostring.patch
|
|
10
12
|
```
|
|
11
13
|
|
|
12
14
|
| Patch | Modified file | Reason | Verification |
|
|
13
15
|
|---|---|---|---|
|
|
14
16
|
| `0001-allow-regexp-own-tostring.patch` | `dist/ses.mjs` | Allow a page script to assign an own `toString` method to a `RegExp` instance despite the frozen prototype. This does not make the prototype writable. | `bun test test/page-scripts.test.js` |
|
|
17
|
+
| `0002-embedder-native-functions-source-text-sloppy-code.patch` | `dist/ses.mjs` | Three embedder hooks for browser fidelity: (1) the toString taming's `markVirtualizedNativeFunction` is left on the host global under `Symbol.for("ses.markVirtualizedNativeFunction")` (taken and deleted by `lib/renderer/native-functions.js`) so Web APIs print as `[native code]`, and the taming's own `toString` is marked native; (2) a `Symbol.for("ses.setFunctionSourceTextTransform")` setter lets the embedder map rewritten source text back to the page's text in `Function.prototype.toString`; (3) an `__sloppyCode__` evaluate option drops the evaluator's `'use strict'`, so classic scripts run as sloppy-mode code. Page code only gets (3) for sources the rewriter parsed, which routes every sloppy `this` through a helper that never yields the host global. | `bun test test/page-scripts.test.js test/script-rewrite.test.js` |
|
|
18
|
+
| `0003-cache-sloppy-evaluators.patch` | `dist/ses.mjs` | `evaluate()` with `sloppyGlobalsMode` or `__sloppyCode__` built a new safe evaluator on every call (scanning the global object for constants and compiling a new evaluator function), which made each page `eval`, `Function` and classic script cost hundreds of microseconds. The evaluator is now kept per compartment and option combination; it only binds non-writable, non-configurable globals as constants, which cannot change. `__moduleShimLexicals__` evaluations are not cached. | `bun test test/page-scripts.test.js test/script-rewrite.test.js` |
|
|
15
19
|
|
|
16
20
|
Keep future local SES changes in numbered patches and update this manifest.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { beforeAll, expect, test } from "bun:test";
|
|
2
|
+
import CanvasKitInit from "../usr/lib/canvaskit/canvaskit.js";
|
|
3
|
+
import { canvasContext, resetCanvas, setCanvasKit } from "../lib/renderer/canvas-2d.js";
|
|
4
|
+
|
|
5
|
+
beforeAll(async () => {
|
|
6
|
+
const wasmBinary = await Bun.file(new URL("../usr/lib/canvaskit/canvaskit.wasm", import.meta.url)).arrayBuffer();
|
|
7
|
+
setCanvasKit(await CanvasKitInit({ wasmBinary }));
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
function canvas(width = 1, height = 1) {
|
|
11
|
+
return { width, height };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The probe web-animations-next-lite uses to accept a CSS colour.
|
|
16
|
+
* CanvasKit turns a string it cannot parse into black, so the two reads match
|
|
17
|
+
* and the sample is opaque black. A string it can parse reads back the same
|
|
18
|
+
* way both times.
|
|
19
|
+
*/
|
|
20
|
+
function sample(context, color) {
|
|
21
|
+
context.fillStyle = "#000";
|
|
22
|
+
context.fillStyle = color;
|
|
23
|
+
const first = context.fillStyle;
|
|
24
|
+
context.fillStyle = "#fff";
|
|
25
|
+
context.fillStyle = color;
|
|
26
|
+
if (first !== context.fillStyle) return null;
|
|
27
|
+
context.fillRect(0, 0, 1, 1);
|
|
28
|
+
const data = [...context.getImageData(0, 0, 1, 1).data];
|
|
29
|
+
context.clearRect(0, 0, 1, 1);
|
|
30
|
+
return data;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("a colour reads back stably, and one CanvasKit cannot parse is black", () => {
|
|
34
|
+
const context = canvasContext(canvas(), "2d");
|
|
35
|
+
expect(context.fillStyle).toBe("#000000");
|
|
36
|
+
expect(sample(context, "not-a-color")).toEqual([0, 0, 0, 255]);
|
|
37
|
+
expect(sample(context, "red")).toEqual([255, 0, 0, 255]);
|
|
38
|
+
expect(sample(context, "rgba(1, 2, 3, 0.5)")).toEqual([1, 2, 3, 128]);
|
|
39
|
+
expect(context.getImageData(0, 0, 1, 1).data.length).toBe(4);
|
|
40
|
+
expect(context.getImageData(0, 0, 1, 1).data[3]).toBe(0);
|
|
41
|
+
expect(canvasContext(canvas(), "webgl")).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the same canvas keeps one context, and changing width clears it", () => {
|
|
45
|
+
const node = canvas(2, 2);
|
|
46
|
+
const context = canvasContext(node, "2d");
|
|
47
|
+
expect(canvasContext(node, "2d")).toBe(context);
|
|
48
|
+
context.fillStyle = "blue";
|
|
49
|
+
context.fillRect(0, 0, 2, 2);
|
|
50
|
+
expect(context.getImageData(1, 1, 1, 1).data[2]).toBe(255);
|
|
51
|
+
node.width = 1;
|
|
52
|
+
resetCanvas(node);
|
|
53
|
+
expect(context.fillStyle).toBe("#000000");
|
|
54
|
+
expect([...context.getImageData(0, 0, 1, 1).data]).toEqual([0, 0, 0, 0]);
|
|
55
|
+
});
|
package/test/compositor.test.js
CHANGED
|
@@ -106,7 +106,9 @@ test("a preview is shown while stylesheets load, then the styled page, then scri
|
|
|
106
106
|
try {
|
|
107
107
|
await context.resize(320, 200, 1);
|
|
108
108
|
await context.navigate(`http://127.0.0.1:${server.port}/`);
|
|
109
|
-
// navigate returned at the first paint: the stylesheet is still pending
|
|
109
|
+
// navigate returned at the first paint: the stylesheet is still pending
|
|
110
|
+
// (held by the server; under load its request may only now be arriving).
|
|
111
|
+
for (const deadline = Date.now() + 10_000; !releaseStyles && Date.now() < deadline;) await Bun.sleep(20);
|
|
110
112
|
expect(releaseStyles).toBeFunction();
|
|
111
113
|
expect(events).toEqual([]);
|
|
112
114
|
expect(context.lifecycleState()).toEqual([]);
|
|
@@ -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>
|
|
@@ -544,3 +546,38 @@ test("incremental restyles of changed subtrees and added sheets equal a full res
|
|
|
544
546
|
same("added sheet");
|
|
545
547
|
window.happyDOM.abort();
|
|
546
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
|
+
});
|
|
@@ -351,7 +351,7 @@ test("a client may set up and navigate before the first renderer is up", async (
|
|
|
351
351
|
fetched++;
|
|
352
352
|
return new Response("<!doctype html><title>early</title><p>x</p>", { headers: { "content-type": "text/html" } });
|
|
353
353
|
} });
|
|
354
|
-
const browser = await createBrowser({ spareRenderer: false, waitForRenderer: false });
|
|
354
|
+
const browser = await createBrowser({ spareRenderer: false, waitForRenderer: false, webkit: false });
|
|
355
355
|
let ready = false;
|
|
356
356
|
browser.ready.then(() => { ready = true; });
|
|
357
357
|
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
|
+
});
|