tor-browser

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

test_group.ts (24876B)


      1 import {
      2  Fixture,
      3  SubcaseBatchState,
      4  SkipTestCase,
      5  TestParams,
      6  UnexpectedPassError,
      7  SubcaseBatchStateFromFixture,
      8  FixtureClass,
      9 } from '../framework/fixture.js';
     10 import {
     11  CaseParamsBuilder,
     12  builderIterateCasesWithSubcases,
     13  kUnitCaseParamsBuilder,
     14  ParamsBuilderBase,
     15  SubcaseParamsBuilder,
     16 } from '../framework/params_builder.js';
     17 import { globalTestConfig } from '../framework/test_config.js';
     18 import { Expectation } from '../internal/logging/result.js';
     19 import { TestCaseRecorder } from '../internal/logging/test_case_recorder.js';
     20 import { extractPublicParams, Merged, mergeParams } from '../internal/params_utils.js';
     21 import { compareQueries, Ordering } from '../internal/query/compare.js';
     22 import {
     23  TestQueryMultiFile,
     24  TestQueryMultiTest,
     25  TestQuerySingleCase,
     26  TestQueryWithExpectation,
     27 } from '../internal/query/query.js';
     28 import { kPathSeparator } from '../internal/query/separators.js';
     29 import {
     30  stringifyPublicParams,
     31  stringifyPublicParamsUniquely,
     32 } from '../internal/query/stringify_params.js';
     33 import { validQueryPart } from '../internal/query/validQueryPart.js';
     34 import { attemptGarbageCollection } from '../util/collect_garbage.js';
     35 import { DeepReadonly } from '../util/types.js';
     36 import { assert, unreachable } from '../util/util.js';
     37 
     38 import { logToWebSocket } from './websocket_logger.js';
     39 
     40 export type RunFn = (
     41  rec: TestCaseRecorder,
     42  expectations?: TestQueryWithExpectation[]
     43 ) => Promise<void>;
     44 
     45 export interface TestCaseID {
     46  readonly test: readonly string[];
     47  readonly params: TestParams;
     48 }
     49 
     50 export interface RunCase {
     51  readonly id: TestCaseID;
     52  readonly isUnimplemented: boolean;
     53  computeSubcaseCount(): number;
     54  run(
     55    rec: TestCaseRecorder,
     56    selfQuery: TestQuerySingleCase,
     57    expectations: TestQueryWithExpectation[]
     58  ): Promise<void>;
     59 }
     60 
     61 // Interface for defining tests
     62 export interface TestGroupBuilder<F extends Fixture> {
     63  test(name: string): TestBuilderWithName<F>;
     64 }
     65 export function makeTestGroup<F extends Fixture>(fixture: FixtureClass<F>): TestGroupBuilder<F> {
     66  return new TestGroup(fixture as unknown as FixtureClass);
     67 }
     68 
     69 // Interfaces for running tests
     70 export interface IterableTestGroup {
     71  iterate(): Iterable<IterableTest>;
     72  validate(fileQuery: TestQueryMultiFile): void;
     73  /** Returns the file-relative test paths of tests which have >0 cases. */
     74  collectNonEmptyTests(): { testPath: string[] }[];
     75 }
     76 export interface IterableTest {
     77  testPath: string[];
     78  description: string | undefined;
     79  readonly testCreationStack: Error;
     80  iterate(caseFilter: TestParams | null): Iterable<RunCase>;
     81 }
     82 
     83 export function makeTestGroupForUnitTesting<F extends Fixture>(
     84  fixture: FixtureClass<F>
     85 ): TestGroup<F> {
     86  return new TestGroup(fixture);
     87 }
     88 
     89 /** The maximum allowed length of a test query string. Checked by tools/validate. */
     90 export const kQueryMaxLength = 375;
     91 
     92 /** Parameter name for batch number (see also TestBuilder.batch). */
     93 const kBatchParamName = 'batch__';
     94 
     95 type TestFn<F extends Fixture, P extends {}> = (
     96  t: F & { params: DeepReadonly<P> }
     97 ) => Promise<void> | void;
     98 type BeforeAllSubcasesFn<S extends SubcaseBatchState, P extends {}> = (
     99  s: S & { params: DeepReadonly<P> }
    100 ) => Promise<void> | void;
    101 
    102 export class TestGroup<F extends Fixture> implements TestGroupBuilder<F> {
    103  private fixture: FixtureClass;
    104  private seen: Set<string> = new Set();
    105  private tests: Array<TestBuilder<SubcaseBatchStateFromFixture<F>, F>> = [];
    106 
    107  constructor(fixture: FixtureClass) {
    108    this.fixture = fixture;
    109  }
    110 
    111  iterate(): Iterable<IterableTest> {
    112    return this.tests;
    113  }
    114 
    115  private checkName(name: string): void {
    116    assert(
    117      // Shouldn't happen due to the rule above. Just makes sure that treating
    118      // unencoded strings as encoded strings is OK.
    119      name === decodeURIComponent(name),
    120      `Not decodeURIComponent-idempotent: ${name} !== ${decodeURIComponent(name)}`
    121    );
    122    assert(!this.seen.has(name), `Duplicate test name: ${name}`);
    123 
    124    this.seen.add(name);
    125  }
    126 
    127  test(name: string): TestBuilderWithName<F> {
    128    const testCreationStack = new Error(`Test created: ${name}`);
    129 
    130    this.checkName(name);
    131 
    132    const parts = name.split(kPathSeparator);
    133    for (const p of parts) {
    134      assert(validQueryPart.test(p), `Invalid test name part ${p}; must match ${validQueryPart}`);
    135    }
    136 
    137    const test = new TestBuilder(parts, this.fixture, testCreationStack);
    138    this.tests.push(test);
    139    return test as unknown as TestBuilderWithName<F>;
    140  }
    141 
    142  validate(fileQuery: TestQueryMultiFile): void {
    143    for (const test of this.tests) {
    144      const testQuery = new TestQueryMultiTest(
    145        fileQuery.suite,
    146        fileQuery.filePathParts,
    147        test.testPath
    148      );
    149      test.validate(testQuery);
    150    }
    151  }
    152 
    153  collectNonEmptyTests(): { testPath: string[] }[] {
    154    const testPaths = [];
    155    for (const test of this.tests) {
    156      if (test.computeCaseCount() > 0) {
    157        testPaths.push({ testPath: test.testPath });
    158      }
    159    }
    160    return testPaths;
    161  }
    162 }
    163 
    164 interface TestBuilderWithName<F extends Fixture> extends TestBuilderWithParams<F, {}, {}> {
    165  desc(description: string): this;
    166  /**
    167   * A noop function to associate a test with the relevant part of the specification.
    168   *
    169   * @param url a link to the spec where test is extracted from.
    170   */
    171  specURL(url: string): this;
    172  /**
    173   * Parameterize the test, generating multiple cases, each possibly having subcases.
    174   *
    175   * The `unit` value passed to the `cases` callback is an immutable constant
    176   * `CaseParamsBuilder<{}>` representing the "unit" builder `[ {} ]`,
    177   * provided for convenience. The non-callback overload can be used if `unit` is not needed.
    178   */
    179  params<CaseP extends {}, SubcaseP extends {}>(
    180    cases: (unit: CaseParamsBuilder<{}>) => ParamsBuilderBase<CaseP, SubcaseP>
    181  ): TestBuilderWithParams<F, CaseP, SubcaseP>;
    182  /**
    183   * Parameterize the test, generating multiple cases, each possibly having subcases.
    184   *
    185   * Use the callback overload of this method if a "unit" builder is needed.
    186   */
    187  params<CaseP extends {}, SubcaseP extends {}>(
    188    cases: ParamsBuilderBase<CaseP, SubcaseP>
    189  ): TestBuilderWithParams<F, CaseP, SubcaseP>;
    190 
    191  /**
    192   * Parameterize the test, generating multiple cases, without subcases.
    193   */
    194  paramsSimple<P extends {}>(cases: Iterable<P>): TestBuilderWithParams<F, P, {}>;
    195 
    196  /**
    197   * Parameterize the test, generating one case with multiple subcases.
    198   */
    199  paramsSubcasesOnly<P extends {}>(subcases: Iterable<P>): TestBuilderWithParams<F, {}, P>;
    200  /**
    201   * Parameterize the test, generating one case with multiple subcases.
    202   *
    203   * The `unit` value passed to the `subcases` callback is an immutable constant
    204   * `SubcaseParamsBuilder<{}>`, with one empty case `{}` and one empty subcase `{}`.
    205   */
    206  paramsSubcasesOnly<P extends {}>(
    207    subcases: (unit: SubcaseParamsBuilder<{}, {}>) => SubcaseParamsBuilder<{}, P>
    208  ): TestBuilderWithParams<F, {}, P>;
    209 }
    210 
    211 interface TestBuilderWithParams<F extends Fixture, CaseP extends {}, SubcaseP extends {}> {
    212  /**
    213   * Limit subcases to a maximum number of per testcase.
    214   * @param b the maximum number of subcases per testcase.
    215   *
    216   * If the number of subcases exceeds `b`, add an internal
    217   * numeric, incrementing `batch__` param to split subcases
    218   * into groups of at most `b` subcases.
    219   */
    220  batch(b: number): this;
    221  /**
    222   * Run a function on shared subcase batch state before each
    223   * batch of subcases.
    224   * @param fn the function to run. It is called with the test
    225   * fixture's shared subcase batch state.
    226   *
    227   * Generally, this function should be careful to avoid mutating
    228   * any state on the shared subcase batch state which could result
    229   * in unexpected order-dependent test behavior.
    230   */
    231  beforeAllSubcases(fn: BeforeAllSubcasesFn<SubcaseBatchStateFromFixture<F>, CaseP>): this;
    232  /**
    233   * Set the test function.
    234   * @param fn the test function.
    235   */
    236  fn(fn: TestFn<F, Merged<CaseP, SubcaseP>>): void;
    237  /**
    238   * Mark the test as unimplemented.
    239   */
    240  unimplemented(): void;
    241 }
    242 
    243 class TestBuilder<S extends SubcaseBatchState, F extends Fixture> {
    244  readonly testPath: string[];
    245  isUnimplemented: boolean;
    246  description: string | undefined;
    247  readonly testCreationStack: Error;
    248 
    249  private readonly fixture: FixtureClass;
    250  private testFn: TestFn<Fixture, {}> | undefined;
    251  private beforeFn: BeforeAllSubcasesFn<SubcaseBatchState, {}> | undefined;
    252  private testCases?: ParamsBuilderBase<{}, {}> = undefined;
    253  private batchSize: number = 0;
    254 
    255  constructor(testPath: string[], fixture: FixtureClass, testCreationStack: Error) {
    256    this.testPath = testPath;
    257    this.isUnimplemented = false;
    258    this.fixture = fixture;
    259    this.testCreationStack = testCreationStack;
    260  }
    261 
    262  desc(description: string): this {
    263    this.description = description.trim();
    264    return this;
    265  }
    266 
    267  specURL(_url: string): this {
    268    return this;
    269  }
    270 
    271  beforeAllSubcases(fn: BeforeAllSubcasesFn<SubcaseBatchState, {}>): this {
    272    assert(this.beforeFn === undefined);
    273    this.beforeFn = fn;
    274    return this;
    275  }
    276 
    277  fn(fn: TestFn<Fixture, {}>): void {
    278    // eslint-disable-next-line no-warning-comments
    279    // MAINTENANCE_TODO: add "TODO" if there's no description? (and make sure it only ends up on
    280    // actual tests, not on test parents in the tree, which is what happens if you do it here, not
    281    // sure why)
    282    assert(this.testFn === undefined);
    283    this.testFn = fn;
    284  }
    285 
    286  batch(b: number): this {
    287    this.batchSize = b;
    288    return this;
    289  }
    290 
    291  unimplemented(): void {
    292    assert(this.testFn === undefined);
    293 
    294    this.description =
    295      (this.description ? this.description + '\n\n' : '') + 'TODO: .unimplemented()';
    296    this.isUnimplemented = true;
    297 
    298    // Use the beforeFn to skip the test, so we don't have to iterate the subcases.
    299    this.beforeFn = () => {
    300      throw new SkipTestCase('test unimplemented');
    301    };
    302    this.testFn = () => {};
    303  }
    304 
    305  /** Perform various validation/"lint" chenks. */
    306  validate(testQuery: TestQueryMultiTest): void {
    307    const testPathString = this.testPath.join(kPathSeparator);
    308    assert(this.testFn !== undefined, () => {
    309      let s = `Test is missing .fn(): ${testPathString}`;
    310      if (this.testCreationStack.stack) {
    311        s += `\n-> test created at:\n${this.testCreationStack.stack}`;
    312      }
    313      return s;
    314    });
    315 
    316    assert(
    317      testQuery.toString().length <= kQueryMaxLength,
    318      () =>
    319        `Test query ${testQuery} is too long. Max length is ${kQueryMaxLength} characters. Please shorten names or reduce parameters.`
    320    );
    321 
    322    if (this.testCases === undefined) {
    323      return;
    324    }
    325 
    326    const seen = new Set<string>();
    327    for (const [caseParams, subcases] of builderIterateCasesWithSubcases(this.testCases, null)) {
    328      const caseQuery = new TestQuerySingleCase(
    329        testQuery.suite,
    330        testQuery.filePathParts,
    331        testQuery.testPathParts,
    332        caseParams
    333      ).toString();
    334      assert(
    335        caseQuery.length <= kQueryMaxLength,
    336        () =>
    337          `Case query ${caseQuery} is too long. Max length is ${kQueryMaxLength} characters. Please shorten names or reduce parameters.`
    338      );
    339 
    340      for (const subcaseParams of subcases ?? [{}]) {
    341        const params = mergeParams(caseParams, subcaseParams);
    342        assert(this.batchSize === 0 || !(kBatchParamName in params));
    343 
    344        // stringifyPublicParams also checks for invalid params values
    345        let testcaseString;
    346        try {
    347          testcaseString = stringifyPublicParams(params);
    348        } catch (e) {
    349          throw new Error(`${e}: ${testPathString}`);
    350        }
    351 
    352        // A (hopefully) unique representation of a params value.
    353        const testcaseStringUnique = stringifyPublicParamsUniquely(params);
    354        assert(
    355          !seen.has(testcaseStringUnique),
    356          `Duplicate public test case+subcase params for test ${testPathString}: ${testcaseString} (${caseQuery})`
    357        );
    358        seen.add(testcaseStringUnique);
    359      }
    360    }
    361  }
    362 
    363  computeCaseCount(): number {
    364    if (this.testCases === undefined) {
    365      return 1;
    366    }
    367 
    368    let caseCount = 0;
    369    for (const [_caseParams, _subcases] of builderIterateCasesWithSubcases(this.testCases, null)) {
    370      caseCount++;
    371    }
    372    return caseCount;
    373  }
    374 
    375  params(
    376    cases: ((unit: CaseParamsBuilder<{}>) => ParamsBuilderBase<{}, {}>) | ParamsBuilderBase<{}, {}>
    377  ): TestBuilder<S, F> {
    378    assert(this.testCases === undefined, 'test case is already parameterized');
    379    if (cases instanceof Function) {
    380      this.testCases = cases(kUnitCaseParamsBuilder);
    381    } else {
    382      this.testCases = cases;
    383    }
    384    return this;
    385  }
    386 
    387  paramsSimple(cases: Iterable<{}>): TestBuilder<S, F> {
    388    assert(this.testCases === undefined, 'test case is already parameterized');
    389    this.testCases = kUnitCaseParamsBuilder.combineWithParams(cases);
    390    return this;
    391  }
    392 
    393  paramsSubcasesOnly(
    394    subcases: Iterable<{}> | ((unit: SubcaseParamsBuilder<{}, {}>) => SubcaseParamsBuilder<{}, {}>)
    395  ): TestBuilder<S, F> {
    396    if (subcases instanceof Function) {
    397      return this.params(subcases(kUnitCaseParamsBuilder.beginSubcases()));
    398    } else {
    399      return this.params(kUnitCaseParamsBuilder.beginSubcases().combineWithParams(subcases));
    400    }
    401  }
    402 
    403  private makeCaseSpecific(params: {}, subcases: Iterable<{}> | undefined) {
    404    assert(this.testFn !== undefined, 'No test function (.fn()) for test');
    405    return new RunCaseSpecific(
    406      this.testPath,
    407      params,
    408      this.isUnimplemented,
    409      subcases,
    410      this.fixture,
    411      this.testFn,
    412      this.beforeFn,
    413      this.testCreationStack
    414    );
    415  }
    416 
    417  *iterate(caseFilter: TestParams | null): IterableIterator<RunCase> {
    418    this.testCases ??= kUnitCaseParamsBuilder;
    419 
    420    // Remove the batch__ from the caseFilter because the params builder doesn't
    421    // know about it (we don't add it until later in this function).
    422    let filterToBatch: number | undefined;
    423    const caseFilterWithoutBatch = caseFilter ? { ...caseFilter } : null;
    424    if (caseFilterWithoutBatch && kBatchParamName in caseFilterWithoutBatch) {
    425      const batchParam = caseFilterWithoutBatch[kBatchParamName];
    426      assert(typeof batchParam === 'number');
    427      filterToBatch = batchParam;
    428      delete caseFilterWithoutBatch[kBatchParamName];
    429    }
    430 
    431    for (const [caseParams, subcases] of builderIterateCasesWithSubcases(
    432      this.testCases,
    433      caseFilterWithoutBatch
    434    )) {
    435      // If batches are not used, yield just one case.
    436      if (this.batchSize === 0 || subcases === undefined) {
    437        yield this.makeCaseSpecific(caseParams, subcases);
    438        continue;
    439      }
    440 
    441      // Same if there ends up being only one batch.
    442      const subcaseArray = Array.from(subcases);
    443      if (subcaseArray.length <= this.batchSize) {
    444        yield this.makeCaseSpecific(caseParams, subcaseArray);
    445        continue;
    446      }
    447 
    448      // There are multiple batches. Helper function for this case:
    449      const makeCaseForBatch = (batch: number) => {
    450        const sliceStart = batch * this.batchSize;
    451        return this.makeCaseSpecific(
    452          { ...caseParams, [kBatchParamName]: batch },
    453          subcaseArray.slice(sliceStart, Math.min(subcaseArray.length, sliceStart + this.batchSize))
    454        );
    455      };
    456 
    457      // If we filter to just one batch, yield it.
    458      if (filterToBatch !== undefined) {
    459        yield makeCaseForBatch(filterToBatch);
    460        continue;
    461      }
    462 
    463      // Finally, if not, yield all of the batches.
    464      for (let batch = 0; batch * this.batchSize < subcaseArray.length; ++batch) {
    465        yield makeCaseForBatch(batch);
    466      }
    467    }
    468  }
    469 }
    470 
    471 class RunCaseSpecific implements RunCase {
    472  readonly id: TestCaseID;
    473  readonly isUnimplemented: boolean;
    474 
    475  private readonly params: {};
    476  private readonly subcases: Iterable<{}> | undefined;
    477  private readonly fixture: FixtureClass;
    478  private readonly fn: TestFn<Fixture, {}>;
    479  private readonly beforeFn?: BeforeAllSubcasesFn<SubcaseBatchState, {}>;
    480  private readonly testCreationStack: Error;
    481 
    482  constructor(
    483    testPath: string[],
    484    params: {},
    485    isUnimplemented: boolean,
    486    subcases: Iterable<{}> | undefined,
    487    fixture: FixtureClass,
    488    fn: TestFn<Fixture, {}>,
    489    beforeFn: BeforeAllSubcasesFn<SubcaseBatchState, {}> | undefined,
    490    testCreationStack: Error
    491  ) {
    492    this.id = { test: testPath, params: extractPublicParams(params) };
    493    this.isUnimplemented = isUnimplemented;
    494    this.params = params;
    495    this.subcases = subcases;
    496    this.fixture = fixture;
    497    this.fn = fn;
    498    this.beforeFn = beforeFn;
    499    this.testCreationStack = testCreationStack;
    500  }
    501 
    502  computeSubcaseCount(): number {
    503    if (this.subcases) {
    504      let count = 0;
    505      for (const _subcase of this.subcases) {
    506        count++;
    507      }
    508      return count;
    509    } else {
    510      return 1;
    511    }
    512  }
    513 
    514  async runTest(
    515    rec: TestCaseRecorder,
    516    sharedState: SubcaseBatchState,
    517    params: TestParams,
    518    throwSkip: boolean,
    519    expectedStatus: Expectation
    520  ): Promise<void> {
    521    try {
    522      rec.beginSubCase();
    523      if (expectedStatus === 'skip') {
    524        throw new SkipTestCase('Skipped by expectations');
    525      }
    526 
    527      const inst = new this.fixture(sharedState, rec, params);
    528      try {
    529        await inst.init();
    530        await this.fn(inst as Fixture & { params: {} });
    531        rec.passed();
    532      } finally {
    533        // Runs as long as constructor succeeded, even if initialization or the test failed.
    534        await inst.finalize();
    535      }
    536    } catch (ex) {
    537      // There was an exception from constructor, init, test, or finalize.
    538      // An error from init or test may have been a SkipTestCase.
    539      // An error from finalize may have been an eventualAsyncExpectation failure
    540      // or unexpected validation/OOM error from the GPUDevice.
    541      rec.threw(ex);
    542      if (throwSkip && ex instanceof SkipTestCase) {
    543        throw ex;
    544      }
    545    } finally {
    546      try {
    547        rec.endSubCase(expectedStatus);
    548      } catch (ex) {
    549        assert(ex instanceof UnexpectedPassError);
    550        ex.message = `Testcase passed unexpectedly.`;
    551        ex.stack = this.testCreationStack.stack;
    552        rec.warn(ex);
    553      }
    554    }
    555  }
    556 
    557  async run(
    558    rec: TestCaseRecorder,
    559    selfQuery: TestQuerySingleCase,
    560    expectations: TestQueryWithExpectation[]
    561  ): Promise<void> {
    562    const getExpectedStatus = (selfQueryWithSubParams: TestQuerySingleCase) => {
    563      let didSeeFail = false;
    564      for (const exp of expectations) {
    565        const ordering = compareQueries(exp.query, selfQueryWithSubParams);
    566        if (ordering === Ordering.Unordered || ordering === Ordering.StrictSubset) {
    567          continue;
    568        }
    569 
    570        switch (exp.expectation) {
    571          // Skip takes precedence. If there is any expectation indicating a skip,
    572          // signal it immediately.
    573          case 'skip':
    574            return 'skip';
    575          case 'fail':
    576            // Otherwise, indicate that we might expect a failure.
    577            didSeeFail = true;
    578            break;
    579          default:
    580            unreachable();
    581        }
    582      }
    583      return didSeeFail ? 'fail' : 'pass';
    584    };
    585 
    586    const { testHeartbeatCallback, maxSubcasesInFlight } = globalTestConfig;
    587    try {
    588      rec.start();
    589      const sharedState = this.fixture.MakeSharedState(rec, this.params);
    590      try {
    591        await sharedState.init();
    592        if (this.beforeFn) {
    593          await this.beforeFn(sharedState);
    594        }
    595        await sharedState.postInit();
    596        testHeartbeatCallback();
    597 
    598        let allPreviousSubcasesFinalizedPromise: Promise<void> = Promise.resolve();
    599        if (this.subcases) {
    600          let totalCount = 0;
    601          let skipCount = 0;
    602 
    603          // If there are too many subcases in flight, starting the next subcase will register
    604          // `resolvePromiseBlockingSubcase` and wait until `subcaseFinishedCallback` is called.
    605          let subcasesInFlight = 0;
    606          let resolvePromiseBlockingSubcase: (() => void) | undefined = undefined;
    607          const subcaseFinishedCallback = () => {
    608            subcasesInFlight -= 1;
    609            // If there is any subcase waiting on a previous subcase to finish,
    610            // unblock it now, and clear the resolve callback.
    611            if (resolvePromiseBlockingSubcase) {
    612              resolvePromiseBlockingSubcase();
    613              resolvePromiseBlockingSubcase = undefined;
    614            }
    615          };
    616 
    617          for (const subParams of this.subcases) {
    618            // Defer subcase logs so that they appear in the correct order.
    619            const subRec = rec.makeDeferredSubRecorder(
    620              `(in subcase: ${stringifyPublicParams(subParams)}) `,
    621              allPreviousSubcasesFinalizedPromise
    622            );
    623 
    624            const params = mergeParams(this.params, subParams);
    625            const subcaseQuery = new TestQuerySingleCase(
    626              selfQuery.suite,
    627              selfQuery.filePathParts,
    628              selfQuery.testPathParts,
    629              params
    630            );
    631 
    632            // Limit the maximum number of subcases in flight.
    633            if (subcasesInFlight >= maxSubcasesInFlight) {
    634              await new Promise<void>(resolve => {
    635                // There should only be one subcase waiting at a time.
    636                assert(resolvePromiseBlockingSubcase === undefined);
    637                resolvePromiseBlockingSubcase = resolve;
    638              });
    639            }
    640 
    641            subcasesInFlight += 1;
    642            // Runs async without waiting so that subsequent subcases can start.
    643            // All finalization steps will be waited on at the end of the testcase.
    644            const finalizePromise = this.runTest(
    645              subRec,
    646              sharedState,
    647              params,
    648              /* throwSkip */ true,
    649              getExpectedStatus(subcaseQuery)
    650            )
    651              .then(() => {
    652                subRec.info(new Error('subcase ran'));
    653              })
    654              .catch(ex => {
    655                if (ex instanceof SkipTestCase) {
    656                  // Convert SkipTestCase to an info message so it won't skip the whole test
    657                  ex.message = 'subcase skipped: ' + ex.message;
    658                  subRec.info(new Error('subcase skipped'));
    659                  ++skipCount;
    660                } else {
    661                  // We are catching all other errors inside runTest(), so this should never happen
    662                  subRec.threw(ex);
    663                }
    664              })
    665              .finally(attemptGarbageCollectionIfDue)
    666              .finally(subcaseFinishedCallback);
    667 
    668            allPreviousSubcasesFinalizedPromise = allPreviousSubcasesFinalizedPromise.then(
    669              () => finalizePromise
    670            );
    671            ++totalCount;
    672          }
    673 
    674          // Wait for all subcases to finalize and report their results.
    675          await allPreviousSubcasesFinalizedPromise;
    676 
    677          if (skipCount === totalCount) {
    678            rec.skipped(new SkipTestCase('all subcases were skipped'));
    679          }
    680        } else {
    681          try {
    682            await this.runTest(
    683              rec,
    684              sharedState,
    685              this.params,
    686              /* throwSkip */ false,
    687              getExpectedStatus(selfQuery)
    688            );
    689          } finally {
    690            await attemptGarbageCollectionIfDue();
    691          }
    692        }
    693      } finally {
    694        testHeartbeatCallback();
    695        // Runs as long as the shared state constructor succeeded, even if initialization or a test failed.
    696        await sharedState.finalize();
    697        testHeartbeatCallback();
    698      }
    699    } catch (ex) {
    700      // There was an exception from sharedState/fixture constructor, init, beforeFn, or test.
    701      // An error from beforeFn may have been SkipTestCase.
    702      // An error from finalize may have been an eventualAsyncExpectation failure
    703      // or unexpected validation/OOM error from the GPUDevice.
    704      rec.threw(ex);
    705    } finally {
    706      rec.finish();
    707 
    708      const msg: CaseTimingLogLine = {
    709        q: selfQuery.toString(),
    710        timems: rec.result.timems,
    711        nonskippedSubcaseCount: rec.nonskippedSubcaseCount,
    712      };
    713      logToWebSocket(JSON.stringify(msg));
    714    }
    715  }
    716 }
    717 
    718 export type CaseTimingLogLine = {
    719  q: string;
    720  /** Total time it took to execute the case. */
    721  timems: number;
    722  /**
    723   * Number of subcases that ran in the case (excluding skipped subcases, so
    724   * they don't dilute the average per-subcase time.
    725   */
    726  nonskippedSubcaseCount: number;
    727 };
    728 
    729 /** Every `subcasesBetweenAttemptingGC` calls to this function will `attemptGarbageCollection()`. */
    730 const attemptGarbageCollectionIfDue: () => Promise<void> = (() => {
    731  // This state is global because garbage is global.
    732  let subcasesSinceLastGC = 0;
    733 
    734  return async function attemptGarbageCollectionIfDue() {
    735    subcasesSinceLastGC++;
    736    if (subcasesSinceLastGC >= globalTestConfig.subcasesBetweenAttemptingGC) {
    737      subcasesSinceLastGC = 0;
    738      return attemptGarbageCollection();
    739    }
    740  };
    741 })();