diff --git a/build/BUILD.wpt b/build/BUILD.wpt index 5084f448a45..8bc9497aca0 100644 --- a/build/BUILD.wpt +++ b/build/BUILD.wpt @@ -2,21 +2,26 @@ # Licensed under the Apache 2.0 license found in the LICENSE file or at: # https://opensource.org/licenses/Apache-2.0 -load("@workerd//:build/wpt_test.bzl", "wpt_get_directories") +load("@workerd//:build/wpt_test.bzl", "wpt_find_modules", "wpt_module") -[filegroup( +[wpt_module( name = dir, - srcs = glob(["{}/**/*".format(dir)]), - visibility = ["//visibility:public"], -) for dir in wpt_get_directories( +) for dir in wpt_find_modules( excludes = [ "dom", + "fetch", ], root = "", )] -[filegroup( +[wpt_module( name = dir, - srcs = glob(["{}/**/*".format(dir)]), - visibility = ["//visibility:public"], -) for dir in wpt_get_directories(root = "dom")] +) for dir in wpt_find_modules(root = "dom")] + +[wpt_module( + name = dir, +) for dir in wpt_find_modules(root = "fetch")] + +exports_files([ + "tools/certs/cacert.pem", +]) diff --git a/build/wpt_test.bzl b/build/wpt_test.bzl index b3ed2a95131..da0dc6df8c5 100644 --- a/build/wpt_test.bzl +++ b/build/wpt_test.bzl @@ -5,9 +5,14 @@ load("@bazel_skylib//lib:paths.bzl", "paths") load("//:build/wd_test.bzl", "wd_test") +### wpt_test macro +### (Invokes wpt_js_test_gen, wpt_wd_test_gen and wd_test to assemble a complete test suite.) +### ----------------------------------------------------------------------------------------- + def wpt_test(name, wpt_directory, config, autogates = []): """ Main entry point. + 1. Generates a workerd test suite in JS. This contains the logic to run each WPT test file, applying the relevant test config. 2. Generates a wd-test file for this test suite. This contains all of the @@ -20,6 +25,7 @@ def wpt_test(name, wpt_directory, config, autogates = []): wpt_tsproject = "//src/wpt:wpt-all@tsproject" harness_as_js = "//src/wpt:harness/harness.js" compat_date = "//src/workerd/io:trimmed-supported-compatibility-date.txt" + wpt_cacert = "@wpt//:tools/certs/cacert.pem" _wpt_js_test_gen( name = js_test_gen_rule, @@ -39,6 +45,7 @@ def wpt_test(name, wpt_directory, config, autogates = []): harness = harness_as_js, compat_date = compat_date, autogates = autogates, + wpt_cacert = wpt_cacert, ) wd_test( @@ -51,41 +58,71 @@ def wpt_test(name, wpt_directory, config, autogates = []): wpt_directory, compat_date, harness_as_js, + wpt_cacert, ], ) -def wpt_get_directories(root, excludes = []): - """ - Globs for files within a WPT directory structure, starting from root. - In addition to an explicitly provided excludes argument, hidden directories - and top-level files are also excluded as they don't contain test content. +### wpt_module macro and rule +### (Discovers test files within the WPT directory tree) +### ---------------------------------------------------- + +def wpt_module(name): """ + Given the name of a directory within the WPT tree, creates a rule providing: - root_pattern = "{}/*".format(root) if root else "*" - return native.glob( - [root_pattern], - exclude = native.glob( - [root_pattern], - exclude_directories = 1, - ) + [".*"] + excludes, - exclude_directories = 0, + srcs: All files within the directory + dir: A reference to the directory itself. + + This info is later used by wpt_test rules to generate JS code and wd-test config + for each test. + """ + return _wpt_module( + name = "{}@module".format(name), + dir = name, + srcs = native.glob(["{}/**/*".format(name)]), + visibility = ["//visibility:public"], ) +WPTModuleInfo = provider(fields = ["base"]) + +def _wpt_module_impl(ctx): + return [ + DefaultInfo(files = depset(ctx.files.srcs)), + WPTModuleInfo(base = ctx.attr.dir.files.to_list()[0]), + ] + +_wpt_module = rule( + implementation = _wpt_module_impl, + attrs = { + "dir": attr.label(allow_single_file = True), + "srcs": attr.label_list(allow_files = True), + }, +) + +### wpt_js_test_gen rule +### (Generates a .js file for each test) +### ------------------------------------ + def _wpt_js_test_gen_impl(ctx): """ - Generates a workerd test suite in JS. This contains the logic to run - each WPT test file, applying the relevant test config. + Generates a workerd test suite in JS. + + This contains the logic to run each WPT test file, applying the relevant test config. """ src = ctx.actions.declare_file("{}-test.generated.js".format(ctx.attr.test_name)) + base = ctx.attr.wpt_directory[WPTModuleInfo].base + files = ctx.attr.wpt_directory.files.to_list() test_files = [file for file in files if file.extension == "js" and not is_in_resources_directory(file)] + ctx.actions.write( output = src, content = WPT_JS_TEST_TEMPLATE.format( test_config = ctx.file.test_config.basename, - cases = generate_external_cases(test_files), - all_test_files = generate_external_file_list(test_files), + cases = generate_external_cases(base, test_files), + all_test_files = generate_external_file_list(base, test_files), + test_name = ctx.attr.test_name, ), ) @@ -93,36 +130,19 @@ def _wpt_js_test_gen_impl(ctx): files = depset([src]), ) -def generate_external_cases(files): - """ - Generate a workerd test case that runs each test file in the WPT module. - """ - return "\n".join([ - "export const {} = run('{}');".format(test_case_name(file.basename), file.basename) - for file in files - ]) - -def generate_external_file_list(files): - """ - Generate a JS list containing the name of every test file in the WPT module" - """ - file_list = ", ".join(["'{}'".format(file.basename) for file in files]) - return "[{}];".format(file_list) - -def test_case_name(filename): - """ - Converts a JS filename to a valid JS identifier for use as a test case name. - WPT files are named with the convention some-words-with-hyphens.some-suffix.js. - We would turn this into someWordsWithHyphensSomeSuffix. - """ - - words = (filename - .removesuffix(".js") - .removesuffix(".any") - .replace(".", "-") - .split("-")) - - return words[0] + "".join([word.capitalize() for word in words[1:]]) +_wpt_js_test_gen = rule( + implementation = _wpt_js_test_gen_impl, + attrs = { + # A string to use as the test name. Used in the wd-test filename and the worker's name + "test_name": attr.string(), + # A file group representing a directory of wpt tests. All files in the group will be embedded. + "wpt_directory": attr.label(), + # A JS file containing the test configuration. + "test_config": attr.label(allow_single_file = True), + # Dependency: The ts_project rule that compiles the tests to JS + "wpt_tsproject": attr.label(), + }, +) WPT_JS_TEST_TEMPLATE = """// This file is autogenerated by wpt_test.bzl // DO NOT EDIT. @@ -130,18 +150,46 @@ import {{ createRunner }} from 'harness/harness'; import config from '{test_config}'; const allTestFiles = {all_test_files}; -const run = createRunner(config, allTestFiles); +const run = createRunner(config, '{test_name}', allTestFiles); {cases} """ +def generate_external_cases(base, files): + """ + Generate a workerd test case that runs each test file in the WPT module. + """ + + result = [] + for file in files: + relative_path = module_relative_path(base, file) + result.append("export const {} = run('{}');".format(test_case_name(relative_path), relative_path)) + + return "\n".join(result) + +def generate_external_file_list(base, files): + """ + Generate a JS list containing the name of every test file in the WPT module + """ + + return "[{}];".format(", ".join([ + "'{}'".format(module_relative_path(base, file)) + for file in files + ])) + +### wpt_wd_test_gen rule +### (Generates a .wd-test file for each test) +### ----------------------------------------- + def _wpt_wd_test_gen_impl(ctx): """ - Generates a wd-test file for this test suite. This contains all of the - paths to modules needed to run the test: generated test suite, test config - file, WPT test scripts, associated JSON resources. + Generates a wd-test file for this test suite. + + This contains all of the paths to modules needed to run the test: generated test suite, test + config file, WPT test scripts, associated JSON resources. """ src = ctx.actions.declare_file("{}.wd-test".format(ctx.attr.test_name)) + base = ctx.attr.wpt_directory[WPTModuleInfo].base ctx.actions.write( output = src, @@ -149,9 +197,10 @@ def _wpt_wd_test_gen_impl(ctx): test_name = ctx.attr.test_name, test_config = ctx.file.test_config.basename, test_js_generated = ctx.file.test_js_generated.basename, - bindings = generate_external_bindings(src.owner, ctx.attr.wpt_directory.files), - harness = wd_relative_path(src.owner, ctx.file.harness), - compat_date = wd_relative_path(src.owner, ctx.file.compat_date), + bindings = generate_external_bindings(src, base, ctx.attr.wpt_directory.files), + harness = wd_test_relative_path(src, ctx.file.harness), + compat_date = wd_test_relative_path(src, ctx.file.compat_date), + wpt_cacert = wd_test_relative_path(src, ctx.file.wpt_cacert), autogates = generate_autogates_field(ctx.attr.autogates), ), ) @@ -160,6 +209,28 @@ def _wpt_wd_test_gen_impl(ctx): files = depset([src]), ) +_wpt_wd_test_gen = rule( + implementation = _wpt_wd_test_gen_impl, + attrs = { + # A string to use as the test name. Used in the wd-test filename and the worker's name + "test_name": attr.string(), + # A file group representing a directory of wpt tests. All files in the group will be embedded. + "wpt_directory": attr.label(), + # A JS file containing the test configuration. + "test_config": attr.label(allow_single_file = True), + # An auto-generated JS file containing the test logic. + "test_js_generated": attr.label(allow_single_file = True), + # Target specifying the location of the WPT test harness + "harness": attr.label(allow_single_file = True), + # Target specifying the location of the trimmed-supported-compatibility-date.txt file + "compat_date": attr.label(allow_single_file = True), + # Target specifying the location of the WPT CA certificate + "wpt_cacert": attr.label(allow_single_file = True), + # A list of autogates to specify in the generated wd-test file + "autogates": attr.string_list(), + }, +) + WPT_WD_TEST_TEMPLATE = """ using Workerd = import "/workerd/workerd.capnp"; const unitTests :Workerd.Config = ( @@ -180,6 +251,16 @@ const unitTests :Workerd.Config = ( compatibilityFlags = ["nodejs_compat", "experimental"], ) ), + ( name = "internet", + network = ( + allow = ["private"], + tlsOptions = ( + trustedCertificates = [ + embed "{wpt_cacert}" + ] + ) + ) + ), ( name = "wpt", disk = ".", @@ -199,23 +280,10 @@ def generate_autogates_field(autogates): autogate_list = ", ".join(['"{}"'.format(autogate) for autogate in autogates]) return "autogates = [{}],".format(autogate_list) -def wd_relative_path(label, target): - """ - Generates a path that can be used in a .wd-test file to refer to another file. Paths are relative - to the .wd-test file. We determine the right path from the label that generated the .wd-test file - """ - return "../" * (label.package.count("/") + label.name.count("/") + 1) + target.short_path - -def is_in_resources_directory(file): - """ - True if a given file is in the resources/ directory of a WPT module. - """ - immediate_parent = paths.basename(file.dirname) - return immediate_parent == "resources" - -def generate_external_bindings(label, files): +def generate_external_bindings(wd_test_file, base, files): """ Generates appropriate bindings for each file in the WPT module: + - JS files: text binding to allow code to be evaluated - JSON files: JSON binding to allow test code to fetch resources """ @@ -223,55 +291,78 @@ def generate_external_bindings(label, files): result = [] for file in files.to_list(): - file_path = wd_relative_path(label, file) - - if is_in_resources_directory(file): - binding_name = "resources/{}".format(file.basename) - else: - binding_name = file.basename - if file.extension == "js": - entry = """(name = "{}", text = embed "{}")""".format(binding_name, file_path) + binding_type = "text" elif file.extension == "json": - entry = """(name = "{}", json = embed "{}")""".format(binding_name, file_path) + binding_type = "json" else: - # For other file types, you can add more conditions or skip them + # Unknown binding type, skip for now continue - result.append(entry) + result.append('(name = "{}", {} = embed "{}")'.format(module_relative_path(base, file), binding_type, wd_test_relative_path(wd_test_file, file))) return ",\n".join(result) -_wpt_wd_test_gen = rule( - implementation = _wpt_wd_test_gen_impl, - attrs = { - # A string to use as the test name. Used in the wd-test filename and the worker's name - "test_name": attr.string(), - # A file group representing a directory of wpt tests. All files in the group will be embedded. - "wpt_directory": attr.label(), - # A JS file containing the test configuration. - "test_config": attr.label(allow_single_file = True), - # An auto-generated JS file containing the test logic. - "test_js_generated": attr.label(allow_single_file = True), - # Target specifying the location of the WPT test harness - "harness": attr.label(allow_single_file = True), - # Target specifying the location of the trimmed-supported-compatibility-date.txt file - "compat_date": attr.label(allow_single_file = True), - # A list of autogates to specify in the generated wd-test file - "autogates": attr.string_list(), - }, -) +### Path manipulation +### ----------------- -_wpt_js_test_gen = rule( - implementation = _wpt_js_test_gen_impl, - attrs = { - # A string to use as the test name. Used in the wd-test filename and the worker's name - "test_name": attr.string(), - # A file group representing a directory of wpt tests. All files in the group will be embedded. - "wpt_directory": attr.label(), - # A JS file containing the test configuration. - "test_config": attr.label(allow_single_file = True), - # Dependency: The ts_project rule that compiles the tests to JS - "wpt_tsproject": attr.label(), - }, -) +def module_relative_path(module_base, file): + """ + Return the relative path of a file inside its parent WPT module. + + For example, within the 'dom/abort' module, the path + 'dom/abort/resources/abort-signal-any-tests.js' would be referred to as + 'resources/abort-signal-any-tests.js' + """ + + return paths.relativize(file.short_path, module_base.short_path) + +def wd_test_relative_path(wd_test_file, file): + """ + Generates a path that can be used in a .wd-test file to refer to another file. + + Paths are relative to the .wd-test file. + """ + + return "../" * (wd_test_file.short_path.count("/")) + file.short_path + +def is_in_resources_directory(file): + """ + True if a given file is in the resources/ directory of a WPT module. + """ + immediate_parent = paths.basename(file.dirname) + return immediate_parent == "resources" + +def test_case_name(filename): + """ + Converts a JS filename to a valid JS identifier for use as a test case name. + + WPT files are named with the convention some-words-with-hyphens.some-suffix.js. + We would turn this into someWordsWithHyphensSomeSuffix. + """ + + words = (filename + .removesuffix(".js") + .replace(".", "-") + .replace("/", "-") + .split("-")) + + return words[0] + "".join([word.capitalize() for word in words[1:]]) + +def wpt_find_modules(root, excludes = []): + """ + Globs for files within a WPT directory structure, starting from root. + + In addition to an explicitly provided excludes argument, hidden directories + and top-level files are also excluded as they don't contain test content. + """ + + root_pattern = "{}/*".format(root) if root else "*" + return native.glob( + [root_pattern], + exclude = native.glob( + [root_pattern], + exclude_directories = 1, + ) + [".*"] + excludes, + exclude_directories = 0, + ) diff --git a/src/wpt/BUILD.bazel b/src/wpt/BUILD.bazel index 111fbd6ac00..684f6208cae 100644 --- a/src/wpt/BUILD.bazel +++ b/src/wpt/BUILD.bazel @@ -5,19 +5,25 @@ load("//:build/wpt_test.bzl", "wpt_test") wpt_test( name = "url", config = "url-test.ts", - wpt_directory = "@wpt//:url", + wpt_directory = "@wpt//:url@module", ) wpt_test( name = "urlpattern", config = "urlpattern-test.ts", - wpt_directory = "@wpt//:urlpattern", + wpt_directory = "@wpt//:urlpattern@module", ) wpt_test( name = "dom/abort", config = "dom/abort-test.ts", - wpt_directory = "@wpt//:dom/abort", + wpt_directory = "@wpt//:dom/abort@module", +) + +wpt_test( + name = "fetch/api", + config = "fetch/api-test.ts", + wpt_directory = "@wpt//:fetch/api@module", ) srcs = glob( diff --git a/src/wpt/fetch/api-test.ts b/src/wpt/fetch/api-test.ts new file mode 100644 index 00000000000..c0910bd7dc1 --- /dev/null +++ b/src/wpt/fetch/api-test.ts @@ -0,0 +1,876 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { type TestRunnerConfig } from 'harness/harness'; + +export default { + 'abort/cache.https.any.js': { + comment: 'Not implemented', + expectedFailures: [ + 'Signals are not stored in the cache API', + "Signals are not stored in the cache API, even if they're already aborted", + ], + }, + 'abort/general.any.js': { + comment: 'These tests will be enabled in a later PR', + skipAllTests: true, + }, + 'abort/request.any.js': { + comment: 'These tests will be enabled in a later PR', + skipAllTests: true, + }, + + 'basic/accept-header.any.js': { + comment: 'Response.type must be basic', + expectedFailures: [ + "Request through fetch should have 'accept' header with value '*/*'", + "Request through fetch should have 'accept' header with value 'custom/*'", + "Request through fetch should have 'accept-language' header with value 'bzh'", + "Request through fetch should have a 'accept-language' header", + ], + }, + 'basic/conditional-get.any.js': { + comment: + "It seems like they expect us to use the ETag if re-requested but return 200 OK anyway. Don't quite get it.", + expectedFailures: ['Testing conditional GET with ETags'], + }, + 'basic/error-after-response.any.js': { + comment: + 'Stream disconnected prematurely and a dropped promise when faced with intentionally bad chunked encoding from WPT', + skipAllTests: true, + }, + 'basic/gc.any.js': { + comment: 'Run WPT tests with --expose-gc if we want to run this test', + skipAllTests: true, + }, + 'basic/header-value-combining.any.js': { + comment: + "Stream disconnected prematurely and a dropped promise. Not yet sure what is triggering about WPT's output", + skipAllTests: true, + }, + 'basic/header-value-null-byte.any.js': { + comment: 'We should return a nicer TypeError instead of "internal error"', + expectedFailures: ['Ensure fetch() rejects null bytes in headers'], + }, + 'basic/historical.any.js': { + comment: 'This test expects us not to implement getAll', + expectedFailures: ['Headers object no longer has a getAll() method'], + }, + 'basic/http-response-code.any.js': {}, + 'basic/integrity.sub.any.js': { + comment: 'Integrity is not implemented', + skipAllTests: true, + }, + 'basic/keepalive.any.js': { + comment: 'Hard to run - involves iframes and workers', + expectedFailures: [ + "[keepalive] simple GET request on 'load' [no payload]; setting up", + "[keepalive] simple GET request on 'unload' [no payload]; setting up", + "[keepalive] simple GET request on 'pagehide' [no payload]; setting up", + "[keepalive] simple POST request on 'load' [no payload]; setting up", + "[keepalive] simple POST request on 'unload' [no payload]; setting up", + "[keepalive] simple POST request on 'pagehide' [no payload]; setting up", + 'simple keepalive test for web workers;', + ], + }, + 'basic/mediasource.window.js': { + comment: 'MediaSource not implemented', + expectedFailures: ['Cannot fetch blob: URL from a MediaSource'], + }, + 'basic/mode-no-cors.sub.any.js': { + comment: 'Request.mode is not relevant to us', + skipAllTests: true, + }, + 'basic/mode-same-origin.any.js': { + comment: 'Request.mode is not relevant to us', + skipAllTests: true, + }, + 'basic/referrer.any.js': { + comment: 'Referrer is not implemented', + skipAllTests: true, + }, + 'basic/request-forbidden-headers.any.js': { + comment: 'We do not have forbidden headers', + skipAllTests: true, + }, + 'basic/request-head.any.js': {}, + 'basic/request-headers-case.any.js': { + comment: 'To be investigated', + expectedFailures: [ + 'Multiple headers with the same name, different case (THIS-IS-A-TEST first)', + 'Multiple headers with the same name, different case (THIS-is-A-test first)', + ], + }, + 'basic/request-headers-nonascii.any.js': {}, + 'basic/request-headers.any.js': { + comment: 'Reasons listed per test case', + expectedFailures: [ + // Float16Array not implemengted + 'Fetch with POST with Float16Array body', + // Fake HTTP method names not accepted + 'Fetch with Chicken', + 'Fetch with Chicken with body', + 'Fetch with TacO and mode "same-origin" needs an Origin header', + 'Fetch with TacO and mode "cors" needs an Origin header', + // WPT is expecting us to insert some kind of User-Agent + 'Fetch with POST with Blob body with mime type', + 'Fetch with PUT without body', + 'Fetch with POST with URLSearchParams body', + 'Fetch with POST with FormData body', + // WPT expects Origin header but we don't support CORS + 'Fetch with PUT and mode "same-origin" needs an Origin header', + 'Fetch with PUT with body', + 'Fetch with POST and mode "no-cors" needs an Origin header', + // WPT is epxecting us to insert some kind of User-Agent + 'Fetch with GET', + 'Fetch with POST with Blob body', + 'Fetch with POST with Float64Array body', + 'Fetch with POST with text body', + 'Fetch with POST with ArrayBuffer body', + 'Fetch with POST with Float32Array body', + 'Fetch with POST with Uint8Array body', + 'Fetch with POST with Int8Array body', + 'Fetch with POST without body', + 'Fetch with HEAD', + 'Fetch with POST and mode "same-origin" needs an Origin header', + 'Fetch with POST with DataView body', + // Response.type must be basic + 'Fetch with GET and mode "cors" does not need an Origin header', + ], + }, + 'basic/request-private-network-headers.tentative.any.js': { + comment: 'We do not have forbidden headers', + skipAllTests: true, + }, + 'basic/request-referrer.any.js': { + comment: 'Referrer is not implemented', + skipAllTests: true, + }, + 'basic/request-upload.any.js': { + comment: 'Multiple reasons for failure; see below', + skipAllTests: true, + }, + 'basic/request-upload.h2.any.js': { + comment: 'Do we support HTTP 2?', + skipAllTests: true, + }, + 'basic/response-null-body.any.js': { + comment: + 'Response begins with hello-worldHTTP/1.1 which will lead to invalid protocol errors coming up on other tests later on', + skipAllTests: true, + }, + 'basic/response-url.sub.any.js': {}, + 'basic/scheme-about.any.js': {}, + 'basic/scheme-blob.sub.any.js': { + comment: 'URL.createObjectURL() is not implemented', + skipAllTests: true, + }, + 'basic/scheme-data.any.js': { + comment: 'Response.type must be basic', + expectedFailures: [ + // For this test: we should not return body when invoking HEAD on data url + 'Fetching [HEAD] data:,response%27s%20body is OK', + 'Fetching data:,response%27s%20body is OK', + 'Fetching data:,response%27s%20body is OK (same-origin)', + 'Fetching data:,response%27s%20body is OK (cors)', + 'Fetching data:text/plain;base64,cmVzcG9uc2UncyBib[...] is OK', + 'Fetching data:image/png;base64,cmVzcG9uc2UncyBib2[...] is OK', + 'Fetching [POST] data:,response%27s%20body is OK', + ], + }, + 'basic/scheme-others.sub.any.js': {}, + 'basic/status.h2.any.js': { + comment: 'Do we support HTTP 2?', + skipAllTests: true, + }, + 'basic/stream-response.any.js': {}, + 'basic/stream-safe-creation.any.js': {}, + 'basic/text-utf8.any.js': { + comment: 'Some kind of unicode nitpickiness. Needs investigation', + expectedFailures: [ + 'UTF-8 with BOM with Request.text()', + 'UTF-8 with BOM with Response.text()', + 'UTF-8 with BOM with fetched data (UTF-8 charset)', + 'UTF-8 with BOM with fetched data (UTF-16 charset)', + ], + }, + + 'body/cloned-any.js': { + comment: + 'At a glance it seems like we are just taking references to them instead of cloning?', + expectedFailures: ['TypedArray is cloned', 'ArrayBuffer is cloned'], + }, + 'body/formdata.any.js': {}, + 'body/mime-type.any.js': { + comment: 'They expected text/html but we kept text/plain', + expectedFailures: ['_Response: Extract a MIME type with clone'], + }, + + 'cors/cors-basic.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-cookies-redirect.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-cookies.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-expose-star.sub.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-filtering.sub.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-keepalive.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-multiple-origins.sub.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-no-preflight.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-origin.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-preflight-cache.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-preflight-not-cors-safelisted.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-preflight-redirect.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-preflight-referrer.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-preflight-response-validation.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-preflight-star.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-preflight-status.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-preflight.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-redirect-credentials.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-redirect-preflight.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'cors/cors-redirect.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + + 'crashtests/huge-fetch.any.js': {}, + + 'credentials/authentication-basic.any.js': { + comment: 'Response.type must be basic', + expectedFailures: [ + 'User-added Authorization header with include mode', + 'User-added Authorization header with omit mode', + 'User-added Authorization header with same-origin mode', + 'User-added bogus Authorization header with omit mode', + ], + }, + 'credentials/authentication-redirection.any.js': { + comment: 'I think this is ok because we do not care about CORS', + expectedFailures: [ + 'getAuthorizationHeaderValue - same origin redirection', + 'getAuthorizationHeaderValue - cross origin redirection', + ], + }, + 'credentials/cookies.any.js': { + comment: 'Request.credentials is not implemented', + skipAllTests: true, + }, + + 'headers/header-setcookie.any.js': { + comment: '(1, 2) To be investigated, (3) CORS is not implemented', + expectedFailures: [ + 'Headers iterator is correctly updated with set-cookie changes', + 'Headers iterator is correctly updated with set-cookie changes #2', + 'Set-Cookie is a forbidden response header', + ], + }, + 'headers/header-values-normalize.any.js': { + comment: 'XMLHttpRequest is not supported', + expectedFailures: [ + 'XMLHttpRequest with value %00', + 'XMLHttpRequest with value %01', + 'XMLHttpRequest with value %02', + 'XMLHttpRequest with value %03', + 'XMLHttpRequest with value %04', + 'XMLHttpRequest with value %05', + 'XMLHttpRequest with value %06', + 'XMLHttpRequest with value %07', + 'XMLHttpRequest with value %08', + 'XMLHttpRequest with value %09', + 'XMLHttpRequest with value %0A', + 'XMLHttpRequest with value %0D', + 'XMLHttpRequest with value %0E', + 'XMLHttpRequest with value %0F', + 'XMLHttpRequest with value %10', + 'XMLHttpRequest with value %11', + 'XMLHttpRequest with value %12', + 'XMLHttpRequest with value %13', + 'XMLHttpRequest with value %14', + 'XMLHttpRequest with value %15', + 'XMLHttpRequest with value %16', + 'XMLHttpRequest with value %17', + 'XMLHttpRequest with value %18', + 'XMLHttpRequest with value %19', + 'XMLHttpRequest with value %1A', + 'XMLHttpRequest with value %1B', + 'XMLHttpRequest with value %1C', + 'XMLHttpRequest with value %1D', + 'XMLHttpRequest with value %1E', + 'XMLHttpRequest with value %1F', + 'XMLHttpRequest with value %20', + ], + }, + 'headers/header-values.any.js': { + comment: 'XMLHTTPRequest is not implemented', + expectedFailures: [ + 'XMLHttpRequest with value x%00x needs to throw', + 'XMLHttpRequest with value x%0Ax needs to throw', + 'XMLHttpRequest with value x%0Dx needs to throw', + 'XMLHttpRequest with all valid values', + ], + }, + 'headers/headers-basic.any.js': { + comment: 'Investigate our Headers implementation', + expectedFailures: [ + 'Create headers with existing headers with custom iterator', + 'Iteration skips elements removed while iterating', + 'Removing elements already iterated over causes an element to be skipped during iteration', + 'Appending a value pair during iteration causes it to be reached during iteration', + 'Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time', + ], + }, + 'headers/headers-casing.any.js': {}, + 'headers/headers-combine.any.js': {}, + 'headers/headers-errors.any.js': { + comment: 'Our validation of header names is too lax', + expectedFailures: [ + 'Create headers giving bad header name as init argument', + 'Create headers giving bad header value as init argument', + 'Check headers get with an invalid name invalidĀ', + 'Check headers delete with an invalid name invalidĀ', + 'Check headers has with an invalid name invalidĀ', + 'Check headers set with an invalid name invalidĀ', + 'Check headers set with an invalid value invalidĀ', + 'Check headers append with an invalid name invalidĀ', + 'Check headers append with an invalid name [object Object]', + 'Check headers append with an invalid value invalidĀ', + ], + }, + 'headers/headers-no-cors.any.js': { + comment: 'Request.mode is not relevant', + skipAllTests: true, + }, + 'headers/headers-normalize.any.js': {}, + 'headers/headers-record.any.js': { + comment: 'Investigate our Headers implementation', + expectedFailures: [ + 'Correct operation ordering with two properties', + 'Correct operation ordering with two properties one of which has an invalid name', + 'Correct operation ordering with two properties one of which has an invalid value', + 'Correct operation ordering with non-enumerable properties', + 'Correct operation ordering with undefined descriptors', + 'Basic operation with Symbol keys', + 'Operation with non-enumerable Symbol keys', + ], + }, + 'headers/headers-structure.any.js': {}, + + 'idlharness.any.js': { + comment: 'Does not contain any relevant tests', + skipAllTests: true, + }, + + 'policies/csp-blocked.js': { + comment: 'CSP is not implemented', + skipAllTests: true, + }, + 'policies/nested-policy.js': { + comment: 'CSP is not implemented', + skipAllTests: true, + }, + 'policies/referrer-no-referrer.js': { + comment: 'CSP is not implemented', + skipAllTests: true, + }, + 'policies/referrer-origin-when-cross-origin.js': { + comment: 'CSP is not implemented', + skipAllTests: true, + }, + 'policies/referrer-origin.js': { + comment: 'CSP is not implemented', + skipAllTests: true, + }, + 'policies/referrer-unsafe-url.js': { + comment: 'CSP is not implemented', + skipAllTests: true, + }, + + 'redirect/redirect-back-to-original-origin.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'redirect/redirect-count.any.js': {}, + 'redirect/redirect-empty-location.any.js': { + comment: + '(1) CORS is not implemented, our behaviour is OK, (2) We are expected to reject fetch in this case', + expectedFailures: [ + 'redirect response with empty Location, manual mode', + 'redirect response with empty Location, follow mode', + ], + }, + 'redirect/redirect-keepalive.any.js': { + comment: 'Keepalive is not implemented', + expectedFailures: [ + '[keepalive][new window][unload] same-origin redirect; setting up', + '[keepalive][new window][unload] same-origin redirect + preflight; setting up', + '[keepalive][new window][unload] cross-origin redirect; setting up', + '[keepalive][new window][unload] cross-origin redirect + preflight; setting up', + '[keepalive][new window][unload] redirect to file URL; setting up', + '[keepalive][new window][unload] redirect to data URL; setting up', + ], + }, + 'redirect/redirect-keepalive.https.any.js': { + comment: 'Keepalive is not implemented', + expectedFailures: [ + '[keepalive][iframe][load] mixed content redirect; setting up', + ], + }, + 'redirect/redirect-location-escape.tentative.any.js': {}, + 'redirect/redirect-location.any.js': { + comment: 'Manual mode apparently expects status to be 0 in these cases', + expectedFailures: [ + 'Redirect 301 in "manual" mode with invalid location', + 'Redirect 303 in "manual" mode without location', + 'Redirect 302 in "manual" mode with data location', + 'Redirect 308 in "manual" mode without location', + 'Redirect 302 in "manual" mode with valid location', + 'Redirect 302 in "manual" mode with invalid location', + 'Redirect 307 in "manual" mode with valid location', + 'Redirect 303 in "manual" mode with invalid location', + 'Redirect 307 in "manual" mode without location', + 'Redirect 308 in "manual" mode with data location', + 'Redirect 308 in "manual" mode with valid location', + 'Redirect 303 in "manual" mode with valid location', + 'Redirect 308 in "manual" mode with invalid location', + 'Redirect 307 in "manual" mode with invalid location', + 'Redirect 307 in "manual" mode with data location', + 'Redirect 303 in "manual" mode with data location', + 'Redirect 301 in "manual" mode with valid location', + 'Redirect 302 in "manual" mode without location', + 'Redirect 301 in "manual" mode without location', + 'Redirect 301 in "manual" mode with data location', + ], + }, + 'redirect/redirect-method.any.js': { + comment: 'Reasons listed per case', + expectedFailures: [ + // Made up method name + 'Redirect 303 with TESTING', + // Content-type header not cleared + 'Redirect 301 with POST', + 'Redirect 303 with POST', + 'Redirect 302 with POST', + 'Redirect 303 with HEAD', + // Response.type must be basic + 'Redirect 302 with GET', + 'Redirect 307 with HEAD', + 'Redirect 301 with GET', + 'Redirect 303 with GET', + 'Redirect 307 with POST (string body)', + 'Redirect 307 with POST (blob body)', + 'Redirect 301 with HEAD', + 'Redirect 302 with HEAD', + 'Redirect 307 with GET', + ], + }, + 'redirect/redirect-mode.any.js': { + comment: 'CORS is not implemented', + skipAllTests: true, + }, + 'redirect/redirect-origin.any.js': { + comment: 'CORS is not implemented', + expectedFailures: [ + '[POST] Redirect 302 Same origin to other origin', + '[GET] Redirect 308 Other origin to same origin', + '[POST] Redirect 307 Other origin to same origin', + '[GET] Redirect 308 Other origin to other origin', + '[GET] Redirect 302 Same origin to other origin', + '[GET] Redirect 307 Other origin to other origin', + '[POST] Redirect 308 Other origin to same origin', + '[POST] Redirect 303 Same origin to other origin', + '[GET] Redirect 301 Other origin to other origin', + '[GET] Redirect 301 Same origin to other origin', + '[GET] Redirect 302 Other origin to same origin', + '[POST] Redirect 301 Other origin to other origin', + '[GET] Redirect 303 Other origin to other origin', + '[GET] Redirect 307 Other origin to same origin', + '[POST] Redirect 302 Other origin to other origin', + '[POST] Redirect 303 Other origin to other origin', + '[POST] Redirect 303 Other origin to same origin', + '[GET] Redirect 308 Same origin to other origin', + '[GET] Redirect 302 Other origin to other origin', + '[POST] Redirect 301 Same origin to other origin', + '[POST] Redirect 302 Other origin to same origin', + '[POST] Redirect 307 Same origin to other origin', + '[GET] Redirect 301 Other origin to same origin', + '[POST] Redirect 308 Other origin to other origin', + '[POST] Redirect 308 Same origin to other origin', + '[POST] Redirect 307 Other origin to other origin', + '[GET] Redirect 307 Same origin to other origin', + '[POST] Redirect 301 Other origin to same origin', + '[GET] Redirect 303 Same origin to other origin', + '[GET] Redirect 303 Other origin to same origin', + ], + }, + 'redirect/redirect-referrer-override.any.js': { + comment: 'Referer is not implemented', + skipAllTests: true, + }, + 'redirect/redirect-referrer.any.js': { + comment: 'Referrer is not implemented', + skipAllTests: true, + }, + 'redirect/redirect-schemes.any.js': {}, + 'redirect/redirect-to-dataurl.any.js': {}, + 'redirect/redirect-upload.h2.any.js': { + comment: 'Do we support HTTP 2?', + skipAllTests: true, + }, + + 'request/forbidden-method.any.js': { + comment: 'We do not have forbidden methods', + skipAllTests: true, + }, + 'request/multi-globals/construct-in-detached-frame.window.js': { + comment: "We don't support detached realms", + expectedFailures: [ + 'creating a request from another request in a detached realm should work', + ], + }, + 'request/request-bad-port.any.js': {}, + 'request/request-cache-default-conditional.any.js': { + comment: 'Will be enabled in a later PR', + skipAllTests: true, + }, + 'request/request-cache-default.any.js': { + comment: 'Will be enabled in a later PR', + skipAllTests: true, + }, + 'request/request-cache-force-cache.any.js': { + comment: 'Will be enabled in a later PR', + skipAllTests: true, + }, + 'request/request-cache-no-cache.any.js': { + comment: 'Will be enabled in a later PR', + skipAllTests: true, + }, + 'request/request-cache-no-store.any.js': { + comment: 'Will be enabled in a later PR', + skipAllTests: true, + }, + 'request/request-cache-only-if-cached.any.js': { + comment: 'Will be enabled in a later PR', + skipAllTests: true, + }, + 'request/request-cache-reload.any.js': { + comment: 'Will be enabled in a later PR', + skipAllTests: true, + }, + 'request/request-cache.js': { + comment: 'Will be enabled in a later PR', + skipAllTests: true, + }, + 'request/request-constructor-init-body-override.any.js': {}, + 'request/request-consume-empty.any.js': { + comment: + 'We seem to be returning the boundary value as text but WPT expects no value', + expectedFailures: ['Consume empty FormData request body as text'], + }, + 'request/request-consume.any.js': {}, + 'request/request-disturbed.any.js': { + comment: + "(1) To be investigated, (2) They're literally passing URL, the constructor, as the URL!?!", + expectedFailures: [ + 'Input request used for creating new request became disturbed even if body is not used', + 'Request construction failure should not set "bodyUsed"', + ], + }, + 'request/request-error.any.js': { + comment: + 'These tests would require us to throw errors for some invalid situations that we just ingore', + expectedFailures: [ + "RequestInit's window is not null", + 'Input URL has credentials', + "RequestInit's mode is navigate", + "RequestInit's referrer is invalid", + "RequestInit's method is forbidden", + "RequestInit's mode is no-cors and method is not simple", + 'Bad referrerPolicy init parameter value', + 'Bad mode init parameter value', + 'Bad credentials init parameter value', + 'Request with cache mode: only-if-cached and fetch mode: same-origin', + ], + }, + 'request/request-error.js': {}, + 'request/request-headers.any.js': { + comment: 'Neither CORS nor header filtering is implemented', + expectedFailures: [ + 'Adding invalid request header "Accept-Charset: KO"', + 'Adding invalid request header "accept-charset: KO"', + 'Adding invalid request header "ACCEPT-ENCODING: KO"', + 'Adding invalid request header "Accept-Encoding: KO"', + 'Adding invalid request header "Access-Control-Request-Headers: KO"', + 'Adding invalid request header "Access-Control-Request-Method: KO"', + 'Adding invalid request header "Connection: KO"', + 'Adding invalid request header "Content-Length: KO"', + 'Adding invalid request header "Cookie: KO"', + 'Adding invalid request header "Cookie2: KO"', + 'Adding invalid request header "Date: KO"', + 'Adding invalid request header "DNT: KO"', + 'Adding invalid request header "Expect: KO"', + 'Adding invalid request header "Host: KO"', + 'Adding invalid request header "Keep-Alive: KO"', + 'Adding invalid request header "Origin: KO"', + 'Adding invalid request header "Referer: KO"', + 'Adding invalid request header "Set-Cookie: KO"', + 'Adding invalid request header "TE: KO"', + 'Adding invalid request header "Trailer: KO"', + 'Adding invalid request header "Transfer-Encoding: KO"', + 'Adding invalid request header "Upgrade: KO"', + 'Adding invalid request header "Via: KO"', + 'Adding invalid request header "Proxy-: KO"', + 'Adding invalid request header "proxy-a: KO"', + 'Adding invalid request header "Sec-: KO"', + 'Adding invalid request header "sec-b: KO"', + 'Adding invalid no-cors request header "Content-Type: KO"', + 'Adding invalid no-cors request header "Potato: KO"', + 'Adding invalid no-cors request header "proxy: KO"', + 'Adding invalid no-cors request header "proxya: KO"', + 'Adding invalid no-cors request header "sec: KO"', + 'Adding invalid no-cors request header "secb: KO"', + 'Adding invalid no-cors request header "Empty-Value: "', + 'Check that request constructor is filtering headers provided as init parameter', + 'Check that no-cors request constructor is filtering headers provided as init parameter', + 'Check that no-cors request constructor is filtering headers provided as part of request parameter', + ], + }, + 'request/request-init-002.any.js': {}, + 'request/request-init-contenttype.any.js': { + comment: + 'We are expected to have a space between multipart/form-data and the boundary field', + expectedFailures: ['Default Content-Type for Request with FormData body'], + }, + 'request/request-init-priority.any.js': { + comment: 'Request.priority is not implemented', + skipAllTests: true, + }, + 'request/request-init-stream.any.js': { + comment: 'Most of these are because duplex is not implemented', + expectedFailures: [ + 'Constructing a Request with a stream holds the original object.', + 'It is error to omit .duplex when the body is a ReadableStream.', + "It is error to set .duplex = 'full' when the body is null.", + "It is error to set .duplex = 'full' when the body is a string.", + "It is error to set .duplex = 'full' when the body is a Uint8Array.", + "It is error to set .duplex = 'full' when the body is a Blob.", + "It is error to set .duplex = 'full' when the body is a ReadableStream.", + 'Constructing a Request with a stream on which read() and releaseLock() are called', + ], + }, + 'request/request-keepalive.any.js': { + comment: 'keepalive is not implemented', + skipAllTests: true, + }, + 'request/request-structure.any.js': { + comment: 'Unimplemented or partially implemented fields', + expectedFailures: [ + 'Check destination attribute', + 'Check referrer attribute', + 'Check referrerPolicy attribute', + 'Check mode attribute', + 'Check credentials attribute', + 'Check cache attribute', + 'Check isReloadNavigation attribute', + 'Check isHistoryNavigation attribute', + 'Check duplex attribute', + ], + }, + + 'response/json.any.js': { + comment: 'Investigate issues with our JSON parser', + expectedFailures: [ + 'Ensure UTF-16 results in an error', + 'Ensure the correct JSON parser is used', + ], + }, + 'response/response-arraybuffer-realm.window.js': { + comment: 'Skipped because it involves iframes', + skipAllTests: true, + }, + 'response/response-blob-realm.any.js': { + comment: 'Skipped because it involves iframes', + skipAllTests: true, + }, + 'response/response-cancel-stream.any.js': {}, + 'response/response-clone-iframe.window.js': { + comment: 'Skipped because it involves iframes', + skipAllTests: true, + }, + 'response/response-clone.any.js': { + comment: + 'Several issues. Firstly, we require the type field to always be passed to ReadableStream', + skipAllTests: true, + }, + 'response/response-consume-empty.any.js': { + comment: + 'We seem to be returning the boundary value as text but WPT expects no value', + expectedFailures: ['Consume empty FormData response body as text'], + }, + 'response/response-consume-stream.any.js': {}, + 'response/response-error-from-stream.any.js': { + comment: + 'Several issues. Firstly, we require the type field to always be passed to ReadableStream', + skipAllTests: true, + }, + 'response/response-error.any.js': { + comment: 'Likely just missing validation', + expectedFailures: ["Throws TypeError when responseInit's statusText is Ā"], + }, + 'response/response-from-stream.any.js': { + comment: + 'Several issues. Firstly, we require the type field to always be passed to ReadableStream', + skipAllTests: true, + }, + 'response/response-headers-guard.any.js': { + comment: 'Likely just missing validation', + expectedFailures: ['Ensure response headers are immutable'], + }, + 'response/response-init-001.any.js': { + comment: 'statusText should be inited to OK', + expectedFailures: ['Check default value for statusText attribute'], + }, + 'response/response-init-002.any.js': {}, + 'response/response-init-contenttype.any.js': { + comment: + 'We are inserting a space between multipart/form-data and the boundary', + expectedFailures: ['Default Content-Type for Response with FormData body'], + }, + 'response/response-static-error.any.js': { + comment: + 'We need to make Headers immutable when constructing Response.error()', + expectedFailures: [ + "the 'guard' of the Headers instance should be immutable", + ], + }, + 'response/response-static-json.any.js': { + comment: 'statusText does not match the status code', + expectedFailures: [ + // For this test specifically: failed to throw on non-encodable data + 'Check static json() throws when data is not encodable', + 'Check response returned by static json() with init undefined', + 'Check response returned by static json() with init {"status":400}', + 'Check response returned by static json() with init {"headers":{}}', + 'Check response returned by static json() with init {"headers":{"content-type":"foo/bar"}}', + 'Check response returned by static json() with init {"headers":{"x-foo":"bar"}}', + ], + }, + 'response/response-static-redirect.any.js': { + comment: 'statusText does not match the status code', + expectedFailures: [ + 'Check default redirect response', + 'Check response returned by static method redirect(), status = 301', + 'Check response returned by static method redirect(), status = 302', + 'Check response returned by static method redirect(), status = 303', + 'Check response returned by static method redirect(), status = 307', + 'Check response returned by static method redirect(), status = 308', + ], + }, + 'response/response-stream-bad-chunk.any.js': { + comment: + 'Several issues. Firstly, we require the type field to always be passed to ReadableStream', + skipAllTests: true, + }, + 'response/response-stream-disturbed-1.any.js': { + comment: + 'Several issues. Firstly, we require the type field to always be passed to ReadableStream', + skipAllTests: true, + }, + 'response/response-stream-disturbed-2.any.js': { + comment: + 'Several issues. Firstly, we require the type field to always be passed to ReadableStream', + skipAllTests: true, + }, + 'response/response-stream-disturbed-3.any.js': { + comment: + 'Several issues. Firstly, we require the type field to always be passed to ReadableStream', + skipAllTests: true, + }, + 'response/response-stream-disturbed-4.any.js': { + comment: + 'Several issues. Firstly, we require the type field to always be passed to ReadableStream', + skipAllTests: true, + }, + 'response/response-stream-disturbed-5.any.js': { + comment: + 'Several issues. Firstly, we require the type field to always be passed to ReadableStream', + skipAllTests: true, + }, + 'response/response-stream-disturbed-6.any.js': { + comment: + 'Several issues. Firstly, we require the type field to always be passed to ReadableStream', + skipAllTests: true, + }, + 'response/response-stream-disturbed-by-pipe.any.js': { + comment: + 'Several issues. Firstly, we require the type field to always be passed to ReadableStream', + skipAllTests: true, + }, + 'response/response-stream-disturbed-util.js': {}, + 'response/response-stream-with-broken-then.any.js': { + comment: + 'Triggers an internal error: promise.h:103: failed: expected Wrappable::tryUnwrapOpaque(isolate, handle) != nullptr', + expectedFailures: [ + 'Attempt to inject {done: false, value: bye} via Object.prototype.then.', + 'Attempt to inject value: undefined via Object.prototype.then.', + 'Attempt to inject undefined via Object.prototype.then.', + 'Attempt to inject 8.2 via Object.prototype.then.', + 'intercepting arraybuffer to body readable stream conversion via Object.prototype.then should not be possible', + 'intercepting arraybuffer to text conversion via Object.prototype.then should not be possible', + ], + }, +} satisfies TestRunnerConfig; diff --git a/src/wpt/harness/harness.ts b/src/wpt/harness/harness.ts index 64ffce192a4..2fb598db815 100644 --- a/src/wpt/harness/harness.ts +++ b/src/wpt/harness/harness.ts @@ -30,9 +30,12 @@ import { ok, throws, fail, + rejects, + match, type AssertPredicate, } from 'node:assert'; +import { default as crypto } from 'node:crypto'; import { default as path } from 'node:path'; type CommonOptions = { @@ -41,6 +44,7 @@ type CommonOptions = { before?: () => void; after?: () => void; replace?: (code: string) => string; + only?: boolean; }; type SuccessOptions = { @@ -52,10 +56,18 @@ type SuccessOptions = { type ErrorOptions = { // A comment is mandatory when there are expected failures or skipped tests comment: string; - expectedFailures?: string[]; - skippedTests?: string[]; - skipAllTests?: boolean; -}; +} & ( + | { + expectedFailures?: string[]; + skippedTests?: string[]; + skipAllTests?: false; + } + | { + expectedFailures?: undefined; + skippedTests?: undefined; + skipAllTests: true; + } +); type TestRunnerOptions = CommonOptions & (SuccessOptions | ErrorOptions); @@ -95,6 +107,7 @@ class Test { public name: string; public properties: unknown; public phase: (typeof Test.Phases)[keyof typeof Test.Phases]; + public cleanup_callbacks: UnknownFunc[] = []; public error?: Error; @@ -143,6 +156,7 @@ class Test { } this.error = new AggregateError([err], this.name); + this.error.stack = ''; this.done(); } @@ -251,6 +265,10 @@ class Test { ); } + public add_cleanup(func: UnknownFunc): void { + this.cleanup_callbacks.push(func); + } + public done(): void { if (this.phase >= Test.Phases.CLEANING) { return; @@ -260,7 +278,10 @@ class Test { } public cleanup(): void { - // Actual cleanup support is not yet needed for the WPT modules we support + // TODO(soon): Cleanup functions can also return a promise instead of being synchronous, but we don't need this for any tests currently. + for (const cleanFn of this.cleanup_callbacks) { + cleanFn(); + } this.phase = Test.Phases.COMPLETE; this.resolve(); } @@ -270,6 +291,9 @@ class Test { // Singleton object used to pass test state between the runner and the harness functions available // to the evaled WPT test code. class RunnerState { + // URL corresponding to the current test file, based on the WPT directory structure. + public testUrl: URL; + // Filename of the current test. Used in error messages. public testFileName: string; @@ -285,11 +309,16 @@ class RunnerState { // Promises returned in the test. The test is done once all promises have resolved. public promises: Promise[] = []; + // Callbacks to be run once the entire test is done. + public completionCallbacks: UnknownFunc[] = []; + public constructor( + testUrl: URL, testFileName: string, env: Env, options: TestRunnerOptions ) { + this.testUrl = testUrl; this.testFileName = testFileName; this.env = env; this.options = options; @@ -299,6 +328,10 @@ class RunnerState { // Exception handling is set up on every promise in the test function that created it. await Promise.all(this.promises); + for (const cleanFn of this.completionCallbacks) { + cleanFn(); + } + const expectedFailures = new Set(this.options.expectedFailures ?? []); const unexpectedFailures = []; @@ -340,10 +373,20 @@ type TestFn = UnknownFunc; type PromiseTestFn = () => Promise; type ThrowingFn = () => unknown; +type HostInfo = { + REMOTE_HOST: string; + HTTP_ORIGIN: string; + HTTP_REMOTE_ORIGIN: string; + HTTPS_ORIGIN: string; + ORIGIN: string; + HTTPS_REMOTE_ORIGIN: string; + HTTP_PORT: string; + HTTPS_PORT: string; +}; declare global { /* eslint-disable no-var -- https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#type-checking-for-globalthis */ var state: RunnerState; - var GLOBAL: { isWindow(): boolean }; + var GLOBAL: { isWindow(): boolean; isWorker(): boolean }; /* eslint-enable no-var */ function test(func: TestFn, name: string, properties?: unknown): void; @@ -364,7 +407,11 @@ declare global { function assert_not_equals(a: unknown, b: unknown, message?: string): void; function assert_true(val: unknown, message?: string): void; function assert_false(val: unknown, message?: string): void; - function assert_array_equals(a: unknown, b: unknown, message?: string): void; + function assert_array_equals( + actual: unknown[], + expected: unknown[], + description?: string + ): void; function assert_object_equals(a: unknown, b: unknown, message?: string): void; function assert_implements(condition: unknown, description?: string): void; function assert_implements_optional( @@ -393,6 +440,33 @@ declare global { property_name: string, description?: string ): void; + function promise_rejects_js( + test: Test, + constructor: typeof Error, + promise: Promise, + description?: string + ): Promise; + function assert_regexp_match( + actual: string, + expected: RegExp, + description?: string + ): void; + function assert_greater_than( + actual: number, + expected: number, + description?: string + ): void; + + function promise_rejects_exactly( + test: Test, + exception: typeof Error, + promise: Promise, + description?: string + ): Promise; + function get_host_info(): HostInfo; + function token(): string; + function setup(func: UnknownFunc): void; + function add_completion_callback(func: UnknownFunc): void; } /** @@ -451,18 +525,57 @@ function sanitize_unpaired_surrogates(str: string): string { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access -- We're just exposing enough stuff for the tests to pass; it's not a perfect match globalThis.Window = Object.getPrototypeOf(globalThis).constructor; +const realFetch = globalThis.fetch; +const realRequest = globalThis.Request; + +function relativizeUrl(input: URL | string): string { + return new URL(input, globalThis.state.testUrl).href; +} + +function relativizeRequest( + input: RequestInfo | URL, + init?: RequestInit +): Request { + if (input instanceof Request) { + return new realRequest( + relativizeRequest(input.url), + new realRequest(input, init) + ); + } else if (input instanceof URL) { + return new realRequest(relativizeUrl(input), init); + } else { + return new realRequest(relativizeUrl(input), init); + } +} + +globalThis.Request = class _Request extends Request { + public constructor(input: RequestInfo | URL, init?: RequestInit) { + super(relativizeRequest(input, init)); + } +}; + +globalThis.Response = class _Response extends Response { + public static override redirect( + url: string | URL, + status?: number + ): Response { + return super.redirect(relativizeUrl(url), status); + } +}; + globalThis.fetch = async ( input: RequestInfo | URL, - _init?: RequestInit - // eslint-disable-next-line @typescript-eslint/require-await -- We are emulating an existing interface that returns a promise + init?: RequestInit ): Promise => { - const url = - input instanceof Request ? input.url.toString() : input.toString(); - const exports: unknown = globalThis.state.env[url]; - const response = new Response(); - // eslint-disable-next-line @typescript-eslint/require-await -- We are emulating an existing interface that returns a promise - response.json = async (): Promise => exports; - return response; + if (typeof input === 'string' && input.endsWith('.json')) { + // WPT sometimes uses fetch to load a resource file, we "serve" this from the bindings + const exports: unknown = globalThis.state.env[input]; + const response = new Response(); + // eslint-disable-next-line @typescript-eslint/require-await -- We are emulating an existing interface that returns a promise + response.json = async (): Promise => exports; + return response; + } + return realFetch(relativizeRequest(input, init)); }; // @ts-expect-error We're just exposing enough stuff for the tests to pass; it's not a perfect match @@ -472,6 +585,9 @@ globalThis.GLOBAL = { isWindow(): boolean { return false; }, + isWorker(): boolean { + return false; + }, }; globalThis.done = (): undefined => undefined; @@ -516,7 +632,9 @@ globalThis.promise_test = (func, name, properties): void => { globalThis.state.promises.push( promise.catch((err: unknown) => { - globalThis.state.errors.push(new AggregateError([err], name)); + globalThis.state.errors.push( + Object.assign(new AggregateError([err], name), { stack: '' }) + ); }) ); }; @@ -579,8 +697,25 @@ globalThis.assert_false = (val, message): void => { strictEqual(val, false, message); }; -globalThis.assert_array_equals = (a, b, message): void => { - deepStrictEqual(a, b, message); +/** + * Assert that ``actual`` and ``expected`` are both arrays, and that the array properties of + * ``actual`` and ``expected`` are all the same value (as for :js:func:`assert_equals`). + * + * @param actual - Test array. + * @param expected - Array that is expected to contain the same values as ``actual``. + * @param [description] - Description of the condition being tested. + */ +globalThis.assert_array_equals = (actual, expected, description): void => { + strictEqual(actual.length, expected.length, description); + + for (let i = 0; i < actual.length; i++) { + strictEqual( + Object.prototype.hasOwnProperty.call(actual, i), + Object.prototype.hasOwnProperty.call(expected, i), + description + ); + strictEqual(actual[i], expected[i], description); + } }; globalThis.assert_object_equals = (a, b, message): void => { @@ -763,6 +898,175 @@ globalThis.assert_not_own_property = ( ); }; +/** + * Assert that a Promise is rejected with the right ECMAScript exception. + * + * @param test - the `Test` to use for the assertion. + * @param constructor - The expected exception constructor. + * @param promise - The promise that's expected to + * reject with the given exception. + * @param [description] Error message to add to assert in case of + * failure. + */ +globalThis.promise_rejects_js = async ( + _test, + constructor, + promise, + description +): Promise => { + return rejects(promise, constructor, description); +}; + +/** + * Assert that ``actual`` matches the RegExp ``expected``. + * + * @param actual - Test string. + * @param expected - RegExp ``actual`` must match. + * @param [description] - Description of the condition being tested. + */ +globalThis.assert_regexp_match = (actual, expected, description): void => { + match(actual, expected, description); +}; + +/** + * Assert that ``actual`` is a number greater than ``expected``. + * + * @param actual - Test value. + * @param expected - Number that ``actual`` must be greater than. + * @param [description] - Description of the condition being tested. + */ +globalThis.assert_greater_than = (actual, expected, description): void => { + ok(actual > expected, description); +}; + +/** + * Assert that a Promise is rejected with the provided value. + * + * @param test - the `Test` to use for the assertion. + * @param exception - The expected value of the rejected promise. + * @param promise - The promise that's expected to + * reject. + * @param [description] Error message to add to assert in case of + * failure. + */ +globalThis.promise_rejects_exactly = ( + _test, + exception, + promise, + description +): Promise => { + return promise + .then(() => { + assert_unreached(`Should have rejected: ${description}`); + }) + .catch((exc: unknown) => { + assert_throws_exactly( + exception, + () => { + throw exc; + }, + description + ); + }); +}; + +globalThis.get_host_info = (): HostInfo => { + const httpUrl = globalThis.state.testUrl; + + const httpsUrl = new URL(httpUrl); + httpsUrl.protocol = 'https'; + httpsUrl.port = '8443'; + + return { + REMOTE_HOST: httpUrl.hostname, + HTTP_ORIGIN: httpUrl.origin, + ORIGIN: httpUrl.origin, + HTTP_REMOTE_ORIGIN: httpUrl.origin, + HTTPS_ORIGIN: httpsUrl.origin, + HTTPS_REMOTE_ORIGIN: httpsUrl.origin, + HTTP_PORT: httpUrl.port, + HTTPS_PORT: httpsUrl.port, + }; +}; + +globalThis.token = (): string => { + return crypto.randomUUID(); +}; + +globalThis.setup = (func): void => { + func(); +}; + +globalThis.add_completion_callback = (func: UnknownFunc): void => { + globalThis.state.completionCallbacks.push(func); +}; + +class Location { + public get ancestorOrigins(): DOMStringList { + return { + length: 0, + item(_index: number): string | null { + return null; + }, + contains(_string: string): boolean { + return false; + }, + }; + } + + public get hash(): string { + return globalThis.state.testUrl.hash; + } + + public get host(): string { + return globalThis.state.testUrl.host; + } + + public get hostname(): string { + return globalThis.state.testUrl.hostname; + } + + public get href(): string { + return globalThis.state.testUrl.href; + } + + public get origin(): string { + return globalThis.state.testUrl.origin; + } + + public get pathname(): string { + return globalThis.state.testUrl.pathname; + } + + public get port(): string { + return globalThis.state.testUrl.port; + } + + public get protocol(): string { + return globalThis.state.testUrl.protocol; + } + + public get search(): string { + return globalThis.state.testUrl.search; + } + + public assign(url: string): void { + globalThis.state.testUrl = new URL(url); + } + + public reload(): void {} + + public replace(url: string): void { + globalThis.state.testUrl = new URL(url); + } + + public toString(): string { + return globalThis.state.testUrl.href; + } +} + +globalThis.location = new Location(); + function shouldRunTest(message: string): boolean { if ((globalThis.state.options.skippedTests ?? []).includes(message)) { return false; @@ -804,7 +1108,7 @@ function parseWptMetadata(code: string): WPTMetadata { break; case 'script': { - meta.scripts.push(path.normalize(groups.value)); + meta.scripts.push(groups.value); break; } @@ -825,36 +1129,68 @@ function parseWptMetadata(code: string): WPTMetadata { return meta; } -function evalOnce( - includedFiles: Set, +function getBindingPath(base: string, rawPath: string): string { + if (path.isAbsolute(rawPath)) { + return rawPath; + } + + return path.relative('/', path.resolve(base, rawPath)); +} + +const EXCLUDED_PATHS = new Set([ + // Implemented in harness.ts + '/common/subset-tests-by-key.js', + '/resources/utils.js', + '/common/utils.js', + '/common/get-host-info.sub.js', +]); + +function replaceInterpolation(code: string): string { + const hostInfo = globalThis.get_host_info(); + + return code + .replace('{{host}}', hostInfo.REMOTE_HOST) + .replace('{{ports[http][0]}}', hostInfo.HTTP_PORT) + .replace('{{ports[http][1]}}', hostInfo.HTTP_PORT) + .replace('{{ports[https][0]}}', hostInfo.HTTPS_PORT); +} + +function getCodeAtPath( env: Env, - path: string, + base: string, + rawPath: string, replace?: (code: string) => string -): void { - if (path === '/common/subset-tests-by-key.js') { - // The functionality in this file is directly implemented in harness.ts - return; - } +): string { + const bindingPath = getBindingPath(base, rawPath); - if (includedFiles.has(path)) { - return; + if (EXCLUDED_PATHS.has(bindingPath)) { + return ''; } - if (typeof env[path] != 'string') { + if (typeof env[bindingPath] != 'string') { // We only have access to the files explicitly declared in the .wd-test, not the full WPT // checkout, so it's possible for tests to include things we can't load. throw new Error( - `Test file ${path} not found. Update wpt_test.bzl to handle this case.` + `Test file ${bindingPath} not found. Update wpt_test.bzl to handle this case.` ); } - includedFiles.add(path); - const code = replace ? replace(env[path]) : env[path]; - env.unsafe.eval(code); + let code = env[bindingPath]; + if (replace) { + code = replace(code); + } + + return replaceInterpolation(code); +} + +function evalAsBlock(env: Env, files: string[]): void { + const block = '{' + files.join('\n') + '}'; + env.unsafe.eval(block); } export function createRunner( config: TestRunnerConfig, + moduleBase: string, allTestFiles: string[] ): (file: string) => TestCase { const testsNotFound = new Set(Object.keys(config)).difference( @@ -867,16 +1203,12 @@ export function createRunner( ); } - // Keeps track of test files which have been included, to avoid loading the same file more - // than once. Test files are executed in the global scope using unsafeEval, so executing the same - // file again could cause redefinition errors depending on what is in the file. - const includedFiles = new Set(); + const onlyFlagUsed = Object.values(config).some((options) => options.only); return (file: string): TestCase => { return { async test(_: unknown, env: Env): Promise { const options = config[file]; - const mainCode = String(env[file]); if (!options) { throw new Error( @@ -884,23 +1216,35 @@ export function createRunner( ); } + if (onlyFlagUsed && !options.only) { + return; + } + if (options.skipAllTests) { console.warn(`All tests in ${file} have been skipped.`); return; } - globalThis.state = new RunnerState(file, env, options); - const meta = parseWptMetadata(mainCode); + const testUrl = new URL( + path.join(moduleBase, file), + 'http://localhost:8000' + ); + + globalThis.state = new RunnerState(testUrl, file, env, options); + const meta = parseWptMetadata(String(env[file])); if (options.before) { options.before(); } + const files = []; + for (const script of meta.scripts) { - evalOnce(includedFiles, env, script); + files.push(getCodeAtPath(env, path.dirname(file), script)); } - evalOnce(includedFiles, env, file, options.replace); + files.push(getCodeAtPath(env, './', file, options.replace)); + evalAsBlock(env, files); if (options.after) { options.after(); diff --git a/src/wpt/urlpattern-test.ts b/src/wpt/urlpattern-test.ts index 723a6a70b1a..d71770c25a8 100644 --- a/src/wpt/urlpattern-test.ts +++ b/src/wpt/urlpattern-test.ts @@ -167,7 +167,7 @@ export default { ], }, 'urlpattern.https.any.js': { - comment: - 'No test cases will run because urlpatterntests.js is already loaded.', + comment: 'Test cases are identical to urlpattern.any.js.', + skipAllTests: true, }, } satisfies TestRunnerConfig;