buninu-browser 0.2.0 → 0.2.1
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/lib/cdp-server.js +3 -1
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +57 -12
- package/lib/layout/text-layout.js +179 -15
- package/lib/network/network-service.js +176 -43
- package/lib/paint/display-list.js +23 -8
- package/lib/render-tree/index.js +45 -0
- package/lib/renderer/bindings.js +4 -4
- package/lib/renderer/host.js +1 -1
- package/lib/renderer/page-realm.js +2 -2
- package/lib/renderer/page-renderer.js +166 -204
- package/lib/style/computed-style.js +301 -220
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- package/package.json +1 -1
- package/test/computed-style.test.js +61 -1
- package/test/headless-shell.test.js +29 -0
- package/test/log.test.js +4 -1
- package/test/network-service.test.js +67 -0
- package/test/page-scripts.test.js +49 -0
- package/test/render-tree.test.js +22 -1
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/text-layout.test.js +69 -1
package/test/log.test.js
CHANGED
|
@@ -22,13 +22,16 @@ test("the CLI writes controller and renderer output to the log file", async () =
|
|
|
22
22
|
});
|
|
23
23
|
const reader = child.stderr.getReader();
|
|
24
24
|
let seen = "";
|
|
25
|
-
|
|
25
|
+
// The endpoint opens first; the sandbox line follows once the first renderer is up.
|
|
26
|
+
while (!seen.includes("renderer sandbox:")) {
|
|
26
27
|
const { value, done } = await reader.read();
|
|
27
28
|
if (done) break;
|
|
28
29
|
seen += new TextDecoder().decode(value);
|
|
29
30
|
}
|
|
30
31
|
child.kill();
|
|
31
32
|
await child.exited;
|
|
33
|
+
expect(seen.indexOf("DevTools listening")).toBeGreaterThan(-1);
|
|
34
|
+
expect(seen.indexOf("DevTools listening")).toBeLessThan(seen.indexOf("renderer sandbox:"));
|
|
32
35
|
const log = await Bun.file(`${dir}/${DEFAULT_LOG_FILE}`).text();
|
|
33
36
|
expect(log).toContain("[log] started pid");
|
|
34
37
|
expect(log).toContain("renderer sandbox: seccomp");
|
|
@@ -149,6 +149,73 @@ test("preflights non-simple requests and strips forbidden headers", async () =>
|
|
|
149
149
|
await expect(network.pageFetch({ url: "file:///etc/passwd" }, document)).rejects.toThrow("cannot load file:");
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
test("coalesces identical subresources, caches fresh bodies, and revalidates ETags", async () => {
|
|
153
|
+
let freshRequests = 0;
|
|
154
|
+
let validatedRequests = 0;
|
|
155
|
+
const network = new NetworkService({ fetch: async (input, init = {}) => {
|
|
156
|
+
const url = new URL(typeof input === "string" ? input : input.url);
|
|
157
|
+
const headers = new Headers(init.headers ?? input.headers);
|
|
158
|
+
if (url.pathname === "/fresh") {
|
|
159
|
+
freshRequests++;
|
|
160
|
+
await Bun.sleep(10);
|
|
161
|
+
return new Response("fresh", { headers: { "cache-control": "public, max-age=60" } });
|
|
162
|
+
}
|
|
163
|
+
validatedRequests++;
|
|
164
|
+
if (headers.get("if-none-match") === '"v1"') return new Response(null, { status: 304 });
|
|
165
|
+
return new Response("validated", { headers: { "cache-control": "no-cache", etag: '"v1"' } });
|
|
166
|
+
} });
|
|
167
|
+
const document = "https://page.test/";
|
|
168
|
+
const [left, right] = await Promise.all([
|
|
169
|
+
network.subresource("https://asset.test/fresh", document),
|
|
170
|
+
network.subresource("https://asset.test/fresh", document),
|
|
171
|
+
]);
|
|
172
|
+
expect([new TextDecoder().decode(left.body), new TextDecoder().decode(right.body), freshRequests])
|
|
173
|
+
.toEqual(["fresh", "fresh", 1]);
|
|
174
|
+
await network.subresource("https://asset.test/fresh", document);
|
|
175
|
+
expect(freshRequests).toBe(1);
|
|
176
|
+
|
|
177
|
+
const first = await network.subresource("https://asset.test/revalidate", document);
|
|
178
|
+
const second = await network.subresource("https://asset.test/revalidate", document);
|
|
179
|
+
expect([new TextDecoder().decode(first.body), new TextDecoder().decode(second.body), validatedRequests])
|
|
180
|
+
.toEqual(["validated", "validated", 2]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("at most six requests per host are in flight; waiting stylesheets go before scripts", async () => {
|
|
184
|
+
let active = 0;
|
|
185
|
+
let peak = 0;
|
|
186
|
+
const started = [];
|
|
187
|
+
const gates = [];
|
|
188
|
+
const network = new NetworkService({ fetch: async (input) => {
|
|
189
|
+
const url = new URL(typeof input === "string" ? input : input.url);
|
|
190
|
+
started.push(url.host + url.pathname);
|
|
191
|
+
if (url.host === "slow.test") {
|
|
192
|
+
active++;
|
|
193
|
+
peak = Math.max(peak, active);
|
|
194
|
+
await new Promise((resolve) => gates.push(resolve));
|
|
195
|
+
active--;
|
|
196
|
+
}
|
|
197
|
+
return new Response("x", { headers: { "cache-control": "no-store" } });
|
|
198
|
+
} });
|
|
199
|
+
const document = "https://page.test/";
|
|
200
|
+
const scripts = Array.from({ length: 8 }, (_, index) => network.subresource(`https://slow.test/s${index}.js`, document, "script"));
|
|
201
|
+
await Bun.sleep(5);
|
|
202
|
+
const stylesheet = network.subresource("https://slow.test/late.css", document, "stylesheet");
|
|
203
|
+
// Another host is not held up by this one's queue.
|
|
204
|
+
await network.subresource("https://other.test/free.js", document, "script");
|
|
205
|
+
expect(started.filter((entry) => entry.startsWith("slow.test"))).toHaveLength(6);
|
|
206
|
+
gates.shift()();
|
|
207
|
+
await Bun.sleep(5);
|
|
208
|
+
// The freed slot went to the stylesheet that came after the waiting scripts.
|
|
209
|
+
expect(started.at(-1)).toBe("slow.test/late.css");
|
|
210
|
+
while (active || gates.length) {
|
|
211
|
+
gates.shift()?.();
|
|
212
|
+
await Bun.sleep(1);
|
|
213
|
+
}
|
|
214
|
+
await Promise.all([...scripts, stylesheet]);
|
|
215
|
+
expect(peak).toBe(6);
|
|
216
|
+
expect(started.filter((entry) => entry.startsWith("slow.test"))).toHaveLength(9);
|
|
217
|
+
});
|
|
218
|
+
|
|
152
219
|
/** Opens a page WebSocket and resolves with how it ended. */
|
|
153
220
|
function socketOutcome(socket) {
|
|
154
221
|
return new Promise((resolve) => {
|
|
@@ -7,6 +7,8 @@ import { RendererHost } from "../lib/renderer/host.js";
|
|
|
7
7
|
*/
|
|
8
8
|
const scripts = new Map();
|
|
9
9
|
const navigations = [];
|
|
10
|
+
// Every resource request with its time, for the preload test.
|
|
11
|
+
const requests = [];
|
|
10
12
|
let renderer;
|
|
11
13
|
|
|
12
14
|
beforeAll(async () => {
|
|
@@ -15,6 +17,11 @@ beforeAll(async () => {
|
|
|
15
17
|
onNavigate: (url) => navigations.push(url),
|
|
16
18
|
fetch: async (url, init = {}) => {
|
|
17
19
|
const target = new URL(String(url));
|
|
20
|
+
requests.push({ url: target.href, at: performance.now() });
|
|
21
|
+
if (target.pathname === "/slow.css") {
|
|
22
|
+
await Bun.sleep(400);
|
|
23
|
+
return new Response("body { color: red }", { headers: { "content-type": "text/css" } });
|
|
24
|
+
}
|
|
18
25
|
const headers = new Headers(init.headers);
|
|
19
26
|
if (target.pathname === "/api/echo") {
|
|
20
27
|
return new Response(JSON.stringify({
|
|
@@ -392,6 +399,26 @@ test("style sheets that scripts add, change or remove apply to the page", async
|
|
|
392
399
|
expect(await height()).toBeLessThan(25);
|
|
393
400
|
});
|
|
394
401
|
|
|
402
|
+
test("DOM records and live form state take their distinct style invalidation paths", async () => {
|
|
403
|
+
await load(`<style>
|
|
404
|
+
.on .child { color: red }
|
|
405
|
+
input:checked + span { display: none }
|
|
406
|
+
</style>
|
|
407
|
+
<div id="parent"><span class="child">child</span></div>
|
|
408
|
+
<input id="box" type="checkbox"><span id="after">after</span>`);
|
|
409
|
+
expect(await evaluate(`(() => {
|
|
410
|
+
const parent = document.getElementById("parent");
|
|
411
|
+
const child = parent.querySelector(".child");
|
|
412
|
+
parent.className = "on";
|
|
413
|
+
void child.offsetWidth;
|
|
414
|
+
const color = getComputedStyle(child).color;
|
|
415
|
+
const box = document.getElementById("box");
|
|
416
|
+
box.checked = true;
|
|
417
|
+
void document.getElementById("after").offsetWidth;
|
|
418
|
+
return color + "/" + getComputedStyle(document.getElementById("after")).display;
|
|
419
|
+
})()`)).toBe("rgba(255, 0, 0, 1)/none");
|
|
420
|
+
});
|
|
421
|
+
|
|
395
422
|
test("childNodes, children and getElementsBy* are live collections", async () => {
|
|
396
423
|
await load(`<div id="host"></div>`);
|
|
397
424
|
expect(await evaluate(`(() => {
|
|
@@ -909,3 +936,25 @@ function pulseServerRunning() {
|
|
|
909
936
|
setTimeout(() => done(false), 1000);
|
|
910
937
|
});
|
|
911
938
|
}
|
|
939
|
+
|
|
940
|
+
test("scripts and module preloads are fetched while the stylesheets load, each once", async () => {
|
|
941
|
+
const base = "https://page.test/preload/";
|
|
942
|
+
scripts.set(`${base}main.js`, "import { value } from './dep.js'; window.preloaded = value;");
|
|
943
|
+
scripts.set(`${base}dep.js`, "export const value = 'dep';");
|
|
944
|
+
scripts.set(`${base}classic.js`, "window.classic = 'ran';");
|
|
945
|
+
scripts.set(`${base}skipped.js`, "window.skipped = 'ran';");
|
|
946
|
+
requests.length = 0;
|
|
947
|
+
const started = performance.now();
|
|
948
|
+
await load(`<link rel="stylesheet" href="/slow.css"><link rel="modulepreload" href="/preload/dep.js">
|
|
949
|
+
<script type="module" src="/preload/main.js"></script><script src="/preload/classic.js"></script>
|
|
950
|
+
<script nomodule src="/preload/skipped.js"></script>`);
|
|
951
|
+
expect(await evaluate("[window.preloaded, window.classic, window.skipped].join()")).toBe("dep,ran,");
|
|
952
|
+
const times = (path) => requests.filter((request) => request.url === `https://page.test${path}`).map((request) => request.at - started);
|
|
953
|
+
// Requested before the 400 ms stylesheet arrived, not after it.
|
|
954
|
+
for (const path of ["/preload/main.js", "/preload/dep.js", "/preload/classic.js"]) {
|
|
955
|
+
expect(times(path).length).toBe(1);
|
|
956
|
+
expect(times(path)[0]).toBeLessThan(times("/slow.css")[0] + 300);
|
|
957
|
+
}
|
|
958
|
+
expect(times("/preload/skipped.js")).toEqual([]);
|
|
959
|
+
});
|
|
960
|
+
|
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
|
+
});
|
|
@@ -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/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
|
+
});
|