tree.js (20249B)
1 /** 2 * AUTO-GENERATED - DO NOT EDIT. Source: https://github.com/gpuweb/cts 3 **/import { loadMetadataForSuite } from '../framework/metadata.js';import { globalTestConfig } from '../framework/test_config.js'; 4 import { assert, now } from '../util/util.js'; 5 6 7 8 import { comparePublicParamsPaths, compareQueries, Ordering } from './query/compare.js'; 9 import { 10 11 TestQueryMultiCase, 12 TestQuerySingleCase, 13 TestQueryMultiFile, 14 TestQueryMultiTest } from 15 './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 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 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 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 93 94 95 constructor(forQuery, root) { 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, 106 root, 107 maxChunkTime) 108 { 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 134 135 136 137 138 139 }) { 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() { 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() { 160 const newRoot = dissolveSingleChildTrees(this.root); 161 assert(newRoot === this.root); 162 } 163 164 toString() { 165 return TestTree.subtreeToString('(root)', this.root, ''); 166 } 167 168 static *iterateSubtreeNodes( 169 subtree, 170 opts) 171 172 173 174 175 { 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) { 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, 210 chunking) 211 { 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 = 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) { 257 if (tree.subtreeCounts) { 258 return `${tree.subtreeCounts.nodesWithTODO} / ${tree.subtreeCounts.tests}`; 259 } else { 260 return ''; 261 } 262 } 263 264 static subtreeToString(name, tree, indent) { 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, 286 queryToLoad, 287 { 288 subqueriesToExpand, 289 fullyExpandSubtrees = [], 290 maxChunkTime = Infinity 291 }) 292 { 293 const suite = queryToLoad.suite; 294 const specs = await loader.listing(suite); 295 296 const subqueriesToExpandEntries = Array.from(subqueriesToExpand.entries()); 297 const seenSubqueriesToExpand = new Array(subqueriesToExpand.length); 298 seenSubqueriesToExpand.fill(false); 299 300 const isCollapsible = (subquery) => 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 = 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 = 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 = 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, 462 description) 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, 474 isCollapsible) 475 { 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, 487 file, 488 isCollapsible) 489 { 490 const subqueryFile = []; 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, 509 file, 510 isCollapsible) 511 { 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, 530 test, 531 testCreationStack, 532 isCollapsible) 533 { 534 const subqueryTest = []; 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, 573 t, 574 checkCollapsible) 575 { 576 const query = tree.query; 577 let name = ''; 578 const subqueryParams = {}; 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( 613 key, 614 parent, 615 createSubtree) 616 { 617 let v; 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; 622 } else { 623 v = { ...createSubtree(), children: new Map() }; 624 parent.children.set(key, v); 625 } 626 return v; 627 } 628 629 function insertLeaf(parent, query, t) { 630 const leaf = { 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) { 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) { 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 }