From a0b8d14ce3582004e9206357ad45488e14606e9d Mon Sep 17 00:00:00 2001 From: Nicholas Paun Date: Tue, 4 Feb 2025 15:49:36 -0800 Subject: [PATCH] Unrevert WPT dom/abort tests (#3462) --- build/BUILD.wpt | 22 +- build/wpt_test.bzl | 61 +++- src/workerd/api/wpt/BUILD.bazel | 2 +- src/workerd/api/wpt/dom/abort-test.ts | 29 ++ src/workerd/api/wpt/eslint.config.mjs | 2 +- src/workerd/api/wpt/tsconfig.json | 2 +- src/workerd/api/wpt/url-test.ts | 21 +- src/workerd/api/wpt/urlpattern-test.ts | 5 + src/wpt/harness.ts | 421 ++++++++++++++++++++----- 9 files changed, 458 insertions(+), 107 deletions(-) create mode 100644 src/workerd/api/wpt/dom/abort-test.ts diff --git a/build/BUILD.wpt b/build/BUILD.wpt index 757b227041c..5084f448a45 100644 --- a/build/BUILD.wpt +++ b/build/BUILD.wpt @@ -2,19 +2,21 @@ # Licensed under the Apache 2.0 license found in the LICENSE file or at: # https://opensource.org/licenses/Apache-2.0 -directories = glob( - ["*"], - exclude = glob( - ["*"], - exclude_directories = 1, - ) + [ - ".*", +load("@workerd//:build/wpt_test.bzl", "wpt_get_directories") + +[filegroup( + name = dir, + srcs = glob(["{}/**/*".format(dir)]), + visibility = ["//visibility:public"], +) for dir in wpt_get_directories( + excludes = [ + "dom", ], - exclude_directories = 0, -) + root = "", +)] [filegroup( name = dir, srcs = glob(["{}/**/*".format(dir)]), visibility = ["//visibility:public"], -) for dir in directories] +) for dir in wpt_get_directories(root = "dom")] diff --git a/build/wpt_test.bzl b/build/wpt_test.bzl index d2d5d5abd58..937b4788281 100644 --- a/build/wpt_test.bzl +++ b/build/wpt_test.bzl @@ -16,6 +16,8 @@ def wpt_test(name, wpt_directory, test_config): js_test_gen_rule = "{}@_wpt_js_test_gen".format(name) test_config_as_js = test_config.removesuffix(".ts") + ".js" + harness = "//src/wpt:wpt-test-harness" + compat_date = "//src/workerd/io:trimmed-supported-compatibility-date.txt" _wpt_js_test_gen( name = js_test_gen_rule, @@ -31,22 +33,41 @@ def wpt_test(name, wpt_directory, test_config): wpt_directory = wpt_directory, test_config = test_config_as_js, test_js_generated = js_test_gen_rule, + harness = harness, + compat_date = compat_date, ) wd_test( name = "{}".format(name), src = wd_test_gen_rule, args = ["--experimental"], - ts_deps = ["//src/wpt:wpt-test-harness"], + ts_deps = [harness], data = [ - "//src/wpt:wpt-test-harness", + harness, test_config, js_test_gen_rule, wpt_directory, - "//src/workerd/io:trimmed-supported-compatibility-date.txt", + compat_date, ], ) +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. + """ + + root_pattern = "{}/*".format(root) if root else "*" + return native.glob( + [root_pattern], + exclude = native.glob( + [root_pattern], + exclude_directories = 1, + ) + [".*"] + excludes, + exclude_directories = 0, + ) + def _wpt_js_test_gen_impl(ctx): """ Generates a workerd test suite in JS. This contains the logic to run @@ -75,7 +96,7 @@ def generate_external_cases(files): for file in files.to_list(): if file.extension == "js": - entry = """export const {} = run(config, '{}');""".format(test_case_name(file.basename), file.basename) + entry = """export const {} = run('{}');""".format(test_case_name(file.basename), file.basename) result.append(entry) return "\n".join(result) @@ -97,9 +118,11 @@ def test_case_name(filename): WPT_JS_TEST_TEMPLATE = """// This file is autogenerated by wpt_test.bzl // DO NOT EDIT. -import {{ run }} from 'wpt:harness'; +import {{ createRunner }} from 'wpt:harness'; import config from '{test_config}'; +const run = createRunner(config); + {cases} """ @@ -109,15 +132,16 @@ def _wpt_wd_test_gen_impl(ctx): 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)) ctx.actions.write( output = src, content = WPT_WD_TEST_TEMPLATE.format( test_name = ctx.attr.test_name, test_config = ctx.file.test_config.basename, - test_js_generated = wd_relative_path(ctx.file.test_js_generated), - bindings = generate_external_bindings(ctx.attr.wpt_directory.files), + 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), ), ) @@ -134,14 +158,14 @@ const unitTests :Workerd.Config = ( modules = [ (name = "worker", esModule = embed "{test_js_generated}"), (name = "{test_config}", esModule = embed "{test_config}"), - (name = "wpt:harness", esModule = embed "../../../../../workerd/src/wpt/harness.js"), + (name = "wpt:harness", esModule = embed "{harness}"), ], bindings = [ (name = "wpt", service = "wpt"), (name = "unsafe", unsafeEval = void), {bindings} ], - compatibilityDate = embed "../../../../../workerd/src/workerd/io/trimmed-supported-compatibility-date.txt", + compatibilityDate = embed "{compat_date}", compatibilityFlags = ["nodejs_compat", "experimental"], ) ), @@ -152,15 +176,14 @@ const unitTests :Workerd.Config = ( ], );""" -def wd_relative_path(file): +def wd_relative_path(label, target): """ - Returns a relative path which can be referenced in the .wd-test file. - This is four directories up from the bazel short_path + 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 - return "../" * 4 + file.short_path - -def generate_external_bindings(files): +def generate_external_bindings(label, files): """ Generates appropriate bindings for each file in the WPT module: - JS files: text binding to allow code to be evaluated @@ -170,7 +193,7 @@ def generate_external_bindings(files): result = [] for file in files.to_list(): - file_path = wd_relative_path(file) + file_path = wd_relative_path(label, file) if file.extension == "js": entry = """(name = "{}", text = embed "{}")""".format(file.basename, file_path) elif file.extension == "json": @@ -195,6 +218,10 @@ _wpt_wd_test_gen = rule( "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), }, ) diff --git a/src/workerd/api/wpt/BUILD.bazel b/src/workerd/api/wpt/BUILD.bazel index e59b7d0375f..190b0b015d1 100644 --- a/src/workerd/api/wpt/BUILD.bazel +++ b/src/workerd/api/wpt/BUILD.bazel @@ -5,7 +5,7 @@ load("@npm//:eslint/package_json.bzl", eslint_bin = "bin") load("//:build/wpt_test.bzl", "wpt_test") -srcs = glob(["*-test.ts"]) +srcs = glob(["**/*-test.ts"]) [wpt_test( name = file.replace("-test.ts", ""), diff --git a/src/workerd/api/wpt/dom/abort-test.ts b/src/workerd/api/wpt/dom/abort-test.ts new file mode 100644 index 00000000000..6010272f10a --- /dev/null +++ b/src/workerd/api/wpt/dom/abort-test.ts @@ -0,0 +1,29 @@ +// 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 'wpt:harness'; + +export default { + 'AbortSignal.any.js': {}, + 'abort-signal-any-tests.js': {}, + 'abort-signal-any.any.js': { + comment: + '(1, 2) Target should be set to signal. (3) Should be investigated.', + expectedFailures: [ + 'AbortSignal.any() follows a single signal (using AbortController)', + 'AbortSignal.any() follows multiple signals (using AbortController)', + 'Abort events for AbortSignal.any() signals fire in the right order (using AbortController)', + ], + includeFile: 'abort-signal-any-tests.js', + }, + 'event.any.js': { + comment: 'Target should be set to signal', + expectedFailures: ['the abort event should have the right properties'], + }, + 'timeout-shadowrealm.any.js': { + comment: 'Enable when ShadowRealm is implemented', + skipAllTests: true, + }, + 'timeout.any.js': {}, +} satisfies TestRunnerConfig; diff --git a/src/workerd/api/wpt/eslint.config.mjs b/src/workerd/api/wpt/eslint.config.mjs index 666098b722b..8f313caa39c 100644 --- a/src/workerd/api/wpt/eslint.config.mjs +++ b/src/workerd/api/wpt/eslint.config.mjs @@ -3,7 +3,7 @@ import { baseConfig } from '../../../../tools/base.eslint.config.mjs'; export default [ ...baseConfig({ tsconfigRootDir: import.meta.dirname }), { - files: ['src/workerd/api/wpt/*-test.js'], + files: ['src/workerd/api/wpt/**/*-test.ts'], rules: { 'sort-keys': 'error', }, diff --git a/src/workerd/api/wpt/tsconfig.json b/src/workerd/api/wpt/tsconfig.json index a77d2f82c47..73003277b2f 100644 --- a/src/workerd/api/wpt/tsconfig.json +++ b/src/workerd/api/wpt/tsconfig.json @@ -26,6 +26,6 @@ "wpt:*": ["../../../wpt/*"] } }, - "include": ["**/*.ts", "../../../wpt/*.ts"], + "include": ["**/*.ts"], "exclude": [] } diff --git a/src/workerd/api/wpt/url-test.ts b/src/workerd/api/wpt/url-test.ts index f4f525e21ae..0bccb33fb76 100644 --- a/src/workerd/api/wpt/url-test.ts +++ b/src/workerd/api/wpt/url-test.ts @@ -5,6 +5,7 @@ import { type TestRunnerConfig } from 'wpt:harness'; export default { + 'IdnaTestV2.window.js': {}, 'a-element-origin.js': { comment: 'Implement globalThis.document', skipAllTests: true, @@ -16,7 +17,6 @@ export default { 'historical.any.js': { comment: 'Fix this eventually', expectedFailures: [ - 'Constructor only takes strings', 'URL: no structured serialize/deserialize support', 'URLSearchParams: no structured serialize/deserialize support', ], @@ -36,7 +36,8 @@ export default { ], }, 'percent-encoding.window.js': { - comment: 'Implement `async_test`', + comment: + 'Implement test code modification feature to allow running this test without document', skipAllTests: true, }, 'toascii.window.js': { @@ -49,10 +50,17 @@ export default { 'Parsing: without base', ], }, + 'url-origin.any.js': {}, + 'url-searchparams.any.js': {}, 'url-setters-a-area.window.js': { comment: 'Implement globalThis.document', skipAllTests: true, }, + 'url-setters-stripping.any.js': {}, + 'url-setters.any.js': {}, + 'url-statics-canparse.any.js': {}, + 'url-statics-parse.any.js': {}, + 'url-tojson.any.js': {}, 'urlencoded-parser.any.js': { comment: 'Requests fail due to HTTP method "LADIDA", responses fail due to shift_jis encoding', @@ -129,6 +137,7 @@ export default { 'response.formData() with input: b=%%2a', ], }, + 'urlsearchparams-append.any.js': {}, 'urlsearchparams-constructor.any.js': { comment: 'Fix this eventually', expectedFailures: [ @@ -138,8 +147,16 @@ export default { 'Construct with object with NULL, non-ASCII, and surrogate keys', ], }, + 'urlsearchparams-delete.any.js': {}, + 'urlsearchparams-foreach.any.js': {}, + 'urlsearchparams-get.any.js': {}, + 'urlsearchparams-getall.any.js': {}, + 'urlsearchparams-has.any.js': {}, + 'urlsearchparams-set.any.js': {}, + 'urlsearchparams-size.any.js': {}, 'urlsearchparams-sort.any.js': { comment: 'Investigate url_search_params::sort in ada-url', expectedFailures: ['Parse and sort: ffi&🌈', 'URL parse and sort: ffi&🌈'], }, + 'urlsearchparams-stringifier.any.js': {}, } satisfies TestRunnerConfig; diff --git a/src/workerd/api/wpt/urlpattern-test.ts b/src/workerd/api/wpt/urlpattern-test.ts index 3ff59a300f3..41c66e73b20 100644 --- a/src/workerd/api/wpt/urlpattern-test.ts +++ b/src/workerd/api/wpt/urlpattern-test.ts @@ -37,6 +37,8 @@ export default { 'Component: hash Left: {"hash":"a"} Right: {"hash":"b"}', ], }, + 'urlpattern-compare.tentative.any.js': {}, + 'urlpattern-compare.tentative.https.any.js': {}, 'urlpattern-hasregexpgroups-tests.js': { comment: 'urlpattern implementation will soon be replaced with ada-url', expectedFailures: [ @@ -45,6 +47,9 @@ export default { '', // This file consists of one unnamed subtest ], }, + 'urlpattern-hasregexpgroups.any.js': {}, + 'urlpattern.any.js': {}, + 'urlpattern.https.any.js': {}, 'urlpatterntests.js': { comment: 'urlpattern implementation will soon be replaced with ada-url', expectedFailures: [ diff --git a/src/wpt/harness.ts b/src/wpt/harness.ts index 0f9b9591ada..d2d3236031f 100644 --- a/src/wpt/harness.ts +++ b/src/wpt/harness.ts @@ -29,12 +29,14 @@ import { deepStrictEqual, ok, throws, + fail, type AssertPredicate, } from 'node:assert'; type CommonOptions = { comment?: string; verbose?: boolean; + includeFile?: string; }; type SuccessOptions = { @@ -66,9 +68,204 @@ type TestCase = { test(_: unknown, env: Env): Promise; }; +type UnknownFunc = (...args: unknown[]) => unknown; + +/** + * A single subtest. A Test is not constructed directly but via the + * :js:func:`test`, :js:func:`async_test` or :js:func:`promise_test` functions. + * + * @param name - This must be unique in a given file and must be + * invariant between runs. + * + */ +/* eslint-disable @typescript-eslint/no-this-alias -- WPT allows for overriding the this environment for a step but defaults to the Test class */ +class Test { + public static Phases = { + INITIAL: 0, + STARTED: 1, + HAS_RESULT: 2, + CLEANING: 3, + COMPLETE: 4, + } as const; + + public name: string; + public properties: unknown; + public phase: (typeof Test.Phases)[keyof typeof Test.Phases]; + + public error?: Error; + + // For convenience, expose a promise that resolves once done() is called + public isDone: Promise; + private resolve: () => void; + + public constructor(name: string, properties: unknown) { + this.name = name; + this.properties = properties; + this.phase = Test.Phases.INITIAL; + + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- void is being used as a valid generic in this context + const { promise, resolve } = Promise.withResolvers(); + this.isDone = promise; + this.resolve = resolve; + } + + /** + * Run a single step of an ongoing test. + * + * @param func - Callback function to run as a step. If + * this throws an :js:func:`AssertionError`, or any other + * exception, the :js:class:`Test` status is set to ``FAIL``. + * @param [this_obj] - The object to use as the this + * value when calling ``func``. Defaults to the :js:class:`Test` object. + */ + public step( + func: UnknownFunc, + this_obj?: object, + ...rest: unknown[] + ): unknown { + if (this.phase > Test.Phases.STARTED) { + return undefined; + } + + if (arguments.length === 1) { + this_obj = this; + } + + try { + return func.call(this_obj, ...rest); + } catch (err) { + if (this.phase >= Test.Phases.HAS_RESULT) { + return undefined; + } + + this.error = new AggregateError([err], this.name); + this.done(); + } + + return undefined; + } + + /** + * Wrap a function so that it runs as a step of the current test. + * + * This allows creating a callback function that will run as a + * test step. + * + * @example + * let t = async_test("Example"); + * onload = t.step_func(e => { + * assert_equals(e.name, "load"); + * // Mark the test as complete. + * t.done(); + * }) + * + * @param func - Function to run as a step. If this + * throws an :js:func:`AssertionError`, or any other exception, + * the :js:class:`Test` status is set to ``FAIL``. + * @param [this_obj] - The object to use as the this + * value when calling ``func``. Defaults to the :js:class:`Test` object. + */ + public step_func(func: UnknownFunc, this_obj?: object): UnknownFunc { + const test_this = this; + + if (arguments.length === 1) { + this_obj = this; + } + + return function (...params: unknown[]) { + return test_this.step.call(test_this, func, this_obj, ...params); + }; + } + + /** + * Wrap a function so that it runs as a step of the current test, + * and automatically marks the test as complete if the function + * returns without error. + * + * @param func - Function to run as a step. If this + * throws an :js:func:`AssertionError`, or any other exception, + * the :js:class:`Test` status is set to ``FAIL``. If it returns + * without error the status is set to ``PASS``. + * @param [this_obj] - The object to use as the this + * value when calling `func`. Defaults to the :js:class:`Test` object. + */ + public step_func_done(func?: UnknownFunc, this_obj?: object): UnknownFunc { + const test_this = this; + + if (arguments.length === 1) { + this_obj = test_this; + } + + return function (...params: unknown[]) { + if (func) { + test_this.step.call(test_this, func, this_obj, ...params); + } + + test_this.done(); + }; + } + + /** + * Return a function that automatically sets the current test to + * ``FAIL`` if it's called. + * + * @param [description] - Error message to add to assert + * in case of failure. + * + */ + public unreached_func(description?: string): UnknownFunc { + return this.step_func(() => { + assert_unreached(description); + }); + } + + /** + * Run a function as a step of the test after a given timeout. + * + * In general it's encouraged to use :js:func:`Test.step_wait` or + * :js:func:`step_wait_func` in preference to this function where possible, + * as they provide better test performance. + * + * @param func - Function to run as a test + * step. + * @param timeout - Time in ms to wait before running the + * test step. + * + */ + public step_timeout( + func: UnknownFunc, + timeout: number, + ...rest: unknown[] + ): ReturnType { + const test_this = this; + + return setTimeout( + this.step_func(function () { + return func.call(test_this, ...rest); + }), + timeout + ); + } + + public done(): void { + if (this.phase >= Test.Phases.CLEANING) { + return; + } + + this.cleanup(); + } + + public cleanup(): void { + // Actual cleanup support is not yet needed for the WPT modules we support + this.phase = Test.Phases.COMPLETE; + this.resolve(); + } +} +/* eslint-enable @typescript-eslint/no-this-alias */ + type TestRunnerFn = (callback: TestFn | PromiseTestFn, message: string) => void; -type TestFn = () => void; -type PromiseTestFn = () => Promise; +type TestFn = UnknownFunc; +type PromiseTestFn = () => Promise; type ThrowingFn = () => unknown; declare global { @@ -77,10 +274,10 @@ declare global { var testOptions: TestRunnerOptions; var GLOBAL: { isWindow(): boolean }; var env: Env; - var promises: { [name: string]: Promise }; + var promises: Promise[]; /* eslint-enable no-var */ - function test(func: TestFn, name: string): void; + function test(func: TestFn, name: string, properties?: unknown): void; function done(): undefined; function subsetTestByKey( _key: string, @@ -93,6 +290,7 @@ declare global { name: string, properties?: unknown ): void; + function async_test(func: TestFn, name: string, properties?: unknown): void; function assert_equals(a: unknown, b: unknown, message?: string): void; function assert_not_equals(a: unknown, b: unknown, message?: string): void; function assert_true(val: unknown, message?: string): void; @@ -121,13 +319,17 @@ declare global { descriptionOrFunc: string | ThrowingFn, maybeDescription?: string ): void; + function assert_not_own_property( + object: object, + property_name: string, + description?: string + ): void; } /** - * @class * Exception type that represents a failing assert. * NOTE: This a custom error type defined by WPT - it's not the same as node:assert's AssertionError - * @param {string} message - Error message. + * @param message - Error message. */ declare class AssertionError extends Error {} function AssertionError(this: AssertionError, message: string): void { @@ -219,15 +421,76 @@ globalThis.subsetTestByKey = ( return testType(testCallback, testMessage); }; -globalThis.promise_test = (func, name, _properties): void => { +globalThis.promise_test = (func, name, properties): void => { if (!shouldRunTest(name)) { return; } - try { - globalThis.promises[name] = func.call(this); - } catch (err) { - globalThis.errors.push(new AggregateError([err], name)); + const testCase = new Test(name, properties); + const promise = testCase.step(func, testCase, testCase); + + if (!(promise instanceof Promise)) { + // The functions passed to promise_test are expected to return a Promise, + // but are not required to be async functions. That means they could throw + // an error immediately when run. + + if (testCase.error) { + globalThis.errors.push(testCase.error); + } else { + globalThis.errors.push( + new Error('Unexpected value returned from promise_test') + ); + } + + return; + } + + globalThis.promises.push( + promise.catch((err: unknown) => { + globalThis.errors.push(new AggregateError([err], name)); + }) + ); +}; + +globalThis.async_test = (func, name, properties): void => { + if (!shouldRunTest(name)) { + return; + } + + const testCase = new Test(name, properties); + testCase.step(func, testCase, testCase); + + globalThis.promises.push( + testCase.isDone.then(() => { + if (testCase.error) { + globalThis.errors.push(testCase.error); + } + }) + ); +}; + +/** + * Create a synchronous test + * + * @param func - Test function. This is executed + * immediately. If it returns without error, the test status is + * set to ``PASS``. If it throws an :js:class:`AssertionError`, or + * any other exception, the test status is set to ``FAIL`` + * (typically from an `assert` function). + * @param name - Test name. This must be unique in a + * given file and must be invariant between runs. + */ +globalThis.test = (func, name, properties): void => { + if (!shouldRunTest(name)) { + return; + } + + const testCase = new Test(name, properties); + testCase.step(func, testCase, testCase); + testCase.done(); + + if (testCase.error) { + globalThis.errors.push(testCase.error); } }; @@ -264,8 +527,8 @@ globalThis.assert_object_equals = (a, b, message): void => { * * assert_implements(window.Foo, 'Foo is not supported'); * - * @param {object} condition The truthy value to test - * @param {string} [description] Error description for the case that the condition is not truthy. + * @param condition The truthy value to test + * @param [description] Error description for the case that the condition is not truthy. */ globalThis.assert_implements = (condition, description): void => { ok(!!condition, description); @@ -281,8 +544,8 @@ globalThis.assert_implements = (condition, description): void => { * assert_implements_optional(video.canPlayType("video/webm"), * "webm video playback not supported"); * - * @param {object} condition The truthy value to test - * @param {string} [description] Error description for the case that the condition is not truthy. + * @param condition The truthy value to test + * @param [description] Error description for the case that the condition is not truthy. */ globalThis.assert_implements_optional = (condition, description): void => { if (!condition) { @@ -294,7 +557,7 @@ globalThis.assert_implements_optional = (condition, description): void => { * Asserts if called. Used to ensure that a specific code path is * not taken e.g. that an error event isn't fired. * - * @param {string} [description] - Description of the condition being tested. + * @param [description] - Description of the condition being tested. */ globalThis.assert_unreached = (description): void => { ok(false, `Reached unreachable code: ${description ?? 'undefined'}`); @@ -303,9 +566,9 @@ globalThis.assert_unreached = (description): void => { /** * Assert a JS Error with the expected constructor is thrown. * - * @param {object} constructor The expected exception constructor. - * @param {Function} func Function which should throw. - * @param {string} [description] Error description for the case that the error is not thrown. + * @param constructor The expected exception constructor. + * @param func Function which should throw. + * @param [description] Error description for the case that the error is not thrown. */ globalThis.assert_throws_js = (constructor, func, description): void => { throws( @@ -320,18 +583,23 @@ globalThis.assert_throws_js = (constructor, func, description): void => { /** * Assert the provided value is thrown. * - * @param {value} exception The expected exception. - * @param {Function} fn Function which should throw. - * @param {string} [description] Error description for the case that the error is not thrown. + * @param exception The expected exception. + * @param fn Function which should throw. + * @param [description] Error description for the case that the error is not thrown. */ globalThis.assert_throws_exactly = (exception, fn, description): void => { - throws( - () => { - fn.call(this); - }, - exception, - description - ); + try { + fn.call(this); + } catch (err) { + strictEqual( + err, + exception, + description ?? "Thrown exception doesn't match expected value" + ); + return; + } + + fail(description ?? 'No exception was thrown'); }; /** @@ -348,7 +616,7 @@ globalThis.assert_throws_exactly = (exception, fn, description): void => { * the third argument the function expected to throw, and the fourth, optional, * argument the assertion description. * - * @param {number|string} type - The expected exception name or + * @param type - The expected exception name or * code. See the `table of names and codes * `_. If a * number is passed it should be one of the numeric code values in @@ -356,11 +624,11 @@ globalThis.assert_throws_exactly = (exception, fn, description): void => { * either be an exception name (e.g. "HierarchyRequestError", * "WrongDocumentError") or the name of the corresponding error * code (e.g. "``HIERARCHY_REQUEST_ERR``", "``WRONG_DOCUMENT_ERR``"). - * @param {Function} descriptionOrFunc - The function expected to + * @param descriptionOrFunc - The function expected to * throw (if the exception comes from another global), or the * optional description of the condition being tested (if the * exception comes from the current global). - * @param {string} [maybeDescription] - Description of the condition + * @param [maybeDescription] - Description of the condition * being tested (if the exception comes from another global). * */ @@ -399,26 +667,22 @@ globalThis.assert_throws_dom = ( }; /** - * Create a synchronous test + * Assert that ``object`` does not have an own property with name ``property_name``. * - * @param {TestFn} func - Test function. This is executed - * immediately. If it returns without error, the test status is - * set to ``PASS``. If it throws an :js:class:`AssertionError`, or - * any other exception, the test status is set to ``FAIL`` - * (typically from an `assert` function). - * @param {String} name - Test name. This must be unique in a - * given file and must be invariant between runs. + * @param object - Object that should not have the given property. + * @param property_name - Property name to test. + * @param [description] - Description of the condition being tested. */ -globalThis.test = (func, name): void => { - if (!shouldRunTest(name)) { - return; - } - - try { - func.call(this); - } catch (err) { - globalThis.errors.push(new AggregateError([err], name)); - } +globalThis.assert_not_own_property = ( + object, + property_name, + description +): void => { + ok( + !Object.prototype.hasOwnProperty.call(object, property_name), + `unexpected property ${property_name} is found on object: ` + + (description ?? '') + ); }; globalThis.errors = []; @@ -439,20 +703,15 @@ function prepare(env: Env, options: TestRunnerOptions): void { globalThis.errors = []; globalThis.testOptions = options; globalThis.env = env; - globalThis.promises = {}; + globalThis.promises = []; } async function validate( testFileName: string, options: TestRunnerOptions ): Promise { - for (const [name, promise] of Object.entries(globalThis.promises)) { - try { - await promise; - } catch (err) { - globalThis.errors.push(new AggregateError([err], name)); - } - } + // Exception handling is set up on every promise in the test function that created it. + await Promise.all(globalThis.promises); const expectedFailures = new Set(options.expectedFailures ?? []); @@ -480,22 +739,34 @@ async function validate( } } -export function run(config: TestRunnerConfig, file: string): TestCase { - const options = config[file] ?? {}; - - return { - async test(_: unknown, env: Env): Promise { - if (options.skipAllTests) { - console.warn(`All tests in ${file} have been skipped.`); - return; - } - - prepare(env, options); - if (typeof env[file] !== 'string') { - throw new Error(`Unable to run ${file}. Code is not a string`); - } - env.unsafe.eval(env[file]); - await validate(file, options); - }, +export function createRunner( + config: TestRunnerConfig +): (file: string) => TestCase { + return (file: string): TestCase => { + return { + async test(_: unknown, env: Env): Promise { + const options = config[file]; + if (!options) { + throw new Error( + `Missing test configuration for ${file}. Specify '${file}': {} for default options.` + ); + } + + if (options.skipAllTests) { + console.warn(`All tests in ${file} have been skipped.`); + return; + } + + prepare(env, options); + + if (options.includeFile) { + env.unsafe.eval(String(env[options.includeFile])); + } + + env.unsafe.eval(String(env[file])); + + await validate(file, options); + }, + }; }; }