Skip to content
File

Blob: src/workerd/api/tests/blob-test.js

javascript334 lines
1// Copyright (c) 2023 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5import { strictEqual, throws } from 'node:assert';
6import { inspect } from 'node:util';
7 
8export const test1 = {
9 async test(ctrl, env, ctx) {
10 let blob = new Blob(['foo', new TextEncoder().encode('bar'), 'baz']);
11 strictEqual(await blob.text(), 'foobarbaz');
12 strictEqual(
13 new TextDecoder().decode(await blob.arrayBuffer()),
14 'foobarbaz'
15 );
16 strictEqual(blob.type, '');
17 
18 let blob2 = new Blob(['xx', blob, 'yy', blob], {
19 type: 'application/whatever',
20 });
21 strictEqual(await blob2.text(), 'xxfoobarbazyyfoobarbaz');
22 strictEqual(blob2.type, 'application/whatever');
23 
24 let blob3 = new Blob();
25 strictEqual(await blob3.text(), '');
26 
27 let slice = blob2.slice(5, 16);
28 strictEqual(await slice.text(), 'barbazyyfoo');
29 strictEqual(slice.type, '');
30 
31 let slice2 = slice.slice(-5, 1234, 'type/type');
32 strictEqual(await slice2.text(), 'yyfoo');
33 strictEqual(slice2.type, 'type/type');
34 
35 strictEqual(await blob2.slice(5).text(), 'barbazyyfoobarbaz');
36 strictEqual(await blob2.slice().text(), 'xxfoobarbazyyfoobarbaz');
37 strictEqual(await blob2.slice(3, 1).text(), '');
38 
39 {
40 let stream = blob.stream();
41 let reader = stream.getReader();
42 let readResult = await reader.read();
43 strictEqual(readResult.done, false);
44 strictEqual(new TextDecoder().decode(readResult.value), 'foobarbaz');
45 readResult = await reader.read();
46 strictEqual(readResult.value, undefined);
47 strictEqual(readResult.done, true);
48 reader.releaseLock();
49 }
50 
51 let before = Date.now();
52 
53 let file = new File([blob, 'qux'], 'filename.txt');
54 strictEqual(file instanceof Blob, true);
55 strictEqual(await file.text(), 'foobarbazqux');
56 strictEqual(file.name, 'filename.txt');
57 strictEqual(file.type, '');
58 if (file.lastModified < before || file.lastModified > Date.now()) {
59 throw new Error('incorrect lastModified');
60 }
61 
62 let file2 = new File(['corge', file], 'file2', {
63 type: 'text/foo',
64 lastModified: 123,
65 });
66 strictEqual(await file2.text(), 'corgefoobarbazqux');
67 strictEqual(file2.name, 'file2');
68 strictEqual(file2.type, 'text/foo');
69 strictEqual(file2.lastModified, 123);
70 
71 try {
72 new Blob(['foo'], { endings: 'native' });
73 throw new Error("use of 'endings' should throw");
74 } catch (err) {
75 if (
76 !err.message.includes(
77 "The 'endings' field on 'Options' is not implemented."
78 )
79 ) {
80 throw err;
81 }
82 }
83 
84 // Test type normalization.
85 strictEqual(new Blob([], { type: 'FoO/bAr' }).type, 'foo/bar');
86 strictEqual(new Blob([], { type: 'FoO\u0019/bAr' }).type, '');
87 strictEqual(new Blob([], { type: 'FoO\u0020/bAr' }).type, 'foo /bar');
88 strictEqual(new Blob([], { type: 'FoO\u007e/bAr' }).type, 'foo\u007e/bar');
89 strictEqual(new Blob([], { type: 'FoO\u0080/bAr' }).type, '');
90 strictEqual(new File([], 'foo.txt', { type: 'FoO/bAr' }).type, 'foo/bar');
91 strictEqual(blob2.slice(1, 2, 'FoO/bAr').type, 'foo/bar');
92 },
93};
94 
95export const test2 = {
96 async test(ctrl, env, ctx) {
97 // This test verifies that a Blob created from a request/response properly reflects
98 // the content and content-type specified by the request/response.
99 const res = await env['request-blob'].fetch(
100 'http://example.org/blob-request',
101 {
102 method: 'POST',
103 body: 'abcd1234',
104 headers: { 'content-type': 'some/type' },
105 }
106 );
107 
108 strictEqual(res.headers.get('content-type'), 'return/type');
109 strictEqual(await res.text(), 'foobarbaz');
110 },
111 async doTest(request) {
112 let b = await request.blob();
113 strictEqual(await b.text(), 'abcd1234');
114 strictEqual(b.type, 'some/type');
115 
116 // Quick check that content-type header is correctly not set when the blob type is an empty
117 // string.
118 strictEqual(
119 new Response(new Blob(['typeful'], { type: 'foo' })).headers.has(
120 'Content-Type'
121 ),
122 true
123 );
124 strictEqual(
125 new Response(new Blob(['typeless'])).headers.has('Content-Type'),
126 false
127 );
128 
129 return new Response(new Blob(['foobar', 'baz'], { type: 'return/type' }));
130 },
131};
132 
133export default {
134 async fetch(request) {
135 if (request.url.endsWith('/blob-request')) {
136 return test2.doTest(request);
137 } else {
138 throw new Error('Unexpected test request');
139 }
140 },
141};
142 
143export const testInspect = {
144 async test(ctrl, env, ctx) {
145 const blob = new Blob(['abc'], { type: 'text/plain' });
146 strictEqual(inspect(blob), "Blob { size: 3, type: 'text/plain' }");
147 
148 const file = new File(['1'], 'file.txt', {
149 type: 'text/plain',
150 lastModified: 1000,
151 });
152 strictEqual(
153 inspect(file),
154 "File { name: 'file.txt', lastModified: 1000, size: 1, type: 'text/plain' }"
155 );
156 },
157};
158 
159export const overLarge = {
160 test() {
161 const blob1 = new Blob([new ArrayBuffer(128 * 1024 * 1024)]);
162 
163 throws(
164 () => {
165 new Blob([new ArrayBuffer(128 * 1024 * 1024 + 1)]);
166 },
167 {
168 message: 'Blob size 134217729 exceeds limit 134217728',
169 name: 'RangeError',
170 }
171 );
172 
173 throws(
174 () => {
175 new Blob([' ', blob1]);
176 },
177 {
178 message: 'Blob size 134217729 exceeds limit 134217728',
179 name: 'RangeError',
180 }
181 );
182 },
183};
184 
185// Test that when a resizable ArrayBuffer is shrunk during Blob construction
186// (via a later element's Symbol.toPrimitive triggering the resize), the data
187// is packed tightly — the shrunk buffer's actual bytes come first, followed
188// by subsequent parts, with no zero-filled gaps in the middle.
189export const resizeDuringConstruction = {
190 async test() {
191 const buffer = new ArrayBuffer(1024, { maxByteLength: 2048 });
192 const view = new Uint8Array(buffer);
193 view.fill(0x41); // fill with 'A'
194 
195 // When JSG converts this to a string, it runs Symbol.toPrimitive which
196 // shrinks the buffer. The TypedArray is length-tracking so its byteLength
197 // drops to 8 before concat() ever reads it.
198 const resizer = {
199 [Symbol.toPrimitive]() {
200 buffer.resize(8);
201 return 'B'.repeat(16);
202 },
203 };
204 
205 const blob = new Blob([view, resizer]);
206 const bytes = await blob.bytes();
207 
208 // view shrank to 8 bytes of 'A', resizer produced 16 bytes of 'B'.
209 // Data should be packed tightly: 8 + 16 = 24.
210 strictEqual(bytes.length, 24);
211 // First 8 bytes are 0x41 ('A') from the shrunk view
212 for (let i = 0; i < 8; i++) {
213 strictEqual(bytes[i], 0x41, `byte ${i} should be 0x41`);
214 }
215 // Next 16 bytes are 0x42 ('B') from the resizer's string
216 for (let i = 8; i < 24; i++) {
217 strictEqual(bytes[i], 0x42, `byte ${i} should be 0x42`);
218 }
219 },
220};
221 
222// Same pattern but the buffer is grown instead of shrunk. Because the resize
223// happens during argument coercion (before concat() runs), the grown view is
224// visible to concat() and its full contents are included in the Blob.
225export const resizeGrowDuringConstruction = {
226 async test() {
227 const buffer = new ArrayBuffer(10, { maxByteLength: 64 });
228 const view = new Uint8Array(buffer);
229 view.fill(0x41); // fill with 'A'
230 
231 const resizer = {
232 [Symbol.toPrimitive]() {
233 buffer.resize(20);
234 new Uint8Array(buffer, 10).fill(0x43); // fill grown region with 'C'
235 return 'B'.repeat(10);
236 },
237 };
238 
239 const blob = new Blob([view, resizer]);
240 const bytes = await blob.bytes();
241 
242 // view grew to 20 during conversion, so concat sees 20 + 10 = 30.
243 strictEqual(bytes.length, 30);
244 // First 10 bytes: original 0x41 ('A')
245 for (let i = 0; i < 10; i++) {
246 strictEqual(bytes[i], 0x41, `byte ${i} should be 0x41`);
247 }
248 // Next 10 bytes: grown region 0x43 ('C')
249 for (let i = 10; i < 20; i++) {
250 strictEqual(bytes[i], 0x43, `byte ${i} should be 0x43`);
251 }
252 // Last 10 bytes: 0x42 ('B') from the resizer string
253 for (let i = 20; i < 30; i++) {
254 strictEqual(bytes[i], 0x42, `byte ${i} should be 0x42`);
255 }
256 },
257};
258 
259// Regression tests for the index desync bug in concat() cachedPartSizes.
260// When empty text or blob parts appeared before non-empty parts, the second
261// pass index fell behind the first pass, causing wrong cachedPartSizes reads.
262export const mixedEmptyParts = {
263 async test() {
264 // Empty string before buffer
265 {
266 const buf = new Uint8Array([1, 2, 3]).buffer;
267 const blob = new Blob(['', buf]);
268 strictEqual(blob.size, 3);
269 const result = new Uint8Array(await blob.arrayBuffer());
270 strictEqual(result[0], 1);
271 strictEqual(result[1], 2);
272 strictEqual(result[2], 3);
273 }
274 
275 // Empty blob before string
276 {
277 const blob = new Blob([new Blob([]), 'hello']);
278 strictEqual(blob.size, 5);
279 strictEqual(await blob.text(), 'hello');
280 }
281 
282 // Buffer, empty string, string
283 {
284 const buf = new Uint8Array([65, 66]).buffer; // "AB"
285 const blob = new Blob([buf, '', 'CD']);
286 strictEqual(blob.size, 4);
287 strictEqual(await blob.text(), 'ABCD');
288 }
289 
290 // Multiple empties before data
291 {
292 const blob = new Blob(['', '', new Uint8Array([1, 2]).buffer, 'abc']);
293 strictEqual(blob.size, 5);
294 const bytes = new Uint8Array(await blob.arrayBuffer());
295 strictEqual(bytes[0], 1);
296 strictEqual(bytes[1], 2);
297 strictEqual(bytes[2], 97); // 'a'
298 strictEqual(bytes[3], 98); // 'b'
299 strictEqual(bytes[4], 99); // 'c'
300 }
301 
302 // Empty blob between non-empty parts
303 {
304 const blob = new Blob(['hello', new Blob([]), ' world']);
305 strictEqual(blob.size, 11);
306 strictEqual(await blob.text(), 'hello world');
307 }
308 
309 // All empty parts
310 {
311 const blob = new Blob(['', new Blob([]), new ArrayBuffer(0)]);
312 strictEqual(blob.size, 0);
313 strictEqual(await blob.text(), '');
314 }
315 },
316};
317 
318export const depthTest = {
319 async test() {
320 let blob = new Blob(['x']);
321 const depth = 100_000;
322 
323 for (let i = 0; i < depth; i++) {
324 blob = blob.slice(0, 1);
325 }
326 
327 // Force GC to trigger recursive traversal
328 gc();
329 
330 // Access the blob to ensure it's still valid
331 console.log('Blob size:', blob.size);
332 },
333};