buninu-browser 0.2.3 → 0.2.4
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 +47 -14
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +15 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +9 -6
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +213 -91
- package/lib/media/byte-pipe.js +2 -2
- package/lib/media/position-audio.js +10 -1
- package/lib/media/source-snapshots.js +126 -0
- package/lib/media/video-keyframe.js +17 -4
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +8 -28
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +42 -13
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +38 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +233 -80
- package/lib/renderer/broadcast-channel.js +76 -0
- package/lib/renderer/canvas-2d.js +257 -14
- package/lib/renderer/canvas-text.js +120 -0
- package/lib/renderer/compositor.js +13 -11
- package/lib/renderer/custom-element-reactions.js +25 -1
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/host.js +28 -4
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +30 -4
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +270 -98
- package/lib/renderer/page-renderer.js +647 -235
- package/lib/renderer/prepared-script-cache.js +50 -0
- package/lib/renderer/rendered-text.js +108 -0
- package/lib/renderer/rewrite-worker.js +4 -3
- package/lib/renderer/script-rewrite.js +13 -2
- package/lib/renderer/scroll-into-view.js +28 -0
- package/lib/renderer/slot-assignment.js +60 -0
- package/lib/renderer/video-presentation.js +23 -4
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +611 -104
- package/lib/style/selector-traits.js +49 -5
- package/package.json +1 -1
- package/test/broadcast-channel.test.js +26 -0
- package/test/canvas-2d.test.js +83 -2
- package/test/canvas-paint.test.js +9 -0
- package/test/canvas-text.test.js +41 -0
- package/test/cdp-stream.test.js +56 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +258 -1
- package/test/display-list.test.js +12 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/canvas-paint.js +117 -0
- package/test/fixtures/canvas-text.js +42 -0
- package/test/fixtures/image-lifetime.js +90 -0
- package/test/fixtures/media-seek-snapshot.js +77 -0
- package/test/fixtures/mse-audio-chirp.webm +0 -0
- package/test/fixtures/mse-switch-large.mp4 +0 -0
- package/test/fixtures/mse-switch-small.mp4 +0 -0
- package/test/fixtures/renderer-memory.js +110 -0
- package/test/fixtures/summary-style.js +28 -0
- package/test/font-loading.test.js +97 -0
- package/test/grid.test.js +58 -0
- package/test/hit-test.test.js +21 -1
- package/test/image-lifetime.test.js +12 -0
- package/test/image-resources.test.js +94 -0
- package/test/local-media.test.js +84 -0
- package/test/media-seek-snapshot.test.js +11 -0
- package/test/media-source-config.test.js +98 -0
- package/test/media-source-webm-accounting.test.js +98 -0
- package/test/media-source-webm.test.js +108 -0
- package/test/navigation.test.js +1 -0
- package/test/network-service.test.js +388 -0
- package/test/page-scripts.test.js +1 -1
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/position-audio.test.js +24 -0
- package/test/positioning.test.js +22 -0
- package/test/prepared-script-cache.test.js +45 -0
- package/test/pulse-client.test.js +10 -3
- package/test/raster-readback.test.js +40 -0
- package/test/render-tree.test.js +28 -0
- package/test/renderer-memory.test.js +11 -0
- package/test/selector-traits.test.js +24 -0
- package/test/shared-frame-pool.test.js +63 -0
- package/test/style-sheets.test.js +51 -0
- package/test/svg.test.js +29 -0
- package/test/text-layout.test.js +106 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +69 -0
- package/tools/browser-lab.js +78 -0
- package/tools/browser-soak.js +75 -0
- package/tools/network-reference.js +49 -0
- package/tools/process-memory.js +38 -0
- package/tools/script-benchmark.js +25 -0
- package/ai-debug.log +0 -5537
|
@@ -53,6 +53,142 @@ afterAll(() => server?.stop(true));
|
|
|
53
53
|
const local = (path) => `http://localhost:${server.port}${path}`;
|
|
54
54
|
const loopback = (path) => `http://127.0.0.1:${server.port}${path}`;
|
|
55
55
|
|
|
56
|
+
test("navigation metadata distinguishes browser UI from page-initiated navigation", async () => {
|
|
57
|
+
const calls = [];
|
|
58
|
+
const network = new NetworkService({ fetch: async (url, init) => {
|
|
59
|
+
calls.push({ url, headers: new Headers(init.headers) });
|
|
60
|
+
return url.endsWith("/start") ? new Response(null, { status: 302, headers: { location: "https://other.test/end" } })
|
|
61
|
+
: new Response("ok");
|
|
62
|
+
} });
|
|
63
|
+
await network.navigate("https://example.test/start");
|
|
64
|
+
for (const { headers } of calls) {
|
|
65
|
+
expect(headers.get("sec-fetch-mode")).toBe("navigate");
|
|
66
|
+
expect(headers.get("sec-fetch-dest")).toBe("document");
|
|
67
|
+
expect(headers.get("sec-fetch-site")).toBe("none");
|
|
68
|
+
expect(headers.has("sec-fetch-user")).toBeFalse();
|
|
69
|
+
}
|
|
70
|
+
calls.length = 0;
|
|
71
|
+
await network.navigate("https://example.test/end", { referrer: "https://example.test/page#section" });
|
|
72
|
+
expect(calls[0].headers.get("sec-fetch-site")).toBe("same-origin");
|
|
73
|
+
expect(calls[0].headers.get("referer")).toBe("https://example.test/page");
|
|
74
|
+
await network.navigate("https://example.test:8443/end", { referrer: "https://example.test/page" });
|
|
75
|
+
expect(calls[1].headers.get("sec-fetch-site")).toBe("same-site");
|
|
76
|
+
expect(calls[1].headers.get("referer")).toBe("https://example.test/");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("resource destinations are separated while referrer-independent cache entries survive navigation", async () => {
|
|
80
|
+
const calls = [];
|
|
81
|
+
const network = new NetworkService({ fetch: async (url, init) => {
|
|
82
|
+
const headers = new Headers(init.headers);
|
|
83
|
+
calls.push(headers);
|
|
84
|
+
return new Response(headers.get("sec-fetch-dest"), { headers: { "cache-control": "max-age=60" } });
|
|
85
|
+
} });
|
|
86
|
+
await network.subresource("https://example.test/asset", "https://example.test/page", "script");
|
|
87
|
+
await network.subresource("https://example.test/asset", "https://example.test/page", "image");
|
|
88
|
+
await network.subresource("https://example.test/asset", "https://example.test/other", "image");
|
|
89
|
+
await network.subresource("https://example.test/asset", "https://example.test/other", "image");
|
|
90
|
+
expect(calls).toHaveLength(2);
|
|
91
|
+
expect(calls.map(headers => headers.get("sec-fetch-dest"))).toEqual(["script", "image"]);
|
|
92
|
+
expect(calls[0].get("sec-fetch-mode")).toBe("no-cors");
|
|
93
|
+
expect(calls[1].get("referer")).toBe("https://example.test/page");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("Vary: Referer isolates representations and validators across documents", async () => {
|
|
97
|
+
const calls = [];
|
|
98
|
+
const network = new NetworkService({ fetch: async (url, init) => {
|
|
99
|
+
const headers = new Headers(init.headers); calls.push(headers);
|
|
100
|
+
return new Response(headers.get("referer"), { headers: { "cache-control": "max-age=60", vary: "Referer", etag: '"page"' } });
|
|
101
|
+
} });
|
|
102
|
+
await network.subresource("https://example.test/image", "https://example.test/one", "image");
|
|
103
|
+
const other = await network.subresource("https://example.test/image", "https://example.test/two", "image");
|
|
104
|
+
await network.subresource("https://example.test/image", "https://example.test/two", "image");
|
|
105
|
+
expect(calls).toHaveLength(2);
|
|
106
|
+
expect(calls[1].has("if-none-match")).toBeFalse();
|
|
107
|
+
expect(new TextDecoder().decode(other.body)).toBe("https://example.test/two");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a cacheable final body does not cache a credential-dependent redirect", async () => {
|
|
111
|
+
const calls = [];
|
|
112
|
+
const network = new NetworkService({ fetch: async (url, init) => {
|
|
113
|
+
calls.push(url);
|
|
114
|
+
if (url.endsWith('/entry')) return new Response(null, { status:302, headers:{location:'https://asset.test/final', 'cache-control':'no-store'} });
|
|
115
|
+
return new Response(new Headers(init.headers).get('cookie') ?? 'anonymous', { headers:{'cache-control':'max-age=60'} });
|
|
116
|
+
}});
|
|
117
|
+
expect(new TextDecoder().decode((await network.subresource('https://page.test/entry','https://page.test/')).body)).toBe('anonymous');
|
|
118
|
+
network.setDocumentCookie('https://asset.test/','session=changed; SameSite=None; Secure');
|
|
119
|
+
expect(new TextDecoder().decode((await network.subresource('https://page.test/entry','https://page.test/')).body)).toBe('session=changed');
|
|
120
|
+
expect(calls.length).toBe(4);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("CORS header value rules distinguish simple values from preflighted ones", async () => {
|
|
124
|
+
const calls = [];
|
|
125
|
+
const network = new NetworkService({ fetch: async (url, init) => {
|
|
126
|
+
const headers = new Headers(init.headers); calls.push({method:init.method, headers});
|
|
127
|
+
return new Response('ok', {headers:{'access-control-allow-origin':'*','access-control-allow-headers':'*','access-control-max-age':'0'}});
|
|
128
|
+
}});
|
|
129
|
+
const check = async (name, value, preflight) => {
|
|
130
|
+
calls.length = 0;
|
|
131
|
+
await network.pageFetch({url:'https://asset.test/api', headers:[[name,value]]},'https://page.test/');
|
|
132
|
+
expect(calls.map(call=>call.method)).toEqual(preflight ? ['OPTIONS','GET'] : ['GET']);
|
|
133
|
+
if (preflight) expect(calls[0].headers.get('access-control-request-headers')).toBe(name);
|
|
134
|
+
};
|
|
135
|
+
await check('accept','text/html, */*;q=0.8',false);
|
|
136
|
+
await check('accept','text/html;variant="wide"',true);
|
|
137
|
+
await check('accept-language','en-US, zh-TW;q=0.8',false);
|
|
138
|
+
await check('accept-language','en_US',true);
|
|
139
|
+
await check('content-type','text/plain;charset=utf-8',false);
|
|
140
|
+
await check('content-type','text/plain;charset="utf-8"',true);
|
|
141
|
+
await check('content-type','text/plain;'+ 'a'.repeat(128),true);
|
|
142
|
+
await check('range','bytes=0-99',false);
|
|
143
|
+
await check('range','bytes=100-',false);
|
|
144
|
+
await check('range','bytes=-99',true);
|
|
145
|
+
await check('range','bytes=100-99',true);
|
|
146
|
+
await check('range','bytes=0-99,200-299',true);
|
|
147
|
+
calls.length=0;
|
|
148
|
+
await network.pageFetch({url:'https://page.test/api',mode:'no-cors',headers:[['range','bytes=0-99'],['accept-language','en_US'],['accept','text/plain']]},'https://page.test/');
|
|
149
|
+
expect(calls[0].headers.get('range')).toBeNull();
|
|
150
|
+
expect(calls[0].headers.get('accept-language')).toBeNull();
|
|
151
|
+
expect(calls[0].headers.get('accept')).toBe('text/plain');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("Fetch Metadata is trustworthy-only and cannot be forged by page headers", async () => {
|
|
155
|
+
const calls = [];
|
|
156
|
+
const network = new NetworkService({ fetch: async (url, init) => {
|
|
157
|
+
calls.push(new Headers(init.headers));
|
|
158
|
+
return new Response("ok");
|
|
159
|
+
} });
|
|
160
|
+
await network.pageFetch({ url: "https://example.test/api", mode: "same-origin",
|
|
161
|
+
headers: [["sec-fetch-mode", "navigate"], ["sec-fetch-site", "none"], ["sec-fetch-user", "?1"]] }, "https://example.test/page");
|
|
162
|
+
expect(calls[0].get("sec-fetch-mode")).toBe("same-origin");
|
|
163
|
+
expect(calls[0].get("sec-fetch-site")).toBe("same-origin");
|
|
164
|
+
expect(calls[0].get("sec-fetch-dest")).toBe("empty");
|
|
165
|
+
expect(calls[0].has("sec-fetch-user")).toBeFalse();
|
|
166
|
+
await network.navigate("http://example.test/page");
|
|
167
|
+
expect(calls[1].has("sec-fetch-mode")).toBeFalse();
|
|
168
|
+
await network.navigate("http://localhost/page");
|
|
169
|
+
expect(calls[2].get("sec-fetch-mode")).toBe("navigate");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a redirect cannot regain same-origin metadata or leak a full referrer", async () => {
|
|
173
|
+
const calls = [];
|
|
174
|
+
const network = new NetworkService({ fetch: async (url, init) => {
|
|
175
|
+
calls.push(new Headers(init.headers));
|
|
176
|
+
const redirects = { "https://example.test/start": "http://localhost/middle", "http://localhost/middle": "https://example.test/end" };
|
|
177
|
+
return redirects[url] ? new Response(null, { status: 302, headers: { location: redirects[url] } }) : new Response("ok");
|
|
178
|
+
} });
|
|
179
|
+
await network.navigate("https://example.test/start", { referrer: "https://example.test/private#anchor" });
|
|
180
|
+
expect(calls.map(headers => headers.get("sec-fetch-site"))).toEqual(["same-origin", "cross-site", "cross-site"]);
|
|
181
|
+
// Loopback HTTP is potentially trustworthy. A cross-origin hop trims the
|
|
182
|
+
// path; returning to the original origin must not restore it.
|
|
183
|
+
expect(calls[1].get("referer")).toBe("https://example.test/");
|
|
184
|
+
expect(calls[2].get("referer")).toBe("https://example.test/");
|
|
185
|
+
calls.length = 0;
|
|
186
|
+
await network.navigate("https://other.test/end", { post: { body: "a=b", contentType: "application/x-www-form-urlencoded",
|
|
187
|
+
referrer: "https://example.test/private#anchor" } });
|
|
188
|
+
expect(calls[0].get("referer")).toBe("https://example.test/");
|
|
189
|
+
expect(calls[0].get("origin")).toBe("https://example.test");
|
|
190
|
+
});
|
|
191
|
+
|
|
56
192
|
test("follows redirects by hand and stores cookies on every hop", async () => {
|
|
57
193
|
seen = [];
|
|
58
194
|
const network = new NetworkService();
|
|
@@ -293,3 +429,255 @@ test("/json/new opens a target on PUT only", async () => {
|
|
|
293
429
|
cdp.stop(true);
|
|
294
430
|
}
|
|
295
431
|
});
|
|
432
|
+
|
|
433
|
+
test("revalidation updates cached headers and validator; no-cache overrides max-age", async () => {
|
|
434
|
+
const validators = [];
|
|
435
|
+
const network = new NetworkService({ fetch: async (_, init) => {
|
|
436
|
+
const etag = new Headers(init.headers).get('if-none-match'); validators.push(etag);
|
|
437
|
+
if (validators.length === 1) return new Response('body', { headers: { etag: '"v1"', 'cache-control': 'max-age=60, no-cache' } });
|
|
438
|
+
return new Response(null, { status: 304, headers: { etag: '"v2"', 'x-version': '2' } });
|
|
439
|
+
} });
|
|
440
|
+
for (let i = 0; i < 3; i++) {
|
|
441
|
+
const r = await network.subresource('https://a.test/style.css','https://a.test/');
|
|
442
|
+
expect(new TextDecoder().decode(r.body)).toBe('body');
|
|
443
|
+
if (i) expect(r.headers).toContainEqual(['x-version','2']);
|
|
444
|
+
}
|
|
445
|
+
expect(validators).toEqual([null,'"v1"','"v2"']);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("Last-Modified validates without ETag; Vary:* and no-store responses are never retained", async () => {
|
|
449
|
+
const seen = [];
|
|
450
|
+
const network = new NetworkService({ fetch: async (url, init) => {
|
|
451
|
+
seen.push([url,new Headers(init.headers).get('if-modified-since')]);
|
|
452
|
+
if (url.endsWith('/modified')) return new Response('x',{headers:{'last-modified':'Wed, 01 Jan 2025 00:00:00 GMT'}});
|
|
453
|
+
return new Response('x',{headers:{'cache-control':url.endsWith('/star')?'max-age=60':'no-store, max-age=60',vary:'*'}});
|
|
454
|
+
} });
|
|
455
|
+
for (const path of ['modified','star','store']) for(let i=0;i<2;i++)await network.subresource(`https://a.test/${path}`,'https://a.test/');
|
|
456
|
+
expect(seen).toHaveLength(6);
|
|
457
|
+
expect(seen[1][1]).toBe('Wed, 01 Jan 2025 00:00:00 GMT');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("cache byte budget evicts least-recently-used data and does not retain oversized bodies", async () => {
|
|
461
|
+
const calls=[];
|
|
462
|
+
const network=new NetworkService({maxSubresourceCacheBytes:8,fetch:async url=>{
|
|
463
|
+
calls.push(url);return new Response(url.endsWith('/large')?'0123456789':'1234',{headers:{'cache-control':'max-age=60'}});
|
|
464
|
+
}});
|
|
465
|
+
const get=path=>network.subresource('https://a.test/'+path,'https://a.test/');
|
|
466
|
+
await get('a');await get('b');await get('a');await get('c');await get('a');await get('b');
|
|
467
|
+
expect(calls.map(u=>u.split('/').at(-1))).toEqual(['a','b','c','b']);
|
|
468
|
+
await get('large');await get('large');expect(calls.filter(u=>u.endsWith('/large'))).toHaveLength(2);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("Age and Date reduce freshness, and immutable alone does not invent a lifetime", async () => {
|
|
472
|
+
let calls=0;
|
|
473
|
+
const network=new NetworkService({fetch:async url=>{
|
|
474
|
+
calls++;return new Response('x',{headers:url.endsWith('/immutable')?{'cache-control':'immutable'}:{'cache-control':'max-age=60',age:'120'}});
|
|
475
|
+
}});
|
|
476
|
+
for(const path of ['aged','immutable'])for(let i=0;i<2;i++)await network.subresource(`https://a.test/${path}`,'https://a.test/');
|
|
477
|
+
expect(calls).toBe(4);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("aborting a queued page fetch releases it without waiting for busy host slots", async () => {
|
|
481
|
+
const gates=[];const calls=[];
|
|
482
|
+
const network=new NetworkService({fetch:async url=>{
|
|
483
|
+
calls.push(url);await new Promise(resolve=>gates.push(resolve));return new Response('ok');
|
|
484
|
+
}});
|
|
485
|
+
const document='https://busy.test/';
|
|
486
|
+
const active=Array.from({length:6},(_,i)=>network.pageFetch({url:document+i},document));
|
|
487
|
+
while(gates.length<6)await Bun.sleep(1);
|
|
488
|
+
const controller=new AbortController();
|
|
489
|
+
const queued=network.pageFetch({url:document+'aborted'},document,{signal:controller.signal});
|
|
490
|
+
controller.abort(new Error('cancelled navigation'));
|
|
491
|
+
await expect(queued).rejects.toThrow('cancelled navigation');
|
|
492
|
+
expect(calls).toHaveLength(6);
|
|
493
|
+
for(const finish of gates)finish();await Promise.all(active);
|
|
494
|
+
expect(calls.some(url=>url.endsWith('/aborted'))).toBe(false);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("successful CORS preflights reuse permission without caching API responses or crossing origins", async () => {
|
|
498
|
+
const calls=[];
|
|
499
|
+
const network=new NetworkService({fetch:async (url,init)=>{
|
|
500
|
+
const headers=new Headers(init.headers);calls.push([url,init.method,headers.get('access-control-request-headers')]);
|
|
501
|
+
return new Response(init.method==='OPTIONS'?null:'live '+calls.length,{headers:{
|
|
502
|
+
'access-control-allow-origin':headers.get('origin'), 'access-control-allow-credentials':'true',
|
|
503
|
+
'access-control-allow-methods':'PUT, DELETE','access-control-allow-headers':'x-token,x-extra',
|
|
504
|
+
'access-control-max-age':'60',
|
|
505
|
+
}});
|
|
506
|
+
}});
|
|
507
|
+
const request={url:'https://api.test/items',method:'PUT',headers:[['x-token','one']]};
|
|
508
|
+
const first=await network.pageFetch(request,'https://page.test/');
|
|
509
|
+
const second=await network.pageFetch({...request,headers:[['x-token','two']]},'https://page.test/next');
|
|
510
|
+
expect(new TextDecoder().decode(first.body)).not.toBe(new TextDecoder().decode(second.body));
|
|
511
|
+
expect(calls.map(c=>c[1])).toEqual(['OPTIONS','PUT','PUT']);
|
|
512
|
+
await network.pageFetch(request,'https://other.test/');
|
|
513
|
+
await network.pageFetch({...request,credentials:'include'},'https://page.test/');
|
|
514
|
+
await network.pageFetch({...request,url:request.url+'/other'},'https://page.test/');
|
|
515
|
+
await network.pageFetch({...request,method:'DELETE'},'https://page.test/');
|
|
516
|
+
await network.pageFetch({...request,headers:[...request.headers,['x-extra','value']]},'https://page.test/');
|
|
517
|
+
expect(calls.filter(c=>c[1]==='OPTIONS')).toHaveLength(6);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("CORS preflight grants expire and max-age zero does not cache", async () => {
|
|
521
|
+
let preflights=0;
|
|
522
|
+
const network=new NetworkService({fetch:async (url,init)=>{
|
|
523
|
+
if(init.method==='OPTIONS')preflights++;
|
|
524
|
+
return new Response(null,{headers:{'access-control-allow-origin':'*','access-control-allow-methods':'PUT',
|
|
525
|
+
'access-control-max-age':url.endsWith('zero')?'0':'1'}});
|
|
526
|
+
}});
|
|
527
|
+
const get=path=>network.pageFetch({url:'https://api.test/'+path,method:'PUT'},'https://page.test/');
|
|
528
|
+
await get('one');await get('one');expect(preflights).toBe(1);
|
|
529
|
+
await Bun.sleep(1050);await get('one');expect(preflights).toBe(2);
|
|
530
|
+
await get('zero');await get('zero');expect(preflights).toBe(4);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("wildcard CORS permissions do not authorize Authorization, and denied grants are not cached", async () => {
|
|
534
|
+
let calls=0;
|
|
535
|
+
const network=new NetworkService({fetch:async()=>{calls++;return new Response(null,{headers:{
|
|
536
|
+
'access-control-allow-origin':'*','access-control-allow-headers':'*','access-control-max-age':'60',
|
|
537
|
+
}});}});
|
|
538
|
+
const request={url:'https://api.test/',headers:[['Authorization','Bearer example']]};
|
|
539
|
+
for(let i=0;i<2;i++)await expect(network.pageFetch(request,'https://page.test/')).rejects.toThrow('does not allow header authorization');
|
|
540
|
+
expect(calls).toBe(2);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("an aborted CORS preflight does not send the API request or cache a grant",async()=>{
|
|
544
|
+
let calls=0;
|
|
545
|
+
const network=new NetworkService({fetch:async(_,init)=>{
|
|
546
|
+
calls++;await new Promise((resolve,reject)=>init.signal.addEventListener('abort',()=>reject(init.signal.reason),{once:true}));
|
|
547
|
+
}});
|
|
548
|
+
const controller=new AbortController();
|
|
549
|
+
const pending=network.pageFetch({url:'https://api.test/',method:'PUT'},'https://page.test/',{signal:controller.signal});
|
|
550
|
+
while(calls===0)await Bun.sleep(1);
|
|
551
|
+
controller.abort(new Error('cancelled preflight'));
|
|
552
|
+
await expect(pending).rejects.toThrow('cancelled preflight');expect(calls).toBe(1);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("aborting a preflight after headers arrive does not retain its permission grant", async () => {
|
|
556
|
+
const calls = [];
|
|
557
|
+
let headersArrived = false;
|
|
558
|
+
const network = new NetworkService({ fetch: async (url, init) => {
|
|
559
|
+
calls.push(init.method);
|
|
560
|
+
const body = calls.length === 1 ? new ReadableStream({ start(controller) {
|
|
561
|
+
headersArrived = true;
|
|
562
|
+
init.signal.addEventListener('abort', () => controller.error(init.signal.reason), {once:true});
|
|
563
|
+
}}) : null;
|
|
564
|
+
return new Response(body,{headers:{'access-control-allow-origin':'*','access-control-allow-methods':'PUT','access-control-max-age':'60'}});
|
|
565
|
+
}});
|
|
566
|
+
const controller = new AbortController();
|
|
567
|
+
const request = {url:'https://api.test/',method:'PUT'};
|
|
568
|
+
const pending = network.pageFetch(request,'https://page.test/',{signal:controller.signal});
|
|
569
|
+
while (!headersArrived) await Bun.sleep(1);
|
|
570
|
+
controller.abort(new Error('cancelled after headers'));
|
|
571
|
+
await expect(pending).rejects.toThrow('cancelled after headers');
|
|
572
|
+
await network.pageFetch(request,'https://page.test/');
|
|
573
|
+
expect(calls).toEqual(['OPTIONS','OPTIONS','PUT']);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("same-origin fetch redirects still enforce CORS, credentials and response-header filtering", async () => {
|
|
577
|
+
const calls = [];
|
|
578
|
+
let allowed = false;
|
|
579
|
+
const network = new NetworkService({ fetch: async (url, init) => {
|
|
580
|
+
const headers = new Headers(init.headers); calls.push({ url, headers });
|
|
581
|
+
if (url === "https://source.test/start") return new Response(null, { status: 302, headers: { location: "https://target.test/data" } });
|
|
582
|
+
return new Response("public", { headers: { ...(allowed ? { "access-control-allow-origin": "https://source.test" } : {}),
|
|
583
|
+
"x-secret": "hidden", "x-public": "visible", "access-control-expose-headers": "x-public" } });
|
|
584
|
+
} });
|
|
585
|
+
network.cookies.store("https://target.test/", ["secret=credential; Secure; SameSite=None"]);
|
|
586
|
+
await expect(network.pageFetch({ url: "https://source.test/start" }, "https://source.test/page")).rejects.toThrow("CORS");
|
|
587
|
+
expect(calls[1].headers.get("cookie")).toBeNull();
|
|
588
|
+
expect(calls[1].headers.get("origin")).toBe("https://source.test");
|
|
589
|
+
allowed = true;
|
|
590
|
+
const response = await network.pageFetch({ url: "https://source.test/start" }, "https://source.test/page");
|
|
591
|
+
expect(response.type).toBe("cors");
|
|
592
|
+
expect(new Headers(response.headers).get("x-secret")).toBeNull();
|
|
593
|
+
expect(new Headers(response.headers).get("x-public")).toBe("visible");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("same-origin mode blocks a cross-origin redirect before contacting its target", async () => {
|
|
597
|
+
const calls = [];
|
|
598
|
+
const network = new NetworkService({ fetch: async url => {
|
|
599
|
+
calls.push(url);
|
|
600
|
+
return new Response(null, { status: 302, headers: { location: "https://target.test/private" } });
|
|
601
|
+
} });
|
|
602
|
+
await expect(network.pageFetch({ url: "https://source.test/start", mode: "same-origin" }, "https://source.test/page"))
|
|
603
|
+
.rejects.toThrow("mode is same-origin");
|
|
604
|
+
expect(calls).toEqual(["https://source.test/start"]);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("no-cors fetches become opaque when a redirect crosses origins", async () => {
|
|
608
|
+
const network = new NetworkService({ fetch: async url => url.includes("source.test")
|
|
609
|
+
? new Response(null, { status: 302, headers: { location: "https://target.test/private" } })
|
|
610
|
+
: new Response("private", { headers: { "x-private": "secret" } }) });
|
|
611
|
+
const result = await network.pageFetch({ url: "https://source.test/start", mode: "no-cors" }, "https://source.test/page");
|
|
612
|
+
expect(result).toMatchObject({ type: "opaque", status: 0, url: "", headers: [], redirected: false });
|
|
613
|
+
expect(result.body.byteLength).toBe(0);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("redirected non-simple requests preflight the new origin and strip Authorization", async () => {
|
|
617
|
+
const calls = [];
|
|
618
|
+
const network = new NetworkService({ fetch: async (url, init) => {
|
|
619
|
+
const headers = new Headers(init.headers); calls.push({ url, method: init.method, headers });
|
|
620
|
+
if (url.includes("source.test")) return new Response(null, { status: 307, headers: { location: "https://target.test/api" } });
|
|
621
|
+
return new Response(null, { status: 204, headers: { "access-control-allow-origin": "https://source.test",
|
|
622
|
+
"access-control-allow-methods": "PUT", "access-control-allow-headers": "x-custom" } });
|
|
623
|
+
} });
|
|
624
|
+
await network.pageFetch({ url: "https://source.test/start", method: "PUT", body: new TextEncoder().encode("body"),
|
|
625
|
+
headers: [["authorization", "Bearer private"], ["x-custom", "value"]] }, "https://source.test/page");
|
|
626
|
+
expect(calls.map(call => call.method)).toEqual(["PUT", "OPTIONS", "PUT"]);
|
|
627
|
+
expect(calls[0].headers.get("authorization")).toBe("Bearer private");
|
|
628
|
+
expect(calls[1].headers.get("access-control-request-headers")).toBe("x-custom");
|
|
629
|
+
expect(calls[2].headers.has("authorization")).toBeFalse();
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("a cross-origin redirect chain checks each hop and taints subsequent Origin", async () => {
|
|
633
|
+
const calls = [];
|
|
634
|
+
const network = new NetworkService({ fetch: async (url, init) => {
|
|
635
|
+
const headers = new Headers(init.headers); calls.push({ url, headers });
|
|
636
|
+
if (url === "https://target.test/start") return new Response(null, { status: 302, headers: {
|
|
637
|
+
location: "https://final.test/end", "access-control-allow-origin": "https://source.test" } });
|
|
638
|
+
return new Response("done", { headers: { "access-control-allow-origin": "null" } });
|
|
639
|
+
} });
|
|
640
|
+
const result = await network.pageFetch({ url: "https://target.test/start" }, "https://source.test/page");
|
|
641
|
+
expect(result.type).toBe("cors");
|
|
642
|
+
expect(calls.map(call => call.headers.get("origin"))).toEqual(["https://source.test", "null"]);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test("canceling one coalesced resource consumer preserves other consumers", async () => {
|
|
646
|
+
let requests = 0, underlyingAborted = false, complete;
|
|
647
|
+
const network = new NetworkService({ fetch: async (url, init) => {
|
|
648
|
+
requests++;
|
|
649
|
+
return new Promise((resolve, reject) => {
|
|
650
|
+
complete = () => resolve(new Response("shared image"));
|
|
651
|
+
init.signal.addEventListener("abort", () => { underlyingAborted = true; reject(init.signal.reason); }, { once: true });
|
|
652
|
+
});
|
|
653
|
+
} });
|
|
654
|
+
const first = new AbortController(), second = new AbortController();
|
|
655
|
+
const one = network.subresource("https://example.test/image", "https://example.test/page", "image", { signal: first.signal });
|
|
656
|
+
const two = network.subresource("https://example.test/image", "https://example.test/page", "image", { signal: second.signal });
|
|
657
|
+
await Bun.sleep(0);
|
|
658
|
+
first.abort("first document closed");
|
|
659
|
+
await expect(one).rejects.toBe("first document closed");
|
|
660
|
+
expect(underlyingAborted).toBeFalse();
|
|
661
|
+
complete();
|
|
662
|
+
expect(new TextDecoder().decode((await two).body)).toBe("shared image");
|
|
663
|
+
expect(requests).toBe(1);
|
|
664
|
+
expect(underlyingAborted).toBeFalse();
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("the last resource consumer aborts its transfer and a later consumer starts fresh", async () => {
|
|
668
|
+
let requests = 0, aborts = 0;
|
|
669
|
+
const network = new NetworkService({ fetch: async (url, init) => {
|
|
670
|
+
if (++requests === 2) return new Response("fresh");
|
|
671
|
+
return new Promise((resolve, reject) => init.signal.addEventListener("abort", () => {
|
|
672
|
+
aborts++; reject(init.signal.reason);
|
|
673
|
+
}, { once: true }));
|
|
674
|
+
} });
|
|
675
|
+
const controller = new AbortController();
|
|
676
|
+
const pending = network.subresource("https://example.test/image", "https://example.test/page", "image", { signal: controller.signal });
|
|
677
|
+
await Bun.sleep(0);
|
|
678
|
+
controller.abort("document closed");
|
|
679
|
+
await expect(pending).rejects.toBe("document closed");
|
|
680
|
+
const fresh = await network.subresource("https://example.test/image", "https://example.test/page", "image");
|
|
681
|
+
expect(new TextDecoder().decode(fresh.body)).toBe("fresh");
|
|
682
|
+
expect([requests, aborts]).toEqual([2, 1]);
|
|
683
|
+
});
|
|
@@ -1216,7 +1216,7 @@ test("ES modules: graphs, live exports, JSON, import.meta, dynamic import, TLA,
|
|
|
1216
1216
|
await load(`
|
|
1217
1217
|
<script type="importmap">{ "imports": { "cdn-lib": "https://cdn.test/lib/index.js" } }</script>
|
|
1218
1218
|
<script type="module" src="/modules/main.js"></script>
|
|
1219
|
-
<script type="module">window.inlineModule = import.meta.url.
|
|
1219
|
+
<script type="module">window.inlineModule = import.meta.url === document.baseURI;</script>`);
|
|
1220
1220
|
expect(await evaluate("moduleResult")).toBe([
|
|
1221
1221
|
// Imports are live bindings, named and namespace alike.
|
|
1222
1222
|
"named:2", "namespace:2", "twice:42", "json:42", "loaded lazily from lazy.js",
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { cropViewport, encodeImage, encodeImageBase64 } from '../lib/paint/frame.js';
|
|
3
|
+
import { FrameCaptureCache } from '../lib/paint/frame-cache.js';
|
|
4
|
+
import { appendParagraphText } from '../lib/paint/paragraph-text.js';
|
|
5
|
+
|
|
6
|
+
function numberedTile() {
|
|
7
|
+
const bytes = new Uint8Array(7 + 3 * 4 * 4);
|
|
8
|
+
const pixels = bytes.subarray(7);
|
|
9
|
+
for (let i = 0; i < 12; i++) pixels.set([i + 1, 20, 30, 255], i * 4);
|
|
10
|
+
return { pixels, pixelWidth: 3, pixelHeight: 4, x: 0, y: 10, width: 3, height: 4, scale: 1,
|
|
11
|
+
background: [99, 0, 0, 128] };
|
|
12
|
+
}
|
|
13
|
+
const reds = (pixels) => Array.from({ length: pixels.length / 4 }, (_, i) => pixels[i * 4]);
|
|
14
|
+
|
|
15
|
+
test('full-width viewport crops reuse immutable tile bytes and respect the source byte offset', () => {
|
|
16
|
+
const tile = numberedTile();
|
|
17
|
+
const result = cropViewport(tile, 0, 11, 3, 2);
|
|
18
|
+
expect(result.pixels.buffer).toBe(tile.pixels.buffer);
|
|
19
|
+
expect(reds(result.pixels)).toEqual([4, 5, 6, 7, 8, 9]);
|
|
20
|
+
expect(result.width).toBe(3);
|
|
21
|
+
expect(result.height).toBe(2);
|
|
22
|
+
expect(reds(cropViewport(tile, 0, 10, 3, 4).pixels)).toEqual(reds(tile.pixels));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('partial crops preserve columns, background alpha, and scaled outside rows', () => {
|
|
26
|
+
const tile = numberedTile();
|
|
27
|
+
expect(reds(cropViewport(tile, 1, 11, 2, 2).pixels)).toEqual([5, 6, 8, 9]);
|
|
28
|
+
const clipped = cropViewport(tile, -1, 9, 4, 3);
|
|
29
|
+
expect(reds(clipped.pixels)).toEqual([99, 99, 99, 99, 99, 1, 2, 3, 99, 4, 5, 6]);
|
|
30
|
+
expect([...clipped.pixels.subarray(0, 4)]).toEqual([99, 0, 0, 128]);
|
|
31
|
+
const scaled = { ...tile, scale: 2, width: 1.5, height: 2 };
|
|
32
|
+
expect(reds(cropViewport(scaled, 0.5, 10.5, 1, 1).pixels)).toEqual([5, 6, 8, 9]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('native base64 capture preserves PNG and JPEG bytes', async () => {
|
|
36
|
+
const tile = numberedTile();
|
|
37
|
+
for (const format of ['png', 'jpeg', 'webp']) {
|
|
38
|
+
const bytes = await encodeImage(tile.pixels, 3, 4, { format, quality: 85 });
|
|
39
|
+
const base64 = await encodeImageBase64(tile.pixels, 3, 4, { format, quality: 85 });
|
|
40
|
+
expect(Buffer.from(base64, 'base64')).toEqual(Buffer.from(bytes));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('encoding releases only its temporary bytes on success and failure', async () => {
|
|
45
|
+
const pixels = new Uint8Array([5, 10, 15, 255]);
|
|
46
|
+
const expected = [...pixels];
|
|
47
|
+
const encoded = await encodeImage(pixels, 1, 1);
|
|
48
|
+
expect(encoded.byteLength).toBeGreaterThan(0);
|
|
49
|
+
expect([...pixels]).toEqual(expected);
|
|
50
|
+
await expect(encodeImage(pixels, 100, 100)).rejects.toThrow();
|
|
51
|
+
expect([...pixels]).toEqual(expected);
|
|
52
|
+
expect((await new Bun.Image(encoded).metadata()).width).toBe(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('simultaneous captures share work and an older completion cannot replace the latest frame', async () => {
|
|
56
|
+
const cache = new FrameCaptureCache();
|
|
57
|
+
let completeOld;
|
|
58
|
+
let captures = 0;
|
|
59
|
+
const first = cache.get('old', () => { captures++; return new Promise(resolve => { completeOld = resolve; }); });
|
|
60
|
+
expect(cache.get('old', () => { captures++; })).toBe(first);
|
|
61
|
+
expect(captures).toBe(1);
|
|
62
|
+
const second = cache.get('new', () => 'new frame');
|
|
63
|
+
expect(await second).toBe('new frame');
|
|
64
|
+
completeOld('old frame');
|
|
65
|
+
expect(await first).toBe('old frame');
|
|
66
|
+
expect(cache.get('new', () => { throw new Error('must retain new'); })).toBe(second);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('capture failures allow retry without discarding a newer frame', async () => {
|
|
70
|
+
const cache = new FrameCaptureCache();
|
|
71
|
+
await expect(cache.get('bad', () => { throw new Error('encode failed'); })).rejects.toThrow('encode failed');
|
|
72
|
+
expect(await cache.get('bad', () => 'retry')).toBe('retry');
|
|
73
|
+
let fail;
|
|
74
|
+
const pending = cache.get('old', () => new Promise((_, reject) => { fail = reject; }));
|
|
75
|
+
const next = cache.get('new', () => 'new');
|
|
76
|
+
fail(new Error('old failed'));
|
|
77
|
+
await expect(pending).rejects.toThrow('old failed');
|
|
78
|
+
expect(cache.get('new', () => 'wrong')).toBe(next);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function textRuns(text) {
|
|
82
|
+
let emoji = false;
|
|
83
|
+
const runs = [];
|
|
84
|
+
appendParagraphText({
|
|
85
|
+
addText: text => runs.push({ text, emoji }),
|
|
86
|
+
pushStyle: () => { emoji = true; },
|
|
87
|
+
pop: () => { emoji = false; },
|
|
88
|
+
}, text, () => ({}));
|
|
89
|
+
return runs;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
test('paragraphs batch ordinary text while preserving whole emoji graphemes and fallback boundaries', () => {
|
|
93
|
+
const ordinary = 'Latin office العربية हिन्दी 中文 e\u0301 ';
|
|
94
|
+
expect(textRuns(ordinary)).toEqual([{ text: ordinary, emoji: false }]);
|
|
95
|
+
expect(textRuns('')).toEqual([{ text: ' ', emoji: false }]);
|
|
96
|
+
const emojis = ['👨👩👧👦', '❤️', '🇹🇼', '1️⃣', '👍🏽'];
|
|
97
|
+
const mixed = `${ordinary}${emojis.join(' + ')} end`;
|
|
98
|
+
const runs = textRuns(mixed);
|
|
99
|
+
expect(runs.map(run => run.text).join('')).toBe(mixed);
|
|
100
|
+
expect(runs.filter(run => run.emoji).map(run => run.text)).toEqual(emojis);
|
|
101
|
+
expect(runs[0].text).toBe(ordinary);
|
|
102
|
+
expect(runs.at(-1)).toEqual({ text: ' end', emoji: false });
|
|
103
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { createPageRenderer, loadGraphics } from "../lib/renderer/page-renderer.js";
|
|
3
|
+
|
|
4
|
+
test("scroll paints reuse shaped text, evict bounded resources, and release paragraphs on navigation/disposal", async () => {
|
|
5
|
+
const { CanvasKit } = await loadGraphics();
|
|
6
|
+
const original = CanvasKit.ParagraphBuilder.MakeFromFontCollection;
|
|
7
|
+
let built = 0;
|
|
8
|
+
let alive = 0;
|
|
9
|
+
CanvasKit.ParagraphBuilder.MakeFromFontCollection = (...args) => {
|
|
10
|
+
const builder = original(...args);
|
|
11
|
+
const build = builder.build.bind(builder);
|
|
12
|
+
builder.build = () => {
|
|
13
|
+
const paragraph = build();
|
|
14
|
+
const dispose = paragraph.delete.bind(paragraph);
|
|
15
|
+
built++;
|
|
16
|
+
alive++;
|
|
17
|
+
paragraph.delete = () => { alive--; dispose(); };
|
|
18
|
+
return paragraph;
|
|
19
|
+
};
|
|
20
|
+
return builder;
|
|
21
|
+
};
|
|
22
|
+
let renderer;
|
|
23
|
+
try {
|
|
24
|
+
renderer = await createPageRenderer({ fetchResource: async url => ({ ok: false, status: 404, url, body: new Uint8Array() }) });
|
|
25
|
+
renderer.resize(400, 200, 1);
|
|
26
|
+
await renderer.loadDocument({ url: "https://paint.test/", status: 200, contentType: "text/html",
|
|
27
|
+
source: `<!doctype html><style>body { margin:0 } p { margin:0; height:30px }</style>${
|
|
28
|
+
Array.from({ length: 400 }, (_, i) => `<p>Article number ${i}: shared text</p>`).join("")}` });
|
|
29
|
+
await renderer.whenLoaded();
|
|
30
|
+
const first = await renderer.screenshot();
|
|
31
|
+
const before = built;
|
|
32
|
+
renderer.scroll(0, 1);
|
|
33
|
+
await renderer.screenshot();
|
|
34
|
+
// No layout invalidation and the same seven visible paragraph items.
|
|
35
|
+
expect(built - before).toBeLessThanOrEqual(1);
|
|
36
|
+
renderer.scroll(0, -1);
|
|
37
|
+
expect(await renderer.screenshot()).toBe(first);
|
|
38
|
+
// A fresh layout/display list still uses identical text shaping inputs.
|
|
39
|
+
const beforeLayout = built;
|
|
40
|
+
renderer.resize(400, 201, 1);
|
|
41
|
+
await renderer.screenshot();
|
|
42
|
+
expect(built - beforeLayout).toBeLessThanOrEqual(1);
|
|
43
|
+
renderer.resize(400, 200, 1);
|
|
44
|
+
expect(await renderer.screenshot()).toBe(first);
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < 40; i++) renderer.scroll(0, 250);
|
|
47
|
+
expect(alive).toBeGreaterThan(0);
|
|
48
|
+
expect(alive).toBeLessThanOrEqual(256);
|
|
49
|
+
renderer.resize(400, 200, 2);
|
|
50
|
+
const zoomed = await renderer.screenshot();
|
|
51
|
+
expect((await new Bun.Image(Buffer.from(zoomed, "base64")).metadata()).width).toBe(800);
|
|
52
|
+
await renderer.loadDocument({ url: "https://paint.test/empty", status: 200, contentType: "text/html", source: "<!doctype html><body></body>" });
|
|
53
|
+
await renderer.whenLoaded();
|
|
54
|
+
expect(alive).toBe(0);
|
|
55
|
+
} finally {
|
|
56
|
+
renderer?.dispose();
|
|
57
|
+
CanvasKit.ParagraphBuilder.MakeFromFontCollection = original;
|
|
58
|
+
}
|
|
59
|
+
expect(alive).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("screenshot encoding options are honored and separated in the frame cache", async () => {
|
|
63
|
+
const { CanvasKit: kit } = await loadGraphics();
|
|
64
|
+
const renderer = await createPageRenderer({});
|
|
65
|
+
const images = [];
|
|
66
|
+
try {
|
|
67
|
+
renderer.resize(400, 200, 1);
|
|
68
|
+
await renderer.loadDocument({ url: "https://capture.test/", contentType: "text/html",
|
|
69
|
+
source: "<body style='margin:0;color:#256;background:#def'><h1>Frame format cache</h1><p>Lossless PNG and JPEG capture</p>" });
|
|
70
|
+
await renderer.whenLoaded();
|
|
71
|
+
const png = await renderer.screenshot();
|
|
72
|
+
const jpeg = await renderer.screenshot({ format: "jpeg", quality: 85 });
|
|
73
|
+
expect((await new Bun.Image(Buffer.from(jpeg, "base64")).metadata()).format).toBe("jpeg");
|
|
74
|
+
const fast = await renderer.screenshot({ optimizeForSpeed: true });
|
|
75
|
+
const pixelBytes = value => {
|
|
76
|
+
const image = kit.MakeImageFromEncoded(Buffer.from(value, "base64"));
|
|
77
|
+
images.push(image);
|
|
78
|
+
return image.readPixels(0, 0, { width: 400, height: 200, colorType: kit.ColorType.RGBA_8888,
|
|
79
|
+
alphaType: kit.AlphaType.Unpremul, colorSpace: kit.ColorSpace.SRGB });
|
|
80
|
+
};
|
|
81
|
+
expect(pixelBytes(fast)).toEqual(pixelBytes(png));
|
|
82
|
+
expect(await renderer.screenshot()).toBe(png);
|
|
83
|
+
} finally {
|
|
84
|
+
for (const image of images) image.delete();
|
|
85
|
+
renderer.dispose();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
@@ -65,3 +65,27 @@ test("an ended prefix or failed reopen never claims to have reached the target",
|
|
|
65
65
|
const sound = track(pipe, 2);
|
|
66
66
|
expect(await advanceAudio(() => sound, 8, { needsMore: () => false, reopen: async () => false })).toBe(false);
|
|
67
67
|
});
|
|
68
|
+
|
|
69
|
+
test("a seek before the first audio epoch cannot keep reopening an unreachable prefix", async () => {
|
|
70
|
+
let sound = { position: 6, decoder: { sampleRate: 1, channels: 1 } }, reopened = 0;
|
|
71
|
+
expect(await advanceAudio(() => sound, 2, {
|
|
72
|
+
needsMore: () => true,
|
|
73
|
+
reopen: async () => { reopened++; sound = { ...sound, position: 6 }; return true; },
|
|
74
|
+
})).toBe(false);
|
|
75
|
+
expect(reopened).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("audio epoch EOF resumes at the next absolute sample cursor", async () => {
|
|
79
|
+
const pipe = createBytePipe(); pipe.finish();
|
|
80
|
+
let sound = { position: 6, pending: null, endTime: 6, snapshotId: 1, openedGeneration: 1, pipe,
|
|
81
|
+
decoder: { sampleRate: 1, channels: 1, nextSamples: () => null } };
|
|
82
|
+
expect(await advanceAudio(() => sound, 7, {
|
|
83
|
+
needsMore: () => true,
|
|
84
|
+
reopen: async () => {
|
|
85
|
+
sound = { ...sound, position: 6, endTime: Infinity, snapshotId: 2, pending: new Float32Array([6, 7]) };
|
|
86
|
+
return true;
|
|
87
|
+
},
|
|
88
|
+
})).toBe(true);
|
|
89
|
+
expect(sound.position).toBe(7);
|
|
90
|
+
expect([...sound.pending]).toEqual([7]);
|
|
91
|
+
});
|