buninu-browser 0.2.1 → 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 +16 -11
- package/lib/happy-dom/parser.js +24 -4
- package/lib/headless-shell.js +66 -19
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +196 -49
- 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 +161 -14
- package/lib/paint/display-list.js +300 -14
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +17 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +985 -68
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/host.js +158 -8
- 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 +1405 -198
- package/lib/renderer/page-renderer.js +1736 -193
- 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 +162 -22
- 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 +38 -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 +1 -1
- package/test/hit-test.test.js +25 -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 +14 -0
- package/test/page-scripts.test.js +1045 -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/renderer-host.test.js +5 -2
- package/test/screenshot-cli.test.js +25 -1
- package/test/script-rewrite.test.js +111 -6
- package/test/setup.js +5 -0
- package/test/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
|
+
});
|
|
@@ -6,8 +6,11 @@ const hosts = [];
|
|
|
6
6
|
afterEach(() => {
|
|
7
7
|
for (const host of hosts.splice(0)) host.close();
|
|
8
8
|
});
|
|
9
|
+
// The watchdog deadline: long enough for a fresh renderer to answer while
|
|
10
|
+
// test files run in parallel, short enough to see a hang killed.
|
|
11
|
+
const TIMEOUT = 4000;
|
|
9
12
|
function host(options = {}) {
|
|
10
|
-
const created = new RendererHost({ entry, timeout:
|
|
13
|
+
const created = new RendererHost({ entry, timeout: TIMEOUT, ...options });
|
|
11
14
|
hosts.push(created);
|
|
12
15
|
return created;
|
|
13
16
|
}
|
|
@@ -32,7 +35,7 @@ test("kills a renderer that misses its deadline and restarts it on the next call
|
|
|
32
35
|
const firstPid = renderer.pid;
|
|
33
36
|
const started = performance.now();
|
|
34
37
|
await expect(renderer.call("hang")).rejects.toThrow("timed out");
|
|
35
|
-
expect(performance.now() - started).toBeLessThan(
|
|
38
|
+
expect(performance.now() - started).toBeLessThan(TIMEOUT + 3000);
|
|
36
39
|
const after = await renderer.call("echo", "next");
|
|
37
40
|
expect(after.pid).not.toBe(firstPid);
|
|
38
41
|
expect(after.argument).toBe("next");
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { runScreenshot, screenshotTargetUrl } from "../lib/screenshot-cli.js";
|
|
6
|
+
import { DESKTOP_USER_AGENT, WEBKIT_USER_AGENT } from "../lib/user-agent.js";
|
|
3
7
|
|
|
4
8
|
test("screenshot target treats Windows drive and UNC paths as files", () => {
|
|
5
9
|
const options = {
|
|
@@ -17,3 +21,23 @@ test("screenshot target preserves URLs and bare hostnames", () => {
|
|
|
17
21
|
expect(screenshotTargetUrl("https://example.com", { platform: "win32" })).toBe("https://example.com");
|
|
18
22
|
expect(screenshotTargetUrl("example.com", { platform: "win32" })).toBe("https://example.com");
|
|
19
23
|
});
|
|
24
|
+
|
|
25
|
+
test("screenshot mode defaults to WebKit and accepts both explicit desktop profiles", async () => {
|
|
26
|
+
const directory = await mkdtemp(join(tmpdir(), "buninu-profile-test-"));
|
|
27
|
+
let userAgent;
|
|
28
|
+
const server = Bun.serve({ port: 0, fetch(request) {
|
|
29
|
+
userAgent = request.headers.get("user-agent");
|
|
30
|
+
return new Response("<!doctype html><p>identity</p>", { headers: { "content-type": "text/html" } });
|
|
31
|
+
} });
|
|
32
|
+
try {
|
|
33
|
+
for (const [flags, expected] of [[[], WEBKIT_USER_AGENT], [["--webkit"], WEBKIT_USER_AGENT], [["--chromium"], DESKTOP_USER_AGENT]]) {
|
|
34
|
+
const filename = join(directory, "page.png");
|
|
35
|
+
await runScreenshot([`--screenshot=${filename}`, "--window-size=64,64", ...flags, `http://127.0.0.1:${server.port}/`]);
|
|
36
|
+
expect(userAgent).toBe(expected);
|
|
37
|
+
expect((await Bun.file(filename).bytes()).slice(0, 8)).toEqual(new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]));
|
|
38
|
+
}
|
|
39
|
+
} finally {
|
|
40
|
+
server.stop(true);
|
|
41
|
+
await rm(directory, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
}, 60_000);
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
escapeSesHtmlComments, prepareDirectEvalSource, restoreSourceText, rewriteClassicScript, rewriteConstructorReads,
|
|
4
|
+
referenceHelper, rewriteClassicScriptSource, rewriteDirectEval,
|
|
5
|
+
} from "../lib/renderer/script-rewrite.js";
|
|
3
6
|
|
|
4
|
-
|
|
7
|
+
// The comment each constructor rewrite leaves for restoreSourceText.
|
|
8
|
+
const rewrite = (source, type) => rewriteConstructorReads(source, type).code.replace(/\/\*\$buninu\$ctor:[^*]*\*\//g, "");
|
|
9
|
+
|
|
10
|
+
test("HTML comment markers in JavaScript values are escaped without changing them", () => {
|
|
11
|
+
const source = 'const a = "<!-- start -->"; const b = /-->/g; const c = `<!-- ${1} -->`;';
|
|
12
|
+
const escaped = escapeSesHtmlComments(source);
|
|
13
|
+
expect(escaped).not.toContain("<!--");
|
|
14
|
+
expect(escaped).not.toContain("-->");
|
|
15
|
+
expect(Function(`${escaped}; return [a, b.test('-->'), c]`)()).toEqual(["<!-- start -->", true, "<!-- 1 -->"]);
|
|
16
|
+
});
|
|
5
17
|
|
|
6
18
|
test("constructor reads go through the helper; writes, delete and optional chains do not", () => {
|
|
7
19
|
expect(rewrite("var A = Object.getPrototypeOf(async function () {}).constructor;"))
|
|
@@ -14,18 +26,111 @@ test("constructor reads go through the helper; writes, delete and optional chain
|
|
|
14
26
|
expect(rewrite(`y = a . constructor ['constructor'];${trigger}`))
|
|
15
27
|
.toBe("y = $buninu$constructor($buninu$constructor(a) ) ; ($buninu$constructor(async () => {}));");
|
|
16
28
|
// Calls keep the receiver (the helper's second argument).
|
|
17
|
-
|
|
18
|
-
|
|
29
|
+
// A constructed one is parenthesized so `new` applies to the result.
|
|
30
|
+
expect(rewrite(`b.constructor(1); new c.constructor(); new d.constructor;${trigger}`))
|
|
31
|
+
.toBe("$buninu$constructor(b, true)(1); new ($buninu$constructor(c))(); new ($buninu$constructor(d)); ($buninu$constructor(async () => {}));");
|
|
19
32
|
const untouched = "a.constructor = 1; a.constructor++; delete a.constructor; for (a.constructor of []); c?.constructor; "
|
|
20
33
|
+ "class K extends B { constructor() { super(); super.constructor } } ({ constructor: 1 });";
|
|
21
34
|
expect(rewrite(untouched + trigger)).toBe(`${untouched} ($buninu$constructor(async () => {}));`);
|
|
22
35
|
});
|
|
23
36
|
|
|
37
|
+
test("new on a rewritten constructor read constructs the object's constructor", () => {
|
|
38
|
+
const code = rewrite("class Point { constructor(x) { this.x = x; } } const p = new Point(1);"
|
|
39
|
+
+ " result = new p.constructor(2); (async () => {}).constructor;");
|
|
40
|
+
const run = new Function("$buninu$constructor", `let result; ${code} return result;`);
|
|
41
|
+
const result = run((object) => object.constructor);
|
|
42
|
+
expect(result).toBeObject();
|
|
43
|
+
expect(result.constructor.name).toBe("Point");
|
|
44
|
+
expect(result.x).toBe(2);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("constructor reads preserve comma expressions as a single receiver", () => {
|
|
48
|
+
const source = 'var a = {}, b = {}; var result = (a.x = 1, b).constructor; (async () => {}).constructor;';
|
|
49
|
+
const rewritten = rewriteConstructorReads(source).code;
|
|
50
|
+
const run = new Function("$buninu$constructor", `${rewritten}; return [result, a.x];`);
|
|
51
|
+
expect(run((value, call = false) => {
|
|
52
|
+
expect(call).toBe(false);
|
|
53
|
+
return value.constructor;
|
|
54
|
+
})).toEqual([Object, 1]);
|
|
55
|
+
expect(restoreSourceText(rewritten)).toBe(source);
|
|
56
|
+
});
|
|
57
|
+
|
|
24
58
|
test("sources that cannot reach Function constructors are not parsed; bad syntax is left to the engine", () => {
|
|
25
|
-
expect(rewriteConstructorReads("x.constructor.name")).toEqual({ code: "x.constructor.name", rewritten: false });
|
|
59
|
+
expect(rewriteConstructorReads("x.constructor.name")).toEqual({ code: "x.constructor.name", rewritten: false, program: null });
|
|
26
60
|
// this.constructor is common and harmless; alone it does not trigger a parse.
|
|
27
61
|
expect(rewriteConstructorReads("async function f() { return this.constructor }").rewritten).toBe(false);
|
|
28
|
-
expect(rewriteConstructorReads("(async () => {}).constructor(")).toEqual({ code: "(async () => {}).constructor(", rewritten: false });
|
|
62
|
+
expect(rewriteConstructorReads("(async () => {}).constructor(")).toEqual({ code: "(async () => {}).constructor(", rewritten: false, program: null });
|
|
29
63
|
expect(rewrite("export const A = (async () => {}).constructor;", "module"))
|
|
30
64
|
.toBe("export const A = ($buninu$constructor(async () => {}));");
|
|
31
65
|
});
|
|
66
|
+
|
|
67
|
+
test("every rewrite of a function's text is undone for Function.prototype.toString", () => {
|
|
68
|
+
const functions = [
|
|
69
|
+
"function f(n) { var t = this; for (; n-->0;) eval(\"n\" + t); return (async () => {}).constructor; }",
|
|
70
|
+
"function g() { return [eval(eval(\"x\")), new (Object.getPrototypeOf(function* () {})).constructor(\"yield 1\"), \"<!-- -->\"]; }",
|
|
71
|
+
"function h() { \"use strict\"; return [this, eval(\"1\"), a.b().constructor(2)]; }",
|
|
72
|
+
];
|
|
73
|
+
for (const text of functions) {
|
|
74
|
+
const classic = rewriteClassicScript(rewriteConstructorReads(rewriteDirectEval(text).code).code);
|
|
75
|
+
const code = escapeSesHtmlComments(classic.code);
|
|
76
|
+
// The function's own text in the rewritten program (after the prologue).
|
|
77
|
+
const own = code.slice(code.indexOf("function ", code.indexOf("=")));
|
|
78
|
+
expect(restoreSourceText(own.slice(0, own.lastIndexOf("}") + 1))).toBe(text);
|
|
79
|
+
}
|
|
80
|
+
// The prepared program starts with its constants; the function's own text follows.
|
|
81
|
+
const prepared = prepareDirectEvalSource("(function () { return this; })", [], false).code;
|
|
82
|
+
expect(restoreSourceText(prepared.slice(prepared.indexOf("(function")))).toBe("(function () { return this; })");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("only parsed, non-strict programs are reported as sloppy-mode code", () => {
|
|
86
|
+
expect(rewriteClassicScript("var a = this;")).toMatchObject({ parsed: true, strict: false });
|
|
87
|
+
expect(rewriteClassicScript("'use strict'; var a = 1;")).toMatchObject({ parsed: true, strict: true });
|
|
88
|
+
expect(rewriteClassicScript("var = ;")).toMatchObject({ parsed: false });
|
|
89
|
+
// A sloppy program's own `this` and its functions' go through the helper.
|
|
90
|
+
expect(rewriteClassicScript("x = this; function f() { return this; }").code).not.toMatch(/[^(]this[^)]/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("the classic-script pipeline parses once when earlier rewrites change nothing, with the same result", () => {
|
|
94
|
+
const sources = [
|
|
95
|
+
"var a = 1; function f() { return this; }",
|
|
96
|
+
"var s = 'eval(1)'; for (var i = 0; i < 2; i++) {}",
|
|
97
|
+
"var g = (async () => {}).constructor; var t = this;",
|
|
98
|
+
"function h(x) { return eval(x); } var y = eval('2');",
|
|
99
|
+
"return 1;",
|
|
100
|
+
];
|
|
101
|
+
for (const source of sources) {
|
|
102
|
+
const stepwise = rewriteClassicScript(rewriteConstructorReads(rewriteDirectEval(source).code).code);
|
|
103
|
+
expect(rewriteClassicScriptSource(source)).toEqual(stepwise);
|
|
104
|
+
}
|
|
105
|
+
const direct = rewriteDirectEval("var s = 'eval(1)';");
|
|
106
|
+
expect([direct.rewritten, direct.program?.type]).toEqual([false, "Program"]);
|
|
107
|
+
expect(rewriteDirectEval("eval(x); return 1;").program).toBe(null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("global variable reads go through the script's global binding, keeping their semantics and text", () => {
|
|
111
|
+
const global = { Q: (x) => x + 1, n: 2, nothing: null };
|
|
112
|
+
global.$buninu$ref = referenceHelper(global);
|
|
113
|
+
const run = (source) => {
|
|
114
|
+
const { code } = rewriteClassicScript(source);
|
|
115
|
+
// Evaluated the way the page realm does: inside `with` scopes over the global.
|
|
116
|
+
return Function("g", `with (g) { return eval(arguments[1]); }`)(global, code.replace("globalThis", "g"));
|
|
117
|
+
};
|
|
118
|
+
expect(rewriteClassicScript("function f(x) { return Q(x) + n; }").code).toContain("($buninu$g.Q ?? $buninu$g.$buninu$ref(\"Q\"))");
|
|
119
|
+
expect(run("(function () { return nothing; })")()).toBe(null);
|
|
120
|
+
expect(run("(function (x) { return Q(x) + n; })")(1)).toBe(4);
|
|
121
|
+
expect(run("(function () { return [typeof missing, typeof Q]; })")()).toEqual(["undefined", "function"]);
|
|
122
|
+
expect(() => run("(function () { return missing; })")()).toThrow("missing is not defined");
|
|
123
|
+
// Locals, parameters and names inside the page's own `with` stay variables; writes stay identifiers.
|
|
124
|
+
const source = "function f(Q, o) { var n = 1; with (o) { k; } k = Q; k += n; return {n, Q, z: x => x + n}; }";
|
|
125
|
+
expect(rewriteClassicScript(source).code).not.toContain("$buninu$g.Q");
|
|
126
|
+
expect(rewriteClassicScript(source).code).not.toContain("$buninu$g.k");
|
|
127
|
+
// Shorthand properties keep their key; the page's text is restored exactly.
|
|
128
|
+
const shorthand = "function s() { return { Q, n, m: Q }; }";
|
|
129
|
+
expect(run(`(${shorthand})`)()).toEqual({ Q: global.Q, n: 2, m: global.Q });
|
|
130
|
+
const code = rewriteClassicScript(shorthand).code;
|
|
131
|
+
const own = code.slice(code.indexOf("function s"));
|
|
132
|
+
expect(restoreSourceText(own.slice(0, own.lastIndexOf("}") + 1))).toBe(shorthand);
|
|
133
|
+
expect(restoreSourceText("(function () { return typeof $buninu$g.Q; })")).toBe("(function () { return typeof Q; })");
|
|
134
|
+
// A block-level function outside functions (Annex B) leaves the script's identifiers as written.
|
|
135
|
+
expect(rewriteClassicScript("{ function b() {} } b(Q);").code).not.toContain("$buninu$g");
|
|
136
|
+
});
|
package/test/setup.js
ADDED
package/test/user-agent.test.js
CHANGED
|
@@ -2,7 +2,16 @@ import { expect, test } from "bun:test";
|
|
|
2
2
|
import { CdpServer } from "../lib/cdp-server.js";
|
|
3
3
|
import { createBrowser } from "../lib/headless-shell.js";
|
|
4
4
|
import { MOBILE_USER_AGENT } from "../lib/renderer/flags.js";
|
|
5
|
-
import { CHROMIUM_VERSION, DESKTOP_USER_AGENT, desktopUserAgent } from "../lib/user-agent.js";
|
|
5
|
+
import { BROWSER_PRODUCT, CHROMIUM_VERSION, DESKTOP_USER_AGENT, WEBKIT_USER_AGENT, desktopUserAgent, webkitFromArgs } from "../lib/user-agent.js";
|
|
6
|
+
|
|
7
|
+
test("CLI identity defaults to WebKit, last desktop flag wins and mobile takes priority", () => {
|
|
8
|
+
for (const [args, expected] of [
|
|
9
|
+
[[], true], [["--webkit"], true], [["--chromium"], false],
|
|
10
|
+
[["--webkit", "--chromium"], false], [["--chromium", "--webkit"], true],
|
|
11
|
+
[["--mobile"], false], [["--mobile", "--webkit"], false],
|
|
12
|
+
[["--webkit", "--mobile"], false], [["--mobile", "--chromium"], false],
|
|
13
|
+
]) expect(webkitFromArgs(args)).toBe(expected);
|
|
14
|
+
});
|
|
6
15
|
|
|
7
16
|
test("desktop user agent uses the same version on each platform", () => {
|
|
8
17
|
for (const [platform, arch, system] of [
|
|
@@ -19,17 +28,17 @@ test("mobile uses current Chrome for Android rather than WebView", () => {
|
|
|
19
28
|
expect(MOBILE_USER_AGENT).toBe(`Mozilla/5.0 (Linux; Android 16; ASUSAI2501C Build/BQ2A.250525.001) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROMIUM_VERSION} Mobile Safari/537.36`);
|
|
20
29
|
});
|
|
21
30
|
|
|
22
|
-
test("
|
|
31
|
+
test("Chromium opt-out reports the same identity through CDP, HTTP and navigator", async () => {
|
|
23
32
|
let requestUserAgent;
|
|
24
33
|
const site = Bun.serve({ port: 0, fetch(request) {
|
|
25
34
|
requestUserAgent = request.headers.get("user-agent");
|
|
26
35
|
return new Response("<!doctype html><title>UA test</title>", { headers: { "content-type": "text/html" } });
|
|
27
36
|
} });
|
|
28
|
-
const browser = await createBrowser({ spareRenderer: false });
|
|
37
|
+
const browser = await createBrowser({ spareRenderer: false, webkit: false });
|
|
29
38
|
const cdp = CdpServer.create(browser.context).listen(0, "127.0.0.1");
|
|
30
39
|
try {
|
|
31
40
|
const version = await fetch(`http://127.0.0.1:${cdp.port}/json/version`).then((response) => response.json());
|
|
32
|
-
expect(version.Browser).toBe(
|
|
41
|
+
expect(version.Browser).toBe(BROWSER_PRODUCT);
|
|
33
42
|
expect(version["User-Agent"]).toBe(DESKTOP_USER_AGENT);
|
|
34
43
|
await browser.context.navigate(`http://127.0.0.1:${site.port}/`);
|
|
35
44
|
expect(requestUserAgent).toBe(DESKTOP_USER_AGENT);
|
|
@@ -40,3 +49,50 @@ test("CDP discovery, HTTP requests, and navigator report the same desktop user a
|
|
|
40
49
|
site.stop(true);
|
|
41
50
|
}
|
|
42
51
|
});
|
|
52
|
+
|
|
53
|
+
test.each([{}, { webkit: true }])("default and explicit WebKit identity is consistent across CDP, requests and navigator: %j", async (options) => {
|
|
54
|
+
let headers;
|
|
55
|
+
const site = Bun.serve({ port: 0, fetch(request) {
|
|
56
|
+
headers = request.headers;
|
|
57
|
+
return new Response("<!doctype html><title>identity</title>", { headers: { "content-type": "text/html" } });
|
|
58
|
+
} });
|
|
59
|
+
const browser = await createBrowser({ spareRenderer: false, ...options });
|
|
60
|
+
const cdp = CdpServer.create(browser.context).listen(0, "127.0.0.1");
|
|
61
|
+
try {
|
|
62
|
+
// Default and explicit WebKit profiles both resist a CDP client's Chrome UA.
|
|
63
|
+
await browser.context.setUserAgent(DESKTOP_USER_AGENT);
|
|
64
|
+
const version = await fetch(`http://127.0.0.1:${cdp.port}/json/version`).then(r => r.json());
|
|
65
|
+
expect(version.Browser).toBe(BROWSER_PRODUCT);
|
|
66
|
+
expect(version["User-Agent"]).toBe(WEBKIT_USER_AGENT);
|
|
67
|
+
await browser.context.navigate(`http://127.0.0.1:${site.port}/`);
|
|
68
|
+
expect(headers.get("user-agent")).toBe(WEBKIT_USER_AGENT);
|
|
69
|
+
expect(headers.has("sec-ch-ua")).toBe(false);
|
|
70
|
+
expect(await browser.context.evaluate(`[
|
|
71
|
+
navigator.userAgent, navigator.vendor, navigator.platform,
|
|
72
|
+
'userAgentData' in navigator, typeof NavigatorUAData, typeof window.chrome,
|
|
73
|
+
]`)).toEqual([WEBKIT_USER_AGENT, "Apple Computer, Inc.", "MacIntel", false, "undefined", "undefined"]);
|
|
74
|
+
} finally {
|
|
75
|
+
cdp.stop(true);
|
|
76
|
+
browser.close();
|
|
77
|
+
site.stop(true);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("scripts added for new documents run before the page's own scripts, in every later document", async () => {
|
|
82
|
+
const site = Bun.serve({ port: 0, fetch: () => new Response(
|
|
83
|
+
"<!doctype html><script>window.seenByPage = window.early;</script>",
|
|
84
|
+
{ headers: { "content-type": "text/html" } },
|
|
85
|
+
) });
|
|
86
|
+
const browser = await createBrowser({ spareRenderer: false });
|
|
87
|
+
try {
|
|
88
|
+
const first = browser.context.addInitScript("window.early = (window.early ?? 0) + 1;");
|
|
89
|
+
await browser.context.navigate(`http://127.0.0.1:${site.port}/`);
|
|
90
|
+
expect(await browser.context.evaluate("window.seenByPage")).toBe(1);
|
|
91
|
+
browser.context.removeInitScript(first);
|
|
92
|
+
await browser.context.navigate(`http://127.0.0.1:${site.port}/again`);
|
|
93
|
+
expect(await browser.context.evaluate("window.seenByPage")).toBe(undefined);
|
|
94
|
+
} finally {
|
|
95
|
+
browser.close();
|
|
96
|
+
site.stop(true);
|
|
97
|
+
}
|
|
98
|
+
});
|