tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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;