Skip to content
File

Blob: src/wpt/harness/test.ts

typescript378 lines
1// Copyright (c) 2017-2022 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4// Copyright © web-platform-tests contributors. BSD license
5// Adapted from Node.js. Copyright Joyent, Inc. and other Node contributors.
6//
7// Permission is hereby granted, free of charge, to any person obtaining a
8// copy of this software and associated documentation files (the
9// "Software"), to deal in the Software without restriction, including
10// without limitation the rights to use, copy, modify, merge, publish,
11// distribute, sublicense, and/or sell copies of the Software, and to permit
12// persons to whom the Software is furnished to do so, subject to the
13// following conditions:
14//
15// The above copyright notice and this permission notice shall be included
16// in all copies or substantial portions of the Software.
17//
18// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
19// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
21// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
22// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
23// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
24// USE OR OTHER DEALINGS IN THE SOFTWARE.
25 
26import {
27 FilterList,
28 type UnknownFunc,
29 type TestFn,
30 type PromiseTestFn,
31} from './common';
32 
33declare global {
34 function promise_test(
35 func: PromiseTestFn,
36 name?: string,
37 properties?: unknown
38 ): void;
39 function async_test(
40 func: TestFn | string,
41 name?: string,
42 properties?: unknown
43 ): Test;
44 function test(func: TestFn, name?: string, properties?: unknown): void;
45}
46 
47type TestErrorType = Error | 'OMITTED' | 'DISABLED' | undefined;
48 
49/**
50 * A single subtest. A Test is not constructed directly but via the
51 * :js:func:`test`, :js:func:`async_test` or :js:func:`promise_test` functions.
52 *
53 * @param name - This must be unique in a given file and must be
54 * invariant between runs.
55 *
56 */
57/* eslint-disable @typescript-eslint/no-this-alias -- WPT allows for overriding the this environment for a step but defaults to the Test class */
58export class Test {
59 static Phases = {
60 INITIAL: 0,
61 STARTED: 1,
62 HAS_RESULT: 2,
63 CLEANING: 3,
64 COMPLETE: 4,
65 } as const;
66 
67 name: string;
68 properties: unknown;
69 phase: (typeof Test.Phases)[keyof typeof Test.Phases];
70 cleanup_callbacks: UnknownFunc[] = [];
71 
72 error: TestErrorType = undefined;
73 
74 // If this test is asynchronous, stores a promise that resolves on test completion
75 promise?: Promise<void>;
76 
77 constructor(name: string, properties?: unknown) {
78 this.name = name;
79 this.properties = properties;
80 this.phase = Test.Phases.INITIAL;
81 }
82 
83 /**
84 * Run a single step of an ongoing test.
85 *
86 * @param func - Callback function to run as a step. If
87 * this throws an :js:func:`AssertionError`, or any other
88 * exception, the :js:class:`Test` status is set to ``FAIL``.
89 * @param [this_obj] - The object to use as the this
90 * value when calling ``func``. Defaults to the :js:class:`Test` object.
91 */
92 step(func: UnknownFunc, this_obj?: object, ...rest: unknown[]): unknown {
93 if (this.phase > Test.Phases.STARTED) {
94 return undefined;
95 }
96 
97 if (arguments.length === 1) {
98 this_obj = this;
99 }
100 
101 try {
102 return func.call(this_obj, ...rest);
103 } catch (err) {
104 if (this.phase >= Test.Phases.HAS_RESULT) {
105 return undefined;
106 }
107 
108 this.error = new AggregateError([err], this.name);
109 this.error.stack = '';
110 this.done();
111 }
112 
113 return undefined;
114 }
115 
116 /**
117 * Wrap a function so that it runs as a step of the current test.
118 *
119 * This allows creating a callback function that will run as a
120 * test step.
121 *
122 * @example
123 * let t = async_test("Example");
124 * onload = t.step_func(e => {
125 * assert_equals(e.name, "load");
126 * // Mark the test as complete.
127 * t.done();
128 * })
129 *
130 * @param func - Function to run as a step. If this
131 * throws an :js:func:`AssertionError`, or any other exception,
132 * the :js:class:`Test` status is set to ``FAIL``.
133 * @param [this_obj] - The object to use as the this
134 * value when calling ``func``. Defaults to the :js:class:`Test` object.
135 */
136 step_func(func: UnknownFunc, this_obj?: object): UnknownFunc {
137 if (arguments.length === 1) {
138 this_obj = this;
139 }
140 
141 return (...params: unknown[]) => this.step(func, this_obj, ...params);
142 }
143 
144 /**
145 * Wrap a function so that it runs as a step of the current test,
146 * and automatically marks the test as complete if the function
147 * returns without error.
148 *
149 * @param func - Function to run as a step. If this
150 * throws an :js:func:`AssertionError`, or any other exception,
151 * the :js:class:`Test` status is set to ``FAIL``. If it returns
152 * without error the status is set to ``PASS``.
153 * @param [this_obj] - The object to use as the this
154 * value when calling `func`. Defaults to the :js:class:`Test` object.
155 */
156 step_func_done(func?: UnknownFunc, this_obj?: object): UnknownFunc {
157 if (arguments.length === 1) {
158 this_obj = this;
159 }
160 
161 return (...params: unknown[]) => {
162 if (func) {
163 this.step(func, this_obj, ...params);
164 }
165 
166 this.done();
167 };
168 }
169 
170 /**
171 * Return a function that automatically sets the current test to
172 * ``FAIL`` if it's called.
173 *
174 * @param [description] - Error message to add to assert
175 * in case of failure.
176 *
177 */
178 unreached_func(description?: string): UnknownFunc {
179 return this.step_func(() => {
180 assert_unreached(description);
181 });
182 }
183 
184 /**
185 * Run a function as a step of the test after a given timeout.
186 *
187 * In general it's encouraged to use :js:func:`Test.step_wait` or
188 * :js:func:`step_wait_func` in preference to this function where possible,
189 * as they provide better test performance.
190 *
191 * @param func - Function to run as a test
192 * step.
193 * @param timeout - Time in ms to wait before running the
194 * test step.
195 *
196 */
197 step_timeout(
198 func: UnknownFunc,
199 timeout: number,
200 ...rest: unknown[]
201 ): ReturnType<typeof setTimeout> {
202 return setTimeout(
203 this.step_func(() => func(...rest)),
204 timeout
205 );
206 }
207 
208 add_cleanup(func: UnknownFunc): void {
209 this.cleanup_callbacks.push(func);
210 }
211 
212 done(): void {
213 if (this.phase >= Test.Phases.CLEANING) {
214 return;
215 }
216 
217 this.cleanup();
218 }
219 
220 cleanup(): void {
221 // TODO(soon): Cleanup functions can also return a promise instead of being synchronous, but we don't need this for any tests currently.
222 for (const cleanFn of this.cleanup_callbacks) {
223 cleanFn();
224 }
225 this.phase = Test.Phases.COMPLETE;
226 }
227}
228 
229/* eslint-enable @typescript-eslint/no-this-alias */
230class SkippedTest extends Test {
231 constructor(name: string, reason: TestErrorType) {
232 super(name);
233 this.error = reason;
234 }
235 
236 override step(
237 _func: UnknownFunc,
238 _this_obj?: object,
239 ..._rest: unknown[]
240 ): unknown {
241 return undefined;
242 }
243}
244 
245class PromiseTest extends Test {
246 // TODO(soon): Extract out promise_test specific behaviour to make code easier to understand.
247}
248 
249globalThis.promise_test = (func, name, properties): void => {
250 if (maybeAddSkippedTest(name ?? '')) {
251 return;
252 }
253 
254 const testCase = new PromiseTest(name ?? '', properties);
255 globalThis.state.subtests.push(testCase);
256 
257 const promise = testCase.step(func, testCase, testCase);
258 
259 if (!(promise instanceof Promise)) {
260 // The functions passed to promise_test are expected to return a Promise,
261 // but are not required to be async functions. That means they could throw
262 // an error immediately when run.
263 
264 if (!testCase.error) {
265 testCase.error = new Error('Unexpected value returned from promise_test');
266 }
267 
268 return;
269 }
270 
271 testCase.promise = promise
272 .then(() => {
273 testCase.done();
274 })
275 .catch((err: unknown) => {
276 testCase.error = Object.assign(new AggregateError([err], name), {
277 stack: '',
278 });
279 });
280};
281 
282class AsyncTest extends Test {
283 #resolve: () => void;
284 
285 constructor(name: string, properties: unknown) {
286 super(name, properties);
287 
288 // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- void is being used as a valid generic in this context
289 const { promise, resolve } = Promise.withResolvers<void>();
290 this.promise = promise;
291 this.#resolve = resolve;
292 }
293 
294 override done(): void {
295 super.done();
296 this.#resolve();
297 }
298}
299 
300globalThis.async_test = (func, name, properties): Test => {
301 // async_test can be called in two ways:
302 // 1. async_test(func, name, properties) - func is a TestFn
303 // 2. async_test(name, properties) - just creates a test with the given name
304 let testName: string;
305 let testFunc: TestFn | undefined;
306 
307 if (typeof func === 'string') {
308 // async_test(name, properties) signature
309 testName = func;
310 testFunc = undefined;
311 // name parameter is actually properties in this case
312 properties = name;
313 } else {
314 // async_test(func, name, properties) signature
315 testName = name ?? '';
316 testFunc = func;
317 }
318 
319 if (maybeAddSkippedTest(testName)) {
320 // Return a dummy test object for skipped tests
321 return new SkippedTest(testName, 'DISABLED');
322 }
323 
324 const testCase = new AsyncTest(testName, properties);
325 globalThis.state.subtests.push(testCase);
326 
327 if (testFunc) {
328 testCase.step(testFunc, testCase, testCase);
329 }
330 
331 return testCase;
332};
333 
334/**
335 * Create a synchronous test
336 *
337 * @param func - Test function. This is executed
338 * immediately. If it returns without error, the test status is
339 * set to ``PASS``. If it throws an :js:class:`AssertionError`, or
340 * any other exception, the test status is set to ``FAIL``
341 * (typically from an `assert` function).
342 * @param name - Test name. This must be unique in a
343 * given file and must be invariant between runs.
344 */
345globalThis.test = (func, name, properties): void => {
346 if (maybeAddSkippedTest(name ?? '')) {
347 return;
348 }
349 
350 const testCase = new Test(name ?? '', properties);
351 globalThis.state.subtests.push(testCase);
352 
353 testCase.step(func, testCase, testCase);
354 testCase.done();
355};
356 
357function maybeAddSkippedTest(message: string): boolean {
358 const disabledTests = new FilterList(globalThis.state.options.disabledTests);
359 
360 if (disabledTests.has(message)) {
361 globalThis.state.subtests.push(new SkippedTest(message, 'DISABLED'));
362 return true;
363 }
364 
365 const omittedTests = new FilterList(globalThis.state.options.omittedTests);
366 
367 if (omittedTests.has(message)) {
368 globalThis.state.subtests.push(new SkippedTest(message, 'OMITTED'));
369 return true;
370 }
371 
372 if (globalThis.state.options.verbose) {
373 console.info('run', message);
374 }
375 
376 return false;
377}