buninu-browser 0.2.2 → 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.
Files changed (118) hide show
  1. package/lib/audio/playback-buffer.js +27 -0
  2. package/lib/audio/pulse-client.js +18 -8
  3. package/lib/cdp-server.js +53 -14
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +20 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +12 -6
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +289 -131
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +42 -0
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +22 -27
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +42 -13
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/rounded-rect.js +82 -0
  24. package/lib/paint/shared-frame-pool.js +105 -0
  25. package/lib/render-tree/index.js +40 -6
  26. package/lib/render-tree/svg.js +15 -7
  27. package/lib/renderer/bindings.js +244 -82
  28. package/lib/renderer/broadcast-channel.js +76 -0
  29. package/lib/renderer/canvas-2d.js +257 -14
  30. package/lib/renderer/canvas-text.js +120 -0
  31. package/lib/renderer/compositor.js +27 -10
  32. package/lib/renderer/custom-element-reactions.js +42 -0
  33. package/lib/renderer/font-loading.js +227 -0
  34. package/lib/renderer/history.js +42 -0
  35. package/lib/renderer/host.js +36 -5
  36. package/lib/renderer/image-loading.js +27 -0
  37. package/lib/renderer/image-resources.js +65 -0
  38. package/lib/renderer/interfaces.js +28 -0
  39. package/lib/renderer/keyboard-input.js +12 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +33 -5
  42. package/lib/renderer/media-playback-events.js +13 -0
  43. package/lib/renderer/media-source.js +126 -55
  44. package/lib/renderer/page-realm.js +278 -147
  45. package/lib/renderer/page-renderer.js +840 -301
  46. package/lib/renderer/piano-input.js +22 -0
  47. package/lib/renderer/pointer-move.js +29 -0
  48. package/lib/renderer/prepared-script-cache.js +50 -0
  49. package/lib/renderer/rendered-text.js +108 -0
  50. package/lib/renderer/rewrite-worker.js +4 -3
  51. package/lib/renderer/script-rewrite.js +13 -2
  52. package/lib/renderer/scroll-into-view.js +28 -0
  53. package/lib/renderer/slot-assignment.js +60 -0
  54. package/lib/renderer/video-presentation.js +145 -0
  55. package/lib/renderer/workers.js +2 -2
  56. package/lib/style/computed-style.js +632 -108
  57. package/lib/style/selector-traits.js +49 -5
  58. package/package.json +1 -1
  59. package/test/broadcast-channel.test.js +26 -0
  60. package/test/canvas-2d.test.js +83 -2
  61. package/test/canvas-paint.test.js +9 -0
  62. package/test/canvas-text.test.js +41 -0
  63. package/test/cdp-stream.test.js +56 -0
  64. package/test/compositor.test.js +38 -0
  65. package/test/computed-style-binding.test.js +151 -0
  66. package/test/computed-style.test.js +279 -1
  67. package/test/display-list.test.js +26 -0
  68. package/test/document-base.test.js +87 -0
  69. package/test/dom-platform.test.js +461 -0
  70. package/test/download.test.js +8 -0
  71. package/test/fetch-lifecycle.test.js +161 -0
  72. package/test/fixtures/canvas-paint.js +117 -0
  73. package/test/fixtures/canvas-text.js +42 -0
  74. package/test/fixtures/image-lifetime.js +90 -0
  75. package/test/fixtures/media-seek-snapshot.js +77 -0
  76. package/test/fixtures/mse-audio-chirp.webm +0 -0
  77. package/test/fixtures/mse-switch-large.mp4 +0 -0
  78. package/test/fixtures/mse-switch-small.mp4 +0 -0
  79. package/test/fixtures/renderer-memory.js +110 -0
  80. package/test/fixtures/summary-style.js +28 -0
  81. package/test/font-loading.test.js +97 -0
  82. package/test/grid.test.js +58 -0
  83. package/test/headless-shell.test.js +22 -0
  84. package/test/hit-test.test.js +21 -1
  85. package/test/image-lifetime.test.js +12 -0
  86. package/test/image-resources.test.js +94 -0
  87. package/test/local-media.test.js +84 -0
  88. package/test/media-playback-events.test.js +22 -0
  89. package/test/media-seek-snapshot.test.js +11 -0
  90. package/test/media-source-config.test.js +98 -0
  91. package/test/media-source-webm-accounting.test.js +98 -0
  92. package/test/media-source-webm.test.js +108 -0
  93. package/test/navigation.test.js +1 -0
  94. package/test/network-service.test.js +388 -0
  95. package/test/page-scripts.test.js +160 -2
  96. package/test/paint-frame.test.js +103 -0
  97. package/test/paint-paragraph-cache.test.js +87 -0
  98. package/test/piano-input.test.js +44 -0
  99. package/test/playback-buffer.test.js +31 -0
  100. package/test/position-audio.test.js +24 -0
  101. package/test/positioning.test.js +22 -0
  102. package/test/prepared-script-cache.test.js +45 -0
  103. package/test/pulse-client.test.js +27 -5
  104. package/test/raster-readback.test.js +40 -0
  105. package/test/render-tree.test.js +28 -0
  106. package/test/renderer-memory.test.js +11 -0
  107. package/test/selector-traits.test.js +24 -0
  108. package/test/shared-frame-pool.test.js +63 -0
  109. package/test/style-sheets.test.js +51 -0
  110. package/test/svg.test.js +29 -0
  111. package/test/text-layout.test.js +168 -0
  112. package/test/video-keyframe.test.js +61 -0
  113. package/test/video-presentation.test.js +136 -0
  114. package/tools/browser-lab.js +78 -0
  115. package/tools/browser-soak.js +75 -0
  116. package/tools/network-reference.js +49 -0
  117. package/tools/process-memory.js +38 -0
  118. package/tools/script-benchmark.js +25 -0
@@ -0,0 +1,98 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createMediaSourceScope } from "../lib/renderer/media-source.js";
3
+
4
+ function context(appendQuota = 1000) {
5
+ const tasks = [];
6
+ const media = createMediaSourceScope({
7
+ origin: "https://media.test", DOMException, appendQuota,
8
+ schedule: task => tasks.push(task),
9
+ });
10
+ const flush = () => { while (tasks.length) tasks.shift()(); };
11
+ const source = new media.MediaSource();
12
+ source.attach("blob:https://media.test/audio");
13
+ flush();
14
+ const buffer = source.addSourceBuffer('audio/webm; codecs="opus"');
15
+ const append = bytes => { buffer.appendBuffer(bytes); flush(); };
16
+ return { source, buffer, flush, append };
17
+ }
18
+
19
+ // One finite Cluster, timecode 0, with a SimpleBlock at 1000 ms.
20
+ const cluster = Uint8Array.from([
21
+ 0x1f, 0x43, 0xb6, 0x75, 0x8a, 0xe7, 0x81, 0x00,
22
+ 0xa3, 0x85, 0x81, 0x03, 0xe8, 0x00, 0x00,
23
+ ]);
24
+ const header = Uint8Array.from([0x1a, 0x45, 0xdf, 0xa3, 0x80]);
25
+
26
+ test("idle abort discards an incomplete WebM element before the next initialization", () => {
27
+ const { buffer, flush, append } = context();
28
+ append(cluster.subarray(0, 7));
29
+ expect(buffer._pipe.size).toBe(0);
30
+ const events = [];
31
+ buffer.addEventListener("abort", () => events.push("abort"));
32
+ buffer.addEventListener("updateend", () => events.push("updateend"));
33
+ buffer.abort();
34
+ flush();
35
+ expect(buffer._accountedBytes).toBe(0);
36
+ expect(events).toEqual([]);
37
+ const next = new Uint8Array(Bun.concatArrayBuffers([header, cluster]));
38
+ append(next);
39
+ expect(buffer._pipe.bytes()).toEqual(next);
40
+ expect(buffer.buffered.end(0)).toBeCloseTo(1.02, 5);
41
+ });
42
+
43
+ test("abort cancels a queued WebM append and clears a previous incomplete element", () => {
44
+ const { buffer, flush, append } = context();
45
+ append(cluster.subarray(0, 7));
46
+ const events = [];
47
+ buffer.addEventListener("abort", () => events.push("abort"));
48
+ buffer.addEventListener("updateend", () => events.push("updateend"));
49
+ buffer.appendBuffer(cluster.subarray(7));
50
+ buffer.abort();
51
+ flush();
52
+ expect(buffer.updating).toBe(false);
53
+ expect(buffer._accountedBytes).toBe(0);
54
+ expect(buffer._pipe.size).toBe(0);
55
+ expect(events).toEqual(["abort", "updateend"]);
56
+ append(cluster);
57
+ expect(buffer._pipe.bytes()).toEqual(cluster);
58
+ });
59
+
60
+ test("a split retry completed after eviction cannot refund another buffer's quota", () => {
61
+ const { source, buffer, flush, append } = context(100);
62
+ const other = source.addSourceBuffer('audio/webm; codecs="opus"');
63
+ // An independent 50-byte Void element reserves the other buffer's quota.
64
+ const padding = new Uint8Array(50);
65
+ padding.set([0xec, 0xb0]);
66
+ other.appendBuffer(padding);
67
+ flush();
68
+ append(cluster);
69
+ append(cluster.subarray(0, 7));
70
+ buffer.remove(0, 2);
71
+ flush();
72
+ append(cluster.subarray(7));
73
+ expect(buffer._accountedBytes).toBe(0);
74
+ expect(other._accountedBytes).toBe(50);
75
+ expect(buffer._pipe.bytes()).toEqual(cluster);
76
+ expect(() => buffer.appendBuffer(new Uint8Array(51))).toThrow("The SourceBuffer is full.");
77
+ });
78
+
79
+ test("repeated unknown Clusters retain only the current unused prefix until abort or EOF", () => {
80
+ for (const finish of ["abort", "endOfStream"]) {
81
+ const { source, buffer, append } = context();
82
+ const live = cluster.slice();
83
+ live[4] = 0xff;
84
+ append(live);
85
+ expect(buffer._accountedBytes).toBe(live.length);
86
+ for (let retry = 0; retry < 3; retry++) {
87
+ append(live);
88
+ // The current prefix remains necessary if a later Block is new.
89
+ expect(buffer._webmStream.pendingSize).toBe(8);
90
+ expect(buffer._accountedBytes).toBe(live.length + 8);
91
+ expect(buffer._pipe.bytes()).toEqual(live);
92
+ }
93
+ if (finish === "abort") buffer.abort();
94
+ else source.endOfStream();
95
+ expect(buffer._webmStream.pendingSize).toBe(0);
96
+ expect(buffer._accountedBytes).toBe(live.length);
97
+ }
98
+ });
@@ -0,0 +1,108 @@
1
+ import { beforeAll, expect, test } from 'bun:test';
2
+ import wasmpeg from '../vendor/wasmpeg/src/js/index.js';
3
+ import { floatToS16 } from '../lib/audio/pcm.js';
4
+ import { createMediaSourceScope } from '../lib/renderer/media-source.js';
5
+ import { createWebMStream, ebmlHeader } from '../lib/media/webm-stream.js';
6
+
7
+ // Self-generated eight-second chirp, 48 kHz mono Opus, 2-second Clusters.
8
+ // Unlike a constant tone, its PCM identifies a repeated earlier segment.
9
+ let fixture, elements;
10
+ beforeAll(async () => {
11
+ await wasmpeg.load();
12
+ fixture = new Uint8Array(await Bun.file(new URL('./fixtures/mse-audio-chirp.webm', import.meta.url)).arrayBuffer());
13
+ elements = createWebMStream().append(fixture);
14
+ });
15
+ function context() {
16
+ const tasks = [], scope = createMediaSourceScope({ origin: 'https://media.test', schedule: task => tasks.push(task), DOMException });
17
+ const flush = () => { while (tasks.length) tasks.shift()(); };
18
+ const source = new scope.MediaSource(); source.attach('blob:https://media.test/audio'); flush();
19
+ const buffer = source.addSourceBuffer('audio/webm; codecs="opus"');
20
+ return { source, buffer, append(bytes) { buffer.appendBuffer(bytes); flush(); } };
21
+ }
22
+ const concat = parts => new Uint8Array(Bun.concatArrayBuffers(parts));
23
+ async function pcm(bytes) {
24
+ const decoder = await wasmpeg.decodeAudio(bytes, { format: 'webm' }), parts = [];
25
+ try { for (let samples; (samples = decoder.nextSamples());) parts.push(floatToS16(samples)); }
26
+ finally { decoder.close(); }
27
+ return concat(parts);
28
+ }
29
+ const cluster = element => element.id === 0x1f43b675;
30
+
31
+ test('split WebM Clusters, whole retries, and mixed old/new appends preserve exact chronological PCM', async () => {
32
+ const c = context(), groups = elements.filter(cluster), init = concat(elements.slice(0, elements.indexOf(groups[0])).map(e => e.bytes));
33
+ expect(groups.length).toBeGreaterThanOrEqual(4);
34
+ for (let i = 0; i < init.length; i += 7) c.append(init.subarray(i, i + 7));
35
+ const first = groups[0].bytes;
36
+ // This was the live failure: a partial first Cluster understated coverage,
37
+ // and a later whole retry inserted its opening PCM after the second one.
38
+ for (let i = 0; i < first.length; i += 137) c.append(first.subarray(i, i + 137));
39
+ c.append(groups[1].bytes);
40
+ const beforeRetry = c.buffer._pipe.size;
41
+ c.append(first);
42
+ expect(c.buffer._pipe.size).toBe(beforeRetry);
43
+ c.append(concat([groups[0].bytes, groups[2].bytes]));
44
+ for (const element of elements.slice(elements.indexOf(groups[3]))) c.append(element.bytes);
45
+ c.source.endOfStream();
46
+ const actual = await pcm(c.buffer._pipe.bytes()), reference = await pcm(fixture);
47
+ expect(actual.length).toBe(reference.length);
48
+ expect(Bun.hash(actual)).toBe(Bun.hash(reference));
49
+ expect(c.buffer.buffered.end(0)).toBeGreaterThanOrEqual(8);
50
+ expect(c.buffer.buffered.end(0)).toBeLessThan(8.1);
51
+ expect(Bun.hash(actual.subarray(0, 48000 * 2))).not.toBe(Bun.hash(actual.subarray(4 * 48000 * 2, 5 * 48000 * 2)));
52
+ });
53
+
54
+ test('unknown-size live Clusters expose complete Blocks before the next Cluster and deduplicate retried Blocks', async () => {
55
+ const c = context(), groups = elements.filter(cluster);
56
+ for (const element of elements.slice(0, elements.indexOf(groups[0]))) c.append(element.bytes);
57
+ let initialSize = c.buffer._pipe.size;
58
+ for (const group of groups) {
59
+ const header = ebmlHeader(group.bytes), idLength = 4;
60
+ const live = group.bytes.slice();
61
+ // Preserve the original VINT width while replacing its value with unknown.
62
+ live[idLength] = (1 << (9 - (header.length - idLength))) - 1;
63
+ live.fill(0xff, idLength + 1, header.length);
64
+ for (let i = 0; i < live.length; i += 101) c.append(live.subarray(i, i + 101));
65
+ expect(c.buffer._pipe.size).toBeGreaterThan(initialSize);
66
+ initialSize = c.buffer._pipe.size;
67
+ // The next header closes the previous unknown Cluster. Its old Blocks
68
+ // must not be played again even though no finite size was available.
69
+ c.append(live);
70
+ expect(c.buffer._pipe.size).toBe(initialSize);
71
+ }
72
+ c.source.endOfStream();
73
+ const actual = await pcm(c.buffer._pipe.bytes()), reference = await pcm(fixture);
74
+ expect(actual.length).toBe(reference.length);
75
+ expect(Bun.hash(actual)).toBe(Bun.hash(reference));
76
+ });
77
+
78
+ test('EBML framing never treats packet payload bytes as Cluster boundaries', () => {
79
+ const stream = createWebMStream();
80
+ const bytes = Uint8Array.from([
81
+ 0x1f, 0x43, 0xb6, 0x75, 0xff, // unknown Cluster
82
+ 0xe7, 0x81, 0,
83
+ 0xa3, 0x89, 0x81, 0, 0, 0, 0x1f, 0x43, 0xb6, 0x75, 0,
84
+ ]);
85
+ const emitted = [];
86
+ for (const byte of bytes) emitted.push(...stream.append(Uint8Array.of(byte)));
87
+ expect(emitted.length).toBe(1);
88
+ expect(emitted[0].cluster.time).toBe(0);
89
+ expect([...emitted[0].bytes]).toEqual([...bytes.subarray(8)]);
90
+ // The standalone framer still owns the prefix until a consumer accepts
91
+ // the Block (SourceBuffer records that on the shared cluster token).
92
+ expect(stream.pendingSize).toBe(8);
93
+ stream.reset();
94
+ expect(stream.pendingSize).toBe(0);
95
+ });
96
+
97
+ test('incremental muxed Clusters retain different tracks at the same presentation time', () => {
98
+ const c = context();
99
+ const bytes = Uint8Array.from([
100
+ 0x1f, 0x43, 0xb6, 0x75, 0xff, 0xe7, 0x81, 0,
101
+ 0xa3, 0x85, 0x81, 0, 0, 0, 1,
102
+ 0xa3, 0x85, 0x82, 0, 0, 0, 2,
103
+ ]);
104
+ c.append(bytes);
105
+ expect([...c.buffer._pipe.bytes()]).toEqual([...bytes]);
106
+ c.append(bytes);
107
+ expect([...c.buffer._pipe.bytes()]).toEqual([...bytes]);
108
+ });
@@ -58,4 +58,5 @@ test("a redirected document has the URL it ended on, and its POST form submits t
58
58
  }]);
59
59
  // The 303 after it was followed with a GET, and the document is at its URL.
60
60
  expect(await context.evaluate("location.href")).toBe(`${origin}/done`);
61
+ expect(await context.evaluate("document.referrer")).toBe(`${origin}/dir/form?step=1`);
61
62
  });
@@ -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
+ });