buninu-browser 0.5.1 → 0.7.0
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/README.md +13 -6
- package/index.js +10 -6
- package/lib/audio/pcm.js +11 -0
- package/lib/audio/pulse-client.js +7 -2
- package/lib/cdp-server.js +94 -46
- package/lib/happy-dom/document-base.js +29 -1
- package/lib/happy-dom/mutation-observer.js +157 -0
- package/lib/happy-dom/parser.js +2 -0
- package/lib/headless-shell.js +49 -9
- package/lib/layout/repaint.js +89 -0
- package/lib/media/mp4-keyframes.js +144 -0
- package/lib/media/video-keyframe.js +29 -1
- package/lib/media/webm-snapshots.js +282 -0
- package/lib/network/cookie-jar.js +18 -2
- package/lib/paint/display-list.js +4 -3
- package/lib/paint/frame.js +12 -5
- package/lib/render-tree/index.js +3 -0
- package/lib/renderer/animation-frames.js +76 -0
- package/lib/renderer/bindings.js +2 -0
- package/lib/renderer/compositor-channel.js +95 -10
- package/lib/renderer/compositor.js +54 -18
- package/lib/renderer/document-cookies.js +87 -0
- package/lib/renderer/font-loading.js +60 -16
- package/lib/renderer/host.js +48 -19
- package/lib/renderer/idle-callbacks.js +109 -0
- package/lib/renderer/main.js +5 -1
- package/lib/renderer/media-source.js +4 -0
- package/lib/renderer/page-realm.js +159 -49
- package/lib/renderer/page-renderer.js +567 -197
- package/lib/renderer/resource-elements.js +88 -0
- package/lib/renderer/script-rewrite.js +18 -1
- package/lib/renderer/seccomp.js +6 -4
- package/lib/renderer/timers.js +71 -0
- package/lib/renderer/video-channel.js +94 -0
- package/lib/renderer/video-mask.js +115 -0
- package/lib/renderer/video-presentation.js +304 -39
- package/lib/renderer/video-worker.js +46 -0
- package/lib/renderer/workers.js +7 -17
- package/lib/style/computed-style.js +257 -61
- package/package.json +1 -1
- package/test/animation-frames.test.js +67 -0
- package/test/canvas-style-snapshot.test.js +18 -0
- package/test/cdp-stream.test.js +96 -3
- package/test/cdp-viewers.test.js +43 -0
- package/test/compositor-channel-ownership.test.js +194 -0
- package/test/compositor.test.js +27 -1
- package/test/computed-style-binding.test.js +49 -0
- package/test/computed-style-connectivity.test.js +13 -0
- package/test/computed-style.test.js +86 -0
- package/test/cookie-sync.test.js +82 -0
- package/test/document-base-cache.test.js +103 -0
- package/test/document-cookies.test.js +117 -0
- package/test/duplicate-style-keys.test.js +11 -0
- package/test/fixtures/animation-frames.js +56 -0
- package/test/fixtures/canvas-style-snapshot.js +81 -0
- package/test/fixtures/computed-style-connectivity.js +101 -0
- package/test/fixtures/computed-style-lazy.js +135 -0
- package/test/fixtures/duplicate-style-keys.js +97 -0
- package/test/fixtures/fake-renderer.js +12 -0
- package/test/fixtures/font-definition-invalidation.js +97 -0
- package/test/fixtures/font-metrics-invalidation.js +87 -0
- package/test/fixtures/full-style-identity.js +36 -0
- package/test/fixtures/hidden-style-engine.js +260 -0
- package/test/fixtures/hidden-style-reentry.js +232 -0
- package/test/fixtures/hidden-style-renderer.js +203 -0
- package/test/fixtures/layout-repaint.js +69 -0
- package/test/fixtures/media-presentation-continuation.js +141 -0
- package/test/fixtures/media-presentation-refill.js +113 -0
- package/test/fixtures/media-probe.js +148 -0
- package/test/fixtures/media-refill-cancel.js +79 -0
- package/test/fixtures/media-repaint.js +48 -0
- package/test/fixtures/media-resource-reselection.js +94 -0
- package/test/fixtures/media-source-attachment.js +136 -0
- package/test/fixtures/media-source-no-decoder.js +17 -0
- package/test/fixtures/media-track-refill.js +132 -0
- package/test/fixtures/media-webm-seek.js +51 -0
- package/test/fixtures/mutation-observer.js +201 -0
- package/test/fixtures/observer-invalidation.js +105 -0
- package/test/fixtures/prepaint-scheduling.js +73 -0
- package/test/fixtures/resource-discovery.js +32 -0
- package/test/fixtures/spare-renderer-scheduling.js +124 -0
- package/test/fixtures/style-propagation.js +26 -0
- package/test/fixtures/svg-node-reuse.js +83 -0
- package/test/fixtures/video-black-replay.js +140 -0
- package/test/fixtures/video-controls-invalidation.js +71 -0
- package/test/fixtures/viewport-bounds.js +148 -0
- package/test/font-loading.test.js +77 -0
- package/test/font-metrics-invalidation.test.js +60 -0
- package/test/full-style-identity.test.js +191 -0
- package/test/hidden-style-lazy.test.js +31 -0
- package/test/hidden-style-reentry.test.js +20 -0
- package/test/idle-callback.test.js +89 -0
- package/test/idle-scheduler.test.js +148 -0
- package/test/index.test.js +1 -0
- package/test/inline-style-cascade.test.js +63 -0
- package/test/layout-repaint-renderer.test.js +9 -0
- package/test/layout-repaint.test.js +222 -0
- package/test/media-presentation-continuation.test.js +14 -0
- package/test/media-presentation-refill.test.js +8 -0
- package/test/media-probe.test.js +16 -0
- package/test/media-repaint.test.js +8 -0
- package/test/media-source-attachment.test.js +34 -0
- package/test/media-track-refill.test.js +26 -0
- package/test/media-webm-seek.test.js +11 -0
- package/test/mp4-keyframes.test.js +36 -0
- package/test/mutation-observer.test.js +91 -0
- package/test/observer-invalidation.test.js +12 -0
- package/test/page-scripts.test.js +35 -0
- package/test/page-timers.test.js +67 -0
- package/test/paint-frame.test.js +18 -1
- package/test/pcm.test.js +63 -0
- package/test/prepaint-scheduling.test.js +13 -0
- package/test/pulse-client.test.js +165 -0
- package/test/raw-capture.test.js +34 -5
- package/test/renderer-host.test.js +51 -0
- package/test/resource-discovery.test.js +12 -0
- package/test/resource-elements.test.js +234 -0
- package/test/root-self-style.test.js +104 -0
- package/test/script-rewrite.test.js +35 -0
- package/test/seccomp.test.js +45 -0
- package/test/spare-renderer-scheduling.test.js +8 -0
- package/test/style-propagation.test.js +225 -0
- package/test/style-sheets.test.js +19 -0
- package/test/svg.test.js +45 -0
- package/test/timers.test.js +58 -0
- package/test/video-black-replay.test.js +34 -0
- package/test/video-capture.test.js +170 -0
- package/test/video-channel.test.js +253 -0
- package/test/video-continuation.test.js +57 -0
- package/test/video-controls-invalidation.test.js +12 -0
- package/test/video-keyframe.test.js +104 -4
- package/test/video-mask.test.js +94 -0
- package/test/video-presentation.test.js +239 -1
- package/test/video-timing-presentation.test.js +115 -0
- package/test/viewport-bounds.test.js +13 -0
- package/test/webm-snapshots.test.js +372 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { RendererHost } from "../lib/renderer/host.js";
|
|
3
|
+
import { NetworkService } from "../lib/network/network-service.js";
|
|
4
|
+
|
|
5
|
+
const origin = "https://cookie.test";
|
|
6
|
+
const pairs = value => value.split("; ").filter(Boolean).sort();
|
|
7
|
+
async function fixture(fetch) {
|
|
8
|
+
const network = new NetworkService({ fetch });
|
|
9
|
+
const renderer = new RendererHost({ network, timeout: 20_000 });
|
|
10
|
+
await renderer.start();
|
|
11
|
+
const load = (url = origin + "/main/page.html") => renderer.call("loadDocument", {
|
|
12
|
+
url, source: "<!doctype html><p>Cookies</p>", contentType: "text/html",
|
|
13
|
+
});
|
|
14
|
+
await load();
|
|
15
|
+
return { network, renderer, load, evaluate: source => renderer.call("evaluate", source) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for (const deleting of [false, true]) {
|
|
19
|
+
test(`delayed response headers preserve a newer script ${deleting ? "deletion" : "write"} and unrelated server cookies`, async () => {
|
|
20
|
+
let release, entered;
|
|
21
|
+
const started = new Promise(resolve => { entered = resolve; });
|
|
22
|
+
const { renderer, network, evaluate } = await fixture(() => new Promise(resolve => {
|
|
23
|
+
release = resolve; entered();
|
|
24
|
+
}));
|
|
25
|
+
try {
|
|
26
|
+
await evaluate(`document.cookie = 'key=before; Path=/'; undefined;`);
|
|
27
|
+
await evaluate(`window.finished = fetch('/delayed').then(() => document.cookie); undefined;`);
|
|
28
|
+
await started;
|
|
29
|
+
// The controller receives headers while the page is executing a task.
|
|
30
|
+
// The setter runs before that task ends and before headers can be delivered.
|
|
31
|
+
const busy = evaluate(`const start = performance.now(); while(performance.now() - start < 180) {};
|
|
32
|
+
document.cookie = ${JSON.stringify(deleting ? "key=; Max-Age=0; Path=/" : "key=after; Path=/")}; document.cookie;`);
|
|
33
|
+
const timer = setTimeout(() => release(new Response("complete", { headers: [
|
|
34
|
+
["set-cookie", "server=2; Path=/"], ["set-cookie", "secret=hidden; Path=/; HttpOnly"],
|
|
35
|
+
] })), 30);
|
|
36
|
+
await busy;
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
const result = await evaluate("finished");
|
|
39
|
+
expect(pairs(result)).toEqual(deleting ? ["server=2"] : ["key=after", "server=2"]);
|
|
40
|
+
expect(pairs(await evaluate("document.cookie"))).toEqual(pairs(network.documentCookie(origin + "/main/page.html")));
|
|
41
|
+
expect(result).not.toContain("secret");
|
|
42
|
+
} finally { renderer.close(); }
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("same-origin frames share writes immediately while retaining their own cookie paths", async () => {
|
|
47
|
+
const { renderer, network, evaluate } = await fixture(() => new Response("<!doctype html><p>Frame</p>", {
|
|
48
|
+
headers: { "content-type": "text/html" },
|
|
49
|
+
}));
|
|
50
|
+
try {
|
|
51
|
+
await evaluate(`window.frame = document.createElement('iframe');
|
|
52
|
+
window.frameReady = new Promise(resolve => frame.onload = resolve);
|
|
53
|
+
frame.src = '/frame/path/page.html'; document.body.append(frame); undefined;`);
|
|
54
|
+
await evaluate("frameReady.then(() => true)");
|
|
55
|
+
const result = await evaluate(`document.cookie = 'shared=parent; Path=/';
|
|
56
|
+
const first = frame.contentDocument.cookie;
|
|
57
|
+
frame.contentDocument.cookie = 'inside=frame';
|
|
58
|
+
frame.contentDocument.cookie = 'shared=child; Path=/';
|
|
59
|
+
[first, document.cookie, frame.contentDocument.cookie];`);
|
|
60
|
+
expect(pairs(result[0])).toEqual(["shared=parent"]);
|
|
61
|
+
expect(pairs(result[1])).toEqual(["shared=child"]);
|
|
62
|
+
expect(pairs(result[2])).toEqual(["inside=frame", "shared=child"]);
|
|
63
|
+
await evaluate("new Promise(resolve => setTimeout(resolve, 20))");
|
|
64
|
+
expect(pairs(network.documentCookie(origin + "/frame/path/other.html"))).toEqual(["inside=frame", "shared=child"]);
|
|
65
|
+
expect(network.documentCookie(origin + "/main/page.html")).toBe("shared=child");
|
|
66
|
+
} finally { renderer.close(); }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("credential-free responses retain the mirror and navigation uses the new document origin", async () => {
|
|
70
|
+
const { renderer, network, evaluate, load } = await fixture(() => new Response("done", {
|
|
71
|
+
headers: { "set-cookie": "shared=ignored; Path=/" },
|
|
72
|
+
}));
|
|
73
|
+
try {
|
|
74
|
+
await evaluate(`document.cookie = 'shared=kept; Path=/'; undefined;`);
|
|
75
|
+
expect(await evaluate(`fetch('/omit', {credentials: 'omit'}).then(() => document.cookie)`)).toBe("shared=kept");
|
|
76
|
+
network.setDocumentCookie("https://other.test/", "other=2; Path=/");
|
|
77
|
+
await load("https://other.test/page");
|
|
78
|
+
expect(await evaluate("document.cookie")).toBe("other=2");
|
|
79
|
+
await load(origin + "/another/page");
|
|
80
|
+
expect(await evaluate("document.cookie")).toBe("shared=kept");
|
|
81
|
+
} finally { renderer.close(); }
|
|
82
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
|
|
3
|
+
|
|
4
|
+
const URL = "https://base.test/page/index.html";
|
|
5
|
+
|
|
6
|
+
/** Runs `body` while counting base[href] lookups per document. */
|
|
7
|
+
function countBaseQueries(body) {
|
|
8
|
+
const { window, document } = parseHTMLDocument("", URL);
|
|
9
|
+
// Each window has its own Document subclass; spy where querySelector is defined.
|
|
10
|
+
let prototype = Object.getPrototypeOf(document);
|
|
11
|
+
while (!Object.hasOwn(prototype, "querySelector")) prototype = Object.getPrototypeOf(prototype);
|
|
12
|
+
const original = prototype.querySelector;
|
|
13
|
+
const counts = new Map();
|
|
14
|
+
prototype.querySelector = function(selector) {
|
|
15
|
+
if (selector === "base[href]") counts.set(this, (counts.get(this) ?? 0) + 1);
|
|
16
|
+
return original.call(this, selector);
|
|
17
|
+
};
|
|
18
|
+
try {
|
|
19
|
+
body(document => counts.get(document) ?? 0);
|
|
20
|
+
} finally {
|
|
21
|
+
prototype.querySelector = original;
|
|
22
|
+
window.happyDOM.abort();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("parsing many URL attributes does not scan for <base> once per attribute", () => {
|
|
27
|
+
const html = `<!doctype html><body>${'<a href="x">x</a><img src="y">'.repeat(2000)}</body>`;
|
|
28
|
+
countBaseQueries(queries => {
|
|
29
|
+
const parsed = parseHTMLDocument(html, URL);
|
|
30
|
+
expect(parsed.document.querySelectorAll("a[href], img[src]").length).toBe(4000);
|
|
31
|
+
expect(parsed.document.querySelector("a").href).toBe("https://base.test/page/x");
|
|
32
|
+
expect(queries(parsed.document)).toBeLessThan(10);
|
|
33
|
+
parsed.window.happyDOM.abort();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("the cached base follows insertion, attribute changes and removal", () => {
|
|
38
|
+
const { window, document } = parseHTMLDocument(`<!doctype html><head></head><body><a id="link" href="next">x</a></body>`, URL);
|
|
39
|
+
const link = document.getElementById("link");
|
|
40
|
+
expect([document.baseURI, link.href]).toEqual([URL, "https://base.test/page/next"]);
|
|
41
|
+
|
|
42
|
+
const base = document.createElement("base");
|
|
43
|
+
base.setAttribute("href", "/assets/");
|
|
44
|
+
document.head.appendChild(base);
|
|
45
|
+
expect([document.baseURI, link.href]).toEqual(["https://base.test/assets/", "https://base.test/assets/next"]);
|
|
46
|
+
|
|
47
|
+
base.setAttribute("href", "/changed/");
|
|
48
|
+
expect(link.href).toBe("https://base.test/changed/next");
|
|
49
|
+
|
|
50
|
+
// An earlier base[href] takes over; a later removal of it falls back to the first one.
|
|
51
|
+
const earlier = document.createElement("base");
|
|
52
|
+
earlier.setAttribute("href", "/earlier/");
|
|
53
|
+
document.head.insertBefore(earlier, base);
|
|
54
|
+
expect(document.baseURI).toBe("https://base.test/earlier/");
|
|
55
|
+
earlier.remove();
|
|
56
|
+
expect(document.baseURI).toBe("https://base.test/changed/");
|
|
57
|
+
|
|
58
|
+
base.removeAttribute("href");
|
|
59
|
+
expect(document.baseURI).toBe(URL);
|
|
60
|
+
base.setAttribute("href", "/again/");
|
|
61
|
+
expect(document.baseURI).toBe("https://base.test/again/");
|
|
62
|
+
|
|
63
|
+
// Removing an ancestor disconnects the base too.
|
|
64
|
+
document.documentElement.removeChild(document.head);
|
|
65
|
+
expect([document.baseURI, link.href]).toEqual([URL, "https://base.test/page/next"]);
|
|
66
|
+
window.happyDOM.abort();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("each document keeps its own base", () => {
|
|
70
|
+
const first = parseHTMLDocument(`<!doctype html><base href="/one/"><a href="x">x</a>`, URL);
|
|
71
|
+
const second = parseHTMLDocument(`<!doctype html><a href="x">x</a>`, URL);
|
|
72
|
+
expect(first.document.baseURI).toBe("https://base.test/one/");
|
|
73
|
+
expect(second.document.baseURI).toBe(URL);
|
|
74
|
+
expect(first.document.querySelector("a").href).toBe("https://base.test/one/x");
|
|
75
|
+
expect(second.document.querySelector("a").href).toBe("https://base.test/page/x");
|
|
76
|
+
first.window.happyDOM.abort();
|
|
77
|
+
second.window.happyDOM.abort();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a <base> change invalidates only its own document", () => {
|
|
81
|
+
countBaseQueries(queries => {
|
|
82
|
+
const first = parseHTMLDocument(`<!doctype html><base href="/one/"><a href="x">x</a>`, URL);
|
|
83
|
+
const second = parseHTMLDocument(`<!doctype html><a href="x">x</a>`, URL);
|
|
84
|
+
expect([first.document.baseURI, second.document.baseURI]).toEqual(["https://base.test/one/", URL]);
|
|
85
|
+
const before = queries(second.document);
|
|
86
|
+
first.document.querySelector("base").setAttribute("href", "/two/");
|
|
87
|
+
expect(first.document.baseURI).toBe("https://base.test/two/");
|
|
88
|
+
expect(second.document.baseURI).toBe(URL);
|
|
89
|
+
expect(queries(second.document)).toBe(before);
|
|
90
|
+
first.window.happyDOM.abort();
|
|
91
|
+
second.window.happyDOM.abort();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("an adopted <base> leaves its old document and joins the new one", () => {
|
|
96
|
+
const first = parseHTMLDocument(`<!doctype html><head><base href="/one/"></head>`, URL);
|
|
97
|
+
const second = parseHTMLDocument(`<!doctype html><head></head>`, URL);
|
|
98
|
+
expect([first.document.baseURI, second.document.baseURI]).toEqual(["https://base.test/one/", URL]);
|
|
99
|
+
second.document.head.appendChild(second.document.adoptNode(first.document.querySelector("base")));
|
|
100
|
+
expect([first.document.baseURI, second.document.baseURI]).toEqual([URL, "https://base.test/one/"]);
|
|
101
|
+
first.window.happyDOM.abort();
|
|
102
|
+
second.window.happyDOM.abort();
|
|
103
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { CookieJar } from "../lib/network/cookie-jar.js";
|
|
3
|
+
import { DocumentCookies } from "../lib/renderer/document-cookies.js";
|
|
4
|
+
|
|
5
|
+
const url = "https://page.test/app/index.html";
|
|
6
|
+
const pairs = value => value.split("; ").filter(Boolean).sort();
|
|
7
|
+
function fixture() {
|
|
8
|
+
let now = 100000, revision = 0;
|
|
9
|
+
const sent = [], jar = new CookieJar({ now: () => now });
|
|
10
|
+
const mirror = new DocumentCookies((value, url, sequence) => sent.push({ value, url, sequence }), () => now);
|
|
11
|
+
const snapshot = (acknowledged = 0) => ({ revision: ++revision, acknowledged, cookies: jar.scriptSnapshot(url) });
|
|
12
|
+
return { jar, mirror, sent, snapshot, advance: ms => { now += ms; } };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test("a delayed snapshot retains newer setters and unrelated server cookies, then acknowledges them", () => {
|
|
16
|
+
const { jar, mirror, sent, snapshot } = fixture();
|
|
17
|
+
jar.store(url, ["key=before; Path=/"]);
|
|
18
|
+
mirror.accept(snapshot());
|
|
19
|
+
mirror.set(url, "key=after; Path=/");
|
|
20
|
+
jar.store(url, ["server=2; Path=/", "secret=hidden; Path=/; HttpOnly"]);
|
|
21
|
+
const delayed = snapshot();
|
|
22
|
+
expect(mirror.accept(delayed)).toBe(true);
|
|
23
|
+
expect(pairs(mirror.get(url))).toEqual(["key=after", "server=2"]);
|
|
24
|
+
expect(JSON.stringify(delayed)).not.toContain("secret");
|
|
25
|
+
jar.store(sent[0].url, [sent[0].value], { fromScript: true });
|
|
26
|
+
const acknowledged = snapshot(sent[0].sequence);
|
|
27
|
+
mirror.accept(acknowledged);
|
|
28
|
+
expect(pairs(mirror.get(url))).toEqual(pairs(jar.documentCookie(url)));
|
|
29
|
+
expect(mirror.accept(delayed)).toBe(false);
|
|
30
|
+
expect(mirror.accept({ ...delayed, revision: acknowledged.revision + 1 })).toBe(false);
|
|
31
|
+
expect(pairs(mirror.get(url))).toEqual(["key=after", "server=2"]);
|
|
32
|
+
// Once acknowledged, a later server overwrite is authoritative.
|
|
33
|
+
jar.store(url, ["key=server; Path=/"]);
|
|
34
|
+
mirror.accept(snapshot(sent[0].sequence));
|
|
35
|
+
expect(pairs(mirror.get(url))).toEqual(["key=server", "server=2"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("pending setters and deletions retain cookie Path, Domain, and Secure rules", () => {
|
|
39
|
+
const { jar, mirror, snapshot } = fixture();
|
|
40
|
+
jar.store(url, ["key=root; Path=/", "key=app; Path=/app", "parent=1; Domain=page.test; Path=/"]);
|
|
41
|
+
mirror.accept(snapshot());
|
|
42
|
+
mirror.set(url, "key=new; Path=/app");
|
|
43
|
+
mirror.set(url, "rejected=1; Domain=other.test");
|
|
44
|
+
mirror.accept(snapshot());
|
|
45
|
+
expect(pairs(mirror.get(url))).toEqual(["key=new", "key=root", "parent=1"]);
|
|
46
|
+
expect(pairs(mirror.get("https://page.test/other"))).toEqual(["key=root", "parent=1"]);
|
|
47
|
+
expect(mirror.get("https://sub.page.test/app/")).toBe("parent=1");
|
|
48
|
+
mirror.set(url, "key=; Path=/app; Max-Age=0");
|
|
49
|
+
jar.store(url, ["unrelated=3; Path=/"]);
|
|
50
|
+
mirror.accept(snapshot());
|
|
51
|
+
expect(pairs(mirror.get(url))).toEqual(["key=root", "parent=1", "unrelated=3"]);
|
|
52
|
+
mirror.set("http://page.test/app/", "secure=1; Secure; Path=/");
|
|
53
|
+
expect(mirror.get(url)).not.toContain("secure=");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("replaying an unacknowledged Max-Age cookie does not restart its lifetime", () => {
|
|
57
|
+
const { mirror, snapshot, advance } = fixture();
|
|
58
|
+
mirror.accept(snapshot());
|
|
59
|
+
mirror.set(url, "short=1; Max-Age=2; Path=/");
|
|
60
|
+
advance(1500);
|
|
61
|
+
mirror.accept(snapshot());
|
|
62
|
+
expect(mirror.get(url)).toBe("short=1");
|
|
63
|
+
advance(600);
|
|
64
|
+
mirror.accept(snapshot());
|
|
65
|
+
expect(mirror.get(url)).toBe("");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("snapshot records exclude HttpOnly and unrelated hosts and cannot share mutable jar records", () => {
|
|
69
|
+
const { jar } = fixture();
|
|
70
|
+
jar.store(url, ["public=1; Path=/", "private=secret; Path=/; HttpOnly", "path=1; Path=/elsewhere"]);
|
|
71
|
+
jar.store("https://other.test/", ["other=secret; Path=/"]);
|
|
72
|
+
const records = jar.scriptSnapshot(url);
|
|
73
|
+
expect(records.map(cookie => cookie.name).sort()).toEqual(["path", "public"]);
|
|
74
|
+
records[0].value = "changed";
|
|
75
|
+
expect(jar.documentCookie(url)).toBe("public=1");
|
|
76
|
+
const mirror = new CookieJar();
|
|
77
|
+
mirror.replaceScriptSnapshot(records);
|
|
78
|
+
records[0].value = "changed again";
|
|
79
|
+
expect(mirror.documentCookie(url)).toBe("public=changed");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("legacy string-only embedders retain replacement semantics", () => {
|
|
83
|
+
const mirror = new DocumentCookies();
|
|
84
|
+
mirror.seed(url, "first=1; second=2");
|
|
85
|
+
expect(pairs(mirror.get(url))).toEqual(["first=1", "second=2"]);
|
|
86
|
+
mirror.seed(url, "second=3", true);
|
|
87
|
+
expect(mirror.get(url)).toBe("second=3");
|
|
88
|
+
mirror.seed(url, "", true);
|
|
89
|
+
expect(mirror.get(url)).toBe("");
|
|
90
|
+
mirror.seed(url, "id=app; id=root; other=1", true);
|
|
91
|
+
expect(mirror.get(url)).toBe("id=app; id=root; other=1");
|
|
92
|
+
mirror.set(url, "other=2; Path=/");
|
|
93
|
+
expect(mirror.get(url)).toBe("id=app; id=root; other=2");
|
|
94
|
+
mirror.set(url, "id=; Max-Age=0");
|
|
95
|
+
expect(mirror.get(url)).toBe("other=2");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("unacknowledged legacy setters do not replay after the snapshot protocol is established", () => {
|
|
99
|
+
const { mirror, snapshot } = fixture();
|
|
100
|
+
for (let i = 0; i < 1000; i++) mirror.set(url, `legacy=${i}; Path=/`);
|
|
101
|
+
expect(mirror.get(url)).toBe("legacy=999");
|
|
102
|
+
mirror.accept(snapshot());
|
|
103
|
+
expect(mirror.get(url)).toBe("");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("an HttpOnly overwrite is rejected by the controller without exposing its value", () => {
|
|
107
|
+
const { jar, mirror, sent, snapshot } = fixture();
|
|
108
|
+
jar.store(url, ["private=hidden; HttpOnly; Path=/", "public=visible; Path=/"]);
|
|
109
|
+
mirror.accept(snapshot());
|
|
110
|
+
mirror.set(url, "private=attempted; Path=/");
|
|
111
|
+
jar.store(sent[0].url, [sent[0].value], { fromScript: true });
|
|
112
|
+
const state = snapshot(sent[0].sequence);
|
|
113
|
+
expect(JSON.stringify(state)).not.toContain("hidden");
|
|
114
|
+
mirror.accept(state);
|
|
115
|
+
expect(mirror.get(url)).toBe("public=visible");
|
|
116
|
+
expect(jar.header(url)).toBe("private=hidden; public=visible");
|
|
117
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
for (const mode of ['plain', 'ses']) {
|
|
4
|
+
test(`duplicate class keys preserve candidate matching and ancestor counts (${mode})`, async () => {
|
|
5
|
+
const child = Bun.spawn([process.execPath, new URL('./fixtures/duplicate-style-keys.js', import.meta.url).pathname, mode],
|
|
6
|
+
{ stdout: 'pipe', stderr: 'pipe' });
|
|
7
|
+
const [exit, stdout, stderr] = await Promise.all([child.exited,
|
|
8
|
+
new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
|
9
|
+
expect({ exit, stdout, stderr }).toEqual({ exit: 0, stdout: '', stderr: '' });
|
|
10
|
+
}, 10_000);
|
|
11
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { createPageRenderer, loadGraphics } from "../../lib/renderer/page-renderer.js";
|
|
4
|
+
import { supportsFrameCheckpoint } from "../../lib/renderer/animation-frames.js";
|
|
5
|
+
await import("../../vendor/ses/dist/ses.mjs");
|
|
6
|
+
await loadGraphics();
|
|
7
|
+
lockdown({ errorTaming: "safe", overrideTaming: "severe", consoleTaming: "unsafe",
|
|
8
|
+
localeTaming: "unsafe", stackFiltering: "concise", reporting: "none" });
|
|
9
|
+
const errors = [], renderer = await createPageRenderer({ reportError: error => errors.push(String(error)) });
|
|
10
|
+
const load = async path => {
|
|
11
|
+
await renderer.loadDocument({ url: "https://frames.test/" + path, contentType: "text/html", source: "<p>Animation frames</p>" });
|
|
12
|
+
await renderer.whenLoaded();
|
|
13
|
+
};
|
|
14
|
+
try {
|
|
15
|
+
renderer.setScripting("ses"); renderer.resize(320, 220, 1); await load("first");
|
|
16
|
+
const result = await renderer.evaluate(`
|
|
17
|
+
new Promise(resolve => {
|
|
18
|
+
const events=[], times=[]; let cancelled, sameBatch;
|
|
19
|
+
const record=(name,time)=>{events.push(name);if(time!==undefined)times.push([name,time]);};
|
|
20
|
+
addEventListener('error',event=>{if(event.message.includes('RAF_PROBE_THROW'))event.preventDefault();});
|
|
21
|
+
requestAnimationFrame(function first(time){
|
|
22
|
+
record('first',time);record(this===window?'this-window':'this-other');
|
|
23
|
+
requestAnimationFrame(time=>record('nested',time));
|
|
24
|
+
Promise.resolve().then(()=>{
|
|
25
|
+
record('promise-one');requestAnimationFrame(time=>record('nested-microtask',time));
|
|
26
|
+
}).then(()=>{record('promise-two');cancelAnimationFrame(cancelled);});
|
|
27
|
+
queueMicrotask(()=>record('queue-microtask'));
|
|
28
|
+
setTimeout(()=>record('timer'),0);
|
|
29
|
+
const end=performance.now()+5;while(performance.now()<end){}
|
|
30
|
+
});
|
|
31
|
+
cancelled=requestAnimationFrame(time=>record('cancelled-from-microtask',time));
|
|
32
|
+
requestAnimationFrame(time=>{record('third',time);cancelAnimationFrame(sameBatch);});
|
|
33
|
+
sameBatch=requestAnimationFrame(time=>record('cancelled-from-callback',time));
|
|
34
|
+
const early=requestAnimationFrame(()=>record('cancelled-early'));cancelAnimationFrame(early);
|
|
35
|
+
requestAnimationFrame(time=>{record('throws',time);throw new Error('RAF_PROBE_THROW');});
|
|
36
|
+
requestAnimationFrame(time=>{record('after-throw',time);requestAnimationFrame(time=>{
|
|
37
|
+
record('finish',time);resolve({events,times,groups:new Set(times.map(entry=>entry[1])).size});
|
|
38
|
+
});});
|
|
39
|
+
})
|
|
40
|
+
`);
|
|
41
|
+
assert.deepEqual(result.events, ["first", "this-window", "promise-one", "queue-microtask", "promise-two", "third", "throws", "after-throw", "timer", "nested", "nested-microtask", "finish"]);
|
|
42
|
+
if (supportsFrameCheckpoint()) {
|
|
43
|
+
assert.equal(result.groups, 2);
|
|
44
|
+
assert.ok(result.times.slice(0, 4).every(entry => entry[1] === result.times[0][1]));
|
|
45
|
+
assert.ok(result.times.slice(4).every(entry => entry[1] === result.times[4][1]));
|
|
46
|
+
assert.ok(result.times[4][1] > result.times[0][1]);
|
|
47
|
+
}
|
|
48
|
+
assert.equal(errors.length, 1); assert.ok(errors[0].includes("RAF_PROBE_THROW")); errors.length = 0;
|
|
49
|
+
await renderer.evaluate(`requestAnimationFrame(() => { throw new Error("old frame"); })`);
|
|
50
|
+
await load("second");
|
|
51
|
+
assert.equal(await renderer.evaluate(`new Promise(resolve => requestAnimationFrame(time => resolve(typeof time)))`), "number");
|
|
52
|
+
await delay(25); assert.deepEqual(errors, []);
|
|
53
|
+
await renderer.evaluate(`requestAnimationFrame(() => { throw new Error("disposed frame"); })`);
|
|
54
|
+
renderer.dispose(); await delay(25); assert.deepEqual(errors, []);
|
|
55
|
+
console.log("animation frame ordering and lifecycle passed");
|
|
56
|
+
} finally { renderer.dispose(); }
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
const root = new URL("../../", import.meta.url).pathname.replace(/\/$/, "");
|
|
3
|
+
const { createPageRenderer } = await import(
|
|
4
|
+
root + "/lib/renderer/page-renderer.js"
|
|
5
|
+
);
|
|
6
|
+
const { LayoutEngine } = await import(root + "/lib/layout/text-layout.js");
|
|
7
|
+
const { StyleEngine } = await import(root + "/lib/style/computed-style.js");
|
|
8
|
+
let layouts = 0,
|
|
9
|
+
computes = 0;
|
|
10
|
+
const layout = LayoutEngine.prototype.layout,
|
|
11
|
+
compute = StyleEngine.prototype.compute;
|
|
12
|
+
LayoutEngine.prototype.layout = function (...args) {
|
|
13
|
+
layouts++;
|
|
14
|
+
return layout.apply(this, args);
|
|
15
|
+
};
|
|
16
|
+
StyleEngine.prototype.compute = function (...args) {
|
|
17
|
+
computes++;
|
|
18
|
+
return compute.apply(this, args);
|
|
19
|
+
};
|
|
20
|
+
const renderer = await createPageRenderer({});
|
|
21
|
+
await import(root + "/vendor/ses/dist/ses.mjs");
|
|
22
|
+
lockdown({
|
|
23
|
+
errorTaming: "safe",
|
|
24
|
+
overrideTaming: "severe",
|
|
25
|
+
consoleTaming: "unsafe",
|
|
26
|
+
localeTaming: "unsafe",
|
|
27
|
+
stackFiltering: "concise",
|
|
28
|
+
reporting: "none",
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
renderer.setScripting("ses");
|
|
32
|
+
await renderer.loadDocument({
|
|
33
|
+
url: "https://canvas.test/",
|
|
34
|
+
contentType: "text/html",
|
|
35
|
+
source:
|
|
36
|
+
'<div id="hidden" style="display:none;font-size:20px"><canvas id="c" width="100" height="50" style="font-size:24px"></canvas></div><canvas id="v" width="100" height="50"></canvas>',
|
|
37
|
+
});
|
|
38
|
+
await renderer.whenLoaded();
|
|
39
|
+
await renderer.evaluate(
|
|
40
|
+
`window.c=document.getElementById('c');window.ctx=c.getContext('2d');window.v=document.getElementById('v');window.vctx=v.getContext('2d')`,
|
|
41
|
+
);
|
|
42
|
+
const measure = await renderer.evaluate(
|
|
43
|
+
`c.style.fontSize='40px';ctx.font='1em sans-serif';ctx.measureText('MMMM').width`,
|
|
44
|
+
);
|
|
45
|
+
const eagerCurrent = await renderer.evaluate(
|
|
46
|
+
`ctx.font='40px sans-serif';ctx.measureText('MMMM').width`,
|
|
47
|
+
);
|
|
48
|
+
assert.equal(measure, eagerCurrent);
|
|
49
|
+
const before = { layouts, computes };
|
|
50
|
+
// Count renderer style/layout work around a batch of canvas operations.
|
|
51
|
+
const result = await renderer.evaluate(
|
|
52
|
+
`(() => {c.style.fontSize='20px';ctx.font='2em sans-serif';const width=ctx.measureText('MMMM').width;v.style.direction='rtl';vctx.direction='inherit';vctx.font='12px sans-serif';vctx.fillText('abc',50,20);for(let i=0;i<30;i++){ctx.measureText('MMMM');vctx.fillText('abc',50,20)}return width;})()`,
|
|
53
|
+
);
|
|
54
|
+
assert.equal(result, eagerCurrent);
|
|
55
|
+
const after = { layouts, computes };
|
|
56
|
+
// evaluate's trailing refresh may produce one layout, never one per draw.
|
|
57
|
+
assert.ok(after.layouts - before.layouts <= 1);
|
|
58
|
+
assert.ok(after.computes - before.computes <= 3);
|
|
59
|
+
const clean = { layouts, computes };
|
|
60
|
+
await renderer.evaluate(
|
|
61
|
+
`for(let i=0;i<30;i++){ctx.measureText('MMMM');vctx.fillText('abc',50,20)}`,
|
|
62
|
+
);
|
|
63
|
+
assert.equal(layouts, clean.layouts);
|
|
64
|
+
assert.equal(computes, clean.computes);
|
|
65
|
+
console.log(
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
pass: true,
|
|
68
|
+
firstPendingMutationMatchesExplicit40px: true,
|
|
69
|
+
before,
|
|
70
|
+
after,
|
|
71
|
+
cleanDraws: {
|
|
72
|
+
layouts: layouts - clean.layouts,
|
|
73
|
+
computes: computes - clean.computes,
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
} finally {
|
|
78
|
+
renderer.dispose();
|
|
79
|
+
LayoutEngine.prototype.layout = layout;
|
|
80
|
+
StyleEngine.prototype.compute = compute;
|
|
81
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { LayoutEngine } from "../../lib/layout/text-layout.js";
|
|
3
|
+
import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
|
|
4
|
+
|
|
5
|
+
const mode = process.argv[2] ?? "ses";
|
|
6
|
+
let layouts = 0;
|
|
7
|
+
const layout = LayoutEngine.prototype.layout;
|
|
8
|
+
LayoutEngine.prototype.layout = function (...args) {
|
|
9
|
+
layouts++;
|
|
10
|
+
return layout.apply(this, args);
|
|
11
|
+
};
|
|
12
|
+
const errors = [];
|
|
13
|
+
const renderer = await createPageRenderer({ reportError: error => errors.push(String(error)) });
|
|
14
|
+
if (mode === "ses") {
|
|
15
|
+
await import("../../vendor/ses/dist/ses.mjs");
|
|
16
|
+
lockdown({ errorTaming: "safe", overrideTaming: "severe", consoleTaming: "unsafe",
|
|
17
|
+
localeTaming: "unsafe", stackFiltering: "concise", reporting: "none" });
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
renderer.setScripting(mode);
|
|
21
|
+
await renderer.loadDocument({ url: "https://styles.test/", contentType: "text/html",
|
|
22
|
+
source: "<!doctype html><style>#accessor{-webkit-line-clamp:2}</style><body></body>" });
|
|
23
|
+
await renderer.whenLoaded();
|
|
24
|
+
|
|
25
|
+
// This must be the first CSSOM declaration in a fresh process: accessors
|
|
26
|
+
// cannot depend on a connected declaration having initialized their schema.
|
|
27
|
+
const detached = await renderer.evaluate(`(() => {
|
|
28
|
+
window.node=document.createElement('div');node.id='accessor';
|
|
29
|
+
node.style.cssText='width:77px;height:19px;color:rgb(12,34,56);--tone:first';
|
|
30
|
+
window.savedStyle=getComputedStyle(node);
|
|
31
|
+
window.readStyle=s=>[s.color,s.width,s.getPropertyValue('--tone'),s.length===0,s.item(0)===''];
|
|
32
|
+
window.savedKeys=Object.keys(savedStyle);
|
|
33
|
+
return {values:readStyle(savedStyle),accessors:['color','width','WebkitLineClamp','webkitLineClamp'].every(key=>key in savedStyle),
|
|
34
|
+
aliases:[savedStyle.WebkitLineClamp,savedStyle.webkitLineClamp],absent:savedStyle.getPropertyValue('unknown-property')};
|
|
35
|
+
})()`);
|
|
36
|
+
assert.deepEqual(detached, { values: ["", "", "", true, true], accessors: true,
|
|
37
|
+
aliases: ["", ""], absent: "" });
|
|
38
|
+
|
|
39
|
+
const connected = await renderer.evaluate(`(() => {
|
|
40
|
+
document.body.appendChild(node);
|
|
41
|
+
window.normalNames=Array.from({length:savedStyle.length},(_,i)=>savedStyle.item(i));
|
|
42
|
+
return {values:readStyle(savedStyle),names:normalNames.includes('color')&&normalNames.includes('width'),
|
|
43
|
+
aliases:[savedStyle.WebkitLineClamp,savedStyle.webkitLineClamp],keysStable:savedKeys.join('|')===Object.keys(savedStyle).join('|')};
|
|
44
|
+
})()`);
|
|
45
|
+
assert.deepEqual(connected, { values: ["rgba(12, 34, 56, 1)", "77px", "first", false, false],
|
|
46
|
+
names: true, aliases: ["2", "2"], keysStable: true });
|
|
47
|
+
|
|
48
|
+
const changes = await renderer.evaluate(`(() => {
|
|
49
|
+
node.style.setProperty('--tone','second');node.style.setProperty('--later','new');
|
|
50
|
+
const customChanged=readStyle(savedStyle);
|
|
51
|
+
const fixedNames=normalNames.join('|')===Array.from({length:savedStyle.length},(_,i)=>savedStyle.item(i)).join('|');
|
|
52
|
+
node.remove();const removed=readStyle(savedStyle),fresh=readStyle(getComputedStyle(node));
|
|
53
|
+
node.style.width='88px';node.style.color='rgb(78,90,12)';
|
|
54
|
+
const changedDetached=readStyle(savedStyle);
|
|
55
|
+
document.body.appendChild(node);const reattached=readStyle(savedStyle);
|
|
56
|
+
return {customChanged,fixedNames,removed,fresh,changedDetached,reattached,
|
|
57
|
+
keysStable:savedKeys.join('|')===Object.keys(savedStyle).join('|')};
|
|
58
|
+
})()`);
|
|
59
|
+
assert.deepEqual(changes, {
|
|
60
|
+
customChanged: ["rgba(12, 34, 56, 1)", "77px", "second", false, false], fixedNames: true,
|
|
61
|
+
removed: ["", "", "", true, true], fresh: ["", "", "", true, true],
|
|
62
|
+
changedDetached: ["", "", "", true, true],
|
|
63
|
+
reattached: ["rgba(78, 90, 12, 1)", "88px", "second", false, false], keysStable: true,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const hidden = await renderer.evaluate(`(() => {
|
|
67
|
+
const parent=document.createElement('div');parent.style.cssText='display:none;color:rgb(99,88,77);--tone:inherited';
|
|
68
|
+
const child=document.createElement('div');child.style.width='3em';parent.appendChild(child);document.body.appendChild(parent);
|
|
69
|
+
const style=getComputedStyle(child),connected=readStyle(style);
|
|
70
|
+
parent.remove();const detached=readStyle(style);document.body.appendChild(parent);
|
|
71
|
+
return {connected,detached,reattached:readStyle(style)};
|
|
72
|
+
})()`);
|
|
73
|
+
assert.deepEqual(hidden, {
|
|
74
|
+
connected: ["rgba(99, 88, 77, 1)", "48px", "inherited", false, false],
|
|
75
|
+
detached: ["", "", "", true, true],
|
|
76
|
+
reattached: ["rgba(99, 88, 77, 1)", "48px", "inherited", false, false],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// A descendant never queried while connected must also avoid stale style
|
|
80
|
+
// retained by an earlier normal style pass after its ancestor is removed.
|
|
81
|
+
await renderer.evaluate(`document.body.insertAdjacentHTML('beforeend',
|
|
82
|
+
'<style>#hidden{display:none;color:red;--tone:old}#hidden .child{width:3em;background:green}</style>'+
|
|
83
|
+
'<div id="hidden"><span class="child" id="child">Hidden</span></div><div id="visible"></div>')`);
|
|
84
|
+
await renderer.screenshot({ format: "rgba" });
|
|
85
|
+
assert.deepEqual(await renderer.evaluate(`(() => {
|
|
86
|
+
const child=document.getElementById('child');child.remove();
|
|
87
|
+
document.getElementById('visible').style.width='19px';const style=getComputedStyle(child);
|
|
88
|
+
return [style.color,style.width,style.backgroundColor,style.getPropertyValue('--tone'),style.length,style.item(0)];
|
|
89
|
+
})()`), ["", "", "", "", 0, ""]);
|
|
90
|
+
|
|
91
|
+
await renderer.evaluate(`window.detached=document.createElement('div');window.ds=getComputedStyle(detached);
|
|
92
|
+
document.body.style.padding='7px'`);
|
|
93
|
+
const before = layouts;
|
|
94
|
+
assert.deepEqual(await renderer.evaluate(`[ds.color,ds.width,ds.length,ds.item(0),ds.getPropertyValue('--tone')]`),
|
|
95
|
+
["", "", 0, "", ""]);
|
|
96
|
+
assert.equal(layouts, before, "detached reads must not flush unrelated layout");
|
|
97
|
+
assert.deepEqual(errors, []);
|
|
98
|
+
} finally {
|
|
99
|
+
renderer.dispose();
|
|
100
|
+
LayoutEngine.prototype.layout = layout;
|
|
101
|
+
}
|