standalone.ts (23069B)
1 // Implements the standalone test runner (see also: /standalone/index.html). 2 3 /* eslint no-console: "off" */ 4 5 import { dataCache } from '../framework/data_cache.js'; 6 import { getResourcePath, setBaseResourcePath } from '../framework/resources.js'; 7 import { globalTestConfig } from '../framework/test_config.js'; 8 import { DefaultTestFileLoader } from '../internal/file_loader.js'; 9 import { Logger } from '../internal/logging/logger.js'; 10 import { LiveTestCaseResult } from '../internal/logging/result.js'; 11 import { parseQuery } from '../internal/query/parseQuery.js'; 12 import { TestQueryLevel } from '../internal/query/query.js'; 13 import { TestTreeNode, TestSubtree, TestTreeLeaf, TestTree } from '../internal/tree.js'; 14 import { 15 getDefaultRequestAdapterOptions, 16 getGPU, 17 setDefaultRequestAdapterOptions, 18 } from '../util/navigator_gpu.js'; 19 import { ErrorWithExtra, unreachable } from '../util/util.js'; 20 21 import { 22 kCTSOptionsInfo, 23 parseSearchParamLikeWithOptions, 24 CTSOptions, 25 OptionInfo, 26 OptionsInfos, 27 camelCaseToSnakeCase, 28 } from './helper/options.js'; 29 import { TestDedicatedWorker, TestSharedWorker, TestServiceWorker } from './helper/test_worker.js'; 30 31 const rootQuerySpec = 'webgpu:*'; 32 let isFullCTS = false; 33 34 globalTestConfig.frameworkDebugLog = console.log; 35 36 // Prompt before reloading to avoid losing test results. 37 function enablePromptBeforeReload() { 38 window.addEventListener('beforeunload', () => { 39 return false; 40 }); 41 } 42 43 const kOpenTestLinkAltText = 'Open'; 44 45 type StandaloneOptions = CTSOptions & { runnow: OptionInfo }; 46 47 const kStandaloneOptionsInfos: OptionsInfos<StandaloneOptions> = { 48 ...kCTSOptionsInfo, 49 runnow: { description: 'run immediately on load' }, 50 }; 51 52 const { queries: qs, options } = parseSearchParamLikeWithOptions( 53 kStandaloneOptionsInfos, 54 window.location.search || rootQuerySpec 55 ); 56 const { runnow, powerPreference, compatibility, forceFallbackAdapter } = options; 57 globalTestConfig.enableDebugLogs = options.debug; 58 globalTestConfig.unrollConstEvalLoops = options.unrollConstEvalLoops; 59 globalTestConfig.compatibility = compatibility; 60 globalTestConfig.enforceDefaultLimits = options.enforceDefaultLimits; 61 globalTestConfig.blockAllFeatures = options.blockAllFeatures; 62 if (options.subcasesBetweenAttemptingGC) { 63 globalTestConfig.subcasesBetweenAttemptingGC = Number(options.subcasesBetweenAttemptingGC); 64 } 65 if (options.casesBetweenReplacingDevice) { 66 globalTestConfig.casesBetweenReplacingDevice = Number(options.casesBetweenReplacingDevice); 67 } 68 globalTestConfig.logToWebSocket = options.logToWebSocket; 69 70 const logger = new Logger(); 71 72 setBaseResourcePath('../out/resources'); 73 74 const testWorker = 75 options.worker === null 76 ? null 77 : options.worker === 'dedicated' 78 ? new TestDedicatedWorker(options) 79 : options.worker === 'shared' 80 ? new TestSharedWorker(options) 81 : options.worker === 'service' 82 ? new TestServiceWorker(options) 83 : unreachable(); 84 85 const autoCloseOnPass = document.getElementById('autoCloseOnPass') as HTMLInputElement; 86 const resultsVis = document.getElementById('resultsVis')!; 87 const progressElem = document.getElementById('progress')!; 88 const progressTestNameElem = progressElem.querySelector('.progress-test-name')!; 89 const stopButtonElem = progressElem.querySelector('button')!; 90 let runDepth = 0; 91 let stopRequested = false; 92 93 stopButtonElem.addEventListener('click', () => { 94 stopRequested = true; 95 }); 96 97 if (powerPreference || compatibility || forceFallbackAdapter) { 98 setDefaultRequestAdapterOptions({ 99 ...(powerPreference && { powerPreference }), 100 ...(compatibility && { featureLevel: 'compatibility' }), 101 ...(forceFallbackAdapter && { forceFallbackAdapter: true }), 102 }); 103 } 104 105 dataCache.setStore({ 106 load: async (path: string) => { 107 const response = await fetch(getResourcePath(`cache/${path}`)); 108 if (!response.ok) { 109 return Promise.reject(response.statusText); 110 } 111 return new Uint8Array(await response.arrayBuffer()); 112 }, 113 }); 114 115 interface SubtreeResult { 116 pass: number; 117 fail: number; 118 warn: number; 119 skip: number; 120 total: number; 121 timems: number; 122 } 123 124 function emptySubtreeResult() { 125 return { pass: 0, fail: 0, warn: 0, skip: 0, total: 0, timems: 0 }; 126 } 127 128 function mergeSubtreeResults(...results: SubtreeResult[]) { 129 const target = emptySubtreeResult(); 130 for (const result of results) { 131 target.pass += result.pass; 132 target.fail += result.fail; 133 target.warn += result.warn; 134 target.skip += result.skip; 135 target.total += result.total; 136 target.timems += result.timems; 137 } 138 return target; 139 } 140 141 type SetCheckedRecursively = () => void; 142 type GenerateSubtreeHTML = (parent: HTMLElement) => SetCheckedRecursively; 143 type RunSubtree = () => Promise<SubtreeResult>; 144 145 interface VisualizedSubtree { 146 generateSubtreeHTML: GenerateSubtreeHTML; 147 runSubtree: RunSubtree; 148 } 149 150 // DOM generation 151 152 function memoize<T>(fn: () => T): () => T { 153 let value: T | undefined; 154 return () => { 155 if (value === undefined) { 156 value = fn(); 157 } 158 return value; 159 }; 160 } 161 162 function makeTreeNodeHTML(tree: TestTreeNode, parentLevel: TestQueryLevel): VisualizedSubtree { 163 let subtree: VisualizedSubtree; 164 165 if ('children' in tree) { 166 subtree = makeSubtreeHTML(tree, parentLevel); 167 } else { 168 subtree = makeCaseHTML(tree); 169 } 170 171 const generateMyHTML = (parentElement: HTMLElement) => { 172 const div = $('<div>').appendTo(parentElement)[0]; 173 return subtree.generateSubtreeHTML(div); 174 }; 175 return { runSubtree: subtree.runSubtree, generateSubtreeHTML: generateMyHTML }; 176 } 177 178 function makeCaseHTML(t: TestTreeLeaf): VisualizedSubtree { 179 // Becomes set once the case has been run once. 180 let caseResult: LiveTestCaseResult | undefined; 181 182 // Becomes set once the DOM for this case exists. 183 let clearRenderedResult: (() => void) | undefined; 184 let updateRenderedResult: (() => void) | undefined; 185 186 const name = t.query.toString(); 187 const runSubtree = async () => { 188 if (clearRenderedResult) clearRenderedResult(); 189 190 const result: SubtreeResult = emptySubtreeResult(); 191 progressTestNameElem.textContent = name; 192 193 const [rec, res] = logger.record(name); 194 caseResult = res; 195 if (testWorker) { 196 await testWorker.run(rec, name); 197 } else { 198 await t.run(rec); 199 } 200 201 result.total++; 202 result.timems += caseResult.timems; 203 switch (caseResult.status) { 204 case 'pass': 205 result.pass++; 206 break; 207 case 'fail': 208 result.fail++; 209 break; 210 case 'skip': 211 result.skip++; 212 break; 213 case 'warn': 214 result.warn++; 215 break; 216 default: 217 unreachable(); 218 } 219 220 if (updateRenderedResult) updateRenderedResult(); 221 222 return result; 223 }; 224 225 const generateSubtreeHTML = (div: HTMLElement) => { 226 div.classList.add('testcase'); 227 228 const caselogs = $('<div>').addClass('testcaselogs').hide(); 229 const [casehead, setChecked] = makeTreeNodeHeaderHTML(t, runSubtree, 2, checked => { 230 checked ? caselogs.show() : caselogs.hide(); 231 }); 232 const casetime = $('<div>').addClass('testcasetime').html('ms').appendTo(casehead); 233 div.appendChild(casehead); 234 div.appendChild(caselogs[0]); 235 236 clearRenderedResult = () => { 237 div.removeAttribute('data-status'); 238 casetime.text('ms'); 239 caselogs.empty(); 240 }; 241 242 updateRenderedResult = () => { 243 if (caseResult) { 244 div.setAttribute('data-status', caseResult.status); 245 246 casetime.text(caseResult.timems.toFixed(4) + ' ms'); 247 248 if (caseResult.logs) { 249 caselogs.empty(); 250 // Show exceptions at the top since they are often unexpected can point out an error in the test itself vs the WebGPU implementation. 251 caseResult.logs 252 .filter(l => l.name === 'EXCEPTION') 253 .forEach(l => { 254 $('<pre>').addClass('testcaselogtext').text(l.toJSON()).appendTo(caselogs); 255 }); 256 for (const l of caseResult.logs) { 257 const caselog = $('<div>').addClass('testcaselog').appendTo(caselogs); 258 $('<button>') 259 .addClass('testcaselogbtn') 260 .attr('alt', 'Log stack to console') 261 .attr('title', 'Log stack to console') 262 .appendTo(caselog) 263 .on('click', () => { 264 consoleLogError(l); 265 }); 266 $('<pre>').addClass('testcaselogtext').appendTo(caselog).text(l.toJSON()); 267 } 268 } 269 } 270 }; 271 272 updateRenderedResult(); 273 274 return setChecked; 275 }; 276 277 return { runSubtree, generateSubtreeHTML }; 278 } 279 280 function makeSubtreeHTML(n: TestSubtree, parentLevel: TestQueryLevel): VisualizedSubtree { 281 let subtreeResult: SubtreeResult = emptySubtreeResult(); 282 // Becomes set once the DOM for this case exists. 283 let clearRenderedResult: (() => void) | undefined; 284 let updateRenderedResult: (() => void) | undefined; 285 286 const { runSubtree, generateSubtreeHTML } = makeSubtreeChildrenHTML( 287 n.children.values(), 288 n.query.level 289 ); 290 291 const runMySubtree = async () => { 292 if (runDepth === 0) { 293 stopRequested = false; 294 progressElem.style.display = ''; 295 // only prompt if this is the full CTS and we started from the root. 296 if (isFullCTS && n.query.filePathParts.length === 0) { 297 enablePromptBeforeReload(); 298 } 299 } 300 if (stopRequested) { 301 const result = emptySubtreeResult(); 302 result.skip = 1; 303 result.total = 1; 304 return result; 305 } 306 307 ++runDepth; 308 309 if (clearRenderedResult) clearRenderedResult(); 310 subtreeResult = await runSubtree(); 311 if (updateRenderedResult) updateRenderedResult(); 312 313 --runDepth; 314 if (runDepth === 0) { 315 progressElem.style.display = 'none'; 316 } 317 318 return subtreeResult; 319 }; 320 321 const generateMyHTML = (div: HTMLElement) => { 322 const subtreeHTML = $('<div>').addClass('subtreechildren'); 323 const generateSubtree = memoize(() => generateSubtreeHTML(subtreeHTML[0])); 324 325 // Hide subtree - it's not generated yet. 326 subtreeHTML.hide(); 327 const [header, setChecked] = makeTreeNodeHeaderHTML(n, runMySubtree, parentLevel, checked => { 328 if (checked) { 329 // Make sure the subtree is generated and then show it. 330 generateSubtree(); 331 subtreeHTML.show(); 332 } else { 333 subtreeHTML.hide(); 334 } 335 }); 336 337 div.classList.add('subtree'); 338 div.classList.add(['', 'multifile', 'multitest', 'multicase'][n.query.level]); 339 div.appendChild(header); 340 div.appendChild(subtreeHTML[0]); 341 342 clearRenderedResult = () => { 343 div.removeAttribute('data-status'); 344 }; 345 346 updateRenderedResult = () => { 347 let status = ''; 348 if (subtreeResult.pass > 0) { 349 status += 'pass'; 350 } 351 if (subtreeResult.fail > 0) { 352 status += 'fail'; 353 } 354 if (subtreeResult.skip === subtreeResult.total && subtreeResult.total > 0) { 355 status += 'skip'; 356 } 357 div.setAttribute('data-status', status); 358 if (autoCloseOnPass.checked && status === 'pass') { 359 div.firstElementChild!.removeAttribute('open'); 360 } 361 }; 362 363 updateRenderedResult(); 364 365 return () => { 366 setChecked(); 367 const setChildrenChecked = generateSubtree(); 368 setChildrenChecked(); 369 }; 370 }; 371 372 return { runSubtree: runMySubtree, generateSubtreeHTML: generateMyHTML }; 373 } 374 375 function makeSubtreeChildrenHTML( 376 children: Iterable<TestTreeNode>, 377 parentLevel: TestQueryLevel 378 ): VisualizedSubtree { 379 const childFns = Array.from(children, subtree => makeTreeNodeHTML(subtree, parentLevel)); 380 381 const runMySubtree = async () => { 382 const results: SubtreeResult[] = []; 383 for (const { runSubtree } of childFns) { 384 if (stopRequested) { 385 break; 386 } 387 results.push(await runSubtree()); 388 } 389 return mergeSubtreeResults(...results); 390 }; 391 const generateMyHTML = (div: HTMLElement) => { 392 const setChildrenChecked = Array.from(childFns, ({ generateSubtreeHTML }) => 393 generateSubtreeHTML(div) 394 ); 395 396 return () => { 397 for (const setChildChecked of setChildrenChecked) { 398 setChildChecked(); 399 } 400 }; 401 }; 402 403 return { runSubtree: runMySubtree, generateSubtreeHTML: generateMyHTML }; 404 } 405 406 function consoleLogError(e: Error | ErrorWithExtra | undefined) { 407 if (e === undefined) return; 408 /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 409 (globalThis as any)._stack = e; 410 console.log('_stack =', e); 411 if ('extra' in e && e.extra !== undefined) { 412 console.log('_stack.extra =', e.extra); 413 } 414 } 415 416 function makeTreeNodeHeaderHTML( 417 n: TestTreeNode, 418 runSubtree: RunSubtree, 419 parentLevel: TestQueryLevel, 420 onChange: (checked: boolean) => void 421 ): [HTMLElement, SetCheckedRecursively] { 422 const isLeaf = 'run' in n; 423 const div = $('<details>').addClass('nodeheader'); 424 const header = $('<summary>').appendTo(div); 425 426 // prevent toggling if user is selecting text from an input element 427 { 428 let lastNodeName = ''; 429 div.on('pointerdown', event => { 430 lastNodeName = event.target.nodeName; 431 }); 432 div.on('click', event => { 433 if (lastNodeName === 'INPUT') { 434 event.preventDefault(); 435 } 436 }); 437 } 438 439 const setChecked = () => { 440 div.prop('open', true); // (does not fire onChange) 441 onChange(true); 442 }; 443 444 const href = createSearchQuery([n.query.toString()]); 445 if (onChange) { 446 div.on('toggle', function (this) { 447 onChange((this as HTMLDetailsElement).open); 448 }); 449 450 // Expand the shallower parts of the tree at load. 451 // Also expand completely within subtrees that are at the same query level 452 // (e.g. s:f:t,* and s:f:t,t,*). 453 if (n.query.level <= lastQueryLevelToExpand || n.query.level === parentLevel) { 454 setChecked(); 455 } 456 } 457 const runtext = isLeaf ? 'Run case' : 'Run subtree'; 458 $('<button>') 459 .addClass(isLeaf ? 'leafrun' : 'subtreerun') 460 .attr('alt', runtext) 461 .attr('title', runtext) 462 .on('click', async () => { 463 if (runDepth > 0) { 464 showInfo('tests are already running'); 465 return; 466 } 467 showInfo(''); 468 console.log(`Starting run for ${n.query}`); 469 // turn off all run buttons 470 $('#resultsVis').addClass('disable-run'); 471 const startTime = performance.now(); 472 await runSubtree(); 473 const dt = performance.now() - startTime; 474 const dtMinutes = dt / 1000 / 60; 475 // turn on all run buttons 476 $('#resultsVis').removeClass('disable-run'); 477 console.log(`Finished run: ${dt.toFixed(1)} ms = ${dtMinutes.toFixed(1)} min`); 478 }) 479 .appendTo(header); 480 $('<a>') 481 .addClass('nodelink') 482 .attr('href', href) 483 .attr('alt', kOpenTestLinkAltText) 484 .attr('title', kOpenTestLinkAltText) 485 .appendTo(header); 486 $('<button>') 487 .addClass('copybtn') 488 .attr('alt', 'copy query') 489 .attr('title', 'copy query') 490 .on('click', () => { 491 void navigator.clipboard.writeText(n.query.toString()); 492 }) 493 .appendTo(header); 494 if ('testCreationStack' in n && n.testCreationStack) { 495 $('<button>') 496 .addClass('testcaselogbtn') 497 .attr('alt', 'Log test creation stack to console') 498 .attr('title', 'Log test creation stack to console') 499 .appendTo(header) 500 .on('click', () => { 501 consoleLogError(n.testCreationStack); 502 }); 503 } 504 const nodetitle = $('<div>').addClass('nodetitle').appendTo(header); 505 const nodecolumns = $('<span>').addClass('nodecolumns').appendTo(nodetitle); 506 { 507 $('<input>') 508 .attr('type', 'text') 509 .attr('title', n.query.toString()) 510 .prop('readonly', true) 511 .addClass('nodequery') 512 .on('click', event => { 513 (event.target as HTMLInputElement).select(); 514 }) 515 .val(n.query.toString()) 516 .appendTo(nodecolumns); 517 if (n.subtreeCounts) { 518 $('<span>') 519 .attr('title', '(Nodes with TODOs) / (Total test count)') 520 .text(TestTree.countsToString(n)) 521 .appendTo(nodecolumns); 522 } 523 } 524 if ('description' in n && n.description) { 525 nodetitle.append(' '); 526 $('<pre>') // 527 .addClass('nodedescription') 528 .text(n.description) 529 .appendTo(header); 530 } 531 return [div[0], setChecked]; 532 } 533 534 // Collapse s:f:t:* or s:f:t:c by default. 535 let lastQueryLevelToExpand: TestQueryLevel = 2; 536 537 /** 538 * Takes an array of string, ParamValue and returns an array of pairs 539 * of [key, value] where value is a string. Converts boolean to '0' or '1'. 540 */ 541 function keyValueToPairs([k, v]: [string, boolean | string | null]): [string, string][] { 542 const key = camelCaseToSnakeCase(k); 543 if (typeof v === 'boolean') { 544 return [[key, v ? '1' : '0']]; 545 } else if (Array.isArray(v)) { 546 return v.map(v => [key, v]); 547 } else { 548 return [[key, v!.toString()]]; 549 } 550 } 551 552 /** 553 * Converts key value pairs to a search string. 554 * Keys will appear in order in the search string. 555 * Values can be undefined, null, boolean, string, or string[] 556 * If the value is falsy the key will not appear in the search string. 557 * If the value is an array the key will appear multiple times. 558 * 559 * @param params Some object with key value pairs. 560 * @returns a search string. 561 */ 562 function prepareParams(params: Record<string, boolean | string | null>): string { 563 const pairsArrays = Object.entries(params) 564 .filter(([, v]) => !(v === false || v === null || v === '0')) 565 .map(keyValueToPairs); 566 const pairs = pairsArrays.flat(); 567 return new URLSearchParams(pairs).toString(); 568 } 569 570 // This is just a cast in one place. 571 export function optionsToRecord(options: CTSOptions) { 572 return options as unknown as Record<string, boolean | string | null>; 573 } 574 575 /** 576 * Given a search query, generates a search parameter string 577 * @param queries array of queries 578 * @param params an optional existing search 579 * @returns a search query string 580 */ 581 function createSearchQuery(queries: string[], params?: string) { 582 params = params === undefined ? prepareParams(optionsToRecord(options)) : params; 583 // Add in q separately to avoid escaping punctuation marks. 584 return `?${params}${params ? '&' : ''}${queries.map(q => 'q=' + q).join('&')}`; 585 } 586 587 /** 588 * Show an info message on the page. 589 * @param msg Message to show 590 */ 591 function showInfo(msg: string) { 592 $('#info')[0].textContent = msg; 593 } 594 595 void (async () => { 596 const loader = new DefaultTestFileLoader(); 597 598 // MAINTENANCE_TODO: start populating page before waiting for everything to load? 599 isFullCTS = qs.length === 1 && qs[0] === rootQuerySpec; 600 601 // Update the URL bar to match the exact current options. 602 const updateURLsWithCurrentOptions = () => { 603 const params = prepareParams(optionsToRecord(options)); 604 let url = `${window.location.origin}${window.location.pathname}`; 605 url += createSearchQuery(qs, params); 606 window.history.replaceState(null, '', url.toString()); 607 document.querySelectorAll(`a[alt=${kOpenTestLinkAltText}]`).forEach(elem => { 608 const a = elem as HTMLAnchorElement; 609 const qs = new URLSearchParams(a.search).getAll('q'); 610 a.search = createSearchQuery(qs, params); 611 }); 612 }; 613 614 const addOptionsToPage = ( 615 options: StandaloneOptions, 616 optionsInfos: typeof kStandaloneOptionsInfos 617 ) => { 618 const optionsElem = $('table#options>tbody')[0]; 619 const optionValues = optionsToRecord(options); 620 621 const createCheckbox = (optionName: string) => { 622 return $(`<input>`) 623 .attr('type', 'checkbox') 624 .prop('checked', optionValues[optionName] as boolean) 625 .on('change', function () { 626 optionValues[optionName] = (this as HTMLInputElement).checked; 627 updateURLsWithCurrentOptions(); 628 }); 629 }; 630 631 const createSelect = (optionName: string, info: OptionInfo) => { 632 const select = $('<select>').on('change', function (this: HTMLSelectElement) { 633 optionValues[optionName] = JSON.parse(this.value); 634 updateURLsWithCurrentOptions(); 635 }); 636 const currentValue = optionValues[optionName]; 637 for (const { value, description } of info.selectValueDescriptions!) { 638 $('<option>') 639 .text(description) 640 .val(JSON.stringify(value)) 641 .prop('selected', value === currentValue) 642 .appendTo(select); 643 } 644 return select; 645 }; 646 647 Object.entries(optionsInfos).forEach(([optionName, info], i) => { 648 const id = `option${i}`; 649 const input = 650 typeof optionValues[optionName] === 'boolean' 651 ? createCheckbox(optionName) 652 : createSelect(optionName, info); 653 input.attr('id', id); 654 $('<tr>') 655 .append($('<td>').append(input)) 656 .append( 657 $('<td>').append($('<label>').attr('for', id).text(camelCaseToSnakeCase(optionName))) 658 ) 659 .append($('<td>').text(info.description)) 660 .appendTo(optionsElem); 661 }); 662 }; 663 addOptionsToPage(options, kStandaloneOptionsInfos); 664 665 let deviceDescription = '<unable to get WebGPU adapter>'; 666 const adapter = await getGPU(null).requestAdapter(getDefaultRequestAdapterOptions()); 667 if (adapter) { 668 deviceDescription = `${adapter.info.vendor} ${adapter.info.architecture} (${adapter.info.description})`; 669 } 670 $('#device')[0].textContent = 'Default WebGPU adapter: ' + deviceDescription; 671 logger.defaultDeviceDescription = deviceDescription; 672 673 if (qs.length !== 1) { 674 showInfo('currently, there must be exactly one ?q='); 675 return; 676 } 677 678 let rootQuery; 679 try { 680 rootQuery = parseQuery(qs[0]); 681 } catch (e) { 682 showInfo((e as Error).toString()); 683 return; 684 } 685 686 if (rootQuery.level > lastQueryLevelToExpand) { 687 lastQueryLevelToExpand = rootQuery.level; 688 } 689 loader.addEventListener('import', ev => { 690 showInfo(`loading: ${ev.data.url}`); 691 }); 692 loader.addEventListener('imported', ev => { 693 showInfo(`imported: ${ev.data.url}`); 694 }); 695 loader.addEventListener('finish', () => { 696 showInfo(''); 697 }); 698 699 let tree; 700 try { 701 tree = await loader.loadTree(rootQuery); 702 } catch (err) { 703 showInfo((err as Error).toString()); 704 return; 705 } 706 707 document.title = `${document.title} ${compatibility ? '(compat)' : ''} - ${rootQuery.toString()}`; 708 709 tree.dissolveSingleChildTrees(); 710 711 const { runSubtree, generateSubtreeHTML } = makeSubtreeHTML(tree.root, 1); 712 const setTreeCheckedRecursively = generateSubtreeHTML(resultsVis); 713 714 document.getElementById('expandall')!.addEventListener('click', () => { 715 setTreeCheckedRecursively(); 716 }); 717 718 function getResultsText() { 719 const saveOptionElement = document.getElementById('saveOnlyFailures') as HTMLInputElement; 720 const onlyFailures = saveOptionElement.checked; 721 const predFunc = (key: string, value: LiveTestCaseResult) => 722 value.status === 'fail' || !onlyFailures; 723 return logger.asJSON(2, predFunc); 724 } 725 726 document.getElementById('copyResultsJSON')!.addEventListener('click', () => { 727 void navigator.clipboard.writeText(getResultsText()); 728 }); 729 730 document.getElementById('saveResultsJSON')!.addEventListener('click', () => { 731 const text = getResultsText(); 732 const blob = new Blob([text], { type: 'text/plain' }); 733 const link = document.createElement('a'); 734 link.download = 'results-webgpu-cts.json'; 735 link.href = window.URL.createObjectURL(blob); 736 link.click(); 737 }); 738 739 if (runnow) { 740 void runSubtree(); 741 } 742 })();