buninu-browser 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -0
- package/README.md +74 -9
- package/ai-debug.log +5537 -0
- package/bunfig.toml +1 -0
- package/buninu-browser.js +4 -0
- package/index.js +5 -0
- package/lib/audio/piano.js +24 -1
- package/lib/audio/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +137 -19
- package/lib/cdp-server.js +22 -11
- package/lib/happy-dom/parser.js +29 -4
- package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
- package/lib/headless-shell.js +69 -19
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +268 -85
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/media/video-keyframe.js +29 -0
- package/lib/network/network-service.js +161 -14
- package/lib/paint/display-list.js +316 -15
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +19 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +995 -69
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/compositor.js +16 -1
- package/lib/renderer/custom-element-reactions.js +18 -0
- package/lib/renderer/history.js +42 -0
- package/lib/renderer/host.js +166 -9
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/main.js +162 -12
- package/lib/renderer/media-playback-events.js +13 -0
- package/lib/renderer/media-source.js +819 -0
- package/lib/renderer/messaging.js +411 -0
- package/lib/renderer/native-functions.js +113 -0
- package/lib/renderer/page-realm.js +1408 -242
- package/lib/renderer/page-renderer.js +1893 -223
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- package/lib/renderer/rejections.js +31 -0
- package/lib/renderer/rewrite-worker.js +22 -0
- package/lib/renderer/script-rewrite.js +805 -57
- package/lib/renderer/trusted-types.js +129 -0
- package/lib/renderer/video-presentation.js +126 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +186 -29
- package/lib/user-agent.js +14 -2
- package/package.json +4 -1
- package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
- package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
- package/patches/happy-dom/README.md +26 -0
- package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
- package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
- package/patches/ses/README.md +4 -0
- package/test/canvas-2d.test.js +55 -0
- package/test/compositor.test.js +41 -1
- package/test/computed-style.test.js +59 -1
- package/test/display-list.test.js +88 -1
- package/test/download.test.js +43 -0
- package/test/fixtures/long-video.mp4 +0 -0
- package/test/fixtures/media-sync.js +57 -0
- package/test/happy-dom-platform-adapters.test.js +25 -0
- package/test/headless-shell.test.js +23 -1
- package/test/hit-test.test.js +25 -1
- package/test/media-playback-events.test.js +22 -0
- package/test/media-source.test.js +156 -0
- package/test/media-sync.test.js +12 -0
- package/test/mobile.test.js +2 -2
- package/test/navigation.test.js +61 -0
- package/test/network-service.test.js +14 -0
- package/test/page-scripts.test.js +1203 -7
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -0
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +84 -0
- package/test/renderer-host.test.js +5 -2
- package/test/screenshot-cli.test.js +25 -1
- package/test/script-rewrite.test.js +111 -6
- package/test/setup.js +5 -0
- package/test/text-layout.test.js +62 -0
- package/test/user-agent.test.js +60 -4
- package/test/video-presentation.test.js +67 -0
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
- package/vendor/ses/dist/ses.mjs +52 -6
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { handlePianoInput } from "../lib/renderer/piano-input.js";
|
|
3
|
+
|
|
4
|
+
const input = { getAttribute: () => "buninu-piano-input" };
|
|
5
|
+
|
|
6
|
+
test("CDP raw keydown uses the physical key even with an empty text field", () => {
|
|
7
|
+
const notes = [];
|
|
8
|
+
const play = n => notes.push(n);
|
|
9
|
+
for (let i = 0; i < 3; i++) {
|
|
10
|
+
expect(handlePianoInput(input, { key: " ", code: "Space", text: "", modifiers: 0, repeat: false }, play)).toBe(true);
|
|
11
|
+
}
|
|
12
|
+
expect(notes).toEqual([69, 69, 69]);
|
|
13
|
+
expect(handlePianoInput(input, { key: "F1", code: "F1", text: "" }, play)).toBe(true);
|
|
14
|
+
expect(notes.at(-1)).toBe(38);
|
|
15
|
+
expect(handlePianoInput(input, { key: " ", code: "Space", text: " ", repeat: true }, play)).toBe(true);
|
|
16
|
+
expect(notes).toHaveLength(4);
|
|
17
|
+
expect(handlePianoInput(input, { key: "Tab", code: "Tab", text: "" }, play)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("piano consumes batched terminal/IME text and plays every mapped note", () => {
|
|
21
|
+
const notes = [];
|
|
22
|
+
expect(handlePianoInput(input, { text: "jk L!中" }, n => notes.push(n))).toBe(true);
|
|
23
|
+
expect(notes).toEqual([71, 73, 69, 74, 45]);
|
|
24
|
+
expect(handlePianoInput(input, { text: "jk" }, null)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("piano consumes repeats without retriggering and supports shifted physical keys", () => {
|
|
28
|
+
const notes = [];
|
|
29
|
+
const play = n => notes.push(n);
|
|
30
|
+
expect(handlePianoInput(input, { key: "j", repeat: true }, play)).toBe(true);
|
|
31
|
+
expect(handlePianoInput(input, { key: " ", repeat: true }, play)).toBe(true);
|
|
32
|
+
expect(handlePianoInput(input, { key: "J", code: "KeyJ", modifiers: 8 }, play)).toBe(true);
|
|
33
|
+
expect(handlePianoInput(input, { key: "j", modifiers: 2 }, play)).toBe(true);
|
|
34
|
+
expect(notes).toEqual([71]);
|
|
35
|
+
expect(handlePianoInput(input, { key: "Tab" }, play)).toBe(false);
|
|
36
|
+
expect(handlePianoInput(input, { key: "Backspace" }, play)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("ordinary, disabled and readonly editors are unaffected", () => {
|
|
40
|
+
expect(handlePianoInput({ getAttribute: () => "editor" }, { text: "jk" })).toBe(false);
|
|
41
|
+
expect(handlePianoInput({ ...input, disabled: true }, { text: "jk" })).toBe(false);
|
|
42
|
+
expect(handlePianoInput({ ...input, readOnly: true }, { text: "jk" })).toBe(false);
|
|
43
|
+
expect(handlePianoInput(null, { text: "jk" })).toBe(false);
|
|
44
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { PlaybackBuffer } from "../lib/audio/playback-buffer.js";
|
|
3
|
+
|
|
4
|
+
test("media prepares a bounded reserve regardless of server credits", () => {
|
|
5
|
+
const buffer = new PlaybackBuffer(1000, 2, 0.5);
|
|
6
|
+
expect(buffer.request(0, 0, 0, 0)).toBe(2000);
|
|
7
|
+
expect(buffer.request(0, 2000, 0, 500)).toBe(0);
|
|
8
|
+
expect(buffer.request(1500, 0, 500, 0)).toBe(0);
|
|
9
|
+
expect(buffer.request(1100, 0, 500, 40)).toBe(400);
|
|
10
|
+
expect(buffer.request(1100, 400, 500, 40)).toBe(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("startup and starvation recovery require playable PCM, not elapsed time", () => {
|
|
14
|
+
const buffer = new PlaybackBuffer(1000, 2, 0.5);
|
|
15
|
+
expect(buffer.ready(40, 0, false)).toBe(false);
|
|
16
|
+
expect(buffer.ready(398, 0, false)).toBe(false);
|
|
17
|
+
expect(buffer.ready(400, 0, false)).toBe(true);
|
|
18
|
+
expect(buffer.ready(40, 300, false)).toBe(true);
|
|
19
|
+
expect(buffer.ready(0, 0, false)).toBe(false);
|
|
20
|
+
expect(buffer.ready(40, 0, false)).toBe(false);
|
|
21
|
+
expect(buffer.ready(400, 0, false)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("short clips drain and piano stays interactive", () => {
|
|
25
|
+
const media = new PlaybackBuffer(1000, 2, 0.5);
|
|
26
|
+
expect(media.ready(40, 0, true)).toBe(true);
|
|
27
|
+
const piano = new PlaybackBuffer(1000, 2, 0.12);
|
|
28
|
+
expect(piano.ready(40, 0, false)).toBe(true);
|
|
29
|
+
expect(piano.request(0, 0, 0, 120)).toBe(120);
|
|
30
|
+
expect(piano.request(0, 120, 0, 120)).toBe(0);
|
|
31
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { advanceAudio } from "../lib/media/position-audio.js";
|
|
3
|
+
import { createBytePipe } from "../lib/media/byte-pipe.js";
|
|
4
|
+
|
|
5
|
+
function track(pipe, length) {
|
|
6
|
+
let next = 2;
|
|
7
|
+
return { position: 0, pending: new Float32Array([0, 1]), pipe, openedGeneration: pipe.generation,
|
|
8
|
+
decoder: { sampleRate: 1, channels: 1, nextSamples() {
|
|
9
|
+
if (next >= length) return null;
|
|
10
|
+
const samples = new Float32Array([next, next + 1]);
|
|
11
|
+
next += 2;
|
|
12
|
+
return samples;
|
|
13
|
+
} } };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test.each([4, 5, 6])("snapshot reopen consumes the pending first chunk before reaching sample %i", async (target) => {
|
|
17
|
+
const pipe = createBytePipe();
|
|
18
|
+
pipe.append(new Uint8Array(1));
|
|
19
|
+
let sound = track(pipe, 2);
|
|
20
|
+
pipe.append(new Uint8Array(1)); // newer data already present: don't wait for another append
|
|
21
|
+
let reopened = 0;
|
|
22
|
+
expect(await advanceAudio(() => sound, target, {
|
|
23
|
+
needsMore: () => true,
|
|
24
|
+
reopen: async () => { reopened++; sound = track(pipe, 10); return true; },
|
|
25
|
+
})).toBe(true);
|
|
26
|
+
expect(reopened).toBe(1);
|
|
27
|
+
expect(sound.position).toBe(target);
|
|
28
|
+
expect([...(sound.pending ?? sound.decoder.nextSamples())][0]).toBe(target);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("network starvation waits for data without replaying an earlier prefix", async () => {
|
|
32
|
+
const pipe = createBytePipe();
|
|
33
|
+
pipe.append(new Uint8Array(1));
|
|
34
|
+
let sound = track(pipe, 2), completed = false;
|
|
35
|
+
const job = advanceAudio(() => sound, 4, {
|
|
36
|
+
needsMore: () => true,
|
|
37
|
+
reopen: async () => { sound = track(pipe, 8); return true; },
|
|
38
|
+
}).then(value => { completed = true; return value; });
|
|
39
|
+
await Bun.sleep(30);
|
|
40
|
+
expect(completed).toBe(false);
|
|
41
|
+
expect(sound.pending).toBeNull();
|
|
42
|
+
pipe.append(new Uint8Array(1));
|
|
43
|
+
expect(await job).toBe(true);
|
|
44
|
+
expect(sound.decoder.nextSamples()[0]).toBe(4);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a cancelled seek cannot reopen a stale decoder after data arrives", async () => {
|
|
48
|
+
const pipe = createBytePipe();
|
|
49
|
+
pipe.append(new Uint8Array(1));
|
|
50
|
+
const sound = track(pipe, 2);
|
|
51
|
+
let active = true, reopened = false;
|
|
52
|
+
const job = advanceAudio(() => sound, 8, {
|
|
53
|
+
needsMore: () => true, active: () => active,
|
|
54
|
+
reopen: async () => { reopened = true; return true; },
|
|
55
|
+
});
|
|
56
|
+
active = false;
|
|
57
|
+
pipe.append(new Uint8Array(1));
|
|
58
|
+
expect(await job).toBe(false);
|
|
59
|
+
expect(reopened).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("an ended prefix or failed reopen never claims to have reached the target", async () => {
|
|
63
|
+
const pipe = createBytePipe();
|
|
64
|
+
pipe.append(new Uint8Array(1));
|
|
65
|
+
const sound = track(pipe, 2);
|
|
66
|
+
expect(await advanceAudio(() => sound, 8, { needsMore: () => false, reopen: async () => false })).toBe(false);
|
|
67
|
+
});
|
package/test/positioning.test.js
CHANGED
|
@@ -26,8 +26,21 @@ function render(html, css) {
|
|
|
26
26
|
});
|
|
27
27
|
return found && { x: found.x, y: found.y, width: found.width, height: found.height };
|
|
28
28
|
};
|
|
29
|
+
// The first line run of an element (an image) or of its text.
|
|
30
|
+
const run = (selector) => {
|
|
31
|
+
const element = document.querySelector(selector);
|
|
32
|
+
for (const fragment of layout.fragments) {
|
|
33
|
+
for (const candidate of fragment.runs) {
|
|
34
|
+
const node = tree.nodesById.get(candidate.nodeId)?.domNode;
|
|
35
|
+
if (node === element || node?.parentElement === element) {
|
|
36
|
+
return { x: candidate.x, y: candidate.y, width: candidate.width, height: candidate.height };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
};
|
|
29
42
|
window.happyDOM.abort();
|
|
30
|
-
return { layout, tree, box };
|
|
43
|
+
return { layout, tree, box, run };
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
test("absolute boxes leave the flow and resolve insets against the positioned ancestor's padding box", () => {
|
|
@@ -54,6 +67,23 @@ test("left+right and top+bottom stretch an absolute box, percentages use the con
|
|
|
54
67
|
expect(box(".half")).toMatchObject({ x: 50, y: 50 });
|
|
55
68
|
});
|
|
56
69
|
|
|
70
|
+
test("a percentage height fills a containing block whose height is definite", () => {
|
|
71
|
+
const { box } = render(
|
|
72
|
+
`<div class="frame"><div class="mid"><div class="fill"></div></div></div>`,
|
|
73
|
+
`.frame { height:200px } .mid { height:50% } .fill { height:100%; background:black }`,
|
|
74
|
+
);
|
|
75
|
+
expect(box(".mid").height).toBe(100);
|
|
76
|
+
expect(box(".fill").height).toBe(100);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a percentage height of an auto-sized parent stays the content height", () => {
|
|
80
|
+
const { box } = render(
|
|
81
|
+
`<div class="frame"><div class="fill">x</div></div>`,
|
|
82
|
+
`.fill { height:100% }`,
|
|
83
|
+
);
|
|
84
|
+
expect(box(".fill").height).toBe(20);
|
|
85
|
+
});
|
|
86
|
+
|
|
57
87
|
test("absolute percentage width uses the full containing block after a left inset", () => {
|
|
58
88
|
const { box } = render(
|
|
59
89
|
`<div class="frame"><div class="first"></div><div class="second"></div></div>`,
|
|
@@ -121,3 +151,67 @@ test("a z-index stacking context paints its auto-positioned descendants after it
|
|
|
121
151
|
// The z-index auto sibling (z 0) paints before the z 32 context.
|
|
122
152
|
expect(later).toBeLessThan(header);
|
|
123
153
|
});
|
|
154
|
+
|
|
155
|
+
test("relative offsets resolve percentages against the containing block, for blocks and inline boxes", () => {
|
|
156
|
+
const { box, run } = render(
|
|
157
|
+
`<div class="frame"><div class="moved"></div><p><span class="up">text</span> <img class="tile" width="20" height="20"></p></div>`,
|
|
158
|
+
`.frame { width:200px; height:100px }
|
|
159
|
+
.moved { position:relative; left:-25%; top:10%; height:10px }
|
|
160
|
+
p { margin:0 }
|
|
161
|
+
.up { position:relative; left:-10px; top:-5px }
|
|
162
|
+
.tile { position:relative; left:-100%; top:0% }`,
|
|
163
|
+
);
|
|
164
|
+
// Percentages of the 200x100 containing block.
|
|
165
|
+
expect(box(".moved")).toMatchObject({ x: -50, y: 10 });
|
|
166
|
+
// The span's text moves with it; the image by a whole containing-block width.
|
|
167
|
+
expect(run(".up")).toMatchObject({ x: -10 });
|
|
168
|
+
expect(run(".tile").x).toBe(50 - 200);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("an inline image sized in percentages takes its line space from the containing block", () => {
|
|
172
|
+
const { run } = render(
|
|
173
|
+
`<div class="wrapper"><img class="big"></div>`,
|
|
174
|
+
`.wrapper { width:100px; height:100px; text-align:center; overflow:hidden }
|
|
175
|
+
.big { width:300%; height:300% }`,
|
|
176
|
+
);
|
|
177
|
+
// 300px wide: the line overflows, so it starts at the left instead of being centered.
|
|
178
|
+
expect(run(".big")).toMatchObject({ x: 0, width: 300, height: 300 });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a float shrinks to fit its contents' min-width and sits at the top of its line", () => {
|
|
182
|
+
const { box } = render(
|
|
183
|
+
`<div class="footer"><div class="left"></div><div class="right"><span class="button">Go</span></div></div>`,
|
|
184
|
+
`.footer { width:300px; height:60px }
|
|
185
|
+
.left { float:left; width:50px; height:48px; margin:6px }
|
|
186
|
+
.right { float:right; margin:8px }
|
|
187
|
+
.button { display:inline-block; min-width:100px; padding:0 10px; height:40px }`,
|
|
188
|
+
);
|
|
189
|
+
// min-width 100 plus padding: 120 wide, against the right edge less its margin.
|
|
190
|
+
expect(box(".right")).toMatchObject({ x: 300 - 8 - 120, y: 8, width: 120 });
|
|
191
|
+
expect(box(".left")).toMatchObject({ x: 6, y: 6 });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a column flex item with a main-axis auto margin is still centered across", () => {
|
|
195
|
+
const { box } = render(
|
|
196
|
+
`<div class="column"><div class="item"></div></div>`,
|
|
197
|
+
`.column { display:flex; flex-direction:column; align-items:center; width:400px }
|
|
198
|
+
.item { margin-top:auto; width:100px; height:20px }`,
|
|
199
|
+
);
|
|
200
|
+
expect(box(".item")).toMatchObject({ x: 150, width: 100 });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("an svg without width and height fills its container and keeps its viewBox's ratio", () => {
|
|
204
|
+
const { run } = render(
|
|
205
|
+
`<div class="icon"><svg id="plain" viewBox="0 -960 960 960"><path d="M0 0h10v10z"/></svg></div>
|
|
206
|
+
<div class="icon"><svg id="lower" viewbox="0 0 24 12"></svg></div>
|
|
207
|
+
<div class="icon"><svg id="sized" width="10" height="5" viewBox="0 0 24 24"></svg></div>
|
|
208
|
+
<div class="icon"><svg id="bare"></svg></div>`,
|
|
209
|
+
`.icon { width:24px }`,
|
|
210
|
+
);
|
|
211
|
+
// 100% of the 24px container; height by the viewBox (a lowercase viewbox too).
|
|
212
|
+
expect(run("#plain")).toMatchObject({ width: 24, height: 24 });
|
|
213
|
+
expect(run("#lower")).toMatchObject({ width: 24, height: 12 });
|
|
214
|
+
// Its own attributes win; with nothing at all it is 150px high.
|
|
215
|
+
expect(run("#sized")).toMatchObject({ width: 10, height: 5 });
|
|
216
|
+
expect(run("#bare")).toMatchObject({ width: 24, height: 150 });
|
|
217
|
+
});
|
|
@@ -111,6 +111,8 @@ test("audio is sent in whole frames, so stereo channels never swap", async () =>
|
|
|
111
111
|
expect(sizes.every((size) => size % 4 === 0)).toBeTrue();
|
|
112
112
|
expect(offsets.every((offset) => offset === 0n)).toBeTrue();
|
|
113
113
|
expect(flags.every((flag) => flag === 0)).toBeTrue();
|
|
114
|
+
// What the server holds, which it may still play after the stream closes.
|
|
115
|
+
expect(stream.sentBytes).toBe(sizes.reduce((sum, size) => sum + size, 0));
|
|
114
116
|
expect(bufferAttributes[0]).toEqual([76800, 15360, 7680]); // 400/80/40 ms at 48 kHz stereo
|
|
115
117
|
await client.openPlayback({ rate: 48000, channels: 1, leadSeconds: 0.12 });
|
|
116
118
|
expect(bufferAttributes[1]).toEqual([15360, 3840, 3840]); // 160/40/40 ms at 48 kHz mono
|
|
@@ -128,3 +130,85 @@ function header(length, channel) {
|
|
|
128
130
|
out.writeUInt32BE(channel, 4);
|
|
129
131
|
return out;
|
|
130
132
|
}
|
|
133
|
+
|
|
134
|
+
test("measured device progress governs the media clock and bounded audio queue through starvation", async () => {
|
|
135
|
+
const { createServer } = await import("node:net");
|
|
136
|
+
let received = 0, played = 0, reports = 0;
|
|
137
|
+
const times = [], errors = [], requests = [];
|
|
138
|
+
const scalar64 = (tag, value) => {
|
|
139
|
+
const bytes = Buffer.alloc(9);
|
|
140
|
+
bytes[0] = tag;
|
|
141
|
+
bytes.writeBigInt64BE(BigInt(value), 1);
|
|
142
|
+
return bytes;
|
|
143
|
+
};
|
|
144
|
+
const server = createServer(socket => {
|
|
145
|
+
let input = Buffer.alloc(0);
|
|
146
|
+
socket.on("data", chunk => {
|
|
147
|
+
input = Buffer.concat([input, chunk]);
|
|
148
|
+
while (input.length >= 20 && input.length >= 20 + input.readUInt32BE(0)) {
|
|
149
|
+
const length = input.readUInt32BE(0), channel = input.readUInt32BE(4);
|
|
150
|
+
const body = input.subarray(20, 20 + length);
|
|
151
|
+
input = input.subarray(20 + length);
|
|
152
|
+
if (channel !== 0xffffffff) { received += length; continue; }
|
|
153
|
+
const command = body.readUInt32BE(1), tag = body.readUInt32BE(6);
|
|
154
|
+
let extra = Buffer.alloc(0);
|
|
155
|
+
if (command === 3) extra = Buffer.from([0x4c, ...u32(1), 0x4c, ...u32(1), 0x4c, ...u32(1e7)]);
|
|
156
|
+
if (command === 14) {
|
|
157
|
+
reports++;
|
|
158
|
+
expect(body[15]).toBe(0x54); // timestamp request, protocol v8
|
|
159
|
+
const timeval = Buffer.from([0x54, ...u32(1), ...u32(0)]);
|
|
160
|
+
// Everything has reached the backend, but the device still holds
|
|
161
|
+
// received-played bytes. REQUEST credits are deliberately unrelated.
|
|
162
|
+
extra = Buffer.concat([scalar64(0x55, (received - played) * 1000), scalar64(0x55, 0),
|
|
163
|
+
Buffer.from([0x31]), timeval, timeval, scalar64(0x72, received), scalar64(0x72, received)]);
|
|
164
|
+
}
|
|
165
|
+
const reply = Buffer.concat([Buffer.from([0x4c, ...u32(2), 0x4c, ...u32(tag)]), extra]);
|
|
166
|
+
socket.write(Buffer.concat([header(reply.length, 0xffffffff), reply]));
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
171
|
+
const client = await PulseClient.connect({ port: server.address().port });
|
|
172
|
+
try {
|
|
173
|
+
const stream = await client.openPlayback({ rate: 500, channels: 1,
|
|
174
|
+
onRequest: bytes => requests.push(bytes),
|
|
175
|
+
onTiming: timing => times.push(timing.seconds), onTimingError: error => errors.push(error) });
|
|
176
|
+
expect(requests).toEqual([2000]); // controller reserve, not the device queue
|
|
177
|
+
stream.write(new Uint8Array(100));
|
|
178
|
+
await Bun.sleep(80);
|
|
179
|
+
expect(received).toBe(0); // a short prefix does not start/stop playback
|
|
180
|
+
stream.write(new Uint8Array(1900));
|
|
181
|
+
await Bun.sleep(180);
|
|
182
|
+
expect(reports).toBeGreaterThan(0);
|
|
183
|
+
expect(received).toBe(500); // at most the 0.5 s queue budget, not 2 s
|
|
184
|
+
expect(stream.queued).toBe(1500); // survives a renderer busy spell in the controller
|
|
185
|
+
expect(times.at(-1)).toBe(0); // server/device hasn't played anything
|
|
186
|
+
played = 100;
|
|
187
|
+
await Bun.sleep(120);
|
|
188
|
+
expect(times.at(-1)).toBeCloseTo(0.1, 5);
|
|
189
|
+
expect(received).toBe(600);
|
|
190
|
+
// A long network/producer pause is NOT permission to catch up by queuing seconds.
|
|
191
|
+
await Bun.sleep(180);
|
|
192
|
+
expect(times.at(-1)).toBeCloseTo(0.1, 5);
|
|
193
|
+
expect(received).toBe(600);
|
|
194
|
+
played = 600;
|
|
195
|
+
await Bun.sleep(120);
|
|
196
|
+
expect(times.at(-1)).toBeCloseTo(0.6, 5);
|
|
197
|
+
expect(received).toBeGreaterThan(1000);
|
|
198
|
+
expect(received).toBeLessThanOrEqual(1100);
|
|
199
|
+
// No more PCM is provided by the producer: continue from the local reserve.
|
|
200
|
+
for (let i = 0; i < 4; i++) {
|
|
201
|
+
played = received;
|
|
202
|
+
await Bun.sleep(150);
|
|
203
|
+
}
|
|
204
|
+
expect(received).toBeGreaterThan(1500);
|
|
205
|
+
expect(received).toBeLessThanOrEqual(2000);
|
|
206
|
+
expect(requests.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(4000);
|
|
207
|
+
expect(errors).toEqual([]);
|
|
208
|
+
await stream.close();
|
|
209
|
+
expect(stream.queued).toBe(0); // pause/seek discards reserve; no extra device tail
|
|
210
|
+
} finally {
|
|
211
|
+
client.close();
|
|
212
|
+
await new Promise(resolve => server.close(resolve));
|
|
213
|
+
}
|
|
214
|
+
});
|
|
@@ -6,8 +6,11 @@ const hosts = [];
|
|
|
6
6
|
afterEach(() => {
|
|
7
7
|
for (const host of hosts.splice(0)) host.close();
|
|
8
8
|
});
|
|
9
|
+
// The watchdog deadline: long enough for a fresh renderer to answer while
|
|
10
|
+
// test files run in parallel, short enough to see a hang killed.
|
|
11
|
+
const TIMEOUT = 4000;
|
|
9
12
|
function host(options = {}) {
|
|
10
|
-
const created = new RendererHost({ entry, timeout:
|
|
13
|
+
const created = new RendererHost({ entry, timeout: TIMEOUT, ...options });
|
|
11
14
|
hosts.push(created);
|
|
12
15
|
return created;
|
|
13
16
|
}
|
|
@@ -32,7 +35,7 @@ test("kills a renderer that misses its deadline and restarts it on the next call
|
|
|
32
35
|
const firstPid = renderer.pid;
|
|
33
36
|
const started = performance.now();
|
|
34
37
|
await expect(renderer.call("hang")).rejects.toThrow("timed out");
|
|
35
|
-
expect(performance.now() - started).toBeLessThan(
|
|
38
|
+
expect(performance.now() - started).toBeLessThan(TIMEOUT + 3000);
|
|
36
39
|
const after = await renderer.call("echo", "next");
|
|
37
40
|
expect(after.pid).not.toBe(firstPid);
|
|
38
41
|
expect(after.argument).toBe("next");
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { runScreenshot, screenshotTargetUrl } from "../lib/screenshot-cli.js";
|
|
6
|
+
import { DESKTOP_USER_AGENT, WEBKIT_USER_AGENT } from "../lib/user-agent.js";
|
|
3
7
|
|
|
4
8
|
test("screenshot target treats Windows drive and UNC paths as files", () => {
|
|
5
9
|
const options = {
|
|
@@ -17,3 +21,23 @@ test("screenshot target preserves URLs and bare hostnames", () => {
|
|
|
17
21
|
expect(screenshotTargetUrl("https://example.com", { platform: "win32" })).toBe("https://example.com");
|
|
18
22
|
expect(screenshotTargetUrl("example.com", { platform: "win32" })).toBe("https://example.com");
|
|
19
23
|
});
|
|
24
|
+
|
|
25
|
+
test("screenshot mode defaults to WebKit and accepts both explicit desktop profiles", async () => {
|
|
26
|
+
const directory = await mkdtemp(join(tmpdir(), "buninu-profile-test-"));
|
|
27
|
+
let userAgent;
|
|
28
|
+
const server = Bun.serve({ port: 0, fetch(request) {
|
|
29
|
+
userAgent = request.headers.get("user-agent");
|
|
30
|
+
return new Response("<!doctype html><p>identity</p>", { headers: { "content-type": "text/html" } });
|
|
31
|
+
} });
|
|
32
|
+
try {
|
|
33
|
+
for (const [flags, expected] of [[[], WEBKIT_USER_AGENT], [["--webkit"], WEBKIT_USER_AGENT], [["--chromium"], DESKTOP_USER_AGENT]]) {
|
|
34
|
+
const filename = join(directory, "page.png");
|
|
35
|
+
await runScreenshot([`--screenshot=${filename}`, "--window-size=64,64", ...flags, `http://127.0.0.1:${server.port}/`]);
|
|
36
|
+
expect(userAgent).toBe(expected);
|
|
37
|
+
expect((await Bun.file(filename).bytes()).slice(0, 8)).toEqual(new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]));
|
|
38
|
+
}
|
|
39
|
+
} finally {
|
|
40
|
+
server.stop(true);
|
|
41
|
+
await rm(directory, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
}, 60_000);
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
escapeSesHtmlComments, prepareDirectEvalSource, restoreSourceText, rewriteClassicScript, rewriteConstructorReads,
|
|
4
|
+
referenceHelper, rewriteClassicScriptSource, rewriteDirectEval,
|
|
5
|
+
} from "../lib/renderer/script-rewrite.js";
|
|
3
6
|
|
|
4
|
-
|
|
7
|
+
// The comment each constructor rewrite leaves for restoreSourceText.
|
|
8
|
+
const rewrite = (source, type) => rewriteConstructorReads(source, type).code.replace(/\/\*\$buninu\$ctor:[^*]*\*\//g, "");
|
|
9
|
+
|
|
10
|
+
test("HTML comment markers in JavaScript values are escaped without changing them", () => {
|
|
11
|
+
const source = 'const a = "<!-- start -->"; const b = /-->/g; const c = `<!-- ${1} -->`;';
|
|
12
|
+
const escaped = escapeSesHtmlComments(source);
|
|
13
|
+
expect(escaped).not.toContain("<!--");
|
|
14
|
+
expect(escaped).not.toContain("-->");
|
|
15
|
+
expect(Function(`${escaped}; return [a, b.test('-->'), c]`)()).toEqual(["<!-- start -->", true, "<!-- 1 -->"]);
|
|
16
|
+
});
|
|
5
17
|
|
|
6
18
|
test("constructor reads go through the helper; writes, delete and optional chains do not", () => {
|
|
7
19
|
expect(rewrite("var A = Object.getPrototypeOf(async function () {}).constructor;"))
|
|
@@ -14,18 +26,111 @@ test("constructor reads go through the helper; writes, delete and optional chain
|
|
|
14
26
|
expect(rewrite(`y = a . constructor ['constructor'];${trigger}`))
|
|
15
27
|
.toBe("y = $buninu$constructor($buninu$constructor(a) ) ; ($buninu$constructor(async () => {}));");
|
|
16
28
|
// Calls keep the receiver (the helper's second argument).
|
|
17
|
-
|
|
18
|
-
|
|
29
|
+
// A constructed one is parenthesized so `new` applies to the result.
|
|
30
|
+
expect(rewrite(`b.constructor(1); new c.constructor(); new d.constructor;${trigger}`))
|
|
31
|
+
.toBe("$buninu$constructor(b, true)(1); new ($buninu$constructor(c))(); new ($buninu$constructor(d)); ($buninu$constructor(async () => {}));");
|
|
19
32
|
const untouched = "a.constructor = 1; a.constructor++; delete a.constructor; for (a.constructor of []); c?.constructor; "
|
|
20
33
|
+ "class K extends B { constructor() { super(); super.constructor } } ({ constructor: 1 });";
|
|
21
34
|
expect(rewrite(untouched + trigger)).toBe(`${untouched} ($buninu$constructor(async () => {}));`);
|
|
22
35
|
});
|
|
23
36
|
|
|
37
|
+
test("new on a rewritten constructor read constructs the object's constructor", () => {
|
|
38
|
+
const code = rewrite("class Point { constructor(x) { this.x = x; } } const p = new Point(1);"
|
|
39
|
+
+ " result = new p.constructor(2); (async () => {}).constructor;");
|
|
40
|
+
const run = new Function("$buninu$constructor", `let result; ${code} return result;`);
|
|
41
|
+
const result = run((object) => object.constructor);
|
|
42
|
+
expect(result).toBeObject();
|
|
43
|
+
expect(result.constructor.name).toBe("Point");
|
|
44
|
+
expect(result.x).toBe(2);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("constructor reads preserve comma expressions as a single receiver", () => {
|
|
48
|
+
const source = 'var a = {}, b = {}; var result = (a.x = 1, b).constructor; (async () => {}).constructor;';
|
|
49
|
+
const rewritten = rewriteConstructorReads(source).code;
|
|
50
|
+
const run = new Function("$buninu$constructor", `${rewritten}; return [result, a.x];`);
|
|
51
|
+
expect(run((value, call = false) => {
|
|
52
|
+
expect(call).toBe(false);
|
|
53
|
+
return value.constructor;
|
|
54
|
+
})).toEqual([Object, 1]);
|
|
55
|
+
expect(restoreSourceText(rewritten)).toBe(source);
|
|
56
|
+
});
|
|
57
|
+
|
|
24
58
|
test("sources that cannot reach Function constructors are not parsed; bad syntax is left to the engine", () => {
|
|
25
|
-
expect(rewriteConstructorReads("x.constructor.name")).toEqual({ code: "x.constructor.name", rewritten: false });
|
|
59
|
+
expect(rewriteConstructorReads("x.constructor.name")).toEqual({ code: "x.constructor.name", rewritten: false, program: null });
|
|
26
60
|
// this.constructor is common and harmless; alone it does not trigger a parse.
|
|
27
61
|
expect(rewriteConstructorReads("async function f() { return this.constructor }").rewritten).toBe(false);
|
|
28
|
-
expect(rewriteConstructorReads("(async () => {}).constructor(")).toEqual({ code: "(async () => {}).constructor(", rewritten: false });
|
|
62
|
+
expect(rewriteConstructorReads("(async () => {}).constructor(")).toEqual({ code: "(async () => {}).constructor(", rewritten: false, program: null });
|
|
29
63
|
expect(rewrite("export const A = (async () => {}).constructor;", "module"))
|
|
30
64
|
.toBe("export const A = ($buninu$constructor(async () => {}));");
|
|
31
65
|
});
|
|
66
|
+
|
|
67
|
+
test("every rewrite of a function's text is undone for Function.prototype.toString", () => {
|
|
68
|
+
const functions = [
|
|
69
|
+
"function f(n) { var t = this; for (; n-->0;) eval(\"n\" + t); return (async () => {}).constructor; }",
|
|
70
|
+
"function g() { return [eval(eval(\"x\")), new (Object.getPrototypeOf(function* () {})).constructor(\"yield 1\"), \"<!-- -->\"]; }",
|
|
71
|
+
"function h() { \"use strict\"; return [this, eval(\"1\"), a.b().constructor(2)]; }",
|
|
72
|
+
];
|
|
73
|
+
for (const text of functions) {
|
|
74
|
+
const classic = rewriteClassicScript(rewriteConstructorReads(rewriteDirectEval(text).code).code);
|
|
75
|
+
const code = escapeSesHtmlComments(classic.code);
|
|
76
|
+
// The function's own text in the rewritten program (after the prologue).
|
|
77
|
+
const own = code.slice(code.indexOf("function ", code.indexOf("=")));
|
|
78
|
+
expect(restoreSourceText(own.slice(0, own.lastIndexOf("}") + 1))).toBe(text);
|
|
79
|
+
}
|
|
80
|
+
// The prepared program starts with its constants; the function's own text follows.
|
|
81
|
+
const prepared = prepareDirectEvalSource("(function () { return this; })", [], false).code;
|
|
82
|
+
expect(restoreSourceText(prepared.slice(prepared.indexOf("(function")))).toBe("(function () { return this; })");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("only parsed, non-strict programs are reported as sloppy-mode code", () => {
|
|
86
|
+
expect(rewriteClassicScript("var a = this;")).toMatchObject({ parsed: true, strict: false });
|
|
87
|
+
expect(rewriteClassicScript("'use strict'; var a = 1;")).toMatchObject({ parsed: true, strict: true });
|
|
88
|
+
expect(rewriteClassicScript("var = ;")).toMatchObject({ parsed: false });
|
|
89
|
+
// A sloppy program's own `this` and its functions' go through the helper.
|
|
90
|
+
expect(rewriteClassicScript("x = this; function f() { return this; }").code).not.toMatch(/[^(]this[^)]/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("the classic-script pipeline parses once when earlier rewrites change nothing, with the same result", () => {
|
|
94
|
+
const sources = [
|
|
95
|
+
"var a = 1; function f() { return this; }",
|
|
96
|
+
"var s = 'eval(1)'; for (var i = 0; i < 2; i++) {}",
|
|
97
|
+
"var g = (async () => {}).constructor; var t = this;",
|
|
98
|
+
"function h(x) { return eval(x); } var y = eval('2');",
|
|
99
|
+
"return 1;",
|
|
100
|
+
];
|
|
101
|
+
for (const source of sources) {
|
|
102
|
+
const stepwise = rewriteClassicScript(rewriteConstructorReads(rewriteDirectEval(source).code).code);
|
|
103
|
+
expect(rewriteClassicScriptSource(source)).toEqual(stepwise);
|
|
104
|
+
}
|
|
105
|
+
const direct = rewriteDirectEval("var s = 'eval(1)';");
|
|
106
|
+
expect([direct.rewritten, direct.program?.type]).toEqual([false, "Program"]);
|
|
107
|
+
expect(rewriteDirectEval("eval(x); return 1;").program).toBe(null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("global variable reads go through the script's global binding, keeping their semantics and text", () => {
|
|
111
|
+
const global = { Q: (x) => x + 1, n: 2, nothing: null };
|
|
112
|
+
global.$buninu$ref = referenceHelper(global);
|
|
113
|
+
const run = (source) => {
|
|
114
|
+
const { code } = rewriteClassicScript(source);
|
|
115
|
+
// Evaluated the way the page realm does: inside `with` scopes over the global.
|
|
116
|
+
return Function("g", `with (g) { return eval(arguments[1]); }`)(global, code.replace("globalThis", "g"));
|
|
117
|
+
};
|
|
118
|
+
expect(rewriteClassicScript("function f(x) { return Q(x) + n; }").code).toContain("($buninu$g.Q ?? $buninu$g.$buninu$ref(\"Q\"))");
|
|
119
|
+
expect(run("(function () { return nothing; })")()).toBe(null);
|
|
120
|
+
expect(run("(function (x) { return Q(x) + n; })")(1)).toBe(4);
|
|
121
|
+
expect(run("(function () { return [typeof missing, typeof Q]; })")()).toEqual(["undefined", "function"]);
|
|
122
|
+
expect(() => run("(function () { return missing; })")()).toThrow("missing is not defined");
|
|
123
|
+
// Locals, parameters and names inside the page's own `with` stay variables; writes stay identifiers.
|
|
124
|
+
const source = "function f(Q, o) { var n = 1; with (o) { k; } k = Q; k += n; return {n, Q, z: x => x + n}; }";
|
|
125
|
+
expect(rewriteClassicScript(source).code).not.toContain("$buninu$g.Q");
|
|
126
|
+
expect(rewriteClassicScript(source).code).not.toContain("$buninu$g.k");
|
|
127
|
+
// Shorthand properties keep their key; the page's text is restored exactly.
|
|
128
|
+
const shorthand = "function s() { return { Q, n, m: Q }; }";
|
|
129
|
+
expect(run(`(${shorthand})`)()).toEqual({ Q: global.Q, n: 2, m: global.Q });
|
|
130
|
+
const code = rewriteClassicScript(shorthand).code;
|
|
131
|
+
const own = code.slice(code.indexOf("function s"));
|
|
132
|
+
expect(restoreSourceText(own.slice(0, own.lastIndexOf("}") + 1))).toBe(shorthand);
|
|
133
|
+
expect(restoreSourceText("(function () { return typeof $buninu$g.Q; })")).toBe("(function () { return typeof Q; })");
|
|
134
|
+
// A block-level function outside functions (Annex B) leaves the script's identifiers as written.
|
|
135
|
+
expect(rewriteClassicScript("{ function b() {} } b(Q);").code).not.toContain("$buninu$g");
|
|
136
|
+
});
|
package/test/setup.js
ADDED
package/test/text-layout.test.js
CHANGED
|
@@ -8,6 +8,68 @@ function boxFor(tree, layout, tagName) {
|
|
|
8
8
|
return layout.boxes.find((box) => tree.nodesById.get(box.nodeId)?.tagName === tagName);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
test("percentage and calc padding reserve an aspect-ratio box and reflow on resize", () => {
|
|
12
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0">
|
|
13
|
+
<div id="ratio" style="position:relative;padding-bottom:calc(100% * 9 / 16)">
|
|
14
|
+
<div id="fill" style="position:absolute;inset:0"></div>
|
|
15
|
+
</div><div id="next" style="height:20px"></div>
|
|
16
|
+
</body>`);
|
|
17
|
+
const styles = new StyleEngine().compute(document);
|
|
18
|
+
const tree = new RenderTreeBuilder().build(document, styles);
|
|
19
|
+
for (const width of [320, 640]) {
|
|
20
|
+
const layout = layoutText(tree, { x:0, y:0, width });
|
|
21
|
+
const rect = id => layout.boxes.find(box => tree.nodesById.get(box.nodeId)?.domNode?.id === id);
|
|
22
|
+
expect(rect('ratio').height).toBe(width * 9 / 16);
|
|
23
|
+
expect(rect('fill').height).toBe(width * 9 / 16);
|
|
24
|
+
expect(rect('next').y).toBe(width * 9 / 16);
|
|
25
|
+
}
|
|
26
|
+
window.happyDOM.abort();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("nested absolute percentage heights use their own containing block, including on resize", () => {
|
|
30
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0">
|
|
31
|
+
<div style="position:relative;height:500px"><div style="position:absolute;bottom:0;width:100%;height:60px">
|
|
32
|
+
<div id="track" style="position:absolute;bottom:56px;width:100%;height:6px">
|
|
33
|
+
<div id="fill" style="position:absolute;bottom:0;width:100%;height:100%"></div>
|
|
34
|
+
</div>
|
|
35
|
+
</div></div></body>`);
|
|
36
|
+
const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
|
|
37
|
+
const engine = new LayoutEngine();
|
|
38
|
+
for (const width of [320, 640]) {
|
|
39
|
+
const layout = engine.layout(tree, { x:0, y:0, width });
|
|
40
|
+
const rect = id => layout.boxes.find(box => tree.nodesById.get(box.nodeId)?.domNode?.id === id);
|
|
41
|
+
expect(rect('fill').height).toBe(6);
|
|
42
|
+
expect(rect('fill').y).toBe(rect('track').y);
|
|
43
|
+
expect(rect('fill').width).toBe(width);
|
|
44
|
+
}
|
|
45
|
+
window.happyDOM.abort();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("legacy vertical line clamping limits lines and adds an ellipsis without changing DOM text", () => {
|
|
49
|
+
for (const display of ["-webkit-box", "block"]) {
|
|
50
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0"><div style="display:${display};-webkit-box-orient:vertical;-webkit-line-clamp:2;font-size:10px;line-height:10px;width:40px;overflow:hidden;overflow-wrap:anywhere">abcdefghijklmnop</div></body>`);
|
|
51
|
+
const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
|
|
52
|
+
const layout = layoutText(tree, { x:0, y:0, width:200, measureText:text=>text.length*10 });
|
|
53
|
+
expect(layout.fragments.length).toBe(display === "block" ? 4 : 2);
|
|
54
|
+
const painted = layout.fragments.flatMap(f=>f.runs).map(r=>r.text).join('');
|
|
55
|
+
expect(painted).toBe(display === "block" ? "abcdefghijklmnop" : "abcdefg…");
|
|
56
|
+
expect(document.querySelector('div').textContent).toBe("abcdefghijklmnop");
|
|
57
|
+
window.happyDOM.abort();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("empty blocks do not add adjoining vertical margins twice", () => {
|
|
62
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0"><div style="padding-top:1px">
|
|
63
|
+
<div style="height:20px;margin-bottom:12px"></div>
|
|
64
|
+
<div style="margin-top:12px"></div><div></div>
|
|
65
|
+
<div id="next" style="height:20px;margin-top:12px"></div>
|
|
66
|
+
</div></body>`);
|
|
67
|
+
const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
|
|
68
|
+
const layout = layoutText(tree, { x:0, y:0, width:200 });
|
|
69
|
+
expect(layout.boxes.find(box => tree.nodesById.get(box.nodeId)?.domNode?.id === 'next').y).toBe(33);
|
|
70
|
+
window.happyDOM.abort();
|
|
71
|
+
});
|
|
72
|
+
|
|
11
73
|
test("creates positioned immutable line fragments with fixed-column wrapping", () => {
|
|
12
74
|
const { document, window } = parseHTMLDocument(
|
|
13
75
|
`<body style="margin:0">1234567890ABCDEFGHIJ<br>中文😀abcdef</body>`,
|