fixture.ts (14097B)
1 import { TestCaseRecorder } from '../internal/logging/test_case_recorder.js'; 2 import { JSONWithUndefined } from '../internal/params_utils.js'; 3 import { assert, ExceptionCheckOptions, unreachable } from '../util/util.js'; 4 5 export class SkipTestCase extends Error {} 6 export class UnexpectedPassError extends Error {} 7 8 export { TestCaseRecorder } from '../internal/logging/test_case_recorder.js'; 9 10 /** The fully-general type for params passed to a test function invocation. */ 11 export type TestParams = { 12 readonly [k: string]: JSONWithUndefined; 13 }; 14 15 type DestroyableObject = 16 | { destroy(): void } 17 | { destroyAsync(): Promise<void> } 18 | { close(): void } 19 | { getExtension(extensionName: 'WEBGL_lose_context'): WEBGL_lose_context } 20 | HTMLVideoElement; 21 22 export class SubcaseBatchState { 23 constructor( 24 protected readonly recorder: TestCaseRecorder, 25 /** The case parameters for this test fixture shared state. Subcase params are not included. */ 26 public readonly params: TestParams 27 ) {} 28 29 /** 30 * Runs before the `.before()` function. 31 * @internal MAINTENANCE_TODO: Make this not visible to test code? 32 */ 33 async init() {} 34 /** 35 * Runs between the `.before()` function and the subcases. 36 * @internal MAINTENANCE_TODO: Make this not visible to test code? 37 */ 38 async postInit() {} 39 /** 40 * Runs after all subcases finish. 41 * @internal MAINTENANCE_TODO: Make this not visible to test code? 42 */ 43 async finalize() {} 44 45 /** Throws an exception marking the subcase as skipped. */ 46 skip(msg: string): never { 47 throw new SkipTestCase(msg); 48 } 49 50 /** Throws an exception making the subcase as skipped if condition is true */ 51 skipIf(cond: boolean, msg: string | (() => string) = '') { 52 if (cond) { 53 this.skip(typeof msg === 'function' ? msg() : msg); 54 } 55 } 56 } 57 58 /** 59 * A Fixture is a class used to instantiate each test sub/case at run time. 60 * A new instance of the Fixture is created for every single test subcase 61 * (i.e. every time the test function is run). 62 */ 63 export class Fixture<S extends SubcaseBatchState = SubcaseBatchState> { 64 private _params: unknown; 65 private _sharedState: S; 66 /** 67 * Interface for recording logs and test status. 68 * 69 * @internal 70 */ 71 readonly rec: TestCaseRecorder; 72 private eventualExpectations: Array<Promise<unknown>> = []; 73 private numOutstandingAsyncExpectations = 0; 74 private objectsToCleanUp: DestroyableObject[] = []; 75 76 public static MakeSharedState(recorder: TestCaseRecorder, params: TestParams): SubcaseBatchState { 77 return new SubcaseBatchState(recorder, params); 78 } 79 80 /** @internal */ 81 constructor(sharedState: S, rec: TestCaseRecorder, params: TestParams) { 82 this._sharedState = sharedState; 83 this.rec = rec; 84 this._params = params; 85 } 86 87 /** 88 * Returns the (case+subcase) parameters for this test function invocation. 89 */ 90 get params(): unknown { 91 return this._params; 92 } 93 94 /** 95 * Gets the test fixture's shared state. This object is shared between subcases 96 * within the same testcase. 97 */ 98 get sharedState(): S { 99 return this._sharedState; 100 } 101 102 /** 103 * Override this to do additional pre-test-function work in a derived fixture. 104 * This has to be a member function instead of an async `createFixture` function, because 105 * we need to be able to ergonomically override it in subclasses. 106 * 107 * @internal MAINTENANCE_TODO: Make this not visible to test code? 108 */ 109 async init(): Promise<void> {} 110 111 /** 112 * Override this to do additional post-test-function work in a derived fixture. 113 * 114 * Called even if init was unsuccessful. 115 * 116 * @internal MAINTENANCE_TODO: Make this not visible to test code? 117 */ 118 async finalize(): Promise<void> { 119 assert( 120 this.numOutstandingAsyncExpectations === 0, 121 'there were outstanding immediateAsyncExpectations (e.g. expectUncapturedError) at the end of the test' 122 ); 123 124 // Loop to exhaust the eventualExpectations in case they chain off each other. 125 while (this.eventualExpectations.length) { 126 const p = this.eventualExpectations.shift()!; 127 try { 128 await p; 129 } catch (ex) { 130 this.rec.threw(ex); 131 } 132 } 133 134 // And clean up any objects now that they're done being used. 135 for (const o of this.objectsToCleanUp) { 136 if ('getExtension' in o) { 137 const WEBGL_lose_context = o.getExtension('WEBGL_lose_context'); 138 if (WEBGL_lose_context) WEBGL_lose_context.loseContext(); 139 } else if ('destroy' in o) { 140 o.destroy(); 141 } else if ('destroyAsync' in o) { 142 await o.destroyAsync(); 143 } else if ('close' in o) { 144 o.close(); 145 } else { 146 // HTMLVideoElement 147 o.src = ''; 148 o.srcObject = null; 149 } 150 } 151 } 152 153 /** 154 * Tracks an object to be cleaned up after the test finishes. 155 * 156 * Usually when creating buffers/textures/query sets, you can use the helpers in GPUTest instead. 157 */ 158 trackForCleanup<T extends DestroyableObject | Promise<DestroyableObject>>(o: T): T { 159 if (o instanceof Promise) { 160 this.eventualAsyncExpectation(() => 161 o.then( 162 o => this.trackForCleanup(o), 163 () => {} 164 ) 165 ); 166 return o; 167 } 168 169 if (o instanceof GPUDevice) { 170 this.objectsToCleanUp.push({ 171 async destroyAsync() { 172 o.destroy(); 173 await o.lost; 174 }, 175 }); 176 } else { 177 this.objectsToCleanUp.push(o); 178 } 179 return o; 180 } 181 182 /** Tracks an object, if it's destroyable, to be cleaned up after the test finishes. */ 183 tryTrackForCleanup<T>(o: T): T { 184 if (typeof o === 'object' && o !== null) { 185 if ( 186 'destroy' in o || 187 'close' in o || 188 o instanceof WebGLRenderingContext || 189 o instanceof WebGL2RenderingContext 190 ) { 191 this.objectsToCleanUp.push(o as unknown as DestroyableObject); 192 } 193 } 194 return o; 195 } 196 197 /** Call requestDevice() and track the device for cleanup. */ 198 requestDeviceTracked(adapter: GPUAdapter, desc: GPUDeviceDescriptor | undefined = undefined) { 199 // eslint-disable-next-line no-restricted-syntax 200 return this.trackForCleanup(adapter.requestDevice(desc)); 201 } 202 203 /** Log a debug message. */ 204 debug(msg: string | (() => string)): void { 205 if (!this.rec.debugging) return; 206 if (typeof msg === 'function') { 207 msg = msg(); 208 } 209 this.rec.debug(new Error(msg)); 210 } 211 212 /** 213 * Log an info message. 214 * **Use sparingly. Use `debug()` instead if logs are only needed with debug logging enabled.** 215 */ 216 info(msg: string): void { 217 this.rec.info(new Error(msg)); 218 } 219 220 /** Throws an exception marking the subcase as skipped. */ 221 skip(msg: string): never { 222 throw new SkipTestCase(msg); 223 } 224 225 /** Throws an exception marking the subcase as skipped if condition is true */ 226 skipIf(cond: boolean, msg: string | (() => string) = '') { 227 if (cond) { 228 this.skip(typeof msg === 'function' ? msg() : msg); 229 } 230 } 231 232 /** Log a warning and increase the result status to "Warn". */ 233 warn(msg?: string): void { 234 this.rec.warn(new Error(msg)); 235 } 236 237 /** Log an error and increase the result status to "ExpectFailed". */ 238 fail(msg?: string): void { 239 this.rec.expectationFailed(new Error(msg)); 240 } 241 242 /** 243 * Wraps an async function. Tracks its status to fail if the test tries to report a test status 244 * before the async work has finished. 245 */ 246 protected async immediateAsyncExpectation<T>(fn: () => Promise<T>): Promise<T> { 247 this.numOutstandingAsyncExpectations++; 248 const ret = await fn(); 249 this.numOutstandingAsyncExpectations--; 250 return ret; 251 } 252 253 /** 254 * Wraps an async function, passing it an `Error` object recording the original stack trace. 255 * The async work will be implicitly waited upon before reporting a test status. 256 */ 257 eventualAsyncExpectation<T>(fn: (niceStack: Error) => Promise<T>): void { 258 const promise = fn(new Error()); 259 this.eventualExpectations.push(promise); 260 } 261 262 private expectErrorValue(expectedError: string | true, ex: unknown, niceStack: Error): void { 263 if (!(ex instanceof Error)) { 264 niceStack.message = `THREW non-error value, of type ${typeof ex}: ${ex}`; 265 this.rec.expectationFailed(niceStack); 266 return; 267 } 268 const actualName = ex.name; 269 if (expectedError !== true && actualName !== expectedError) { 270 niceStack.message = `THREW ${actualName}, instead of ${expectedError}: ${ex}`; 271 this.rec.expectationFailed(niceStack); 272 } else { 273 niceStack.message = `OK: threw ${actualName}: ${ex.message}`; 274 this.rec.debug(niceStack); 275 } 276 } 277 278 /** Expect that the provided promise resolves (fulfills). */ 279 shouldResolve(p: Promise<unknown>, msg?: string): void { 280 this.eventualAsyncExpectation(async niceStack => { 281 const m = msg ? ': ' + msg : ''; 282 try { 283 await p; 284 niceStack.message = 'resolved as expected' + m; 285 } catch (ex) { 286 niceStack.message = `REJECTED${m}`; 287 if (ex instanceof Error) { 288 niceStack.message += '\n' + ex.message; 289 } 290 this.rec.expectationFailed(niceStack); 291 } 292 }); 293 } 294 295 /** Expect that the provided promise rejects, with the provided exception name. */ 296 shouldReject( 297 expectedName: string, 298 p: Promise<unknown>, 299 { allowMissingStack = false, message }: ExceptionCheckOptions = {} 300 ): void { 301 this.eventualAsyncExpectation(async niceStack => { 302 const m = message ? ': ' + message : ''; 303 try { 304 await p; 305 niceStack.message = 'DID NOT REJECT' + m; 306 this.rec.expectationFailed(niceStack); 307 } catch (ex) { 308 this.expectErrorValue(expectedName, ex, niceStack); 309 if (!allowMissingStack) { 310 if (!(ex instanceof Error && typeof ex.stack === 'string')) { 311 const exMessage = ex instanceof Error ? ex.message : '?'; 312 niceStack.message = `rejected as expected, but missing stack (${exMessage})${m}`; 313 this.rec.expectationFailed(niceStack); 314 } 315 } 316 } 317 }); 318 } 319 320 /** 321 * Expect that the provided function throws (if `true` or `string`) or not (if `false`). 322 * If a string is provided, expect that the throw exception has that name. 323 * 324 * MAINTENANCE_TODO: Change to `string | false` so the exception name is always checked. 325 */ 326 shouldThrow( 327 expectedError: string | boolean, 328 fn: () => void, 329 { allowMissingStack = false, message }: ExceptionCheckOptions = {} 330 ) { 331 const m = message ? ': ' + message : ''; 332 try { 333 fn(); 334 if (expectedError === false) { 335 this.rec.debug(new Error('did not throw, as expected' + m)); 336 } else { 337 this.rec.expectationFailed(new Error('unexpectedly did not throw' + m)); 338 } 339 } catch (ex) { 340 if (expectedError === false) { 341 this.rec.expectationFailed(new Error('threw unexpectedly' + m)); 342 } else { 343 this.expectErrorValue(expectedError, ex, new Error(m)); 344 if (!allowMissingStack) { 345 if (!(ex instanceof Error && typeof ex.stack === 'string')) { 346 this.rec.expectationFailed(new Error('threw as expected, but missing stack' + m)); 347 } 348 } 349 } 350 } 351 } 352 353 /** 354 * Expect that a condition is true. 355 * 356 * Note: You can pass a boolean condition, or a function that returns a boolean. 357 * The advantage to passing a function is that if it's short it is self documenting. 358 * 359 * t.expect(size >= maxSize); // prints Expect OK: 360 * t.expect(() => size >= maxSize) // prints Expect OK: () => size >= maxSize 361 */ 362 expect(cond: boolean | (() => boolean), msg?: string): boolean { 363 if (typeof cond === 'function') { 364 if (msg === undefined) { 365 msg = cond.toString(); 366 } 367 cond = cond(); 368 } 369 if (cond) { 370 const m = msg ? ': ' + msg : ''; 371 this.rec.debug(new Error('expect OK' + m)); 372 } else { 373 this.rec.expectationFailed(new Error(msg)); 374 } 375 return cond; 376 } 377 378 /** 379 * If the argument is an `Error`, fail (or warn). If it's `undefined`, no-op. 380 * If the argument is an array, apply the above behavior on each of elements. 381 */ 382 expectOK( 383 error: Error | undefined | (Error | undefined)[], 384 { mode = 'fail', niceStack }: { mode?: 'fail' | 'warn'; niceStack?: Error } = {} 385 ): void { 386 const handleError = (error: Error | undefined) => { 387 if (error instanceof Error) { 388 if (niceStack) { 389 error.stack = niceStack.stack; 390 } 391 if (mode === 'fail') { 392 this.rec.expectationFailed(error); 393 } else if (mode === 'warn') { 394 this.rec.warn(error); 395 } else { 396 unreachable(); 397 } 398 } 399 }; 400 401 if (Array.isArray(error)) { 402 for (const e of error) { 403 handleError(e); 404 } 405 } else { 406 handleError(error); 407 } 408 } 409 410 eventualExpectOK( 411 error: Promise<Error | undefined | (Error | undefined)[]>, 412 { mode = 'fail' }: { mode?: 'fail' | 'warn' } = {} 413 ) { 414 this.eventualAsyncExpectation(async niceStack => { 415 this.expectOK(await error, { mode, niceStack }); 416 }); 417 } 418 } 419 420 export type SubcaseBatchStateFromFixture<F> = F extends Fixture<infer S> ? S : never; 421 422 /** 423 * FixtureClass encapsulates a constructor for fixture and a corresponding 424 * shared state factory function. An interface version of the type is also 425 * defined for mixin declaration use ONLY. The interface version is necessary 426 * because mixin classes need a constructor with a single any[] rest 427 * parameter. 428 */ 429 export type FixtureClass<F extends Fixture = Fixture> = { 430 new (sharedState: SubcaseBatchStateFromFixture<F>, log: TestCaseRecorder, params: TestParams): F; 431 MakeSharedState(recorder: TestCaseRecorder, params: TestParams): SubcaseBatchStateFromFixture<F>; 432 }; 433 export type FixtureClassInterface<F extends Fixture = Fixture> = { 434 /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 435 new (...args: any[]): F; 436 MakeSharedState(recorder: TestCaseRecorder, params: TestParams): SubcaseBatchStateFromFixture<F>; 437 }; 438 export type FixtureClassWithMixin<FC, M> = FC extends FixtureClass<infer F> 439 ? FixtureClass<F & M> 440 : never;