diff --git a/lib/internal/blob.js b/lib/internal/blob.js index 6d359c5adf7e..aea19d6e7471 100644 --- a/lib/internal/blob.js +++ b/lib/internal/blob.js @@ -1,7 +1,12 @@ 'use strict'; const { + ArrayBufferPrototypeGetByteLength, + ArrayBufferPrototypeGetDetached, ArrayPrototypePush, + DataViewPrototypeGetBuffer, + DataViewPrototypeGetByteLength, + DataViewPrototypeGetByteOffset, MathMax, MathMin, ObjectDefineProperties, @@ -12,10 +17,14 @@ const { PromiseWithResolvers, RegExpPrototypeExec, RegExpPrototypeSymbolReplace, + SafeWeakSet, StringPrototypeSplit, StringPrototypeToLowerCase, Symbol, SymbolToStringTag, + TypedArrayPrototypeGetBuffer, + TypedArrayPrototypeGetByteLength, + TypedArrayPrototypeGetByteOffset, Uint8Array, } = primordials; @@ -43,7 +52,9 @@ const { const { isAnyArrayBuffer, + isArrayBuffer, isArrayBufferView, + isDataView, } = require('internal/util/types'); const { @@ -55,22 +66,19 @@ const { const { inspect } = require('internal/util/inspect'); const { converters, + createDictionaryConverter, + createEnumConverter, createSequenceConverter, } = require('internal/webidl'); const { codes: { ERR_BUFFER_TOO_LARGE, - ERR_INVALID_ARG_VALUE, ERR_INVALID_STATE, ERR_INVALID_THIS, }, } = require('internal/errors'); -const { - validateDictionary, -} = require('internal/validators'); - const { setImmediate, } = require('timers'); @@ -81,6 +89,8 @@ const kHandle = Symbol('kHandle'); const kType = Symbol('kType'); const kLength = Symbol('kLength'); const kNotCloneable = Symbol('kNotCloneable'); +const kAlreadyConverted = Symbol('kAlreadyConverted'); +const blobBrand = new SafeWeakSet(); const disallowedTypeCharacters = /[^\u{0020}-\u{007E}]/u; @@ -110,16 +120,14 @@ function lazyTextDecoderStream() { const { EOL } = require('internal/constants'); function isBlob(object) { - return object?.[kHandle] !== undefined; + return blobBrand.has(object); } function getSource(source, endings) { if (isBlob(source)) - return [source.size, source[kHandle]]; + return [source[kLength], source[kHandle]]; - if (isAnyArrayBuffer(source)) { - source = new Uint8Array(source); - } else if (!isArrayBufferView(source)) { + if (typeof source === 'string') { if (endings === 'native') source = RegExpPrototypeSymbolReplace(/\r\n|\r|\n/g, source, EOL); source = enc.encode(source); @@ -128,21 +136,49 @@ function getSource(source, endings) { // We copy into a new Uint8Array because the underlying // BackingStores are going to be detached and owned by // the Blob. - const { buffer, byteOffset, byteLength } = source; - const slice = buffer.slice(byteOffset, byteOffset + byteLength); - return [byteLength, new Uint8Array(slice)]; + const isBuffer = isArrayBuffer(source); + const isDataViewSource = !isBuffer && isDataView(source); + const buffer = isBuffer ? source : + isDataViewSource ? DataViewPrototypeGetBuffer(source) : TypedArrayPrototypeGetBuffer(source); + // Check before reading byteOffset and byteLength, whose DataView getters + // throw when the backing buffer is detached. + if (ArrayBufferPrototypeGetDetached(buffer)) + return [0, new Uint8Array()]; + const byteOffset = isBuffer ? 0 : + isDataViewSource ? DataViewPrototypeGetByteOffset(source) : TypedArrayPrototypeGetByteOffset(source); + const byteLength = isBuffer ? ArrayBufferPrototypeGetByteLength(source) : + isDataViewSource ? DataViewPrototypeGetByteLength(source) : TypedArrayPrototypeGetByteLength(source); + const copy = new Uint8Array(new Uint8Array(buffer, byteOffset, byteLength)); + return [byteLength, copy]; } -const sourcesConverter = createSequenceConverter((source, opts = kEmptyObject) => { +const blobPartsConverter = createSequenceConverter((source, opts = kEmptyObject) => { if (isBlob(source)) { return source; } + // Match browsers for bare SharedArrayBuffer pending a resolution of + // https://github.com/whatwg/webidl/issues/1491. if (isAnyArrayBuffer(source) || isArrayBufferView(source)) { return converters.BufferSource(source, opts); } - return converters.DOMString(source, opts); + return converters.USVString(source, opts); }); +const blobPropertyBag = [ + { + key: 'endings', + converter: createEnumConverter('EndingType', ['transparent', 'native']), + defaultValue: () => 'transparent', + }, + { + key: 'type', + converter: converters.DOMString, + defaultValue: () => '', + }, +]; + +const blobPropertyBagConverter = createDictionaryConverter('BlobPropertyBag', blobPropertyBag); + class Blob { /** * @typedef {string|ArrayBuffer|ArrayBufferView|Blob} SourcePart @@ -156,40 +192,38 @@ class Blob { * }} [options] * @constructs Blob */ - constructor(sources = [], options) { + constructor(sources = [], options, alreadyConverted) { markTransferMode(this, true, false); - const sources_ = sourcesConverter(sources, { - __proto__: null, - context: 'sources', - }); - - validateDictionary(options, 'options'); - let { - endings = 'transparent', - type = '', - } = options ?? kEmptyObject; - - endings = `${endings}`; - if (endings !== 'transparent' && endings !== 'native') - throw new ERR_INVALID_ARG_VALUE('options.endings', endings); + // File converts all of its arguments before entering the Blob constructor. + if (alreadyConverted !== kAlreadyConverted) { + sources = blobPartsConverter(sources, { + __proto__: null, + context: 'sources', + }); + options = blobPropertyBagConverter(options, { + __proto__: null, + context: 'options', + }); + } + const { endings, type } = options; let length = 0; - for (let i = 0; i < sources_.length; ++i) { - const { 0: len, 1: src } = getSource(sources_[i], endings); + for (let i = 0; i < sources.length; ++i) { + const { 0: len, 1: src } = getSource(sources[i], endings); length += len; - sources_[i] = src; + sources[i] = src; } if (length > kMaxLength) throw new ERR_BUFFER_TOO_LARGE(kMaxLength); - this[kHandle] = _createBlob(sources_, length); + this[kHandle] = _createBlob(sources, length); this[kLength] = length; - type = `${type}`; this[kType] = RegExpPrototypeExec(disallowedTypeCharacters, type) !== null ? '' : StringPrototypeToLowerCase(type); + blobBrand.add(this); } [kInspect](depth, options) { @@ -226,6 +260,7 @@ class Blob { this[kHandle] = handle; this[kType] = type; this[kLength] = length; + blobBrand.add(this); } /** @@ -357,6 +392,7 @@ function TransferableBlob(handle, length, type = '') { this[kHandle] = handle; this[kType] = type; this[kLength] = length; + blobBrand.add(this); } ObjectSetPrototypeOf(TransferableBlob.prototype, Blob.prototype); @@ -701,12 +737,15 @@ async function* createBlobReaderIterable(reader, options = kEmptyObject) { module.exports = { Blob, + blobPartsConverter, + blobPropertyBag, createBlob, createBlobFromFilePath, createBlobReaderIterable, createBlobReaderStream, getBlobDataSync, isBlob, + kAlreadyConverted, kHandle, kType, resolveObjectURL, diff --git a/lib/internal/file.js b/lib/internal/file.js index aa05a602975e..befbb4e11a30 100644 --- a/lib/internal/file.js +++ b/lib/internal/file.js @@ -3,19 +3,25 @@ const { DateNow, FunctionPrototypeCall, - NumberIsNaN, ObjectDefineProperties, ObjectSetPrototypeOf, - StringPrototypeToWellFormed, Symbol, SymbolToStringTag, } = primordials; const { Blob, + blobPartsConverter, + blobPropertyBag, + kAlreadyConverted, TransferableBlob, } = require('internal/blob'); +const { + converters, + createDictionaryConverter, +} = require('internal/webidl'); + const { customInspectSymbol: kInspect, kEnumerableProperty, @@ -40,6 +46,11 @@ const { const kState = Symbol('state'); +const filePropertyBagConverter = createDictionaryConverter('FilePropertyBag', [ + blobPropertyBag, + [{ key: 'lastModified', converter: converters['long long'] }], +]); + function isFile(object) { return object?.[kState] !== undefined; } @@ -64,22 +75,13 @@ class File extends Blob { throw new ERR_MISSING_ARGS('fileBits', 'fileName'); } - super(fileBits, options); + fileBits = blobPartsConverter(fileBits, { __proto__: null, context: 'fileBits' }); + fileName = converters.USVString(fileName, { __proto__: null, context: 'fileName' }); + options = filePropertyBagConverter(options, { __proto__: null, context: 'options' }); - let { lastModified } = options ?? kEmptyObject; - - if (lastModified !== undefined) { - // Using Number(...) will not throw an error for bigints. - lastModified = +lastModified; - - if (NumberIsNaN(lastModified)) { - lastModified = 0; - } - } else { - lastModified = DateNow(); - } + super(fileBits, options, kAlreadyConverted); - this[kState] = new FileState(StringPrototypeToWellFormed(`${fileName}`), lastModified); + this[kState] = new FileState(fileName, options.lastModified ?? DateNow()); } get name() { diff --git a/test/fixtures/wpt/FileAPI/BlobURL/WEB_FEATURES.yml b/test/fixtures/wpt/FileAPI/BlobURL/WEB_FEATURES.yml new file mode 100644 index 000000000000..69d4514d8f3a --- /dev/null +++ b/test/fixtures/wpt/FileAPI/BlobURL/WEB_FEATURES.yml @@ -0,0 +1,2 @@ +rules: +- "**": [file] diff --git a/test/fixtures/wpt/FileAPI/BlobURL/cross-partition-navigation.https.html b/test/fixtures/wpt/FileAPI/BlobURL/cross-partition-navigation.https.html index a92e0f7a8a9b..978370f8805b 100644 --- a/test/fixtures/wpt/FileAPI/BlobURL/cross-partition-navigation.https.html +++ b/test/fixtures/wpt/FileAPI/BlobURL/cross-partition-navigation.https.html @@ -247,5 +247,52 @@ }); }, "Blob URL area element click should enforce noopener for a cross-top-level-site navigation"); +const open_blob_url_window_via_svg_a_click = (blob_url) => ` + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const a = document.createElementNS("http://www.w3.org/2000/svg", "a"); + a.setAttribute("href", "${blob_url}"); + a.setAttribute("target", "_blank"); + a.setAttribute("rel", "opener"); + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.textContent = "click"; + a.appendChild(text); + svg.appendChild(a); + document.body.appendChild(svg); + a.dispatchEvent(new MouseEvent("click", {bubbles: true})); +`; + +// Tests blob URL SVG `` click for same and +// cross partition iframes. +promise_test(t => { + return new Promise(async (resolve, reject) => { + try { + // Creates same and cross partition iframes. + const noopener_response_queue = token(); + + const [cross_site_iframe_uuid, same_site_iframe_uuid] = await create_test_iframes(t, token()); + + const blob = new Blob([opener_check_frame_html(noopener_response_queue)], {type : "text/html"}); + const blob_url = URL.createObjectURL(blob); + + // Attempt to click blob URL in cross partition iframe. + await send(cross_site_iframe_uuid, open_blob_url_window_via_svg_a_click(blob_url)); + const noopener_response_1 = await receive(noopener_response_queue); + if (noopener_response_1 !== opener_null_response) { + reject(`Blob URL page opener wasn't null in not-same-top-level-site iframe.`); + } + + // Attempt to click blob URL in same partition iframe. + await send(same_site_iframe_uuid, open_blob_url_window_via_svg_a_click(blob_url)); + const noopener_response_2 = await receive(noopener_response_queue); + if (noopener_response_2 !== opener_not_null_response) { + reject(`Blob URL page opener was null in same-top-level-site iframe`); + } + resolve(); + } catch (e) { + reject(e); + } + }); +}, "Blob URL SVG element click should enforce noopener for a cross-top-level-site navigation"); + diff --git a/test/fixtures/wpt/FileAPI/BlobURL/opaque-origin.html b/test/fixtures/wpt/FileAPI/BlobURL/opaque-origin.html new file mode 100644 index 000000000000..c17e902128a5 --- /dev/null +++ b/test/fixtures/wpt/FileAPI/BlobURL/opaque-origin.html @@ -0,0 +1,47 @@ + + + + +Opaque origin blob URL created from workers + + + + + + + diff --git a/test/fixtures/wpt/FileAPI/FileReader/WEB_FEATURES.yml b/test/fixtures/wpt/FileAPI/FileReader/WEB_FEATURES.yml new file mode 100644 index 000000000000..69d4514d8f3a --- /dev/null +++ b/test/fixtures/wpt/FileAPI/FileReader/WEB_FEATURES.yml @@ -0,0 +1,2 @@ +rules: +- "**": [file] diff --git a/test/fixtures/wpt/FileAPI/FileReaderSync.worker.js b/test/fixtures/wpt/FileAPI/FileReaderSync.worker.js index 3d7a0222f312..372cc3c38505 100644 --- a/test/fixtures/wpt/FileAPI/FileReaderSync.worker.js +++ b/test/fixtures/wpt/FileAPI/FileReaderSync.worker.js @@ -28,7 +28,7 @@ test(() => { test(() => { var data = readerSync.readAsDataURL(empty_blob); - assert_equals(data.indexOf("data:"), 0); + assert_equals(data, "data:application/octet-stream;base64,"); }, "readAsDataURL with empty blob"); test(() => { @@ -41,6 +41,12 @@ test(() => { assert_equals(data, ""); }, "readAsBinaryString with empty blob"); +test(() => { + var data = readerSync.readAsBinaryString(new Blob(["σ"])); + assert_equals(data.length, 2, "The result length is 2"); + assert_equals(data, "\xcf\x83", "The result is \xcf\x83"); +}, "readAsBinaryString with multi-byte UTF-8 char"); + test(() => { var data = readerSync.readAsArrayBuffer(blob); assert_true(data instanceof ArrayBuffer); diff --git a/test/fixtures/wpt/FileAPI/WEB_FEATURES.yml b/test/fixtures/wpt/FileAPI/WEB_FEATURES.yml index 8d279ceaaeab..ea7c8227a285 100644 --- a/test/fixtures/wpt/FileAPI/WEB_FEATURES.yml +++ b/test/fixtures/wpt/FileAPI/WEB_FEATURES.yml @@ -1,3 +1,2 @@ -features: -- name: file - files: "**" +rules: +- "*": [file] diff --git a/test/fixtures/wpt/FileAPI/blob/Blob-array-buffer-data-isolation.html b/test/fixtures/wpt/FileAPI/blob/Blob-array-buffer-data-isolation.html new file mode 100644 index 000000000000..1bb7f3b2d797 --- /dev/null +++ b/test/fixtures/wpt/FileAPI/blob/Blob-array-buffer-data-isolation.html @@ -0,0 +1,85 @@ + + +Blob data isolation from input ArrayBuffers + + + + + + diff --git a/test/fixtures/wpt/FileAPI/blob/Blob-constructor-detached-buffer.any.js b/test/fixtures/wpt/FileAPI/blob/Blob-constructor-detached-buffer.any.js new file mode 100644 index 000000000000..1868199cfb83 --- /dev/null +++ b/test/fixtures/wpt/FileAPI/blob/Blob-constructor-detached-buffer.any.js @@ -0,0 +1,59 @@ +// META: title=Blob constructor with detached ArrayBuffer/ArrayBufferView +// META: script=../support/Blob.js +'use strict'; + +function detachBuffer(buffer) { + // Transfer the buffer to detach it. Works in both window and worker contexts. + new MessageChannel().port1.postMessage(buffer, [buffer]); +} + +test_blob(function() { + const buffer = new ArrayBuffer(4); + const view = new Uint8Array(buffer); + view[0] = 0x41; + view[1] = 0x42; + view[2] = 0x43; + view[3] = 0x44; + detachBuffer(buffer); + return new Blob([view]); +}, { + expected: '', + type: '', + desc: 'Blob from a detached ArrayBufferView should be empty' +}); + +test_blob(function() { + const buffer = new ArrayBuffer(4); + const view = new Uint8Array(buffer, 1, 2); // offset=1, length=2 + view[0] = 0x41; + view[1] = 0x42; + detachBuffer(buffer); + return new Blob([view]); +}, { + expected: '', + type: '', + desc: 'Blob from a detached ArrayBufferView with offset should be empty' +}); + +test_blob(function() { + const buffer = new ArrayBuffer(4); + detachBuffer(buffer); + return new Blob([buffer]); +}, { + expected: '', + type: '', + desc: 'Blob from a detached ArrayBuffer should be empty' +}); + +test_blob(function() { + const buffer = new ArrayBuffer(4); + const view = new Uint8Array(buffer); + view[0] = 0x41; + detachBuffer(buffer); + // Mix detached view with a string + return new Blob([view, 'hello']); +}, { + expected: 'hello', + type: '', + desc: 'Blob from a detached ArrayBufferView mixed with string, detached part ignored' +}); diff --git a/test/fixtures/wpt/FileAPI/blob/Blob-constructor-endings.any.js b/test/fixtures/wpt/FileAPI/blob/Blob-constructor-endings.any.js new file mode 100644 index 000000000000..1057818328e5 --- /dev/null +++ b/test/fixtures/wpt/FileAPI/blob/Blob-constructor-endings.any.js @@ -0,0 +1,35 @@ +// META: title=Blob constructor: endings option +[ + 'transparent', + 'native' +].forEach(value => test(t => { + assert_class_string(new Blob([], {endings: value}), 'Blob', + `Constructor should allow "${value}" endings`); +}, `Valid "endings" value: ${JSON.stringify(value)}`)); + +[ + null, + '', + 'invalidEnumValue', + 'Transparent', + 'NATIVE', + 0, + {} +].forEach(value => test(t => { + assert_throws_js(TypeError, () => new Blob([], {endings: value}), + 'Blob constructor should throw'); +}, `Invalid "endings" value: ${JSON.stringify(value)}`)); + +test(t => { + const test_error = {name: 'test'}; + assert_throws_exactly( + test_error, + () => new Blob([], { get endings() { throw test_error; }}), + 'Blob constructor should propagate exceptions from "endings" property'); +}, 'Exception propagation from options'); + +test(t => { + let got = false; + new Blob([], { get endings() { got = true; } }); + assert_true(got, 'The "endings" property was accessed during construction.'); +}, 'The "endings" options property is used'); \ No newline at end of file diff --git a/test/fixtures/wpt/FileAPI/blob/Blob-constructor-endings.html b/test/fixtures/wpt/FileAPI/blob/Blob-constructor-endings.html index 04edd2a303b1..a395ad78d2ee 100644 --- a/test/fixtures/wpt/FileAPI/blob/Blob-constructor-endings.html +++ b/test/fixtures/wpt/FileAPI/blob/Blob-constructor-endings.html @@ -19,41 +19,6 @@ }); } -[ - 'transparent', - 'native' -].forEach(value => test(t => { - assert_class_string(new Blob([], {endings: value}), 'Blob', - `Constructor should allow "${value}" endings`); -}, `Valid "endings" value: ${JSON.stringify(value)}`)); - -[ - null, - '', - 'invalidEnumValue', - 'Transparent', - 'NATIVE', - 0, - {} -].forEach(value => test(t => { - assert_throws_js(TypeError, () => new Blob([], {endings: value}), - 'Blob constructor should throw'); -}, `Invalid "endings" value: ${JSON.stringify(value)}`)); - -test(t => { - const test_error = {name: 'test'}; - assert_throws_exactly( - test_error, - () => new Blob([], { get endings() { throw test_error; }}), - 'Blob constructor should propagate exceptions from "endings" property'); -}, 'Exception propagation from options'); - -test(t => { - let got = false; - new Blob([], { get endings() { got = true; } }); - assert_true(got, 'The "endings" property was accessed during construction.'); -}, 'The "endings" options property is used'); - [ {name: 'LF', input: '\n', native: native_ending}, {name: 'CR', input: '\r', native: native_ending}, diff --git a/test/fixtures/wpt/FileAPI/blob/Blob-constructor.any.js b/test/fixtures/wpt/FileAPI/blob/Blob-constructor.any.js index 03155b99cb20..bdcc19416003 100644 --- a/test/fixtures/wpt/FileAPI/blob/Blob-constructor.any.js +++ b/test/fixtures/wpt/FileAPI/blob/Blob-constructor.any.js @@ -335,6 +335,8 @@ test_blob(function() { desc: "ArrayBuffer elements of the blobParts array should be supported." }); +var isLittleEndian = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1; + test_blob(function() { return new Blob([ new Uint8Array([0x50, 0x41, 0x53, 0x53]), @@ -346,7 +348,7 @@ test_blob(function() { new Float32Array([0xD341500000]) ]); }, { - expected: "PASSPASSPASSPASSPASSPASSPASS", + expected: isLittleEndian ? "PASSPASSPASSPASSPASSPASSPASS" : "PASSPASSAPSSAPSSSSAPSSAPSSAP", type: "", desc: "Passing typed arrays as elements of the blobParts array should work." }); @@ -355,20 +357,20 @@ test_blob(function() { new Float16Array([2.65625, 58.59375]) ]); }, { - expected: "PASS", + expected: isLittleEndian ? "PASS" : "APSS", type: "", desc: "Passing a Float16Array as element of the blobParts array should work." }); test_blob(function() { + // 0x535 3415053534150 + // 0x535 = 0b010100110101 -> Sign = +, Exponent = 1333 - 1023 = 310 + // 0x13415053534150 * 2**(-52) + // ==> 0x13415053534150 * 2**258 = 2510297372767036725005267563121821874921913208671273727396467555337665343087229079989707079680 return new Blob([ - // 0x535 3415053534150 - // 0x535 = 0b010100110101 -> Sign = +, Exponent = 1333 - 1023 = 310 - // 0x13415053534150 * 2**(-52) - // ==> 0x13415053534150 * 2**258 = 2510297372767036725005267563121821874921913208671273727396467555337665343087229079989707079680 new Float64Array([2510297372767036725005267563121821874921913208671273727396467555337665343087229079989707079680]) ]); }, { - expected: "PASSPASS", + expected: isLittleEndian ? "PASSPASS" : "SSAPSSAP", type: "", desc: "Passing a Float64Array as element of the blobParts array should work." }); @@ -379,7 +381,7 @@ test_blob(function() { new BigUint64Array([BigInt("0x5353415053534150")]) ]); }, { - expected: "PASSPASSPASSPASS", + expected: isLittleEndian ? "PASSPASSPASSPASS" : "SSAPSSAPSSAPSSAP", type: "", desc: "Passing BigInt typed arrays as elements of the blobParts array should work." }); @@ -528,3 +530,4 @@ type_tests.forEach(function(t) { assert_equals(b.type, t[2]); }, "Blob with type " + format_value(t[1])); }); + diff --git a/test/fixtures/wpt/FileAPI/blob/Blob-textStream.any.js b/test/fixtures/wpt/FileAPI/blob/Blob-textStream.any.js new file mode 100644 index 000000000000..7f22d8bb330d --- /dev/null +++ b/test/fixtures/wpt/FileAPI/blob/Blob-textStream.any.js @@ -0,0 +1,90 @@ +// META: title=Blob textStream +// META: global=window,worker +// META: script=../support/Blob.js +'use strict'; + +async function readAllChunks(stream) { + const reader = stream.getReader(); + const chunks = []; + while (true) { + const {done, value} = await reader.read(); + if (done) { + break; + } + chunks.push(value); + } + return chunks; +} + +test(() => { + assert_true('textStream' in Blob.prototype, "textStream exists on Blob.prototype"); + assert_equals(typeof Blob.prototype.textStream, "function", "Blob.prototype.textStream is a function"); +}, "textStream method existence"); + +promise_test(async () => { + const blob = new Blob(["hello world"]); + const stream = blob.textStream(); + assert_true(stream instanceof ReadableStream, "textStream() returns a ReadableStream"); + + const chunks = await readAllChunks(stream); + assert_greater_than(chunks.length, 0); + for (const chunk of chunks) { + assert_equals(typeof chunk, "string", "each chunk should be a string"); + } + assert_equals(chunks.join(""), "hello world", "concatenated chunks match the blob content"); +}, "Blob.textStream() basic functionality"); + +promise_test(async () => { + const blob = new Blob(); + const stream = blob.textStream(); + assert_true(stream instanceof ReadableStream); + const chunks = await readAllChunks(stream); + assert_equals(chunks.length, 0, "no chunks should be read from empty blob textStream"); +}, "Blob.textStream() with empty blob"); + +promise_test(async () => { + const blob = new Blob(["hello ", "world"]); + const stream = blob.textStream(); + const chunks = await readAllChunks(stream); + assert_equals(chunks.join(""), "hello world"); +}, "Blob.textStream() with multi-part blob"); + +promise_test(async () => { + const buffer = new Uint8Array([0x68, 0x00, 0x65, 0x00, 0x6c, 0x00, 0x6c, 0x00, 0x6f, 0x00]); // "hello" in UTF-16LE + const blob = new Blob([buffer], { + type: "text/plain; charset=utf-16le" + }); + const stream = blob.textStream(); + const chunks = await readAllChunks(stream); + assert_equals(chunks.join(""), "h\0e\0l\0l\0o\0", "ignores charset=utf-16le type parameter"); +}, "Blob.textStream() ignores type charset (UTF-16LE)"); + +promise_test(async () => { + const blob = new Blob([new TextEncoder().encode("hello")], { + type: "text/plain; charset=invalid-charset" + }); + const stream = blob.textStream(); + const chunks = await readAllChunks(stream); + assert_equals(chunks.join(""), "hello", "ignores invalid-charset type parameter and decodes as UTF-8"); +}, "Blob.textStream() ignores invalid type charset (invalid-charset)"); + +promise_test(async () => { + const blob = new Blob([new TextEncoder().encode("é")], { + type: "text/plain; charset=iso-8859-1" + }); + const stream = blob.textStream(); + const chunks = await readAllChunks(stream); + assert_equals(chunks.join(""), "é", "ignores charset=iso-8859-1 type parameter and decodes as UTF-8"); +}, "Blob.textStream() ignores type charset (iso-8859-1)"); + +promise_test(async () => { + const blob = new Blob(["hello"]); + const stream1 = blob.textStream(); + const stream2 = blob.textStream(); + assert_not_equals(stream1, stream2, "multiple calls return new streams"); + + const chunks1 = await readAllChunks(stream1); + const chunks2 = await readAllChunks(stream2); + assert_equals(chunks1.join(""), "hello"); + assert_equals(chunks2.join(""), "hello"); +}, "Blob.textStream() can be called multiple times"); diff --git a/test/fixtures/wpt/FileAPI/blob/WEB_FEATURES.yml b/test/fixtures/wpt/FileAPI/blob/WEB_FEATURES.yml new file mode 100644 index 000000000000..69d4514d8f3a --- /dev/null +++ b/test/fixtures/wpt/FileAPI/blob/WEB_FEATURES.yml @@ -0,0 +1,2 @@ +rules: +- "**": [file] diff --git a/test/fixtures/wpt/FileAPI/file/File-constructor-endings.any.js b/test/fixtures/wpt/FileAPI/file/File-constructor-endings.any.js new file mode 100644 index 000000000000..ac0c0a45a658 --- /dev/null +++ b/test/fixtures/wpt/FileAPI/file/File-constructor-endings.any.js @@ -0,0 +1,35 @@ +// META: title=File constructor: endings option +[ + 'transparent', + 'native' +].forEach(value => test(t => { + assert_class_string(new File([], "name", {endings: value}), 'File', + `Constructor should allow "${value}" endings`); +}, `Valid "endings" value: ${JSON.stringify(value)}`)); + +[ + null, + '', + 'invalidEnumValue', + 'Transparent', + 'NATIVE', + 0, + {} +].forEach(value => test(t => { + assert_throws_js(TypeError, () => new File([], "name", {endings: value}), + 'File constructor should throw'); +}, `Invalid "endings" value: ${JSON.stringify(value)}`)); + +test(t => { + const test_error = {name: 'test'}; + assert_throws_exactly( + test_error, + () => new File([], "name", { get endings() { throw test_error; }}), + 'File constructor should propagate exceptions from "endings" property'); +}, 'Exception propagation from options'); + +test(t => { + let got = false; + new File([], "name", { get endings() { got = true; } }); + assert_true(got, 'The "endings" property was accessed during construction.'); +}, 'The "endings" options property is used'); \ No newline at end of file diff --git a/test/fixtures/wpt/FileAPI/file/File-constructor-endings.html b/test/fixtures/wpt/FileAPI/file/File-constructor-endings.html index 1282b6c5ac2c..ffbc9867e9b9 100644 --- a/test/fixtures/wpt/FileAPI/file/File-constructor-endings.html +++ b/test/fixtures/wpt/FileAPI/file/File-constructor-endings.html @@ -19,41 +19,6 @@ }); } -[ - 'transparent', - 'native' -].forEach(value => test(t => { - assert_class_string(new File([], "name", {endings: value}), 'File', - `Constructor should allow "${value}" endings`); -}, `Valid "endings" value: ${JSON.stringify(value)}`)); - -[ - null, - '', - 'invalidEnumValue', - 'Transparent', - 'NATIVE', - 0, - {} -].forEach(value => test(t => { - assert_throws_js(TypeError, () => new File([], "name", {endings: value}), - 'File constructor should throw'); -}, `Invalid "endings" value: ${JSON.stringify(value)}`)); - -test(t => { - const test_error = {name: 'test'}; - assert_throws_exactly( - test_error, - () => new File([], "name", { get endings() { throw test_error; }}), - 'File constructor should propagate exceptions from "endings" property'); -}, 'Exception propagation from options'); - -test(t => { - let got = false; - new File([], "name", { get endings() { got = true; } }); - assert_true(got, 'The "endings" property was accessed during construction.'); -}, 'The "endings" options property is used'); - [ {name: 'LF', input: '\n', native: native_ending}, {name: 'CR', input: '\r', native: native_ending}, diff --git a/test/fixtures/wpt/FileAPI/file/File-lastModified.html b/test/fixtures/wpt/FileAPI/file/File-lastModified.html new file mode 100644 index 000000000000..b61e1b9b4d9f --- /dev/null +++ b/test/fixtures/wpt/FileAPI/file/File-lastModified.html @@ -0,0 +1,18 @@ + + +File's lastModified + + + + + diff --git a/test/fixtures/wpt/FileAPI/file/WEB_FEATURES.yml b/test/fixtures/wpt/FileAPI/file/WEB_FEATURES.yml new file mode 100644 index 000000000000..69d4514d8f3a --- /dev/null +++ b/test/fixtures/wpt/FileAPI/file/WEB_FEATURES.yml @@ -0,0 +1,2 @@ +rules: +- "**": [file] diff --git a/test/fixtures/wpt/FileAPI/filelist-section/WEB_FEATURES.yml b/test/fixtures/wpt/FileAPI/filelist-section/WEB_FEATURES.yml new file mode 100644 index 000000000000..841cbf85c271 --- /dev/null +++ b/test/fixtures/wpt/FileAPI/filelist-section/WEB_FEATURES.yml @@ -0,0 +1,3 @@ +rules: +- filelist_multiple_selected_files-manual.html: [input-file-multiple] +- "*": [file] diff --git a/test/fixtures/wpt/FileAPI/reading-data-section/FileReader-multiple-reads.any.js b/test/fixtures/wpt/FileAPI/reading-data-section/FileReader-multiple-reads.any.js index 4b19c69b4251..25ff25b06d66 100644 --- a/test/fixtures/wpt/FileAPI/reading-data-section/FileReader-multiple-reads.any.js +++ b/test/fixtures/wpt/FileAPI/reading-data-section/FileReader-multiple-reads.any.js @@ -79,3 +79,66 @@ async_test(function() { }); reader.readAsText(blob_1); }, 'test abort and restart in onloadstart event for readAsText'); + +promise_test(async t => { + const reader = new FileReader(); + const events = []; + + for (const type of ['abort', 'load', 'loadend']) { + reader.addEventListener(type, () => events.push(type)); + } + + reader.addEventListener('error', t.unreached_func('Unexpected error event')); + reader.addEventListener('abort', t.step_func(() => { + reader.readAsText(new Blob(['second'])); + }), + {once: true}); + + const firstLoadstart = new Promise(resolve => { + reader.addEventListener('loadstart', resolve, {once: true}); + }); + const secondLoadend = new Promise(resolve => { + reader.addEventListener('loadend', t.step_func(() => { + if (reader.result === 'second') { + resolve(); + } + })); + }); + + reader.readAsText(new Blob([new Uint8Array(1024 * 1024)])); + await firstLoadstart; + reader.abort(); + await secondLoadend; + + assert_array_equals(events, ['abort', 'load', 'loadend']); +}, 'A new read in an abort handler suppresses the old read loadend'); + +promise_test(async t => { + const reader = new FileReader(); + const events = []; + let load_count = 0; + + for (const type of ['load', 'loadend']) { + reader.addEventListener(type, () => events.push(type)); + } + + reader.addEventListener('error', t.unreached_func('Unexpected error event')); + reader.addEventListener('load', t.step_func(() => { + if (++load_count === 1) { + reader.readAsText(new Blob(['second'])); + } + })); + + const secondLoadend = new Promise(resolve => { + reader.addEventListener('loadend', t.step_func(() => { + if (reader.result === 'second') { + resolve(); + } + })); + }); + + reader.readAsText(new Blob(['first'])); + await secondLoadend; + + assert_array_equals(events, ['load', 'load', 'loadend']); +}, 'A new read in a load handler suppresses the old read loadend'); diff --git a/test/fixtures/wpt/FileAPI/reading-data-section/WEB_FEATURES.yml b/test/fixtures/wpt/FileAPI/reading-data-section/WEB_FEATURES.yml new file mode 100644 index 000000000000..69d4514d8f3a --- /dev/null +++ b/test/fixtures/wpt/FileAPI/reading-data-section/WEB_FEATURES.yml @@ -0,0 +1,2 @@ +rules: +- "**": [file] diff --git a/test/fixtures/wpt/FileAPI/reading-data-section/filereader_abort.any.js b/test/fixtures/wpt/FileAPI/reading-data-section/filereader_abort.any.js index c778ae55bb57..2563dce5ec2d 100644 --- a/test/fixtures/wpt/FileAPI/reading-data-section/filereader_abort.any.js +++ b/test/fixtures/wpt/FileAPI/reading-data-section/filereader_abort.any.js @@ -36,3 +36,32 @@ assert_equals(readerAbort.readyState, readerAbort.DONE); }); }, "Aborting after read"); + + promise_test(t => { + var reader = new FileReader(); + + var eventWatcher = new EventWatcher(t, reader, + ['abort', 'loadstart', 'loadend', 'error', 'load']); + + // First read: run to completion so the reader reaches the DONE state. + reader.readAsText(new Blob(["first read"])); + return eventWatcher.wait_for('loadstart') + .then(() => eventWatcher.wait_for('load')) + .then(() => eventWatcher.wait_for('loadend')) + .then(() => { + assert_equals(reader.readyState, reader.DONE); + + // Reuse the same reader for a second read and abort it while it is + // still LOADING. 'abort' and 'loadend' are dispatched synchronously, + // so call wait_for before calling abort. + reader.readAsText(new Blob(["second read"])); + assert_equals(reader.readyState, reader.LOADING); + var nextEvent = eventWatcher.wait_for(['abort', 'loadend']); + reader.abort(); + return nextEvent; + }) + .then(() => { + assert_equals(reader.result, null); + assert_equals(reader.readyState, reader.DONE); + }); + }, "Aborting a reused reader after a previous read completed"); diff --git a/test/fixtures/wpt/FileAPI/reading-data-section/filereader_readAsDataURL.html b/test/fixtures/wpt/FileAPI/reading-data-section/filereader_readAsDataURL.html new file mode 100644 index 000000000000..70ca365616d8 --- /dev/null +++ b/test/fixtures/wpt/FileAPI/reading-data-section/filereader_readAsDataURL.html @@ -0,0 +1,27 @@ + + +FileAPI Test: FileReader.readAsDataURL + + + + + diff --git a/test/fixtures/wpt/FileAPI/reading-data-section/filereader_readAsText_blob_type_charset.any.js b/test/fixtures/wpt/FileAPI/reading-data-section/filereader_readAsText_blob_type_charset.any.js new file mode 100644 index 000000000000..3692fbc2928b --- /dev/null +++ b/test/fixtures/wpt/FileAPI/reading-data-section/filereader_readAsText_blob_type_charset.any.js @@ -0,0 +1,51 @@ +// META: title=FileAPI Test: readAsText uses the Blob type's charset when no encoding argument is given + +// Per the File API "read as text" algorithm, the encoding is determined in this +// order: (1) the explicit `encoding` argument if it is a supported encoding; +// (2) otherwise, if the blob's `type` has a `charset` parameter, that charset; +// (3) otherwise UTF-8. These tests exercise step (2) with BOM-free data so that +// the charset parameter is the only thing that can select the right decoder +// (i.e. they are not accidentally satisfied by BOM sniffing). + +async_test(function() { + // 0x80 is the Euro sign (U+20AC) in windows-1252. As UTF-8 it is a lone + // continuation byte and decodes to U+FFFD. + var blob = new Blob([new Uint8Array([0x80])], { type: "text/plain;charset=windows-1252" }); + var reader = new FileReader(); + + reader.onloadend = this.step_func_done(function() { + assert_equals(reader.result, "€", + "readAsText should honor the windows-1252 charset from the Blob type and decode 0x80 as U+20AC, not UTF-8 U+FFFD."); + }); + + reader.readAsText(blob); +}, "readAsText() uses the charset parameter of the Blob's type (windows-1252)"); + +async_test(function() { + // "héllo" in windows-1252: 0xE9 is 'é'. As UTF-8, the lone 0xE9 is an + // invalid lead byte and decodes to U+FFFD. + var blob = new Blob([new Uint8Array([0x68, 0xE9, 0x6C, 0x6C, 0x6F])], { type: "text/plain;charset=windows-1252" }); + var reader = new FileReader(); + + reader.onloadend = this.step_func_done(function() { + assert_equals(reader.result, "héllo", + "readAsText should decode the body using the windows-1252 charset declared in the Blob type."); + }); + + reader.readAsText(blob); +}, "readAsText() decodes a windows-1252 Blob body using the type's charset"); + +async_test(function() { + // An explicit encoding argument must still win over the Blob type charset + // (step 1 of the algorithm). This already works in WebKit and guards against + // a fix that over-corrects by always preferring the type charset. + var blob = new Blob([new Uint8Array([0x80])], { type: "text/plain;charset=UTF-8" }); + var reader = new FileReader(); + + reader.onloadend = this.step_func_done(function() { + assert_equals(reader.result, "€", + "An explicit encoding argument takes precedence over the Blob type's charset."); + }); + + reader.readAsText(blob, "windows-1252"); +}, "readAsText() encoding argument overrides the Blob type's charset"); diff --git a/test/fixtures/wpt/FileAPI/unicode.html b/test/fixtures/wpt/FileAPI/unicode.any.js similarity index 61% rename from test/fixtures/wpt/FileAPI/unicode.html rename to test/fixtures/wpt/FileAPI/unicode.any.js index ce3e3579d7c2..841b937a67f6 100644 --- a/test/fixtures/wpt/FileAPI/unicode.html +++ b/test/fixtures/wpt/FileAPI/unicode.any.js @@ -1,46 +1,31 @@ - - -Blob/Unicode interaction: normalization and encoding - - - diff --git a/test/fixtures/wpt/FileAPI/url/WEB_FEATURES.yml b/test/fixtures/wpt/FileAPI/url/WEB_FEATURES.yml new file mode 100644 index 000000000000..69d4514d8f3a --- /dev/null +++ b/test/fixtures/wpt/FileAPI/url/WEB_FEATURES.yml @@ -0,0 +1,2 @@ +rules: +- "**": [file] diff --git a/test/fixtures/wpt/FileAPI/url/url-in-tags-revoke.window.js b/test/fixtures/wpt/FileAPI/url/url-in-tags-revoke.window.js index 1cdad79f7e34..476daba6f1b3 100644 --- a/test/fixtures/wpt/FileAPI/url/url-in-tags-revoke.window.js +++ b/test/fixtures/wpt/FileAPI/url/url-in-tags-revoke.window.js @@ -94,11 +94,30 @@ async_test(t => { e.onload = t.step_func_done(() => { assert_equals(window.script_test_result, run_result); }); + e.onerror = t.unreached_func(); document.body.appendChild(e); URL.revokeObjectURL(url); }, 'Fetching a blob URL immediately before revoking it works in