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.
Files changed (90) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/ai-debug.log +5537 -0
  4. package/bunfig.toml +1 -0
  5. package/buninu-browser.js +4 -0
  6. package/index.js +5 -0
  7. package/lib/audio/piano.js +24 -1
  8. package/lib/audio/playback-buffer.js +27 -0
  9. package/lib/audio/pulse-client.js +137 -19
  10. package/lib/cdp-server.js +22 -11
  11. package/lib/happy-dom/parser.js +29 -4
  12. package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
  13. package/lib/headless-shell.js +69 -19
  14. package/lib/input/hit-test.js +25 -7
  15. package/lib/layout/text-layout.js +268 -85
  16. package/lib/log.js +5 -2
  17. package/lib/media/byte-pipe.js +82 -0
  18. package/lib/media/position-audio.js +42 -0
  19. package/lib/media/video-keyframe.js +29 -0
  20. package/lib/network/network-service.js +161 -14
  21. package/lib/paint/display-list.js +316 -15
  22. package/lib/paint/gradient.js +11 -5
  23. package/lib/render-tree/index.js +19 -2
  24. package/lib/render-tree/svg.js +2 -1
  25. package/lib/renderer/bindings.js +995 -69
  26. package/lib/renderer/canvas-2d.js +92 -0
  27. package/lib/renderer/compositor.js +16 -1
  28. package/lib/renderer/custom-element-reactions.js +18 -0
  29. package/lib/renderer/history.js +42 -0
  30. package/lib/renderer/host.js +166 -9
  31. package/lib/renderer/image-loading.js +27 -0
  32. package/lib/renderer/interfaces.js +215 -0
  33. package/lib/renderer/keyboard-input.js +12 -0
  34. package/lib/renderer/main.js +162 -12
  35. package/lib/renderer/media-playback-events.js +13 -0
  36. package/lib/renderer/media-source.js +819 -0
  37. package/lib/renderer/messaging.js +411 -0
  38. package/lib/renderer/native-functions.js +113 -0
  39. package/lib/renderer/page-realm.js +1408 -242
  40. package/lib/renderer/page-renderer.js +1893 -223
  41. package/lib/renderer/piano-input.js +22 -0
  42. package/lib/renderer/pointer-move.js +29 -0
  43. package/lib/renderer/rejections.js +31 -0
  44. package/lib/renderer/rewrite-worker.js +22 -0
  45. package/lib/renderer/script-rewrite.js +805 -57
  46. package/lib/renderer/trusted-types.js +129 -0
  47. package/lib/renderer/video-presentation.js +126 -0
  48. package/lib/renderer/workers.js +327 -0
  49. package/lib/screenshot-cli.js +2 -0
  50. package/lib/style/computed-style.js +186 -29
  51. package/lib/user-agent.js +14 -2
  52. package/package.json +4 -1
  53. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  54. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  55. package/patches/happy-dom/README.md +26 -0
  56. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  57. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  58. package/patches/ses/README.md +4 -0
  59. package/test/canvas-2d.test.js +55 -0
  60. package/test/compositor.test.js +41 -1
  61. package/test/computed-style.test.js +59 -1
  62. package/test/display-list.test.js +88 -1
  63. package/test/download.test.js +43 -0
  64. package/test/fixtures/long-video.mp4 +0 -0
  65. package/test/fixtures/media-sync.js +57 -0
  66. package/test/happy-dom-platform-adapters.test.js +25 -0
  67. package/test/headless-shell.test.js +23 -1
  68. package/test/hit-test.test.js +25 -1
  69. package/test/media-playback-events.test.js +22 -0
  70. package/test/media-source.test.js +156 -0
  71. package/test/media-sync.test.js +12 -0
  72. package/test/mobile.test.js +2 -2
  73. package/test/navigation.test.js +61 -0
  74. package/test/network-service.test.js +14 -0
  75. package/test/page-scripts.test.js +1203 -7
  76. package/test/piano-input.test.js +44 -0
  77. package/test/playback-buffer.test.js +31 -0
  78. package/test/position-audio.test.js +67 -0
  79. package/test/positioning.test.js +95 -1
  80. package/test/pulse-client.test.js +84 -0
  81. package/test/renderer-host.test.js +5 -2
  82. package/test/screenshot-cli.test.js +25 -1
  83. package/test/script-rewrite.test.js +111 -6
  84. package/test/setup.js +5 -0
  85. package/test/text-layout.test.js +62 -0
  86. package/test/user-agent.test.js +60 -4
  87. package/test/video-presentation.test.js +67 -0
  88. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  89. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  90. package/vendor/ses/dist/ses.mjs +52 -6
@@ -0,0 +1,115 @@
1
+ diff --git a/packages/happy-dom/src/query-selector/SelectorParser.ts b/packages/happy-dom/src/query-selector/SelectorParser.ts
2
+ index 60cc951..62cf4b9 100644
3
+ --- a/packages/happy-dom/src/query-selector/SelectorParser.ts
4
+ +++ b/packages/happy-dom/src/query-selector/SelectorParser.ts
5
+ @@ -12,10 +12,11 @@ import type { TGlobalMatchFunction } from './TGlobalMatchFunction.js';
6
+ /**
7
+ * Selector group RegExp.
8
+ *
9
+ - * Group 1: Combinator (" ", ",", "+", ">", "̣~")
10
+ - * Group 2: Parentheses or brackets.
11
+ + * Group 1: An escaped character (e.g. "\:", "\31 "), skipped.
12
+ + * Group 2: Combinator (" ", ",", "+", ">", "̣~")
13
+ + * Group 3: Parentheses, brackets or quotes.
14
+ */
15
+ -const SELECTOR_GROUP_REGEXP = /(\s*[\s,+>~]\s*)|([\[\]\(\)"'])/g;
16
+ +const SELECTOR_GROUP_REGEXP = /(\\[0-9a-fA-F]{1,6}\s?|\\[\s\S])|(\s*[\s,+>~]\s*)|([\[\]\(\)"'])/g;
17
+
18
+ /**
19
+ * Selector RegExp.
20
+ @@ -45,7 +46,7 @@ const SELECTOR_GROUP_REGEXP = /(\s*[\s,+>~]\s*)|([\[\]\(\)"'])/g;
21
+ * Group 23: Pseudo element (e.g. "::after", "::-webkit-inner-spin-button").
22
+ */
23
+ const SELECTOR_REGEXP =
24
+ - /(\*)|([a-zA-Z0-9\u00A0-\uFFFF-]+)|#(([a-zA-Z0-9\u00A0-\uFFFF_-]|\\.)+)|\.(([a-zA-Z0-9\u00A0-\uFFFF_-]|\\.)+)|\[(([a-zA-Z0-9-_]|\\.)+)\]|\[(([a-zA-Z0-9-_]|\\.)+)\s*([~|^$*]{0,1})\s*=\s*("([^"]*)"|'([^']*)')\s*(s|i){0,1}\]|\[(([a-zA-Z0-9-_]|\\.)+)\s*([~|^$*]{0,1})\s*=\s*(([a-zA-Z0-9\u00A0-\uFFFF_¤£-]|\\.)+)\]|:([a-zA-Z-]+)\s*\(.+\)|:([a-zA-Z-]+)|::([a-zA-Z-]+)/g;
25
+ + /(\*)|((?:[a-zA-Z0-9\u00A0-\uFFFF-]|\\[0-9a-fA-F]{1,6}\s?|\\.)+)|#(([a-zA-Z0-9\u00A0-\uFFFF_-]|\\[0-9a-fA-F]{1,6}\s?|\\.)+)|\.(([a-zA-Z0-9\u00A0-\uFFFF_-]|\\[0-9a-fA-F]{1,6}\s?|\\.)+)|\[(([a-zA-Z0-9-_]|\\[0-9a-fA-F]{1,6}\s?|\\.)+)\]|\[(([a-zA-Z0-9-_]|\\[0-9a-fA-F]{1,6}\s?|\\.)+)\s*([~|^$*]{0,1})\s*=\s*("((?:[^"\\]|\\[\s\S])*)"|'((?:[^'\\]|\\[\s\S])*)')\s*(s|i){0,1}\]|\[(([a-zA-Z0-9-_]|\\[0-9a-fA-F]{1,6}\s?|\\.)+)\s*([~|^$*]{0,1})\s*=\s*(([a-zA-Z0-9\u00A0-\uFFFF_¤£-]|\\[0-9a-fA-F]{1,6}\s?|\\.)+)\]|:([a-zA-Z-]+)\s*\(.+\)|:([a-zA-Z-]+)|::([a-zA-Z-]+)/g;
26
+
27
+ /**
28
+ * Selector pseudo RegExp.
29
+ @@ -55,11 +56,6 @@ const SELECTOR_REGEXP =
30
+ */
31
+ const SELECTOR_PSEUDO_REGEXP = /:([a-zA-Z-]+)|([()])/g;
32
+
33
+ -/**
34
+ - * Escaped Character RegExp.
35
+ - */
36
+ -const ESCAPED_CHARACTER_REGEXP = /\\/g;
37
+ -
38
+ /**
39
+ * CSS Escape RegExp.
40
+ *
41
+ @@ -187,6 +183,11 @@ export default class SelectorParser {
42
+
43
+ while ((match = regExp.exec(selector))) {
44
+ if (match[1]) {
45
+ + // An escaped character (CSS Syntax §4.3.7) is part of the selector it is in,
46
+ + // never a combinator, bracket or quote.
47
+ + continue;
48
+ + }
49
+ + if (match[2]) {
50
+ // Matches combinator (" ", ",", "+", ">", "̣~")
51
+ // We should ignore combinators that are inside parentheses, brackets or apostrophes
52
+
53
+ @@ -198,7 +199,7 @@ export default class SelectorParser {
54
+ ) {
55
+ const childSelector = selector.substring(lastIndex, match.index).trim();
56
+
57
+ - switch (match[1].trim()) {
58
+ + switch (match[2].trim()) {
59
+ case ',':
60
+ selectorItem = this.getSelectorGroupItem(childSelector, combinator);
61
+ if (!selectorItem) {
62
+ @@ -262,7 +263,7 @@ export default class SelectorParser {
63
+ } else {
64
+ // Matches parentheses or brackets.
65
+
66
+ - switch (match[2]) {
67
+ + switch (match[3]) {
68
+ case '(':
69
+ if (depth.singleApostrophe === 0 && depth.doubleApostrophe === 0) {
70
+ depth.parentheses++;
71
+ @@ -365,22 +366,22 @@ export default class SelectorParser {
72
+ } else if (match[2]) {
73
+ // Matches tag name, e.g. "div"
74
+
75
+ - selectorItem.tagName = match[2].toUpperCase();
76
+ + selectorItem.tagName = SelectorParser.cssUnescape(match[2]).toUpperCase();
77
+ } else if (match[3]) {
78
+ // Matches ID, e.g. "#id"
79
+
80
+ - selectorItem.id = match[3].replace(ESCAPED_CHARACTER_REGEXP, '');
81
+ + selectorItem.id = SelectorParser.cssUnescape(match[3]);
82
+ } else if (match[5]) {
83
+ // Matches class names, e.g. ".class1"
84
+
85
+ selectorItem.classNames = selectorItem.classNames || [];
86
+ - selectorItem.classNames.push(match[5].replace(ESCAPED_CHARACTER_REGEXP, ''));
87
+ + selectorItem.classNames.push(SelectorParser.cssUnescape(match[5]));
88
+ } else if (match[7]) {
89
+ // Matches attributes without value, e.g. [attr]
90
+
91
+ selectorItem.attributes = selectorItem.attributes || [];
92
+ selectorItem.attributes.push({
93
+ - name: match[7].replace(ESCAPED_CHARACTER_REGEXP, ''),
94
+ + name: SelectorParser.cssUnescape(match[7]),
95
+ operator: null,
96
+ value: null,
97
+ modifier: null,
98
+ @@ -393,7 +394,7 @@ export default class SelectorParser {
99
+ const unescapedValue = SelectorParser.cssUnescape(value);
100
+ selectorItem.attributes = selectorItem.attributes || [];
101
+ selectorItem.attributes.push({
102
+ - name: match[9].replace(ESCAPED_CHARACTER_REGEXP, ''),
103
+ + name: SelectorParser.cssUnescape(match[9]),
104
+ operator: match[11] || null,
105
+ value: unescapedValue,
106
+ modifier: <'s'>match[15] || null,
107
+ @@ -409,7 +410,7 @@ export default class SelectorParser {
108
+ const unescapedValue = SelectorParser.cssUnescape(match[19]);
109
+ selectorItem.attributes = selectorItem.attributes || [];
110
+ selectorItem.attributes.push({
111
+ - name: match[16].replace(ESCAPED_CHARACTER_REGEXP, ''),
112
+ + name: SelectorParser.cssUnescape(match[16]),
113
+ operator: match[18] || null,
114
+ value: unescapedValue,
115
+ modifier: null,
@@ -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 };
@@ -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
+ });
@@ -62,6 +62,44 @@ test("the compositor answers screenshots and scrolling while the main thread is
62
62
  }, 30_000);
63
63
 
64
64
  let server;
65
+ test("video continues presenting while page JavaScript blocks, then freezes on pause", async () => {
66
+ const host = new RendererHost({ timeout: 20_000,
67
+ fetch: async () => new Response(Bun.file(new URL("./fixtures/long-video.mp4", import.meta.url)),
68
+ { headers: { "content-type": "video/mp4" } }) });
69
+ try {
70
+ await host.start();
71
+ await host.call("resize", 160, 120, 1);
72
+ await host.call("loadDocument", { url: "https://page.test/", contentType: "text/html",
73
+ source: '<body style="margin:0"><video id="v" width="160" height="120" src="/movie.mp4"></video>' });
74
+ await host.call("whenLoaded");
75
+ await host.call("evaluate", "document.getElementById('v').play()");
76
+ await Bun.sleep(400);
77
+ const busy = host.call("evaluate", "(() => { const end=Date.now()+2000; while(Date.now()<end); return true; })()");
78
+ await Bun.sleep(200);
79
+ const first = await host.composite("screenshot");
80
+ await Bun.sleep(600);
81
+ const second = await host.composite("screenshot");
82
+ expect(Bun.hash(second)).not.toBe(Bun.hash(first));
83
+ expect(await busy).toBe(true);
84
+ await host.call("evaluate", "document.getElementById('v').pause()");
85
+ const paused = await host.composite("screenshot");
86
+ await Bun.sleep(350);
87
+ expect(await host.composite("screenshot")).toBe(paused);
88
+ await host.call("evaluate", "document.getElementById('v').currentTime=5");
89
+ await Bun.sleep(250);
90
+ const sought = await host.composite("screenshot");
91
+ expect(Bun.hash(sought)).not.toBe(Bun.hash(paused));
92
+ await host.call("evaluate", "document.getElementById('v').play()");
93
+ await Bun.sleep(400);
94
+ const resumed = await host.composite("screenshot");
95
+ await Bun.sleep(400);
96
+ expect(Bun.hash(await host.composite("screenshot"))).not.toBe(Bun.hash(resumed));
97
+ await host.call("loadDocument", { url: "https://page.test/next", contentType: "text/html", source: '<body style="background:red">next</body>' });
98
+ const next = await host.composite("screenshot");
99
+ await Bun.sleep(300);
100
+ expect(await host.composite("screenshot")).toBe(next);
101
+ } finally { host.close(); }
102
+ }, 30_000);
65
103
  let releaseStyles;
66
104
  let releaseScript;
67
105
  beforeAll(() => {
@@ -106,7 +144,9 @@ test("a preview is shown while stylesheets load, then the styled page, then scri
106
144
  try {
107
145
  await context.resize(320, 200, 1);
108
146
  await context.navigate(`http://127.0.0.1:${server.port}/`);
109
- // navigate returned at the first paint: the stylesheet is still pending.
147
+ // navigate returned at the first paint: the stylesheet is still pending
148
+ // (held by the server; under load its request may only now be arriving).
149
+ for (const deadline = Date.now() + 10_000; !releaseStyles && Date.now() < deadline;) await Bun.sleep(20);
110
150
  expect(releaseStyles).toBeFunction();
111
151
  expect(events).toEqual([]);
112
152
  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 { computeElementStyle, matchSelectors, parseDeclarations, StyleEngine } from "../lib/style/computed-style.js";
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>
@@ -25,6 +27,27 @@ test("computes the initial UA and inherited style subset", () => {
25
27
  window.happyDOM.abort();
26
28
  });
27
29
 
30
+ test(":empty includes text nodes, but ignores comments and empty text nodes", () => {
31
+ const { document, window } = parseHTMLDocument('<span id="text">0</span><span id="space"> </span><span id="empty"><!--comment--></span>');
32
+ const styles = new StyleEngine().compute(document, ['span:empty { display:none }']);
33
+ expect(document.querySelector('#text').matches(':empty')).toBe(false);
34
+ expect(styles.get(document.querySelector('#text')).display).toBe('inline');
35
+ expect(styles.get(document.querySelector('#space')).display).toBe('inline');
36
+ expect(styles.get(document.querySelector('#empty')).display).toBe('none');
37
+ window.happyDOM.abort();
38
+ });
39
+
40
+ test("percentage root fonts set the basis for rem and descendant percentage fonts", () => {
41
+ const { document, window } = parseHTMLDocument(`<html style="font-size:62.5%"><body>
42
+ <div id="relative" style="font-size:1.4rem"><span id="child" style="font-size:150%">text</span></div>
43
+ </body></html>`);
44
+ const styles = new StyleEngine().compute(document);
45
+ expect(styles.get(document.documentElement).fontSize).toBe(10);
46
+ expect(styles.get(document.getElementById('relative')).fontSize).toBe(14);
47
+ expect(styles.get(document.getElementById('child')).fontSize).toBe(21);
48
+ window.happyDOM.abort();
49
+ });
50
+
28
51
  test("inline declarations validate values and preserve important priority", () => {
29
52
  const { document, window } = parseHTMLDocument(`<div id="box" style="
30
53
  display: block; display: none !important; display: inline;
@@ -544,3 +567,38 @@ test("incremental restyles of changed subtrees and added sheets equal a full res
544
567
  same("added sheet");
545
568
  window.happyDOM.abort();
546
569
  });
570
+
571
+ test("background shorthand and longhands keep image layers with their position, size and repeat", () => {
572
+ const { document, window } = parseHTMLDocument(`<body>
573
+ <div id="short" style="background: url(icon.png) no-repeat center / 32px 16px, linear-gradient(red, blue) #fff"></div>
574
+ <div id="long" style="background: url('a.png'); background-size: cover; background-position: right 4px top; background-repeat: repeat-x"></div>
575
+ <div id="color" style="background: url(x.png); background: #000"></div>
576
+ <div id="invalid" style="background: url(x.png) bogus-token"></div>
577
+ </body>`);
578
+ const engine = new StyleEngine().compute(document);
579
+ const style = (id) => engine.get(document.getElementById(id));
580
+ expect(style("short")).toMatchObject({
581
+ backgroundImage: "url(icon.png), linear-gradient(red, blue)",
582
+ backgroundPosition: "center, 0% 0%",
583
+ backgroundSize: "32px 16px, auto",
584
+ backgroundRepeat: "no-repeat, repeat",
585
+ backgroundColor: "rgba(255, 255, 255, 1)",
586
+ });
587
+ expect(style("long")).toMatchObject({
588
+ backgroundImage: "url('a.png')", backgroundSize: "cover", backgroundPosition: "right 4px top", backgroundRepeat: "repeat-x",
589
+ });
590
+ // A later shorthand resets the image layers.
591
+ expect(style("color")).toMatchObject({ backgroundImage: null, backgroundColor: "rgba(0, 0, 0, 1)" });
592
+ // An invalid shorthand is dropped as a whole.
593
+ expect(style("invalid")).toMatchObject({ backgroundImage: null, backgroundColor: "rgba(0, 0, 0, 0)" });
594
+ window.happyDOM.abort();
595
+ });
596
+
597
+ test("a linked style sheet's url()s resolve against the sheet", () => {
598
+ expect(absolutizeStyleSheetUrls(
599
+ `a { background: url(img/a.png) } b { background: url("../b.png") } c { background: url(data:image/png;base64,AA) }
600
+ d { background: url(https://cdn.test/d.png) } e { mask: url(#m) }`,
601
+ "https://site.test/css/main.css",
602
+ )).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) }
603
+ d { background: url(https://cdn.test/d.png) } e { mask: url(#m) }`);
604
+ });
@@ -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
 
@@ -20,6 +20,20 @@ function displayListFor(html, css, resources = new WeakMap()) {
20
20
  return { document, window, tree, displayList: buildDisplayList(layout, tree) };
21
21
  }
22
22
 
23
+ test("native video controls are opt-in and do not cover a site's own player UI", () => {
24
+ const { window, displayList } = displayListFor('<video></video><video controls></video>', 'video { width:100px; height:60px }');
25
+ expect(displayList.items.filter(item => item.op === 'drawVideo').map(item => item.controls)).toEqual([false, true]);
26
+ window.happyDOM.abort();
27
+ });
28
+
29
+ test("rounded overflow containers clip descendant media, including transformed clips", () => {
30
+ const { window, displayList } = displayListFor('<div><video></video></div>',
31
+ 'div { width:100px;height:60px;overflow:hidden;border-radius:12px;transform:scale(2);transform-origin:0 0 } video {width:100px;height:60px}');
32
+ const video = displayList.items.find(item => item.op === 'drawVideo');
33
+ expect(video.roundedClips).toEqual([{rect:{x:0,y:0,width:200,height:120},radii:{x:24,y:24}}]);
34
+ window.happyDOM.abort();
35
+ });
36
+
23
37
  test("paints box decorations in tree order before inline content", () => {
24
38
  const { window, displayList } = displayListFor(
25
39
  `<div class="card"><p>hi <code>x</code></p></div>`,
@@ -126,3 +140,76 @@ test("visibility, opacity and overflow/clip clipping apply to descendants", () =
126
140
  window.happyDOM.abort();
127
141
  });
128
142
 
143
+
144
+ function backgroundList(html, css, resources) {
145
+ const { document, window } = parseHTMLDocument(`<body style="margin:0">${html}</body>`);
146
+ const styles = new StyleEngine().compute(document, [css]);
147
+ const tree = new RenderTreeBuilder().build(document, styles);
148
+ const layout = layoutText(tree, {
149
+ x: 0, y: 0, width: 200, lineHeight: 20, measureText: (text) => text.length * 10, fontMetrics: () => ({ ascent: 16, height: 20 }),
150
+ });
151
+ const displayList = buildDisplayList(layout, tree, null, (url) => resources[url] ?? null);
152
+ window.happyDOM.abort();
153
+ return displayList;
154
+ }
155
+
156
+ test("background images are sized, positioned and repeated in the padding box, painted in the border box", () => {
157
+ const icon = { width: 48, height: 24, image: {} };
158
+ const items = backgroundList(
159
+ `<div class="a"></div><div class="b"></div><div class="c"></div><div class="missing"></div>`,
160
+ `div { height:60px; width:100px }
161
+ .a { background: url(icon.png) no-repeat center / 32px; border: 2px solid black }
162
+ .b { background-image: url(icon.png); background-size: cover; background-repeat: repeat-x }
163
+ .c { background: url(icon.png) no-repeat right 5px bottom 10px }
164
+ .missing { background: url(none.png) }`,
165
+ { "icon.png": icon },
166
+ ).items.filter((item) => item.op === "drawBackgroundImage");
167
+ // The image that has not arrived paints nothing.
168
+ expect(items).toHaveLength(3);
169
+ const [a, b, c] = items;
170
+ // 32px wide keeps the 2:1 ratio, centered in the 100x60 padding box inside the border.
171
+ expect(a).toMatchObject({ tile: { x: 2 + 34, y: 2 + 22, width: 32, height: 16 }, repeatX: false, repeatY: false });
172
+ expect(a.bounds).toEqual({ x: 36, y: 24, width: 32, height: 16 });
173
+ // cover: scaled to fill both sides; repeat-x spans the box horizontally only.
174
+ expect(b).toMatchObject({ tile: { x: 0, y: 64, width: 120, height: 60 }, repeatX: true, repeatY: false });
175
+ expect(b.bounds).toEqual({ x: 0, y: 64, width: 100, height: 60 });
176
+ // Edge offsets: 5px from the right, 10px from the bottom.
177
+ expect(c.tile).toEqual({ x: 100 - 5 - 48, y: 124 + 60 - 10 - 24, width: 48, height: 24 });
178
+ });
179
+
180
+ test("transform: scale paints and hit-tests a box and its contents about its origin", () => {
181
+ const displayList = backgroundList(
182
+ `<div class="tile"><span>x</span></div>`,
183
+ `.tile { width:100px; height:100px; background:#123; transform: scale(.5); overflow: hidden }`,
184
+ {},
185
+ );
186
+ const fill = displayList.items.find((item) => item.op === "fillRect");
187
+ expect(fill.transform).toEqual({ a: 0.5, d: 0.5, e: 25, f: 25 });
188
+ expect(fill.bounds).toEqual({ x: 25, y: 25, width: 50, height: 50 });
189
+ // Its overflow clip shrinks with it.
190
+ const text = displayList.items.find((item) => item.op === "drawText");
191
+ expect(text.clipRect).toEqual({ x: 25, y: 25, width: 50, height: 50 });
192
+ expect(hitTarget(displayList, 10, 10, { x: 0, y: 0 })).not.toBe(fill.nodeId);
193
+ expect(hitTarget(displayList, 30, 30, { x: 0, y: 0 })).toBe(fill.nodeId);
194
+ });
195
+
196
+ test("a scroll container moves its content by its scroll offset and reports how far the content reaches", () => {
197
+ const { document, window } = parseHTMLDocument(`<body style="margin:0"><div id="box"><p>a</p><p>b</p><p>c</p></div></body>`);
198
+ const styles = new StyleEngine().compute(document, [`#box { height:40px; overflow:auto; background:#123 } p { margin:0; height:20px }`]);
199
+ const tree = new RenderTreeBuilder().build(document, styles);
200
+ const layout = layoutText(tree, {
201
+ x: 0, y: 0, width: 200, lineHeight: 20, measureText: (text) => text.length * 10, fontMetrics: () => ({ ascent: 16, height: 20 }),
202
+ });
203
+ const box = document.getElementById("box");
204
+ const displayList = buildDisplayList(layout, tree, null, null, (element) => element === box ? { x: 0, y: 15 } : null);
205
+ window.happyDOM.abort();
206
+ const background = displayList.items.find((item) => item.op === "fillRect");
207
+ // The container itself stays; its text moves up by the offset, clipped to it.
208
+ expect(background.transform).toBeUndefined();
209
+ const second = displayList.items.find((item) => item.op === "drawText" && item.text === "b");
210
+ expect(second.transform).toEqual({ a: 1, d: 1, e: 0, f: -15 });
211
+ expect(second.clipRect).toEqual({ x: 0, y: 0, width: 200, height: 40 });
212
+ const boxId = [...tree.nodesById.values()].find((node) => node.domNode === box).id;
213
+ expect(displayList.scrollers.get(boxId)).toMatchObject({ bottom: 60 });
214
+ expect(hitTarget(displayList, 5, 10, { x: 0, y: 0 })).toBe(second.nodeId);
215
+ });