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 }