tor-browser

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

tree.ts (23511B)


      1 import { loadMetadataForSuite, TestMetadataListing } from '../framework/metadata.js';
      2 import { globalTestConfig } from '../framework/test_config.js';
      3 import { RunCase, RunFn } from '../internal/test_group.js';
      4 import { assert, now } from '../util/util.js';
      5 
      6 import { TestFileLoader } from './file_loader.js';
      7 import { TestParamsRW } from './params_utils.js';
      8 import { comparePublicParamsPaths, compareQueries, Ordering } from './query/compare.js';
      9 import {
     10  TestQuery,
     11  TestQueryMultiCase,
     12  TestQuerySingleCase,
     13  TestQueryMultiFile,
     14  TestQueryMultiTest,
     15 } from './query/query.js';
     16 import { kBigSeparator, kWildcard, kPathSeparator, kParamSeparator } from './query/separators.js';
     17 import { stringifySingleParam } from './query/stringify_params.js';
     18 import { StacklessError } from './util.js';
     19 
     20 // `loadTreeForQuery()` loads a TestTree for a given queryToLoad.
     21 // The resulting tree is a linked-list all the way from `suite:*` to queryToLoad,
     22 // and under queryToLoad is a tree containing every case matched by queryToLoad.
     23 //
     24 // `subqueriesToExpand` influences the `collapsible` flag on nodes in the resulting tree.
     25 // A node is considered "collapsible" if none of the subqueriesToExpand is a StrictSubset
     26 // of that node.
     27 //
     28 // In WebKit/Blink-style web_tests, an expectation file marks individual cts.https.html "variants
     29 // as "Failure", "Crash", etc. By passing in the list of expectations as the subqueriesToExpand,
     30 // we can programmatically subdivide the cts.https.html "variants" list to be able to implement
     31 // arbitrarily-fine suppressions (instead of having to suppress entire test files, which would
     32 // lose a lot of coverage).
     33 //
     34 // `iterateCollapsedNodes()` produces the list of queries for the variants list.
     35 //
     36 // Though somewhat complicated, this system has important benefits:
     37 //   - Avoids having to suppress entire test files, which would cause large test coverage loss.
     38 //   - Minimizes the number of page loads needed for fine-grained suppressions.
     39 //     (In the naive case, we could do one page load per test case - but the test suite would
     40 //     take impossibly long to run.)
     41 //   - Enables developers to put any number of tests in one file as appropriate, without worrying
     42 //     about expectation granularity.
     43 
     44 interface TestTreeNodeBase<T extends TestQuery> {
     45  readonly query: T;
     46  /**
     47   * Readable "relative" name for display in standalone runner.
     48   * Not always the exact relative name, because sometimes there isn't
     49   * one (e.g. s:f:* relative to s:f,*), but something that is readable.
     50   */
     51  readonly readableRelativeName: string;
     52  subtreeCounts?: { tests: number; nodesWithTODO: number; totalTimeMS: number };
     53  subcaseCount?: number;
     54 }
     55 
     56 export interface TestSubtree<T extends TestQuery = TestQuery> extends TestTreeNodeBase<T> {
     57  readonly children: Map<string, TestTreeNode>;
     58  collapsible: boolean;
     59  description?: string;
     60  readonly testCreationStack?: Error;
     61 }
     62 
     63 export interface TestTreeLeaf extends TestTreeNodeBase<TestQuerySingleCase> {
     64  readonly run: RunFn;
     65  readonly isUnimplemented?: boolean;
     66  subtreeCounts?: undefined;
     67  subcaseCount: number;
     68 }
     69 
     70 export type TestTreeNode = TestSubtree | TestTreeLeaf;
     71 
     72 /**
     73 * When iterating through "collapsed" tree nodes, indicates how many "query levels" to traverse
     74 * through before starting to collapse nodes.
     75 *
     76 * Corresponds with TestQueryLevel, but excludes 4 (SingleCase):
     77 * - 1 = MultiFile. Expands so every file is in the collapsed tree.
     78 * - 2 = MultiTest. Expands so every test is in the collapsed tree.
     79 * - 3 = MultiCase. Expands so every case is in the collapsed tree (i.e. collapsing disabled).
     80 */
     81 export type ExpandThroughLevel = 1 | 2 | 3;
     82 
     83 export class TestTree {
     84  /**
     85   * The `queryToLoad` that this test tree was created for.
     86   * Test trees are always rooted at `suite:*`, but they only contain nodes that fit
     87   * within `forQuery`.
     88   *
     89   * This is used for `iterateCollapsedNodes` which only starts collapsing at the next
     90   * `TestQueryLevel` after `forQuery`.
     91   */
     92  readonly forQuery: TestQuery;
     93  readonly root: TestSubtree;
     94 
     95  private constructor(forQuery: TestQuery, root: TestSubtree) {
     96    this.forQuery = forQuery;
     97    this.root = root;
     98    assert(
     99      root.query.level === 1 && root.query.depthInLevel === 0,
    100      'TestTree root must be the root (suite:*)'
    101    );
    102  }
    103 
    104  static async create(
    105    forQuery: TestQuery,
    106    root: TestSubtree,
    107    maxChunkTime: number
    108  ): Promise<TestTree> {
    109    const suite = forQuery.suite;
    110 
    111    let chunking = undefined;
    112    if (Number.isFinite(maxChunkTime)) {
    113      const metadata = loadMetadataForSuite(`./src/${suite}`);
    114      assert(metadata !== null, `metadata for ${suite} is missing, but maxChunkTime was requested`);
    115      chunking = { metadata, maxChunkTime };
    116    }
    117    await TestTree.propagateCounts(root, chunking);
    118 
    119    return new TestTree(forQuery, root);
    120  }
    121 
    122  /**
    123   * Iterate through the leaves of a version of the tree which has been pruned to exclude
    124   * subtrees which:
    125   * - are at a deeper `TestQueryLevel` than `this.forQuery`, and
    126   * - were not a `Ordering.StrictSubset` of any of the `subqueriesToExpand` during tree creation.
    127   */
    128  iterateCollapsedNodes({
    129    includeIntermediateNodes = false,
    130    includeEmptySubtrees = false,
    131    alwaysExpandThroughLevel,
    132  }: {
    133    /** Whether to include intermediate tree nodes or only collapsed-leaves. */
    134    includeIntermediateNodes?: boolean;
    135    /** Whether to include collapsed-leaves with no children. */
    136    includeEmptySubtrees?: boolean;
    137    /** Never collapse nodes up through this level. */
    138    alwaysExpandThroughLevel: ExpandThroughLevel;
    139  }): IterableIterator<Readonly<TestTreeNode>> {
    140    const expandThroughLevel = Math.max(this.forQuery.level, alwaysExpandThroughLevel);
    141    return TestTree.iterateSubtreeNodes(this.root, {
    142      includeIntermediateNodes,
    143      includeEmptySubtrees,
    144      expandThroughLevel,
    145    });
    146  }
    147 
    148  iterateLeaves(): IterableIterator<Readonly<TestTreeLeaf>> {
    149    return TestTree.iterateSubtreeLeaves(this.root);
    150  }
    151 
    152  /**
    153   * Dissolve nodes which have only one child, e.g.:
    154   *   a,* { a,b,* { a,b:* { ... } } }
    155   * collapses down into:
    156   *   a,* { a,b:* { ... } }
    157   * which is less needlessly verbose when displaying the tree in the standalone runner.
    158   */
    159  dissolveSingleChildTrees(): void {
    160    const newRoot = dissolveSingleChildTrees(this.root);
    161    assert(newRoot === this.root);
    162  }
    163 
    164  toString(): string {
    165    return TestTree.subtreeToString('(root)', this.root, '');
    166  }
    167 
    168  static *iterateSubtreeNodes(
    169    subtree: TestSubtree,
    170    opts: {
    171      includeIntermediateNodes: boolean;
    172      includeEmptySubtrees: boolean;
    173      expandThroughLevel: number;
    174    }
    175  ): IterableIterator<TestTreeNode> {
    176    if (opts.includeIntermediateNodes) {
    177      yield subtree;
    178    }
    179 
    180    for (const [, child] of subtree.children) {
    181      if ('children' in child) {
    182        // Is a subtree
    183        const collapsible = child.collapsible && child.query.level > opts.expandThroughLevel;
    184        if (child.children.size > 0 && !collapsible) {
    185          yield* TestTree.iterateSubtreeNodes(child, opts);
    186        } else if (child.children.size > 0 || opts.includeEmptySubtrees) {
    187          // Don't yield empty subtrees (e.g. files with no tests) unless includeEmptySubtrees
    188          yield child;
    189        }
    190      } else {
    191        // Is a leaf
    192        yield child;
    193      }
    194    }
    195  }
    196 
    197  static *iterateSubtreeLeaves(subtree: TestSubtree): IterableIterator<TestTreeLeaf> {
    198    for (const [, child] of subtree.children) {
    199      if ('children' in child) {
    200        yield* TestTree.iterateSubtreeLeaves(child);
    201      } else {
    202        yield child;
    203      }
    204    }
    205  }
    206 
    207  /** Propagate the subtreeTODOs/subtreeTests state upward from leaves to parent nodes. */
    208  static async propagateCounts(
    209    subtree: TestSubtree,
    210    chunking: { metadata: TestMetadataListing; maxChunkTime: number } | undefined
    211  ): Promise<{ tests: number; nodesWithTODO: number; totalTimeMS: number; subcaseCount: number }> {
    212    subtree.subtreeCounts ??= { tests: 0, nodesWithTODO: 0, totalTimeMS: 0 };
    213    subtree.subcaseCount = 0;
    214    for (const [, child] of subtree.children) {
    215      if ('children' in child) {
    216        const counts = await TestTree.propagateCounts(child, chunking);
    217        subtree.subtreeCounts.tests += counts.tests;
    218        subtree.subtreeCounts.nodesWithTODO += counts.nodesWithTODO;
    219        subtree.subtreeCounts.totalTimeMS += counts.totalTimeMS;
    220        subtree.subcaseCount += counts.subcaseCount;
    221      } else {
    222        subtree.subcaseCount = child.subcaseCount;
    223      }
    224    }
    225 
    226    // If we're chunking based on a maxChunkTime, then at each
    227    // TestQueryMultiCase node of the tree we look at its total time. If the
    228    // total time is larger than the maxChunkTime, we set collapsible=false to
    229    // make sure it gets split up in the output. Note:
    230    // - TestQueryMultiTest and higher nodes are never set to collapsible anyway, so we ignore them.
    231    // - TestQuerySingleCase nodes can't be collapsed, so we ignore them.
    232    if (chunking && subtree.query instanceof TestQueryMultiCase) {
    233      const testLevelQuery = new TestQueryMultiCase(
    234        subtree.query.suite,
    235        subtree.query.filePathParts,
    236        subtree.query.testPathParts,
    237        {}
    238      ).toString();
    239 
    240      const metadata = chunking.metadata;
    241 
    242      const subcaseTiming: number | undefined = metadata[testLevelQuery]?.subcaseMS;
    243      if (subcaseTiming !== undefined) {
    244        const totalTiming = subcaseTiming * subtree.subcaseCount;
    245        subtree.subtreeCounts.totalTimeMS = totalTiming;
    246        if (totalTiming > chunking.maxChunkTime) {
    247          subtree.collapsible = false;
    248        }
    249      }
    250    }
    251 
    252    return { ...subtree.subtreeCounts, subcaseCount: subtree.subcaseCount ?? 0 };
    253  }
    254 
    255  /** Displays counts in the format `(Nodes with TODOs) / (Total test count)`. */
    256  static countsToString(tree: TestTreeNode): string {
    257    if (tree.subtreeCounts) {
    258      return `${tree.subtreeCounts.nodesWithTODO} / ${tree.subtreeCounts.tests}`;
    259    } else {
    260      return '';
    261    }
    262  }
    263 
    264  static subtreeToString(name: string, tree: TestTreeNode, indent: string): string {
    265    const collapsible = 'run' in tree ? '>' : tree.collapsible ? '+' : '-';
    266    let s =
    267      indent +
    268      `${collapsible} ${TestTree.countsToString(tree)} ${JSON.stringify(name)} => ${tree.query}`;
    269    if ('children' in tree) {
    270      if (tree.description !== undefined) {
    271        s += `\n${indent}  | ${JSON.stringify(tree.description)}`;
    272      }
    273 
    274      for (const [name, child] of tree.children) {
    275        s += '\n' + TestTree.subtreeToString(name, child, indent + '  ');
    276      }
    277    }
    278    return s;
    279  }
    280 }
    281 
    282 // MAINTENANCE_TODO: Consider having subqueriesToExpand actually impact the depth-order of params
    283 // in the tree.
    284 export async function loadTreeForQuery(
    285  loader: TestFileLoader,
    286  queryToLoad: TestQuery,
    287  {
    288    subqueriesToExpand,
    289    fullyExpandSubtrees = [],
    290    maxChunkTime = Infinity,
    291  }: { subqueriesToExpand: TestQuery[]; fullyExpandSubtrees?: TestQuery[]; maxChunkTime?: number }
    292 ): Promise<TestTree> {
    293  const suite = queryToLoad.suite;
    294  const specs = await loader.listing(suite);
    295 
    296  const subqueriesToExpandEntries = Array.from(subqueriesToExpand.entries());
    297  const seenSubqueriesToExpand: boolean[] = new Array(subqueriesToExpand.length);
    298  seenSubqueriesToExpand.fill(false);
    299 
    300  const isCollapsible = (subquery: TestQuery) =>
    301    subqueriesToExpandEntries.every(([i, toExpand]) => {
    302      const ordering = compareQueries(toExpand, subquery);
    303 
    304      // If toExpand == subquery, no expansion is needed (but it's still "seen").
    305      if (ordering === Ordering.Equal) seenSubqueriesToExpand[i] = true;
    306      return ordering !== Ordering.StrictSubset;
    307    }) &&
    308    fullyExpandSubtrees.every(toExpand => {
    309      const ordering = compareQueries(toExpand, subquery);
    310      return ordering === Ordering.Unordered;
    311    });
    312 
    313  // L0 = suite-level, e.g. suite:*
    314  // L1 =  file-level, e.g. suite:a,b:*
    315  // L2 =  test-level, e.g. suite:a,b:c,d:*
    316  // L3 =  case-level, e.g. suite:a,b:c,d:
    317  let foundCase = false;
    318  // L0 is suite:*
    319  const subtreeL0 = makeTreeForSuite(suite, isCollapsible);
    320 
    321  const imports_start = now();
    322  const pEntriesWithImports = []; // Promise<entry with importedSpec>[]
    323  for (const entry of specs) {
    324    if (entry.file.length === 0 && 'readme' in entry) {
    325      // Suite-level readme.
    326      setSubtreeDescriptionAndCountTODOs(subtreeL0, entry.readme);
    327      continue;
    328    }
    329 
    330    {
    331      const queryL1 = new TestQueryMultiFile(suite, entry.file);
    332      const orderingL1 = compareQueries(queryL1, queryToLoad);
    333      if (orderingL1 === Ordering.Unordered) {
    334        // File path is not matched by this query.
    335        continue;
    336      }
    337    }
    338 
    339    // We're going to be fetching+importing a bunch of things, so do it in async.
    340    const pEntryWithImport = (async () => {
    341      if ('readme' in entry) {
    342        return entry;
    343      } else {
    344        return {
    345          ...entry,
    346          importedSpec: await loader.importSpecFile(queryToLoad.suite, entry.file),
    347        };
    348      }
    349    })();
    350 
    351    const kForceSerialImporting = false;
    352    if (kForceSerialImporting) {
    353      await pEntryWithImport;
    354    }
    355    pEntriesWithImports.push(pEntryWithImport);
    356  }
    357 
    358  const entriesWithImports = await Promise.all(pEntriesWithImports);
    359  if (globalTestConfig.frameworkDebugLog) {
    360    const imported_time = performance.now() - imports_start;
    361    globalTestConfig.frameworkDebugLog(
    362      `Imported importedSpecFiles[${entriesWithImports.length}] in ${imported_time}ms.`
    363    );
    364  }
    365 
    366  for (const entry of entriesWithImports) {
    367    if ('readme' in entry) {
    368      // Entry is a README that is an ancestor or descendant of the query.
    369      // (It's included for display in the standalone runner.)
    370 
    371      // readmeSubtree is suite:a,b,*
    372      // (This is always going to dedup with a file path, if there are any test spec files under
    373      // the directory that has the README).
    374      const readmeSubtree: TestSubtree<TestQueryMultiFile> = addSubtreeForDirPath(
    375        subtreeL0,
    376        entry.file,
    377        isCollapsible
    378      );
    379      setSubtreeDescriptionAndCountTODOs(readmeSubtree, entry.readme);
    380      continue;
    381    }
    382 
    383    // Entry is a spec file.
    384    const spec = entry.importedSpec;
    385    // subtreeL1 is suite:a,b:*
    386    const subtreeL1: TestSubtree<TestQueryMultiTest> = addSubtreeForFilePath(
    387      subtreeL0,
    388      entry.file,
    389      isCollapsible
    390    );
    391    setSubtreeDescriptionAndCountTODOs(subtreeL1, spec.description);
    392 
    393    let groupHasTests = false;
    394    for (const t of spec.g.iterate()) {
    395      groupHasTests = true;
    396      {
    397        const queryL2 = new TestQueryMultiCase(suite, entry.file, t.testPath, {});
    398        const orderingL2 = compareQueries(queryL2, queryToLoad);
    399        if (orderingL2 === Ordering.Unordered) {
    400          // Test path is not matched by this query.
    401          continue;
    402        }
    403      }
    404 
    405      // subtreeL2 is suite:a,b:c,d:*
    406      const subtreeL2: TestSubtree<TestQueryMultiCase> = addSubtreeForTestPath(
    407        subtreeL1,
    408        t.testPath,
    409        t.testCreationStack,
    410        isCollapsible
    411      );
    412      // This is 1 test. Set tests=1 then count TODOs.
    413      subtreeL2.subtreeCounts ??= { tests: 1, nodesWithTODO: 0, totalTimeMS: 0 };
    414      if (t.description) setSubtreeDescriptionAndCountTODOs(subtreeL2, t.description);
    415 
    416      let caseFilter = null;
    417      if ('params' in queryToLoad) {
    418        caseFilter = queryToLoad.params;
    419      }
    420 
    421      // MAINTENANCE_TODO: If tree generation gets too slow, avoid actually iterating the cases in a
    422      // file if there's no need to (based on the subqueriesToExpand).
    423      for (const c of t.iterate(caseFilter)) {
    424        // iterate() guarantees c's query is equal to or a subset of queryToLoad.
    425 
    426        if (queryToLoad instanceof TestQuerySingleCase) {
    427          // A subset is OK if it's TestQueryMultiCase, but for SingleCase it must match exactly.
    428          const ordering = comparePublicParamsPaths(c.id.params, queryToLoad.params);
    429          if (ordering !== Ordering.Equal) {
    430            continue;
    431          }
    432        }
    433 
    434        // Leaf for case is suite:a,b:c,d:x=1;y=2
    435        addLeafForCase(subtreeL2, c, isCollapsible);
    436        foundCase = true;
    437      }
    438    }
    439    if (!groupHasTests && !subtreeL1.subtreeCounts) {
    440      throw new StacklessError(
    441        `${subtreeL1.query} has no tests - it must have "TODO" in its description`
    442      );
    443    }
    444  }
    445 
    446  for (const [i, sq] of subqueriesToExpandEntries) {
    447    const subquerySeen = seenSubqueriesToExpand[i];
    448    if (!subquerySeen) {
    449      throw new StacklessError(
    450        `subqueriesToExpand entry did not match anything \
    451 (could be wrong, or could be redundant with a previous subquery):\n  ${sq.toString()}`
    452      );
    453    }
    454  }
    455  assert(foundCase, `Query \`${queryToLoad.toString()}\` does not match any cases`);
    456 
    457  return TestTree.create(queryToLoad, subtreeL0, maxChunkTime);
    458 }
    459 
    460 function setSubtreeDescriptionAndCountTODOs(
    461  subtree: TestSubtree<TestQueryMultiFile>,
    462  description: string
    463 ) {
    464  assert(subtree.description === undefined);
    465  subtree.description = description.trim();
    466  subtree.subtreeCounts ??= { tests: 0, nodesWithTODO: 0, totalTimeMS: 0 };
    467  if (subtree.description.indexOf('TODO') !== -1) {
    468    subtree.subtreeCounts.nodesWithTODO++;
    469  }
    470 }
    471 
    472 function makeTreeForSuite(
    473  suite: string,
    474  isCollapsible: (sq: TestQuery) => boolean
    475 ): TestSubtree<TestQueryMultiFile> {
    476  const query = new TestQueryMultiFile(suite, []);
    477  return {
    478    readableRelativeName: suite + kBigSeparator,
    479    query,
    480    children: new Map(),
    481    collapsible: isCollapsible(query),
    482  };
    483 }
    484 
    485 function addSubtreeForDirPath(
    486  tree: TestSubtree<TestQueryMultiFile>,
    487  file: string[],
    488  isCollapsible: (sq: TestQuery) => boolean
    489 ): TestSubtree<TestQueryMultiFile> {
    490  const subqueryFile: string[] = [];
    491  // To start, tree is suite:*
    492  // This loop goes from that -> suite:a,* -> suite:a,b,*
    493  for (const part of file) {
    494    subqueryFile.push(part);
    495    tree = getOrInsertSubtree(part, tree, () => {
    496      const query = new TestQueryMultiFile(tree.query.suite, subqueryFile);
    497      return {
    498        readableRelativeName: part + kPathSeparator + kWildcard,
    499        query,
    500        collapsible: isCollapsible(query),
    501      };
    502    });
    503  }
    504  return tree;
    505 }
    506 
    507 function addSubtreeForFilePath(
    508  tree: TestSubtree<TestQueryMultiFile>,
    509  file: string[],
    510  isCollapsible: (sq: TestQuery) => boolean
    511 ): TestSubtree<TestQueryMultiTest> {
    512  // To start, tree is suite:*
    513  // This goes from that -> suite:a,* -> suite:a,b,*
    514  tree = addSubtreeForDirPath(tree, file, isCollapsible);
    515  // This goes from that -> suite:a,b:*
    516  const subtree = getOrInsertSubtree('', tree, () => {
    517    const query = new TestQueryMultiTest(tree.query.suite, tree.query.filePathParts, []);
    518    assert(file.length > 0, 'file path is empty');
    519    return {
    520      readableRelativeName: file[file.length - 1] + kBigSeparator + kWildcard,
    521      query,
    522      collapsible: isCollapsible(query),
    523    };
    524  });
    525  return subtree;
    526 }
    527 
    528 function addSubtreeForTestPath(
    529  tree: TestSubtree<TestQueryMultiTest>,
    530  test: readonly string[],
    531  testCreationStack: Error,
    532  isCollapsible: (sq: TestQuery) => boolean
    533 ): TestSubtree<TestQueryMultiCase> {
    534  const subqueryTest: string[] = [];
    535  // To start, tree is suite:a,b:*
    536  // This loop goes from that -> suite:a,b:c,* -> suite:a,b:c,d,*
    537  for (const part of test) {
    538    subqueryTest.push(part);
    539    tree = getOrInsertSubtree(part, tree, () => {
    540      const query = new TestQueryMultiTest(
    541        tree.query.suite,
    542        tree.query.filePathParts,
    543        subqueryTest
    544      );
    545      return {
    546        readableRelativeName: part + kPathSeparator + kWildcard,
    547        query,
    548        collapsible: isCollapsible(query),
    549      };
    550    });
    551  }
    552  // This goes from that -> suite:a,b:c,d:*
    553  return getOrInsertSubtree('', tree, () => {
    554    const query = new TestQueryMultiCase(
    555      tree.query.suite,
    556      tree.query.filePathParts,
    557      subqueryTest,
    558      {}
    559    );
    560    assert(subqueryTest.length > 0, 'subqueryTest is empty');
    561    return {
    562      readableRelativeName: subqueryTest[subqueryTest.length - 1] + kBigSeparator + kWildcard,
    563      kWildcard,
    564      query,
    565      testCreationStack,
    566      collapsible: isCollapsible(query),
    567    };
    568  });
    569 }
    570 
    571 function addLeafForCase(
    572  tree: TestSubtree<TestQueryMultiTest>,
    573  t: RunCase,
    574  checkCollapsible: (sq: TestQuery) => boolean
    575 ): void {
    576  const query = tree.query;
    577  let name: string = '';
    578  const subqueryParams: TestParamsRW = {};
    579 
    580  // To start, tree is suite:a,b:c,d:*
    581  // This loop goes from that -> suite:a,b:c,d:x=1;* -> suite:a,b:c,d:x=1;y=2;*
    582  for (const [k, v] of Object.entries(t.id.params)) {
    583    name = stringifySingleParam(k, v);
    584    subqueryParams[k] = v;
    585 
    586    tree = getOrInsertSubtree(name, tree, () => {
    587      const subquery = new TestQueryMultiCase(
    588        query.suite,
    589        query.filePathParts,
    590        query.testPathParts,
    591        subqueryParams
    592      );
    593      return {
    594        readableRelativeName: name + kParamSeparator + kWildcard,
    595        query: subquery,
    596        collapsible: checkCollapsible(subquery),
    597      };
    598    });
    599  }
    600 
    601  // This goes from that -> suite:a,b:c,d:x=1;y=2
    602  const subquery = new TestQuerySingleCase(
    603    query.suite,
    604    query.filePathParts,
    605    query.testPathParts,
    606    subqueryParams
    607  );
    608  checkCollapsible(subquery); // mark seenSubqueriesToExpand
    609  insertLeaf(tree, subquery, t);
    610 }
    611 
    612 function getOrInsertSubtree<T extends TestQuery>(
    613  key: string,
    614  parent: TestSubtree,
    615  createSubtree: () => Omit<TestSubtree<T>, 'children'>
    616 ): TestSubtree<T> {
    617  let v: TestSubtree<T>;
    618  const child = parent.children.get(key);
    619  if (child !== undefined) {
    620    assert('children' in child); // Make sure cached subtree is not actually a leaf
    621    v = child as TestSubtree<T>;
    622  } else {
    623    v = { ...createSubtree(), children: new Map() };
    624    parent.children.set(key, v);
    625  }
    626  return v;
    627 }
    628 
    629 function insertLeaf(parent: TestSubtree, query: TestQuerySingleCase, t: RunCase) {
    630  const leaf: TestTreeLeaf = {
    631    readableRelativeName: readableNameForCase(query),
    632    query,
    633    run: (rec, expectations) => t.run(rec, query, expectations || []),
    634    isUnimplemented: t.isUnimplemented,
    635    subcaseCount: t.computeSubcaseCount(),
    636  };
    637 
    638  // This is a leaf (e.g. s:f:t:x=1;* -> s:f:t:x=1). The key is always ''.
    639  const key = '';
    640  assert(!parent.children.has(key), `Duplicate testcase: ${query}`);
    641  parent.children.set(key, leaf);
    642 }
    643 
    644 function dissolveSingleChildTrees(tree: TestTreeNode): TestTreeNode {
    645  if ('children' in tree) {
    646    const shouldDissolveThisTree =
    647      tree.children.size === 1 && tree.query.depthInLevel !== 0 && tree.description === undefined;
    648    if (shouldDissolveThisTree) {
    649      // Loops exactly once
    650      for (const [, child] of tree.children) {
    651        // Recurse on child
    652        return dissolveSingleChildTrees(child);
    653      }
    654    }
    655 
    656    for (const [k, child] of tree.children) {
    657      // Recurse on each child
    658      const newChild = dissolveSingleChildTrees(child);
    659      if (newChild !== child) {
    660        tree.children.set(k, newChild);
    661      }
    662    }
    663  }
    664  return tree;
    665 }
    666 
    667 /** Generate a readable relative name for a case (used in standalone). */
    668 function readableNameForCase(query: TestQuerySingleCase): string {
    669  const paramsKeys = Object.keys(query.params);
    670  if (paramsKeys.length === 0) {
    671    return query.testPathParts[query.testPathParts.length - 1] + kBigSeparator;
    672  } else {
    673    const lastKey = paramsKeys[paramsKeys.length - 1];
    674    return stringifySingleParam(lastKey, query.params[lastKey]);
    675  }
    676 }