diff options
author | Luca Casonato <lucacasonato@yahoo.com> | 2021-04-14 22:49:16 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-04-14 22:49:16 +0200 |
commit | 353e79c796efc6c6aa328abb4d7cef5e642944af (patch) | |
tree | 5ae6d1778c0a1dfa53ac62e409d6da8cdb31285f | |
parent | 5214acd3d9dec56ee159544f0f6bf9834a62c097 (diff) |
chore: align FormData to spec (#10169)
This PR aligns `FormData` to spec. All WPT tests are passing.
-rw-r--r-- | cli/tests/unit/form_data_test.ts | 212 | ||||
-rw-r--r-- | cli/tests/unit/unit_tests.ts | 1 | ||||
-rw-r--r-- | op_crates/fetch/21_formdata.js | 529 | ||||
-rw-r--r-- | op_crates/fetch/26_fetch.js | 483 | ||||
-rw-r--r-- | op_crates/fetch/internal.d.ts | 9 | ||||
-rw-r--r-- | op_crates/fetch/lib.rs | 4 | ||||
-rw-r--r-- | op_crates/file/01_file.js | 12 | ||||
-rw-r--r-- | op_crates/file/02_filereader.js | 4 | ||||
-rw-r--r-- | op_crates/file/internal.d.ts | 2 | ||||
-rw-r--r-- | op_crates/web/01_mimesniff.js | 52 | ||||
-rw-r--r-- | op_crates/web/internal.d.ts | 3 | ||||
-rw-r--r-- | op_crates/web/lib.deno_web.d.ts | 51 | ||||
-rw-r--r-- | op_crates/webidl/00_webidl.js | 45 | ||||
-rw-r--r-- | op_crates/webidl/internal.d.ts | 12 | ||||
-rw-r--r-- | runtime/js/99_main.js | 3 | ||||
m--------- | test_util/wpt | 0 | ||||
-rw-r--r-- | tools/wpt/expectation.json | 16 |
17 files changed, 741 insertions, 697 deletions
diff --git a/cli/tests/unit/form_data_test.ts b/cli/tests/unit/form_data_test.ts deleted file mode 100644 index 1a948631d..000000000 --- a/cli/tests/unit/form_data_test.ts +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. -import { - assert, - assertEquals, - assertStringIncludes, - unitTest, -} from "./test_util.ts"; - -unitTest(function formDataHasCorrectNameProp(): void { - assertEquals(FormData.name, "FormData"); -}); - -unitTest(function formDataParamsAppendSuccess(): void { - const formData = new FormData(); - formData.append("a", "true"); - assertEquals(formData.get("a"), "true"); -}); - -unitTest(function formDataParamsDeleteSuccess(): void { - const formData = new FormData(); - formData.append("a", "true"); - formData.append("b", "false"); - assertEquals(formData.get("b"), "false"); - formData.delete("b"); - assertEquals(formData.get("a"), "true"); - assertEquals(formData.get("b"), null); -}); - -unitTest(function formDataParamsGetAllSuccess(): void { - const formData = new FormData(); - formData.append("a", "true"); - formData.append("b", "false"); - formData.append("a", "null"); - assertEquals(formData.getAll("a"), ["true", "null"]); - assertEquals(formData.getAll("b"), ["false"]); - assertEquals(formData.getAll("c"), []); -}); - -unitTest(function formDataParamsGetSuccess(): void { - const formData = new FormData(); - formData.append("a", "true"); - formData.append("b", "false"); - formData.append("a", "null"); - // deno-lint-ignore no-explicit-any - formData.append("d", undefined as any); - // deno-lint-ignore no-explicit-any - formData.append("e", null as any); - assertEquals(formData.get("a"), "true"); - assertEquals(formData.get("b"), "false"); - assertEquals(formData.get("c"), null); - assertEquals(formData.get("d"), "undefined"); - assertEquals(formData.get("e"), "null"); -}); - -unitTest(function formDataParamsHasSuccess(): void { - const formData = new FormData(); - formData.append("a", "true"); - formData.append("b", "false"); - assert(formData.has("a")); - assert(formData.has("b")); - assert(!formData.has("c")); -}); - -unitTest(function formDataParamsSetSuccess(): void { - const formData = new FormData(); - formData.append("a", "true"); - formData.append("b", "false"); - formData.append("a", "null"); - assertEquals(formData.getAll("a"), ["true", "null"]); - assertEquals(formData.getAll("b"), ["false"]); - formData.set("a", "false"); - assertEquals(formData.getAll("a"), ["false"]); - // deno-lint-ignore no-explicit-any - formData.set("d", undefined as any); - assertEquals(formData.get("d"), "undefined"); - // deno-lint-ignore no-explicit-any - formData.set("e", null as any); - assertEquals(formData.get("e"), "null"); -}); - -unitTest(function fromDataUseFile(): void { - const formData = new FormData(); - const file = new File(["foo"], "bar", { - type: "text/plain", - }); - formData.append("file", file); - assertEquals(formData.get("file"), file); -}); - -unitTest(function formDataSetEmptyBlobSuccess(): void { - const formData = new FormData(); - formData.set("a", new Blob([]), "blank.txt"); - formData.get("a"); - /* TODO Fix this test. - assert(file instanceof File); - if (typeof file !== "string") { - assertEquals(file.name, "blank.txt"); - } - */ -}); - -unitTest(function formDataBlobFilename(): void { - const formData = new FormData(); - const content = new TextEncoder().encode("deno"); - formData.set("a", new Blob([content])); - const file = formData.get("a"); - assert(file instanceof File); - assertEquals(file.name, "blob"); -}); - -unitTest(function formDataParamsForEachSuccess(): void { - const init = [ - ["a", "54"], - ["b", "true"], - ]; - const formData = new FormData(); - for (const [name, value] of init) { - formData.append(name, value); - } - let callNum = 0; - formData.forEach((value, key, parent): void => { - assertEquals(formData, parent); - assertEquals(value, init[callNum][1]); - assertEquals(key, init[callNum][0]); - callNum++; - }); - assertEquals(callNum, init.length); -}); - -unitTest(function formDataParamsArgumentsCheck(): void { - const methodRequireOneParam = [ - "delete", - "getAll", - "get", - "has", - "forEach", - ] as const; - - const methodRequireTwoParams = ["append", "set"] as const; - - methodRequireOneParam.forEach((method): void => { - const formData = new FormData(); - let hasThrown = 0; - let errMsg = ""; - try { - // deno-lint-ignore no-explicit-any - (formData as any)[method](); - hasThrown = 1; - } catch (err) { - errMsg = err.message; - if (err instanceof TypeError) { - hasThrown = 2; - } else { - hasThrown = 3; - } - } - assertEquals(hasThrown, 2); - assertStringIncludes( - errMsg, - `${method} requires at least 1 argument, but only 0 present`, - ); - }); - - methodRequireTwoParams.forEach((method: string): void => { - const formData = new FormData(); - let hasThrown = 0; - let errMsg = ""; - - try { - // deno-lint-ignore no-explicit-any - (formData as any)[method](); - hasThrown = 1; - } catch (err) { - errMsg = err.message; - if (err instanceof TypeError) { - hasThrown = 2; - } else { - hasThrown = 3; - } - } - assertEquals(hasThrown, 2); - assertStringIncludes( - errMsg, - `${method} requires at least 2 arguments, but only 0 present`, - ); - - hasThrown = 0; - errMsg = ""; - try { - // deno-lint-ignore no-explicit-any - (formData as any)[method]("foo"); - hasThrown = 1; - } catch (err) { - errMsg = err.message; - if (err instanceof TypeError) { - hasThrown = 2; - } else { - hasThrown = 3; - } - } - assertEquals(hasThrown, 2); - assertStringIncludes( - errMsg, - `${method} requires at least 2 arguments, but only 1 present`, - ); - }); -}); - -unitTest(function toStringShouldBeWebCompatibility(): void { - const formData = new FormData(); - assertEquals(formData.toString(), "[object FormData]"); -}); diff --git a/cli/tests/unit/unit_tests.ts b/cli/tests/unit/unit_tests.ts index ebf87651d..4cfd3d961 100644 --- a/cli/tests/unit/unit_tests.ts +++ b/cli/tests/unit/unit_tests.ts @@ -24,7 +24,6 @@ import "./file_test.ts"; import "./filereader_test.ts"; import "./files_test.ts"; import "./filter_function_test.ts"; -import "./form_data_test.ts"; import "./format_error_test.ts"; import "./fs_events_test.ts"; import "./get_random_values_test.ts"; diff --git a/op_crates/fetch/21_formdata.js b/op_crates/fetch/21_formdata.js new file mode 100644 index 000000000..106b67da4 --- /dev/null +++ b/op_crates/fetch/21_formdata.js @@ -0,0 +1,529 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +// @ts-check +/// <reference path="../webidl/internal.d.ts" /> +/// <reference path="../web/internal.d.ts" /> +/// <reference path="../file/internal.d.ts" /> +/// <reference path="../file/lib.deno_file.d.ts" /> +/// <reference path="./internal.d.ts" /> +/// <reference path="./11_streams_types.d.ts" /> +/// <reference path="./lib.deno_fetch.d.ts" /> +/// <reference lib="esnext" /> +"use strict"; + +((window) => { + const webidl = globalThis.__bootstrap.webidl; + const { Blob, File, _byteSequence } = globalThis.__bootstrap.file; + + const entryList = Symbol("entry list"); + + /** + * @param {string} name + * @param {string | Blob} value + * @param {string | undefined} filename + * @returns {FormDataEntry} + */ + function createEntry(name, value, filename) { + if (value instanceof Blob && !(value instanceof File)) { + value = new File([value[_byteSequence]], "blob", { type: value.type }); + } + if (value instanceof File && filename !== undefined) { + value = new File([value[_byteSequence]], filename, { + type: value.type, + lastModified: value.lastModified, + }); + } + return { + name, + // @ts-expect-error because TS is not smart enough + value, + }; + } + + /** + * @typedef FormDataEntry + * @property {string} name + * @property {FormDataEntryValue} value + */ + + class FormData { + get [Symbol.toStringTag]() { + return "FormData"; + } + + /** @type {FormDataEntry[]} */ + [entryList] = []; + + /** @param {void} form */ + constructor(form) { + if (form !== undefined) { + webidl.illegalConstructor(); + } + this[webidl.brand] = webidl.brand; + } + + /** + * @param {string} name + * @param {string | Blob} valueOrBlobValue + * @param {string} [filename] + * @returns {void} + */ + append(name, valueOrBlobValue, filename) { + webidl.assertBranded(this, FormData); + const prefix = "Failed to execute 'append' on 'FormData'"; + webidl.requiredArguments(arguments.length, 2, { prefix }); + + name = webidl.converters["USVString"](name, { + prefix, + context: "Argument 1", + }); + if (valueOrBlobValue instanceof Blob) { + valueOrBlobValue = webidl.converters["Blob"](valueOrBlobValue, { + prefix, + context: "Argument 2", + }); + if (filename !== undefined) { + filename = webidl.converters["USVString"](filename, { + prefix, + context: "Argument 3", + }); + } + } else { + valueOrBlobValue = webidl.converters["USVString"](valueOrBlobValue, { + prefix, + context: "Argument 2", + }); + } + + const entry = createEntry(name, valueOrBlobValue, filename); + + this[entryList].push(entry); + } + + /** + * @param {string} name + * @returns {void} + */ + delete(name) { + webidl.assertBranded(this, FormData); + const prefix = "Failed to execute 'name' on 'FormData'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + + name = webidl.converters["USVString"](name, { + prefix, + context: "Argument 1", + }); + + const list = this[entryList]; + for (let i = 0; i < list.length; i++) { + if (list[i].name === name) { + list.splice(i, 1); + i--; + } + } + } + + /** + * @param {string} name + * @returns {FormDataEntryValue | null} + */ + get(name) { + webidl.assertBranded(this, FormData); + const prefix = "Failed to execute 'get' on 'FormData'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + + name = webidl.converters["USVString"](name, { + prefix, + context: "Argument 1", + }); + + for (const entry of this[entryList]) { + if (entry.name === name) return entry.value; + } + return null; + } + + /** + * @param {string} name + * @returns {FormDataEntryValue[]} + */ + getAll(name) { + webidl.assertBranded(this, FormData); + const prefix = "Failed to execute 'getAll' on 'FormData'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + + name = webidl.converters["USVString"](name, { + prefix, + context: "Argument 1", + }); + + const returnList = []; + for (const entry of this[entryList]) { + if (entry.name === name) returnList.push(entry.value); + } + return returnList; + } + + /** + * @param {string} name + * @returns {boolean} + */ + has(name) { + webidl.assertBranded(this, FormData); + const prefix = "Failed to execute 'has' on 'FormData'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + + name = webidl.converters["USVString"](name, { + prefix, + context: "Argument 1", + }); + + for (const entry of this[entryList]) { + if (entry.name === name) return true; + } + return false; + } + + /** + * @param {string} name + * @param {string | Blob} valueOrBlobValue + * @param {string} [filename] + * @returns {void} + */ + set(name, valueOrBlobValue, filename) { + webidl.assertBranded(this, FormData); + const prefix = "Failed to execute 'set' on 'FormData'"; + webidl.requiredArguments(arguments.length, 2, { prefix }); + + name = webidl.converters["USVString"](name, { + prefix, + context: "Argument 1", + }); + if (valueOrBlobValue instanceof Blob) { + valueOrBlobValue = webidl.converters["Blob"](valueOrBlobValue, { + prefix, + context: "Argument 2", + }); + if (filename !== undefined) { + filename = webidl.converters["USVString"](filename, { + prefix, + context: "Argument 3", + }); + } + } else { + valueOrBlobValue = webidl.converters["USVString"](valueOrBlobValue, { + prefix, + context: "Argument 2", + }); + } + + const entry = createEntry(name, valueOrBlobValue, filename); + + const list = this[entryList]; + let added = false; + for (let i = 0; i < list.length; i++) { + if (list[i].name === name) { + if (!added) { + list[i] = entry; + added = true; + } else { + list.splice(i, 1); + i--; + } + } + } + if (!added) { + list.push(entry); + } + } + } + + webidl.mixinPairIterable("FormData", FormData, entryList, "name", "value"); + + const encoder = new TextEncoder(); + + class MultipartBuilder { + /** + * @param {FormData} formData + */ + constructor(formData) { + this.entryList = formData[entryList]; + this.boundary = this.#createBoundary(); + /** @type {Uint8Array[]} */ + this.chunks = []; + } + + /** + * @returns {string} + */ + getContentType() { + return `multipart/form-data; boundary=${this.boundary}`; + } + + /** + * @returns {Uint8Array} + */ + getBody() { + for (const { name, value } of this.entryList) { + if (value instanceof File) { + this.#writeFile(name, value); + } else this.#writeField(name, value); + } + + this.chunks.push(encoder.encode(`\r\n--${this.boundary}--`)); + + let totalLength = 0; + for (const chunk of this.chunks) { + totalLength += chunk.byteLength; + } + + const finalBuffer = new Uint8Array(totalLength); + let i = 0; + for (const chunk of this.chunks) { + finalBuffer.set(chunk, i); + i += chunk.byteLength; + } + + return finalBuffer; + } + + #createBoundary = () => { + return ( + "----------" + + Array.from(Array(32)) + .map(() => Math.random().toString(36)[2] || 0) + .join("") + ); + }; + + /** + * @param {[string, string][]} headers + * @returns {void} + */ + #writeHeaders = (headers) => { + let buf = (this.chunks.length === 0) ? "" : "\r\n"; + + buf += `--${this.boundary}\r\n`; + for (const [key, value] of headers) { + buf += `${key}: ${value}\r\n`; + } + buf += `\r\n`; + + this.chunks.push(encoder.encode(buf)); + }; + + /** + * @param {string} field + * @param {string} filename + * @param {string} [type] + * @returns {void} + */ + #writeFileHeaders = ( + field, + filename, + type, + ) => { + /** @type {[string, string][]} */ + const headers = [ + [ + "Content-Disposition", + `form-data; name="${field}"; filename="${filename}"`, + ], + ["Content-Type", type || "application/octet-stream"], + ]; + return this.#writeHeaders(headers); + }; + + /** + * @param {string} field + * @returns {void} + */ + #writeFieldHeaders = (field) => { + /** @type {[string, string][]} */ + const headers = [["Content-Disposition", `form-data; name="${field}"`]]; + return this.#writeHeaders(headers); + }; + + /** + * @param {string} field + * @param {string} value + * @returns {void} + */ + #writeField = (field, value) => { + this.#writeFieldHeaders(field); + this.chunks.push(encoder.encode(value)); + }; + + /** + * @param {string} field + * @param {File} value + * @returns {void} + */ + #writeFile = (field, value) => { + this.#writeFileHeaders(field, value.name, value.type); + this.chunks.push(value[_byteSequence]); + }; + } + + /** + * @param {FormData} formdata + * @returns {{body: Uint8Array, contentType: string}} + */ + function encodeFormData(formdata) { + const builder = new MultipartBuilder(formdata); + return { + body: builder.getBody(), + contentType: builder.getContentType(), + }; + } + + /** + * @param {string} value + * @returns {Map<string, string>} + */ + function parseContentDisposition(value) { + /** @type {Map<string, string>} */ + const params = new Map(); + // Forced to do so for some Map constructor param mismatch + value + .split(";") + .slice(1) + .map((s) => s.trim().split("=")) + .filter((arr) => arr.length > 1) + .map(([k, v]) => [k, v.replace(/^"([^"]*)"$/, "$1")]) + .forEach(([k, v]) => params.set(k, v)); + return params; + } + + const LF = "\n".codePointAt(0); + const CR = "\r".codePointAt(0); + const decoder = new TextDecoder("utf-8"); + + class MultipartParser { + /** + * @param {Uint8Array} body + * @param {string | undefined} boundary + */ + constructor(body, boundary) { + if (!boundary) { + throw new TypeError("multipart/form-data must provide a boundary"); + } + + this.boundary = `--${boundary}`; + this.body = body; + this.boundaryChars = encoder.encode(this.boundary); + } + + /** + * @param {string} headersText + * @returns {{ headers: Headers, disposition: Map<string, string> }} + */ + #parseHeaders = (headersText) => { + const headers = new Headers(); + const rawHeaders = headersText.split("\r\n"); + for (const rawHeader of rawHeaders) { + const sepIndex = rawHeader.indexOf(":"); + if (sepIndex < 0) { + continue; // Skip this header + } + const key = rawHeader.slice(0, sepIndex); + const value = rawHeader.slice(sepIndex + 1); + headers.set(key, value); + } + + const disposition = parseContentDisposition( + headers.get("Content-Disposition") ?? "", + ); + + return { headers, disposition }; + }; + + /** + * @returns {FormData} + */ + parse() { + const formData = new FormData(); + let headerText = ""; + let boundaryIndex = 0; + let state = 0; + let fileStart = 0; + + for (let i = 0; i < this.body.length; i++) { + const byte = this.body[i]; + const prevByte = this.body[i - 1]; + const isNewLine = byte === LF && prevByte === CR; + + if (state === 1 || state === 2 || state == 3) { + headerText += String.fromCharCode(byte); + } + if (state === 0 && isNewLine) { + state = 1; + } else if (state === 1 && isNewLine) { + state = 2; + const headersDone = this.body[i + 1] === CR && + this.body[i + 2] === LF; + + if (headersDone) { + state = 3; + } + } else if (state === 2 && isNewLine) { + state = 3; + } else if (state === 3 && isNewLine) { + state = 4; + fileStart = i + 1; + } else if (state === 4) { + if (this.boundaryChars[boundaryIndex] !== byte) { + boundaryIndex = 0; + } else { + boundaryIndex++; + } + + if (boundaryIndex >= this.boundary.length) { + const { headers, disposition } = this.#parseHeaders(headerText); + const content = this.body.subarray( + fileStart, + i - boundaryIndex - 1, + ); + // https://fetch.spec.whatwg.org/#ref-for-dom-body-formdata + const filename = disposition.get("filename"); + const name = disposition.get("name"); + + state = 5; + // Reset + boundaryIndex = 0; + headerText = ""; + + if (!name) { + continue; // Skip, unknown name + } + + if (filename) { + const blob = new Blob([content], { + type: headers.get("Content-Type") || "application/octet-stream", + }); + formData.append(name, blob, filename); + } else { + formData.append(name, decoder.decode(content)); + } + } + } else if (state === 5 && isNewLine) { + state = 1; + } + } + + return formData; + } + } + + /** + * @param {Uint8Array} body + * @param {string | undefined} boundary + * @returns {FormData} + */ + function parseFormData(body, boundary) { + const parser = new MultipartParser(body, boundary); + return parser.parse(); + } + + globalThis.__bootstrap.formData = { FormData, encodeFormData, parseFormData }; +})(globalThis); diff --git a/op_crates/fetch/26_fetch.js b/op_crates/fetch/26_fetch.js index e713b48ed..d07121e86 100644 --- a/op_crates/fetch/26_fetch.js +++ b/op_crates/fetch/26_fetch.js @@ -3,6 +3,7 @@ // @ts-check /// <reference path="../../core/lib.deno_core.d.ts" /> /// <reference path="../web/internal.d.ts" /> +/// <reference path="../url/internal.d.ts" /> /// <reference path="../web/lib.deno_web.d.ts" /> /// <reference path="./11_streams_types.d.ts" /> /// <reference path="./internal.d.ts" /> @@ -16,11 +17,12 @@ // provided by "deno_web" const { URLSearchParams } = window.__bootstrap.url; const { getLocationHref } = window.__bootstrap.location; + const { FormData, parseFormData, encodeFormData } = + window.__bootstrap.formData; + const { parseMimeType } = window.__bootstrap.mimesniff; - const { requiredArguments } = window.__bootstrap.fetchUtil; const { ReadableStream, isReadableStreamDisturbed } = window.__bootstrap.streams; - const { DomIterableMixin } = window.__bootstrap.domIterable; const { Headers } = window.__bootstrap.headers; const { Blob, _byteSequence, File } = window.__bootstrap.file; @@ -203,395 +205,6 @@ } /** - * @param {string} value - * @returns {Map<string, string>} - */ - function getHeaderValueParams(value) { - /** @type {Map<string, string>} */ - const params = new Map(); - // Forced to do so for some Map constructor param mismatch - value - .split(";") - .slice(1) - .map((s) => s.trim().split("=")) - .filter((arr) => arr.length > 1) - .map(([k, v]) => [k, v.replace(/^"([^"]*)"$/, "$1")]) - .forEach(([k, v]) => params.set(k, v)); - return params; - } - - const decoder = new TextDecoder(); - const encoder = new TextEncoder(); - const CR = "\r".charCodeAt(0); - const LF = "\n".charCodeAt(0); - - const dataSymbol = Symbol("data"); - - /** - * @param {Blob | string} value - * @param {string | undefined} filename - * @returns {FormDataEntryValue} - */ - function parseFormDataValue(value, filename) { - if (value instanceof File) { - return new File([value], filename || value.name, { - type: value.type, - lastModified: value.lastModified, - }); - } else if (value instanceof Blob) { - return new File([value], filename || "blob", { - type: value.type, - }); - } else { - return String(value); - } - } - - class FormDataBase { - /** @type {[name: string, entry: FormDataEntryValue][]} */ - [dataSymbol] = []; - - /** - * @param {string} name - * @param {string | Blob} value - * @param {string} [filename] - * @returns {void} - */ - append(name, value, filename) { - requiredArguments("FormData.append", arguments.length, 2); - name = String(name); - this[dataSymbol].push([name, parseFormDataValue(value, filename)]); - } - - /** - * @param {string} name - * @returns {void} - */ - delete(name) { - requiredArguments("FormData.delete", arguments.length, 1); - name = String(name); - let i = 0; - while (i < this[dataSymbol].length) { - if (this[dataSymbol][i][0] === name) { - this[dataSymbol].splice(i, 1); - } else { - i++; - } - } - } - - /** - * @param {string} name - * @returns {FormDataEntryValue[]} - */ - getAll(name) { - requiredArguments("FormData.getAll", arguments.length, 1); - name = String(name); - const values = []; - for (const entry of this[dataSymbol]) { - if (entry[0] === name) { - values.push(entry[1]); - } - } - - return values; - } - - /** - * @param {string} name - * @returns {FormDataEntryValue | null} - */ - get(name) { - requiredArguments("FormData.get", arguments.length, 1); - name = String(name); - for (const entry of this[dataSymbol]) { - if (entry[0] === name) { - return entry[1]; - } - } - - return null; - } - - /** - * @param {string} name - * @returns {boolean} - */ - has(name) { - requiredArguments("FormData.has", arguments.length, 1); - name = String(name); - return this[dataSymbol].some((entry) => entry[0] === name); - } - - /** - * @param {string} name - * @param {string | Blob} value - * @param {string} [filename] - * @returns {void} - */ - set(name, value, filename) { - requiredArguments("FormData.set", arguments.length, 2); - name = String(name); - - // If there are any entries in the context object’s entry list whose name - // is name, replace the first such entry with entry and remove the others - let found = false; - let i = 0; - while (i < this[dataSymbol].length) { - if (this[dataSymbol][i][0] === name) { - if (!found) { - this[dataSymbol][i][1] = parseFormDataValue(value, filename); - found = true; - } else { - this[dataSymbol].splice(i, 1); - continue; - } - } - i++; - } - - // Otherwise, append entry to the context object’s entry list. - if (!found) { - this[dataSymbol].push([name, parseFormDataValue(value, filename)]); - } - } - - get [Symbol.toStringTag]() { - return "FormData"; - } - } - - class FormData extends DomIterableMixin(FormDataBase, dataSymbol) {} - - class MultipartBuilder { - /** - * @param {FormData} formData - * @param {string} [boundary] - */ - constructor(formData, boundary) { - this.formData = formData; - this.boundary = boundary ?? this.#createBoundary(); - this.writer = new Buffer(); - } - - /** - * @returns {string} - */ - getContentType() { - return `multipart/form-data; boundary=${this.boundary}`; - } - - /** - * @returns {Uint8Array} - */ - getBody() { - for (const [fieldName, fieldValue] of this.formData.entries()) { - if (fieldValue instanceof File) { - this.#writeFile(fieldName, fieldValue); - } else this.#writeField(fieldName, fieldValue); - } - - this.writer.writeSync(encoder.encode(`\r\n--${this.boundary}--`)); - - return this.writer.bytes(); - } - - #createBoundary = () => { - return ( - "----------" + - Array.from(Array(32)) - .map(() => Math.random().toString(36)[2] || 0) - .join("") - ); - }; - - /** - * @param {[string, string][]} headers - * @returns {void} - */ - #writeHeaders = (headers) => { - let buf = this.writer.empty() ? "" : "\r\n"; - - buf += `--${this.boundary}\r\n`; - for (const [key, value] of headers) { - buf += `${key}: ${value}\r\n`; - } - buf += `\r\n`; - - this.writer.writeSync(encoder.encode(buf)); - }; - - /** - * @param {string} field - * @param {string} filename - * @param {string} [type] - * @returns {void} - */ - #writeFileHeaders = ( - field, - filename, - type, - ) => { - /** @type {[string, string][]} */ - const headers = [ - [ - "Content-Disposition", - `form-data; name="${field}"; filename="${filename}"`, - ], - ["Content-Type", type || "application/octet-stream"], - ]; - return this.#writeHeaders(headers); - }; - - /** - * @param {string} field - * @returns {void} - */ - #writeFieldHeaders = (field) => { - /** @type {[string, string][]} */ - const headers = [["Content-Disposition", `form-data; name="${field}"`]]; - return this.#writeHeaders(headers); - }; - - /** - * @param {string} field - * @param {string} value - * @returns {void} - */ - #writeField = (field, value) => { - this.#writeFieldHeaders(field); - this.writer.writeSync(encoder.encode(value)); - }; - - /** - * @param {string} field - * @param {File} value - * @returns {void} - */ - #writeFile = (field, value) => { - this.#writeFileHeaders(field, value.name, value.type); - this.writer.writeSync(value[_byteSequence]); - }; - } - - class MultipartParser { - /** - * @param {Uint8Array} body - * @param {string | undefined} boundary - */ - constructor(body, boundary) { - if (!boundary) { - throw new TypeError("multipart/form-data must provide a boundary"); - } - - this.boundary = `--${boundary}`; - this.body = body; - this.boundaryChars = encoder.encode(this.boundary); - } - - /** - * @param {string} headersText - * @returns {{ headers: Headers, disposition: Map<string, string> }} - */ - #parseHeaders = (headersText) => { - const headers = new Headers(); - const rawHeaders = headersText.split("\r\n"); - for (const rawHeader of rawHeaders) { - const sepIndex = rawHeader.indexOf(":"); - if (sepIndex < 0) { - continue; // Skip this header - } - const key = rawHeader.slice(0, sepIndex); - const value = rawHeader.slice(sepIndex + 1); - headers.set(key, value); - } - - return { - headers, - disposition: getHeaderValueParams( - headers.get("Content-Disposition") ?? "", - ), - }; - }; - - /** - * @returns {FormData} - */ - parse() { - const formData = new FormData(); - let headerText = ""; - let boundaryIndex = 0; - let state = 0; - let fileStart = 0; - - for (let i = 0; i < this.body.length; i++) { - const byte = this.body[i]; - const prevByte = this.body[i - 1]; - const isNewLine = byte === LF && prevByte === CR; - - if (state === 1 || state === 2 || state == 3) { - headerText += String.fromCharCode(byte); - } - if (state === 0 && isNewLine) { - state = 1; - } else if (state === 1 && isNewLine) { - state = 2; - const headersDone = this.body[i + 1] === CR && - this.body[i + 2] === LF; - - if (headersDone) { - state = 3; - } - } else if (state === 2 && isNewLine) { - state = 3; - } else if (state === 3 && isNewLine) { - state = 4; - fileStart = i + 1; - } else if (state === 4) { - if (this.boundaryChars[boundaryIndex] !== byte) { - boundaryIndex = 0; - } else { - boundaryIndex++; - } - - if (boundaryIndex >= this.boundary.length) { - const { headers, disposition } = this.#parseHeaders(headerText); - const content = this.body.subarray( - fileStart, - i - boundaryIndex - 1, - ); - // https://fetch.spec.whatwg.org/#ref-for-dom-body-formdata - const filename = disposition.get("filename"); - const name = disposition.get("name"); - - state = 5; - // Reset - boundaryIndex = 0; - headerText = ""; - - if (!name) { - continue; // Skip, unknown name - } - - if (filename) { - const blob = new Blob([content], { - type: headers.get("Content-Type") || "application/octet-stream", - }); - formData.append(name, blob, filename); - } else { - formData.append(name, decoder.decode(content)); - } - } - } else if (state === 5 && isNewLine) { - state = 1; - } - } - - return formData; - } - } - - /** * @param {string} name * @param {BodyInit | null} bodySource */ @@ -785,46 +398,46 @@ /** @returns {Promise<FormData>} */ async formData() { const formData = new FormData(); - if (hasHeaderValueOf(this.#contentType, "multipart/form-data")) { - const params = getHeaderValueParams(this.#contentType); - - // ref: https://tools.ietf.org/html/rfc2046#section-5.1 - const boundary = params.get("boundary"); - const body = new Uint8Array(await this.arrayBuffer()); - const multipartParser = new MultipartParser(body, boundary); - - return multipartParser.parse(); - } else if ( - hasHeaderValueOf(this.#contentType, "application/x-www-form-urlencoded") - ) { - // From https://github.com/github/fetch/blob/master/fetch.js - // Copyright (c) 2014-2016 GitHub, Inc. MIT License - const body = await this.text(); - try { - body - .trim() - .split("&") - .forEach((bytes) => { - if (bytes) { - const split = bytes.split("="); - if (split.length >= 2) { - // @ts-expect-error this is safe because of the above check - const name = split.shift().replace(/\+/g, " "); - const value = split.join("=").replace(/\+/g, " "); - formData.append( - decodeURIComponent(name), - decodeURIComponent(value), - ); + const mimeType = parseMimeType(this.#contentType); + if (mimeType) { + if (mimeType.type === "multipart" && mimeType.subtype === "form-data") { + // ref: https://tools.ietf.org/html/rfc2046#section-5.1 + const boundary = mimeType.parameters.get("boundary"); + const body = new Uint8Array(await this.arrayBuffer()); + return parseFormData(body, boundary); + } else if ( + mimeType.type === "application" && + mimeType.subtype === "x-www-form-urlencoded" + ) { + // From https://github.com/github/fetch/blob/master/fetch.js + // Copyright (c) 2014-2016 GitHub, Inc. MIT License + const body = await this.text(); + try { + body + .trim() + .split("&") + .forEach((bytes) => { + if (bytes) { + const split = bytes.split("="); + if (split.length >= 2) { + // @ts-expect-error this is safe because of the above check + const name = split.shift().replace(/\+/g, " "); + const value = split.join("=").replace(/\+/g, " "); + formData.append( + decodeURIComponent(name), + decodeURIComponent(value), + ); + } } - } - }); - } catch (e) { - throw new TypeError("Invalid form urlencoded format"); + }); + } catch (e) { + throw new TypeError("Invalid form urlencoded format"); + } + return formData; } - return formData; - } else { - throw new TypeError("Invalid form data"); } + + throw new TypeError("Invalid form data"); } /** @returns {Promise<string>} */ @@ -1374,17 +987,9 @@ body = init.body[_byteSequence]; contentType = init.body.type; } else if (init.body instanceof FormData) { - let boundary; - if (headers.has("content-type")) { - const params = getHeaderValueParams("content-type"); - boundary = params.get("boundary"); - } - const multipartBuilder = new MultipartBuilder( - init.body, - boundary, - ); - body = multipartBuilder.getBody(); - contentType = multipartBuilder.getContentType(); + const res = encodeFormData(init.body); + body = res.body; + contentType = res.contentType; } else if (init.body instanceof ReadableStream) { body = init.body; } diff --git a/op_crates/fetch/internal.d.ts b/op_crates/fetch/internal.d.ts index e02bc6ed2..3206008c5 100644 --- a/op_crates/fetch/internal.d.ts +++ b/op_crates/fetch/internal.d.ts @@ -19,6 +19,15 @@ declare namespace globalThis { Headers: typeof Headers; }; + declare var formData: { + FormData: typeof FormData; + encodeFormData(formdata: FormData): { + body: Uint8Array; + contentType: string; + }; + parseFormData(body: Uint8Array, boundary: string | undefined): FormData; + }; + declare var streams: { ReadableStream: typeof ReadableStream; isReadableStreamDisturbed(stream: ReadableStream): boolean; diff --git a/op_crates/fetch/lib.rs b/op_crates/fetch/lib.rs index ae716af41..9eeda059a 100644 --- a/op_crates/fetch/lib.rs +++ b/op_crates/fetch/lib.rs @@ -71,6 +71,10 @@ pub fn init(isolate: &mut JsRuntime) { include_str!("20_headers.js"), ), ( + "deno:op_crates/fetch/21_formdata.js", + include_str!("21_formdata.js"), + ), + ( "deno:op_crates/fetch/26_fetch.js", include_str!("26_fetch.js"), ), diff --git a/op_crates/file/01_file.js b/op_crates/file/01_file.js index 17762c14a..47b44a08e 100644 --- a/op_crates/file/01_file.js +++ b/op_crates/file/01_file.js @@ -139,6 +139,10 @@ const _byteSequence = Symbol("[[ByteSequence]]"); class Blob { + get [Symbol.toStringTag]() { + return "Blob"; + } + /** @type {string} */ #type; @@ -286,10 +290,6 @@ } return bytes.buffer; } - - get [Symbol.toStringTag]() { - return "Blob"; - } } webidl.converters["Blob"] = webidl.createInterfaceConverter("Blob", Blob); @@ -336,6 +336,10 @@ const _LastModfied = Symbol("[[LastModified]]"); class File extends Blob { + get [Symbol.toStringTag]() { + return "File"; + } + /** @type {string} */ [_Name]; /** @type {number} */ diff --git a/op_crates/file/02_filereader.js b/op_crates/file/02_filereader.js index b32cbfce9..640c6dd9e 100644 --- a/op_crates/file/02_filereader.js +++ b/op_crates/file/02_filereader.js @@ -24,6 +24,10 @@ const aborted = Symbol("[[aborted]]"); class FileReader extends EventTarget { + get [Symbol.toStringTag]() { + return "FileReader"; + } + /** @type {"empty" | "loading" | "done"} */ [state] = "empty"; /** @type {null | string | ArrayBuffer} */ diff --git a/op_crates/file/internal.d.ts b/op_crates/file/internal.d.ts index 91a61d811..dd892e736 100644 --- a/op_crates/file/internal.d.ts +++ b/op_crates/file/internal.d.ts @@ -9,7 +9,7 @@ declare namespace globalThis { Blob: typeof Blob & { [globalThis.__bootstrap.file._byteSequence]: Uint8Array; }; - _byteSequence: unique symbol; + readonly _byteSequence: unique symbol; File: typeof File & { [globalThis.__bootstrap.file._byteSequence]: Uint8Array; }; diff --git a/op_crates/web/01_mimesniff.js b/op_crates/web/01_mimesniff.js index 918343f2c..f58130132 100644 --- a/op_crates/web/01_mimesniff.js +++ b/op_crates/web/01_mimesniff.js @@ -10,6 +10,25 @@ ((window) => { const { collectSequenceOfCodepoints } = window.__bootstrap.infra; + /** + * @param {string[]} chars + * @returns {string} + */ + function regexMatcher(chars) { + const matchers = chars.map((char) => { + if (char.length === 1) { + return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`; + } else if (char.length === 3 && char[1] === "-") { + return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}-\\u${ + char.charCodeAt(2).toString(16).padStart(4, "0") + }`; + } else { + throw TypeError("unreachable"); + } + }); + return matchers.join(""); + } + const HTTP_TAB_OR_SPACE = ["\u0009", "\u0020"]; const HTTP_WHITESPACE = ["\u000A", "\u000D", ...HTTP_TAB_OR_SPACE]; @@ -35,14 +54,25 @@ "\u007E", ...ASCII_ALPHANUMERIC, ]; - const HTTP_TOKEN_CODE_POINT_RE = new RegExp(`^[${HTTP_TOKEN_CODE_POINT}]+$`); + const HTTP_TOKEN_CODE_POINT_RE = new RegExp( + `^[${regexMatcher(HTTP_TOKEN_CODE_POINT)}]+$`, + ); const HTTP_QUOTED_STRING_TOKEN_POINT = [ "\u0009", "\u0020-\u007E", "\u0080-\u00FF", ]; const HTTP_QUOTED_STRING_TOKEN_POINT_RE = new RegExp( - `^[${HTTP_QUOTED_STRING_TOKEN_POINT}]+$`, + `^[${regexMatcher(HTTP_QUOTED_STRING_TOKEN_POINT)}]+$`, + ); + const HTTP_WHITESPACE_MATCHER = regexMatcher(HTTP_WHITESPACE); + const HTTP_WHITESPACE_PREFIX_RE = new RegExp( + `^[${HTTP_WHITESPACE_MATCHER}]+`, + "g", + ); + const HTTP_WHITESPACE_SUFFIX_RE = new RegExp( + `[${HTTP_WHITESPACE_MATCHER}]+$`, + "g", ); /** @@ -106,8 +136,8 @@ */ function parseMimeType(input) { // 1. - input = input.replaceAll(new RegExp(`^[${HTTP_WHITESPACE}]+`, "g"), ""); - input = input.replaceAll(new RegExp(`[${HTTP_WHITESPACE}]+$`, "g"), ""); + input = input.replaceAll(HTTP_WHITESPACE_PREFIX_RE, ""); + input = input.replaceAll(HTTP_WHITESPACE_SUFFIX_RE, ""); // 2. let position = 0; @@ -123,9 +153,7 @@ position = res1.position; // 4. - if (type === "" || !HTTP_TOKEN_CODE_POINT_RE.test(type)) { - return null; - } + if (type === "" || !HTTP_TOKEN_CODE_POINT_RE.test(type)) return null; // 5. if (position >= endOfInput) return null; @@ -143,12 +171,10 @@ position = res2.position; // 8. - subtype = subtype.replaceAll(new RegExp(`[${HTTP_WHITESPACE}]+$`, "g"), ""); + subtype = subtype.replaceAll(HTTP_WHITESPACE_SUFFIX_RE, ""); // 9. - if (subtype === "" || !HTTP_TOKEN_CODE_POINT_RE.test(subtype)) { - return null; - } + if (subtype === "" || !HTTP_TOKEN_CODE_POINT_RE.test(subtype)) return null; // 10. const mimeType = { @@ -216,7 +242,7 @@ // 11.9.2. parameterValue = parameterValue.replaceAll( - new RegExp(`[${HTTP_WHITESPACE}]+$`, "g"), + HTTP_WHITESPACE_SUFFIX_RE, "", ); @@ -224,7 +250,7 @@ if (parameterValue === "") continue; } - // 11.9. + // 11.10. if ( parameterName !== "" && HTTP_TOKEN_CODE_POINT_RE.test(parameterName) && HTTP_QUOTED_STRING_TOKEN_POINT_RE.test(parameterValue) && diff --git a/op_crates/web/internal.d.ts b/op_crates/web/internal.d.ts index 18220b08e..5681edc7b 100644 --- a/op_crates/web/internal.d.ts +++ b/op_crates/web/internal.d.ts @@ -4,6 +4,9 @@ /// <reference lib="esnext" /> declare namespace globalThis { + declare var TextEncoder: typeof TextEncoder; + declare var TextDecoder: typeof TextDecoder; + declare namespace __bootstrap { declare var infra: { collectSequenceOfCodepoints( diff --git a/op_crates/web/lib.deno_web.d.ts b/op_crates/web/lib.deno_web.d.ts index 79b56f68e..0fce6ce6b 100644 --- a/op_crates/web/lib.deno_web.d.ts +++ b/op_crates/web/lib.deno_web.d.ts @@ -87,45 +87,44 @@ declare class Event { */ declare class EventTarget { /** Appends an event listener for events whose type attribute value is type. - * The callback argument sets the callback that will be invoked when the event - * is dispatched. - * - * The options argument sets listener-specific options. For compatibility this - * can be a boolean, in which case the method behaves exactly as if the value - * was specified as options's capture. - * - * When set to true, options's capture prevents callback from being invoked - * when the event's eventPhase attribute value is BUBBLING_PHASE. When false - * (or not present), callback will not be invoked when event's eventPhase - * attribute value is CAPTURING_PHASE. Either way, callback will be invoked if - * event's eventPhase attribute value is AT_TARGET. - * - * When set to true, options's passive indicates that the callback will not - * cancel the event by invoking preventDefault(). This is used to enable - * performance optimizations described in § 2.8 Observing event listeners. - * - * When set to true, options's once indicates that the callback will only be - * invoked once after which the event listener will be removed. - * - * The event listener is appended to target's event listener list and is not - * appended if it has the same type, callback, and capture. */ + * The callback argument sets the callback that will be invoked when the event + * is dispatched. + * + * The options argument sets listener-specific options. For compatibility this + * can be a boolean, in which case the method behaves exactly as if the value + * was specified as options's capture. + * + * When set to true, options's capture prevents callback from being invoked + * when the event's eventPhase attribute value is BUBBLING_PHASE. When false + * (or not present), callback will not be invoked when event's eventPhase + * attribute value is CAPTURING_PHASE. Either way, callback will be invoked if + * event's eventPhase attribute value is AT_TARGET. + * + * When set to true, options's passive indicates that the callback will not + * cancel the event by invoking preventDefault(). This is used to enable + * performance optimizations described in § 2.8 Observing event listeners. + * + * When set to true, options's once indicates that the callback will only be + * invoked once after which the event listener will be removed. + * + * The event listener is appended to target's event listener list and is not + * appended if it has the same type, callback, and capture. */ addEventListener( type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions, ): void; /** Dispatches a synthetic event event to target and returns true if either - * event's cancelable attribute value is false or its preventDefault() method - * was not invoked, and false otherwise. */ + * event's cancelable attribute value is false or its preventDefault() method + * was not invoked, and false otherwise. */ dispatchEvent(event: Event): boolean; /** Removes the event listener in target's event listener list with the same - * type, callback, and options. */ + * type, callback, and options. */ removeEventListener( type: string, callback: EventListenerOrEventListenerObject | null, options?: EventListenerOptions | boolean, ): void; - [Symbol.toStringTag]: string; } interface EventListener { diff --git a/op_crates/webidl/00_webidl.js b/op_crates/webidl/00_webidl.js index 843fd329e..508abe44d 100644 --- a/op_crates/webidl/00_webidl.js +++ b/op_crates/webidl/00_webidl.js @@ -802,6 +802,50 @@ throw new TypeError("Illegal constructor"); } + function mixinPairIterable(name, prototype, dataSymbol, keyKey, valueKey) { + const methods = { + *entries() { + assertBranded(this, prototype); + for (const entry of this[dataSymbol]) { + yield [entry[keyKey], entry[valueKey]]; + } + }, + [Symbol.iterator]() { + assertBranded(this, prototype); + return this.entries(); + }, + *keys() { + assertBranded(this, prototype); + for (const entry of this[dataSymbol]) { + yield entry[keyKey]; + } + }, + *values() { + assertBranded(this, prototype); + for (const entry of this[dataSymbol]) { + yield entry[valueKey]; + } + }, + forEach(idlCallback, thisArg) { + assertBranded(this, prototype); + const prefix = `Failed to execute 'forEach' on '${name}'`; + requiredArguments(arguments.length, 1, { prefix }); + idlCallback = converters["Function"](idlCallback, { + prefix, + context: "Argument 1", + }); + idlCallback = idlCallback.bind(thisArg ?? globalThis); + const pairs = this[dataSymbol]; + for (let i = 0; i < pairs.length; i++) { + const entry = pairs[i]; + idlCallback(entry[valueKey], entry[keyKey], this); + } + }, + }; + + return Object.assign(prototype.prototype, methods); + } + window.__bootstrap ??= {}; window.__bootstrap.webidl = { makeException, @@ -817,5 +861,6 @@ createBranded, assertBranded, illegalConstructor, + mixinPairIterable, }; })(this); diff --git a/op_crates/webidl/internal.d.ts b/op_crates/webidl/internal.d.ts index 425ee674e..ca72566a5 100644 --- a/op_crates/webidl/internal.d.ts +++ b/op_crates/webidl/internal.d.ts @@ -286,6 +286,18 @@ declare namespace globalThis { v: Record<K, V>, opts: ValueConverterOpts, ) => any; + + /** + * Mix in the iterable declarations defined in WebIDL. + * https://heycam.github.io/webidl/#es-iterable + */ + declare function mixinPairIterable( + name: string, + prototype: any, + dataSymbol: symbol, + keyKey: string | number | symbol, + valueKey: string | number | symbol, + ): void; } } } diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index 977755007..dd7e3793c 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -29,6 +29,7 @@ delete Object.prototype.__proto__; const webgpu = window.__bootstrap.webgpu; const webSocket = window.__bootstrap.webSocket; const file = window.__bootstrap.file; + const formData = window.__bootstrap.formData; const fetch = window.__bootstrap.fetch; const prompt = window.__bootstrap.prompt; const denoNs = window.__bootstrap.denoNs; @@ -261,7 +262,7 @@ delete Object.prototype.__proto__; EventTarget: util.nonEnumerable(EventTarget), File: util.nonEnumerable(file.File), FileReader: util.nonEnumerable(fileReader.FileReader), - FormData: util.nonEnumerable(fetch.FormData), + FormData: util.nonEnumerable(formData.FormData), Headers: util.nonEnumerable(headers.Headers), MessageEvent: util.nonEnumerable(MessageEvent), Performance: util.nonEnumerable(performance.Performance), diff --git a/test_util/wpt b/test_util/wpt -Subproject a522daf78a71c2252d10c978f09cf0575aceb79 +Subproject e19bdbe96243f2ba548c1fd01c0812d645ba0c6 diff --git a/tools/wpt/expectation.json b/tools/wpt/expectation.json index 587bd69c8..98fc105d5 100644 --- a/tools/wpt/expectation.json +++ b/tools/wpt/expectation.json @@ -578,6 +578,10 @@ "Parsing: <file://example.net/C:/> against <about:blank>", "Parsing: <file://1.2.3.4/C:/> against <about:blank>", "Parsing: <file://[1::8]/C:/> against <about:blank>", + "Parsing: <C|/> against <file://host/>", + "Parsing: </C:/> against <file://host/>", + "Parsing: <file:C:/> against <file://host/>", + "Parsing: <file:/C:/> against <file://host/>", "Parsing: <file://localhost//a//../..//foo> against <about:blank>", "Parsing: <file://localhost////foo> against <about:blank>", "Parsing: <file:////foo> against <about:blank>", @@ -753,5 +757,17 @@ "queue-microtask.any.js": true } } + }, + "xhr": { + "formdata": { + "append.any.js": true, + "constructor.any.js": true, + "delete.any.js": true, + "foreach.any.js": true, + "get.any.js": true, + "has.any.js": true, + "set-blob.any.js": true, + "set.any.js": true + } } }
\ No newline at end of file |