buninu-browser 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -0
- package/README.md +74 -9
- package/bunfig.toml +1 -0
- package/buninu-browser.js +4 -0
- package/index.js +5 -0
- package/lib/audio/piano.js +24 -1
- package/lib/audio/pulse-client.js +120 -12
- package/lib/cdp-server.js +19 -12
- package/lib/happy-dom/parser.js +24 -4
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +120 -28
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +365 -54
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/network/network-service.js +326 -46
- package/lib/paint/display-list.js +322 -21
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +62 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +989 -72
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/host.js +159 -9
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/main.js +160 -12
- package/lib/renderer/media-source.js +819 -0
- package/lib/renderer/messaging.js +411 -0
- package/lib/renderer/native-functions.js +113 -0
- package/lib/renderer/page-realm.js +1407 -200
- package/lib/renderer/page-renderer.js +1899 -394
- package/lib/renderer/rejections.js +31 -0
- package/lib/renderer/rewrite-worker.js +22 -0
- package/lib/renderer/script-rewrite.js +805 -57
- package/lib/renderer/trusted-types.js +129 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +463 -242
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- package/lib/user-agent.js +14 -2
- package/package.json +4 -1
- package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
- package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
- package/patches/happy-dom/README.md +26 -0
- package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
- package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
- package/patches/ses/README.md +4 -0
- package/test/canvas-2d.test.js +55 -0
- package/test/compositor.test.js +3 -1
- package/test/computed-style.test.js +98 -1
- package/test/display-list.test.js +74 -1
- package/test/download.test.js +35 -0
- package/test/fixtures/long-video.mp4 +0 -0
- package/test/fixtures/media-sync.js +57 -0
- package/test/happy-dom-platform-adapters.test.js +25 -0
- package/test/headless-shell.test.js +29 -0
- package/test/hit-test.test.js +25 -1
- package/test/log.test.js +4 -1
- package/test/media-source.test.js +156 -0
- package/test/media-sync.test.js +12 -0
- package/test/mobile.test.js +2 -2
- package/test/navigation.test.js +61 -0
- package/test/network-service.test.js +81 -0
- package/test/page-scripts.test.js +1094 -7
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +69 -0
- package/test/render-tree.test.js +22 -1
- package/test/renderer-host.test.js +5 -2
- package/test/screenshot-cli.test.js +25 -1
- package/test/script-rewrite.test.js +111 -6
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/setup.js +5 -0
- package/test/text-layout.test.js +69 -1
- package/test/user-agent.test.js +60 -4
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
- package/vendor/ses/dist/ses.mjs +52 -6
|
@@ -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,70 @@ 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 = [];
|
|
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
|
+
onTiming: timing => times.push(timing.seconds), onTimingError: error => errors.push(error) });
|
|
175
|
+
stream.write(new Uint8Array(2000));
|
|
176
|
+
await Bun.sleep(180);
|
|
177
|
+
expect(reports).toBeGreaterThan(0);
|
|
178
|
+
expect(received).toBe(500); // at most the 0.5 s queue budget, not 2 s
|
|
179
|
+
expect(times.at(-1)).toBe(0); // server/device hasn't played anything
|
|
180
|
+
played = 100;
|
|
181
|
+
await Bun.sleep(120);
|
|
182
|
+
expect(times.at(-1)).toBeCloseTo(0.1, 5);
|
|
183
|
+
expect(received).toBe(600);
|
|
184
|
+
// A long network/producer pause is NOT permission to catch up by queuing seconds.
|
|
185
|
+
await Bun.sleep(180);
|
|
186
|
+
expect(times.at(-1)).toBeCloseTo(0.1, 5);
|
|
187
|
+
expect(received).toBe(600);
|
|
188
|
+
played = 600;
|
|
189
|
+
await Bun.sleep(120);
|
|
190
|
+
expect(times.at(-1)).toBeCloseTo(0.6, 5);
|
|
191
|
+
expect(received).toBeGreaterThan(1000);
|
|
192
|
+
expect(received).toBeLessThanOrEqual(1100);
|
|
193
|
+
expect(errors).toEqual([]);
|
|
194
|
+
await stream.close();
|
|
195
|
+
} finally {
|
|
196
|
+
client.close();
|
|
197
|
+
await new Promise(resolve => server.close(resolve));
|
|
198
|
+
}
|
|
199
|
+
});
|
package/test/render-tree.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
|
|
3
|
-
import { RenderTreeBuilder, renderTreeText } from "../lib/render-tree/index.js";
|
|
3
|
+
import { RenderTreeBuilder, renderTreeText, sameLayoutInput } from "../lib/render-tree/index.js";
|
|
4
4
|
|
|
5
5
|
test("builds an engine-owned tree and excludes non-rendered DOM nodes", () => {
|
|
6
6
|
const { document, window } = parseHTMLDocument(`<body>
|
|
@@ -115,3 +115,24 @@ test("audio with controls is a 300x32 player painted from its state; without con
|
|
|
115
115
|
expect(renderTreeText(tree)).not.toContain("fallback");
|
|
116
116
|
window.happyDOM.abort();
|
|
117
117
|
});
|
|
118
|
+
|
|
119
|
+
test("detects whether a rebuilt tree changes layout input", async () => {
|
|
120
|
+
const { StyleEngine } = await import("../lib/style/computed-style.js");
|
|
121
|
+
const { document, window } = parseHTMLDocument(`<body><p>visible</p><script>old</script></body>`);
|
|
122
|
+
const builder = new RenderTreeBuilder();
|
|
123
|
+
const styles = new StyleEngine().compute(document);
|
|
124
|
+
const first = builder.build(document, styles);
|
|
125
|
+
const firstParagraph = first.root.children[0];
|
|
126
|
+
|
|
127
|
+
document.querySelector("script").textContent = "new metadata";
|
|
128
|
+
const metadataOnly = builder.build(document, styles);
|
|
129
|
+
expect(sameLayoutInput(first.root, metadataOnly.root)).toBe(true);
|
|
130
|
+
expect(metadataOnly.root).toBe(first.root);
|
|
131
|
+
expect(metadataOnly.root.children[0]).toBe(firstParagraph);
|
|
132
|
+
|
|
133
|
+
document.querySelector("p").textContent = "different visible text";
|
|
134
|
+
const visibleChange = builder.build(document, styles);
|
|
135
|
+
expect(sameLayoutInput(metadataOnly.root, visibleChange.root)).toBe(false);
|
|
136
|
+
expect(visibleChange.root.children[0]).not.toBe(firstParagraph);
|
|
137
|
+
window.happyDOM.abort();
|
|
138
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { selectorKeys } from "../lib/style/selector-keys.js";
|
|
3
|
+
|
|
4
|
+
test("indexes a selector by its subject's first key and its ancestors' keys", () => {
|
|
5
|
+
expect(selectorKeys("DIV.Foo > SPAN#Bar.baz[Data-X]")).toEqual({
|
|
6
|
+
subject: [{ kind: "id", name: "Bar" }],
|
|
7
|
+
ancestors: ["div", ".Foo"],
|
|
8
|
+
});
|
|
9
|
+
// Sibling compounds are not ancestors; attribute and type names are lowercased.
|
|
10
|
+
expect(selectorKeys("ul [Role] + LI:hover")).toEqual({ subject: [{ kind: "tag", name: "li" }], ancestors: ["ul"] });
|
|
11
|
+
// :root is the "html" bucket as a subject, but not an ancestor filter key.
|
|
12
|
+
expect(selectorKeys(":root").subject).toEqual([{ kind: "tag", name: "html" }]);
|
|
13
|
+
expect(selectorKeys(":ROOT .x")).toEqual({ subject: [{ kind: "class", name: "x" }], ancestors: [] });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("escapes, quotes and digits in names", () => {
|
|
17
|
+
expect(selectorKeys(".a\\:hover .b\\.c").ancestors).toEqual([".a:hover"]);
|
|
18
|
+
expect(selectorKeys(".a\\:hover .b\\.c").subject).toEqual([{ kind: "class", name: "b.c" }]);
|
|
19
|
+
// A hex escape cannot be resolved here: the name is left out.
|
|
20
|
+
expect(selectorKeys(".\\31 0").subject).toBeNull();
|
|
21
|
+
// Class names cannot start with a digit; ids can.
|
|
22
|
+
expect(selectorKeys(".1a").subject).toBeNull();
|
|
23
|
+
expect(selectorKeys("#1a").subject).toEqual([{ kind: "id", name: "1a" }]);
|
|
24
|
+
// Brackets and "." inside a quoted attribute value are not structure.
|
|
25
|
+
expect(selectorKeys('[data-x="a] .b"] .c')).toEqual({ subject: [{ kind: "class", name: "c" }], ancestors: ["[data-x"] });
|
|
26
|
+
expect(selectorKeys(".日本 .語")).toEqual({ subject: [{ kind: "class", name: "語" }], ancestors: [".日本"] });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test(":is() and :where() alternatives each need a key; anything not understood is unindexed", () => {
|
|
30
|
+
expect(selectorKeys(":is(.a, #b, p)").subject).toEqual([
|
|
31
|
+
{ kind: "class", name: "a" }, { kind: "id", name: "b" }, { kind: "tag", name: "p" },
|
|
32
|
+
]);
|
|
33
|
+
expect(selectorKeys(":where(.a .b, .c)").subject).toBeNull();
|
|
34
|
+
expect(selectorKeys(":is(.a, :not(.b))").subject).toBeNull();
|
|
35
|
+
expect(selectorKeys("ns|a b").ancestors).toEqual([]);
|
|
36
|
+
for (const selector of ["", "> a", "a >", "a 'unterminated", ":is(.a"]) {
|
|
37
|
+
expect(selectorKeys(selector)).toEqual({ subject: null, ancestors: [] });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { sheetTraits } from "../lib/style/selector-traits.js";
|
|
3
|
+
|
|
4
|
+
test("finds :has() compounds and sibling-target compounds in a sheet", () => {
|
|
5
|
+
const traits = sheetTraits(`
|
|
6
|
+
/* .ignored:has(a) {} */
|
|
7
|
+
.a:has(> img), .b .c:has(+ .d)::before { color: red }
|
|
8
|
+
@media (min-width: 1px) { .e { color: blue } }
|
|
9
|
+
.f:has(.g) .h, .i { color: green }
|
|
10
|
+
li:first-child + li .x, ul > li:last-child ~ li { color: black }
|
|
11
|
+
.esc\\:a .y:not([data-z="a b"]) { color: white }
|
|
12
|
+
`);
|
|
13
|
+
expect([...traits.hasCompounds]).toEqual([".a:has(> img)", ".c:has(+ .d)", ".f:has(.g)"]);
|
|
14
|
+
expect(traits.hasSiblings).toBe(true);
|
|
15
|
+
expect([...traits.siblingTargetCompounds]).toEqual(["li:first-child + li"]);
|
|
16
|
+
expect([...traits.structuralCompounds]).toEqual(["li:first-child", "li:last-child"]);
|
|
17
|
+
expect(traits.descendantFeatures.has(".esc:a")).toBe(true);
|
|
18
|
+
expect(traits.sibling && traits.firstChild && traits.lastChild).toBe(true);
|
|
19
|
+
});
|
package/test/setup.js
ADDED
package/test/text-layout.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
|
|
3
|
-
import { layoutText } from "../lib/layout/text-layout.js";
|
|
3
|
+
import { LayoutEngine, layoutText } from "../lib/layout/text-layout.js";
|
|
4
4
|
import { RenderTreeBuilder } from "../lib/render-tree/index.js";
|
|
5
5
|
import { StyleEngine } from "../lib/style/computed-style.js";
|
|
6
6
|
|
|
@@ -439,3 +439,71 @@ test("a grid body stacks items sharing an area and aligns its content with place
|
|
|
439
439
|
expect([icon.x, a.x, b.x]).toEqual([180, 185, 165]);
|
|
440
440
|
window.happyDOM.abort();
|
|
441
441
|
});
|
|
442
|
+
|
|
443
|
+
test("retained block results match a clean layout after a sibling changes", () => {
|
|
444
|
+
const { document, window } = parseHTMLDocument(`<body><section><p>unchanged subtree</p></section><section id="edit">short</section></body>`);
|
|
445
|
+
const styles = new StyleEngine().compute(document);
|
|
446
|
+
const builder = new RenderTreeBuilder();
|
|
447
|
+
const engine = new LayoutEngine({
|
|
448
|
+
measureText: (text) => text.length * 10,
|
|
449
|
+
fontMetrics: () => ({ ascent: 16, height: 20 }),
|
|
450
|
+
});
|
|
451
|
+
const options = { x: 0, y: 0, width: 240, measureText: (text) => text.length * 10, fontMetrics: () => ({ ascent: 16, height: 20 }) };
|
|
452
|
+
engine.layout(builder.build(document, styles), options);
|
|
453
|
+
|
|
454
|
+
document.getElementById("edit").textContent = "a changed sibling which wraps onto another line";
|
|
455
|
+
const tree = builder.build(document, styles);
|
|
456
|
+
const retained = engine.layout(tree, options);
|
|
457
|
+
const clean = layoutText(tree, options);
|
|
458
|
+
expect(retained).toEqual(clean);
|
|
459
|
+
window.happyDOM.abort();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
/** A layout with paint layers as ranks: replays renumber layers, keeping their order. */
|
|
463
|
+
function rankedLayout(layout) {
|
|
464
|
+
const orders = new Set([0]);
|
|
465
|
+
for (const entry of [...layout.boxes, ...layout.fragments]) {
|
|
466
|
+
for (let layer = entry.layer; layer; layer = layer.parent) orders.add(layer.order);
|
|
467
|
+
}
|
|
468
|
+
const rank = new Map([...orders].sort((a, b) => a - b).map((order, index) => [order, index]));
|
|
469
|
+
const layer = (value) => value && { rank: rank.get(value.order), z: value.z, context: value.context, parent: value.parent ? rank.get(value.parent.order) : null };
|
|
470
|
+
return {
|
|
471
|
+
height: layout.height,
|
|
472
|
+
boxes: layout.boxes.map((box) => ({ ...box, layer: layer(box.layer) })),
|
|
473
|
+
fragments: layout.fragments.map(({ id, ...fragment }) => ({ ...fragment, layer: layer(fragment.layer) })),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function retainedAndClean(html, change) {
|
|
478
|
+
const { document, window } = parseHTMLDocument(html);
|
|
479
|
+
const styles = new StyleEngine().compute(document);
|
|
480
|
+
const builder = new RenderTreeBuilder();
|
|
481
|
+
const measure = { measureText: (text) => text.length * 10, fontMetrics: () => ({ ascent: 16, height: 20 }) };
|
|
482
|
+
const engine = new LayoutEngine(measure);
|
|
483
|
+
const options = { x: 0, y: 0, width: 240, viewportHeight: 300, ...measure };
|
|
484
|
+
engine.layout(builder.build(document, styles), options);
|
|
485
|
+
change(document);
|
|
486
|
+
const tree = builder.build(document, styles);
|
|
487
|
+
const result = { retained: engine.layout(tree, options), clean: layoutText(tree, options), document };
|
|
488
|
+
window.happyDOM.abort();
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
test("positioned boxes inside a retained block are replayed in their paint layers", () => {
|
|
493
|
+
const { retained, clean } = retainedAndClean(`<body><p id="edit">short</p>
|
|
494
|
+
<section><div style="position:relative;z-index:2">raised <span style="position:relative">inner</span></div>
|
|
495
|
+
<div style="position:relative">auto</div><p style="position:absolute;top:5px;z-index:-1">behind</p></section></body>`,
|
|
496
|
+
(document) => { document.getElementById("edit").textContent = "a longer paragraph that wraps onto two lines"; });
|
|
497
|
+
expect(rankedLayout(retained)).toEqual(rankedLayout(clean));
|
|
498
|
+
// The section moved down with the wrapped paragraph, and its layers came along.
|
|
499
|
+
expect(new Set(retained.boxes.map((box) => box.layer.order)).size).toBe(new Set(clean.boxes.map((box) => box.layer.order)).size);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("an out-of-flow box keeps its static position when the block around its placeholder is replayed", () => {
|
|
503
|
+
// The absolute box's containing block is the positioned div; its placeholder is in the section,
|
|
504
|
+
// which is replayed (moved down) after the paragraph above it grows.
|
|
505
|
+
const { retained, clean } = retainedAndClean(`<body><div style="position:relative"><p id="edit">short</p>
|
|
506
|
+
<section><p>above</p><div style="position:absolute">static</div><p>below</p></section></div></body>`,
|
|
507
|
+
(document) => { document.getElementById("edit").textContent = "a longer paragraph that wraps onto two lines"; });
|
|
508
|
+
expect(rankedLayout(retained)).toEqual(rankedLayout(clean));
|
|
509
|
+
});
|