github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/webapp/views/wpt-results.js (about) 1 /** 2 * Copyright 2018 The WPT Dashboard Project. All rights reserved. 3 * Use of this source code is governed by a BSD-style license that can be 4 * found in the LICENSE file. 5 */ 6 7 import '../components/info-banner.js'; 8 import { LoadingState } from '../components/loading-state.js'; 9 import '../components/path.js'; 10 import '../components/test-file-results.js'; 11 import '../components/test-results-history-timeline.js'; 12 import '../components/test-run.js'; 13 import '../components/test-runs-query-builder.js'; 14 import { TestRunsUIBase } from '../components/test-runs.js'; 15 import '../components/test-search.js'; 16 import { WPTColors } from '../components/wpt-colors.js'; 17 import { WPTFlags } from '../components/wpt-flags.js'; 18 import '../components/wpt-permalinks.js'; 19 import '../components/wpt-metadata.js'; 20 import { AmendMetadataMixin } from '../components/wpt-amend-metadata.js'; 21 import '../node_modules/@polymer/iron-collapse/iron-collapse.js'; 22 import '../node_modules/@polymer/iron-icon/iron-icon.js'; 23 import '../node_modules/@polymer/iron-icons/editor-icons.js'; 24 import '../node_modules/@polymer/iron-icons/image-icons.js'; 25 import '../node_modules/@polymer/paper-button/paper-button.js'; 26 import '../node_modules/@polymer/paper-icon-button/paper-icon-button.js'; 27 import '../node_modules/@polymer/paper-spinner/paper-spinner-lite.js'; 28 import '../node_modules/@polymer/paper-styles/color.js'; 29 import '../node_modules/@polymer/paper-tabs/paper-tabs.js'; 30 import '../node_modules/@polymer/paper-toast/paper-toast.js'; 31 import '../node_modules/@polymer/polymer/lib/elements/dom-if.js'; 32 import '../node_modules/@polymer/polymer/lib/elements/dom-repeat.js'; 33 import '../node_modules/@polymer/polymer/polymer-element.js'; 34 import { html } from '../node_modules/@polymer/polymer/polymer-element.js'; 35 import { PathInfo } from '../components/path.js'; 36 import { Pluralizer } from '../components/pluralize.js'; 37 38 const TEST_TYPES = ['manual', 'reftest', 'testharness', 'visual', 'wdspec']; 39 40 // Map of abbreviations for status values stored in summary files. 41 // This is used to expand the status to its full value after being 42 // abbreviated for smaller storage in summary files. 43 // NOTE: If a new status abbreviation is added here, the mapping 44 // at results_processor/wptreport.py will also require the change. 45 const STATUS_ABBREVIATIONS = { 46 'P': 'PASS', 47 'O': 'OK', 48 'F': 'FAIL', 49 'S': 'SKIP', 50 'E': 'ERROR', 51 'N': 'NOTRUN', 52 'C': 'CRASH', 53 'T': 'TIMEOUT', 54 'PF': 'PRECONDITION_FAILED' 55 }; 56 const PASSING_STATUSES = ['O', 'P']; 57 58 // VIEW_ENUM contains the different values for the `view` query parameter. 59 const VIEW_ENUM = { 60 Subtest: 'subtest', 61 Interop: 'interop', 62 Test: 'test' 63 } 64 65 class WPTResults extends AmendMetadataMixin(Pluralizer(WPTColors(WPTFlags(PathInfo(LoadingState(TestRunsUIBase)))))) { 66 static get template() { 67 return html` 68 <style include="wpt-colors"> 69 :host { 70 display: block; 71 font-size: 15px; 72 } 73 table { 74 width: 100%; 75 border-collapse: collapse; 76 } 77 tr.spec { 78 background-color: var(--paper-grey-200); 79 } 80 tr td { 81 padding: 0.25em 0.5em; 82 } 83 tr.spec td { 84 padding: 0.2em 0.5em; 85 border: solid 1px var(--paper-grey-300); 86 } 87 thead { 88 border-bottom: 8px solid white; 89 } 90 th { 91 background: white; 92 position: sticky; 93 top: 0; 94 z-index: 1; 95 } 96 .path { 97 margin-bottom: 16px; 98 } 99 .path-separator { 100 padding: 0 0.1em; 101 margin: 0 0.2em; 102 } 103 .top, 104 .delta { 105 background-color: var(--paper-grey-200); 106 } 107 span.delta.regressions { 108 color: var(--paper-red-700); 109 } 110 span.delta.passes { 111 color: var(--paper-green-700); 112 } 113 td.none { 114 visibility: hidden; 115 } 116 td.numbers { 117 white-space: nowrap; 118 color: black; 119 } 120 td[triage] { 121 cursor: pointer; 122 } 123 td[triage]:hover { 124 opacity: 0.7; 125 box-shadow: 5px 5px 5px; 126 } 127 td[selected] { 128 border: 2px solid #000000; 129 } 130 .totals-row { 131 border-top: 4px solid white; 132 padding: 4px; 133 } 134 .yellow-button { 135 color: var(--paper-yellow-500); 136 margin-left: 32px; 137 } 138 .history { 139 margin: 32px 0; 140 text-align: center; 141 } 142 .history h3 span { 143 color: var(--paper-red-500); 144 } 145 #show-history { 146 background: var(--paper-blue-700); 147 color: white; 148 } 149 .test-type { 150 margin-left: 8px; 151 padding: 4px; 152 border-radius: 4px; 153 background-color: var(--paper-blue-100); 154 } 155 @media (max-width: 1200px) { 156 table tr td:first-child::after { 157 content: ""; 158 display: inline-block; 159 vertical-align: top; 160 min-height: 30px; 161 } 162 } 163 .sort-col { 164 border-top: 4px solid white; 165 padding: 4px; 166 } 167 .sort-button { 168 margin-left: -15px; 169 } 170 .view-triage { 171 margin-left: 30px; 172 } 173 .pointer { 174 cursor: help; 175 } 176 177 .channel-area { 178 display: flex; 179 max-width: fit-content; 180 margin-inline: auto; 181 border-radius: 3px; 182 margin-bottom:20px; 183 box-shadow: var(--shadow-elevation-2dp_-_box-shadow); 184 } 185 186 .channel-area > paper-button { 187 margin: 0; 188 } 189 190 .channel-area > paper-button:first-of-type { 191 border-top-right-radius: 0; 192 border-bottom-right-radius: 0; 193 } 194 195 .channel-area > paper-button:last-of-type { 196 border-top-left-radius: 0; 197 border-bottom-left-radius: 0; 198 } 199 .unselected { 200 background-color: white; 201 } 202 .selected { 203 background-color: var(--paper-blue-700); 204 color: white; 205 } 206 207 .selected::before { 208 --_size: 1rem; 209 --_half-size: calc(var(--_size) / 2); 210 211 content: ""; 212 position: absolute; 213 bottom: calc(var(--_half-size) * -1 + 1px); 214 width: var(--_size); 215 height: var(--_half-size); 216 left: calc(50% - var(--_half-size)); 217 background: var(--paper-blue-700); 218 clip-path: polygon(46% 100%, 0 0, 100% 0); 219 } 220 </style> 221 222 <paper-toast id="selected-toast" duration="0"> 223 <span>[[triageToastMsg(selectedMetadata.length)]]</span> 224 <paper-button class="view-triage" on-click="openAmendMetadata" raised="[[hasSelections]]" disabled="[[!hasSelections]]">TRIAGE</paper-button> 225 </paper-toast> 226 227 <template is="dom-if" if="[[isInvalidDiffUse(diff, testRuns)]]"> 228 <paper-toast id="diffInvalid" duration="0" text="'diff' was requested, but is only valid when comparing two runs." opened> 229 <paper-button onclick="[[dismissToast]]" class="yellow-button">Close</paper-button> 230 </paper-toast> 231 </template> 232 233 <paper-toast id="runsNotInCache" duration="5000" text="One or more of the runs requested is currently being loaded into the cache. Trying again..."></paper-toast> 234 235 <template is="dom-if" if="[[resultsLoadFailed]]"> 236 <info-banner type="error"> 237 Failed to fetch test runs. 238 </info-banner> 239 </template> 240 241 <template is="dom-if" if="[[queryBuilder]]"> 242 <iron-collapse opened="[[editingQuery]]"> 243 <test-runs-query-builder query="[[query]]" 244 on-submit="[[submitQuery]]"> 245 </test-runs-query-builder> 246 </iron-collapse> 247 </template> 248 249 <template is="dom-if" if="[[testRuns]]"> 250 <template is="dom-if" if="{{ pathIsATestFile }}"> 251 <test-file-results test-runs="[[testRuns]]" 252 subtest-row-count={{subtestRowCount}} 253 path="[[path]]" 254 structured-search="[[structuredSearch]]" 255 labels="[[labels]]" 256 products="[[products]]" 257 diff-run="[[diffRun]]" 258 is-triage-mode="[[isTriageMode]]" 259 metadata-map="[[metadataMap]]"> 260 </test-file-results> 261 </template> 262 <template is="dom-if" if="[[shouldDisplayToggle(canViewInteropScores, pathIsATestFile)]]"> 263 <div class="channel-area"> 264 <paper-button id="toggleInterop" class\$="[[ interopButtonClass(view) ]]" on-click="clickInterop">Interop View</paper-button> 265 <template is="dom-if" if="[[showViewEqTest]]"> 266 <paper-button id="toggleTestView" class\$="[[ testViewButtonClass(view) ]]" on-click="clickTestView">Test View</paper-button> 267 </template> 268 <paper-button id="toggleDefault" class\$="[[ defaultButtonClass(view) ]]" on-click="clickDefault">Default View</paper-button> 269 </div> 270 </template> 271 272 <template is="dom-if" if="{{ !pathIsATestFile }}"> 273 <table> 274 <thead> 275 <tr> 276 <th>Path</th> 277 <template is="dom-repeat" items="[[testRuns]]" as="testRun"> 278 <!-- Repeats for as many different browser test runs are available --> 279 <th><test-run test-run="[[testRun]]" show-source show-platform></test-run></th> 280 </template> 281 <template is="dom-if" if="[[diffRun]]"> 282 <th> 283 <test-run test-run="[[diffRun]]"></test-run> 284 <paper-icon-button icon="filter-list" onclick="[[toggleDiffFilter]]" title="Toggle filtering to only show differences"></paper-icon-button> 285 </th> 286 </template> 287 </tr> 288 </thead> 289 290 <tbody> 291 <template is="dom-if" if="[[displayedNodes]]"> 292 <tr class="sort-col"> 293 <td> 294 <paper-icon-button class="sort-button" src=[[getSortIcon(isPathSorted)]] onclick="[[sortTestName]]" aria-label="Sort the test name column"></paper-icon-button> 295 </td> 296 <template is="dom-repeat" items="[[sortCol]]" as="sortItem"> 297 <td> 298 <paper-icon-button class="sort-button" src=[[getSortIcon(sortItem)]] onclick="[[sortTestResults(index)]]" aria-label="Sort the test result column"></paper-icon-button> 299 </td> 300 </template> 301 </tr> 302 </template> 303 304 <template is="dom-repeat" items="{{displayedNodes}}" as="node"> 305 <tr> 306 <td onclick="[[handleTriageSelect(null, node, testRun)]]" onmouseover="[[handleTriageHover(null, node, testRun)]]"> 307 <path-part 308 prefix="/results" 309 path="[[ node.path ]]" 310 query="{{ query }}" 311 is-dir="{{ node.isDir }}" 312 is-triage-mode=[[isTriageMode]]> 313 </path-part> 314 <template is="dom-if" if="[[shouldDisplayMetadata(null, node.path, metadataMap)]]"> 315 <a href="[[ getMetadataUrl(null, node.path, metadataMap) ]]" target="_blank"><iron-icon class="bug" icon="bug-report"></iron-icon></a> 316 </template> 317 <template is="dom-if" if="[[shouldDisplayTestLabel(node.path, labelMap)]]"> 318 <iron-icon class="bug" icon="label" title="[[getTestLabelTitle(node.path, labelMap)]]"></iron-icon> 319 </template> 320 </td> 321 322 <template is="dom-repeat" items="[[testRuns]]" as="testRun"> 323 <td class\$="numbers [[ testResultClass(node, index, testRun, 'passes') ]]" onclick="[[handleTriageSelect(index, node, testRun)]]" onmouseover="[[handleTriageHover(index, node, testRun)]]"> 324 <template is="dom-if" if="[[diffRun]]"> 325 <span class\$="passes [[ testResultClass(node, index, testRun, 'passes') ]]">{{ getNodeResultDataByPropertyName(node, index, testRun, 'subtest_passes') }}</span> 326 / 327 <span class\$="total [[ testResultClass(node, index, testRun, 'total') ]]">{{ getNodeResultDataByPropertyName(node, index, testRun, 'subtest_total') }}</span> 328 </template> 329 <template is="dom-if" if="[[!diffRun]]"> 330 <span class\$="passes [[ testResultClass(node, index, testRun, 'passes') ]]">{{ getNodeResult(node, index) }}</span> 331 <template is="dom-if" if="[[ shouldDisplayHarnessWarning(node, index) ]]"> 332 <span class="pointer" title\$="Harness [[ getStatusDisplay(node, index) ]]"> ⚠️</span> 333 </template> 334 </template> 335 <template is="dom-if" if="[[shouldDisplayMetadata(index, node.path, metadataMap)]]"> 336 <a href="[[ getMetadataUrl(index, node.path, metadataMap) ]]" target="_blank"><iron-icon class="bug" icon="bug-report"></iron-icon></a> 337 </template> 338 </td> 339 </template> 340 341 <template is="dom-if" if="[[diffRun]]"> 342 <td class\$="numbers [[ testResultClass(node, index, diffRun, 'passes') ]]"> 343 <template is="dom-if" if="[[node.diff]]"> 344 <span class="delta passes">{{ getNodeResultDataByPropertyName(node, -1, diffRun, 'passes') }}</span> 345 / 346 <span class="delta regressions">{{ getNodeResultDataByPropertyName(node, -1, diffRun, 'regressions') }}</span> 347 / 348 <span class="delta total">{{ getNodeResultDataByPropertyName(node, -1, diffRun, 'total') }}</span> 349 </template> 350 </td> 351 </template> 352 </tr> 353 </template> 354 355 <template is="dom-if" if="[[ shouldDisplayTotals(displayedTotals, diffRun) ]]"> 356 <tr class="totals-row"> 357 <td> 358 <code><strong>[[getTotalText()]]</strong></code> 359 </td> 360 <template is="dom-repeat" items="[[displayedTotals]]" as="columnTotal"> 361 <td class\$="numbers [[ getTotalsClass(columnTotal) ]]"> 362 <span class\$="total [[ getTotalsClass(columnTotal) ]]">{{ getTotalDisplay(columnTotal) }}</span> 363 </td> 364 </template> 365 </tr> 366 </template> 367 </tbody> 368 </table> 369 370 <template is="dom-if" if="[[noResults]]"> 371 <info-banner type="info"> 372 No results. 373 </info-banner> 374 </template> 375 </template> 376 </template> 377 378 <template is="dom-if" if="[[pathIsATestFile]]"> 379 <div class="history"> 380 <template is="dom-if" if="[[!showHistory]]"> 381 <paper-button id="show-history" onclick="[[showHistoryClicked()]]" raised> 382 Show history timeline 383 </paper-button> 384 </template> 385 <template is="dom-if" if="[[showHistory]]"> 386 <h3> 387 History: 388 </h3> 389 <template is="dom-if" if="[[pathIsATestFile]]"> 390 <test-results-history-timeline 391 path="[[path]]" 392 show-test-history="[[showHistory]]" 393 subtest-names="[[subtestNames]]"> 394 </test-results-history-timeline> 395 </template> 396 </template> 397 </div> 398 </template> 399 400 <template is="dom-if" if="[[displayMetadata]]"> 401 <wpt-metadata products="[[displayedProducts]]" 402 path="[[path]]" 403 search-results="[[searchResults]]" 404 metadata-map="{{metadataMap}}" 405 label-map="{{labelMap}}" 406 triage-notifier="[[triageNotifier]]"></wpt-metadata> 407 </template> 408 <wpt-amend-metadata id="amend" selected-metadata="{{selectedMetadata}}" path="[[path]]"></wpt-amend-metadata> 409 `; 410 } 411 412 static get is() { 413 return 'wpt-results'; 414 } 415 416 static get properties() { 417 return { 418 path: { 419 type: String, 420 observer: 'pathUpdated', 421 notify: true, 422 }, 423 pathIsASubfolderOrFile: { 424 type: Boolean, 425 computed: 'computePathIsASubfolderOrFile(pathIsASubfolder, pathIsATestFile)' 426 }, 427 liveTestDomain: { 428 type: String, 429 computed: 'computeLiveTestDomain()', 430 }, 431 structuredSearch: Object, 432 searchResults: { 433 type: Array, 434 value: [], 435 notify: true, 436 }, 437 subtestRowCount: { 438 type: Number, 439 notify: true 440 }, 441 testPaths: { 442 type: Set, 443 computed: 'computeTestPaths(searchResults)', 444 notify: true, 445 }, 446 displayedNodes: { 447 type: Array, 448 value: [], 449 }, 450 displayedTests: { 451 type: Array, 452 computed: 'computeDisplayedTests(path, searchResults)', 453 }, 454 displayedTotals: { 455 type: Array, 456 value: [], 457 }, 458 metadataMap: Object, 459 labelMap: Object, 460 // Users request to show a diff column. 461 diff: Boolean, 462 diffRun: { 463 type: Object, 464 value: null, 465 }, 466 diffURL: { 467 type: String, 468 computed: 'computeDiffURL(testRuns)', 469 }, 470 showHistory: { 471 type: Boolean, 472 value: false, 473 }, 474 subtestNames: { 475 type: Array, 476 value:[] 477 }, 478 resultsLoadFailed: Boolean, 479 noResults: Boolean, 480 editingQuery: { 481 type: Boolean, 482 value: false, 483 }, 484 sortCol: { 485 type: Array, 486 value: [], 487 }, 488 isPathSorted: { 489 type: Boolean, 490 value: false, 491 }, 492 canViewInteropScores: { 493 type: Boolean, 494 value: false 495 }, 496 onlyShowDifferences: Boolean, 497 // path => {type, file[, refPath]} simplification. 498 screenshots: Array, 499 triageNotifier: Boolean, 500 }; 501 } 502 503 static get observers() { 504 return [ 505 'clearSelectedCells(selectedMetadata)', 506 'handleTriageMode(isTriageMode)', 507 'changeView(view)' 508 ]; 509 } 510 511 isInvalidDiffUse(diff, testRuns) { 512 return diff && testRuns && testRuns.length !== 2; 513 } 514 515 computePathIsASubfolderOrFile(isSubfolder, isFile) { 516 return isSubfolder || isFile; 517 } 518 519 computeLiveTestDomain() { 520 if (this.webPlatformTestsLive) { 521 return 'wpt.live'; 522 } 523 return 'w3c-test.org'; 524 } 525 526 computeTestPaths(searchResults) { 527 const paths = searchResults && searchResults.map(r => r.test) || []; 528 return new Set(paths); 529 } 530 531 computeDisplayedTests(path, searchResults) { 532 return searchResults 533 && searchResults.map(r => r.test).filter(name => name.startsWith(path)) 534 || []; 535 } 536 537 computeDiffURL(testRuns) { 538 if (!testRuns || testRuns.length !== 2) { 539 return; 540 } 541 let url = new URL('/api/diff', window.location); 542 for (const run of testRuns) { 543 url.searchParams.append('run_id', run.id); 544 } 545 url.searchParams.set('filter', this.diffFilter); 546 return url; 547 } 548 549 constructor() { 550 super(); 551 this.onLoadingComplete = () => { 552 this.noResults = !this.resultsLoadFailed 553 && !(this.searchResults && this.searchResults.length); 554 }; 555 this.toggleQueryEdit = () => { 556 this.editingQuery = !this.editingQuery; 557 }; 558 this.toggleDiffFilter = () => { 559 this.onlyShowDifferences = !this.onlyShowDifferences; 560 this.refreshDisplayedNodes(); 561 }; 562 this.dismissToast = e => e.target.closest('paper-toast').close(); 563 this.reloadPendingMetadata = this.handleReloadPendingMetadata.bind(this); 564 this.sortTestName = this.sortTestName.bind(this); 565 } 566 567 connectedCallback() { 568 super.connectedCallback(); 569 this.addEventListener('triagemetadata', this.reloadPendingMetadata); 570 this.addEventListener('subtestrows', this.handleGetSubtestRows); 571 } 572 573 disconnectedCallback() { 574 this.removeEventListener('triagemetadata', this.reloadPendingMetadata); 575 super.disconnectedCallback(); 576 } 577 578 loadData() { 579 this.resultsLoadFailed = false; 580 this.load( 581 this.loadRuns().then(async runs => { 582 // Pass current (un)structured query is passed to fetchResults(). 583 this.fetchResults( 584 this.structuredQueries && this.structuredSearch || this.search); 585 586 // Load a diff data into this.diffRun, if needed. 587 if (this.diff && runs && runs.length === 2) { 588 this.diffRun = { 589 revision: 'diff', 590 browser_name: 'diff', 591 }; 592 this.fetchDiff(); 593 } 594 }), 595 () => { 596 this.resultsLoadFailed = true; 597 } 598 ); 599 } 600 601 reloadData() { 602 if (!this.diff) { 603 this.diffRun = null; 604 } 605 this.testRuns = []; 606 this.sortCol = []; 607 this.searchResults = []; 608 this.displayedTotals = []; 609 this.refreshDisplayedNodes(); 610 this.loadData(); 611 } 612 613 handleGetSubtestRows(event) { 614 this.subtestNames = event.detail.rows.map(subtestRow => { 615 // The overall test status is given as an empty string. 616 if(subtestRow.name === 'Harness status' || subtestRow.name === 'Test status') { 617 return ''; 618 } 619 return subtestRow.name.replace(/\s/g, ' '); 620 }).filter(subtestName => subtestName !== 'Duration') 621 } 622 623 fetchResults(q) { 624 if (!this.testRuns || !this.testRuns.length) { 625 return; 626 } 627 628 let url = new URL('/api/search', window.location); 629 let fetchOpts; 630 631 if (this.structuredQueries) { 632 const body = { 633 run_ids: this.testRuns.map(r => r.id), 634 }; 635 if (q) { 636 body.query = q; 637 } 638 if (this.diff && this.diffFromAPI) { 639 url.searchParams.set('diff', true); 640 url.searchParams.set('filter', this.diffFilter); 641 } 642 fetchOpts = { 643 method: 'POST', 644 body: JSON.stringify(body), 645 }; 646 } else { 647 url.searchParams.set( 648 'run_ids', 649 this.testRuns.map(r => r.id.toString()).join(',')); 650 if (q) { 651 url.searchParams.set('q', q); 652 } 653 } 654 this.sortCol = new Array(this.testRuns.length).fill(false); 655 656 // Fetch search results and refresh display nodes. If fetch error is HTTP' 657 // 422, expect backend to attempt write-on-read of missing data. In such 658 // cases, retry fetch up to 5 times with 5000ms waits in between. 659 const toast = this.shadowRoot.querySelector('#runsNotInCache'); 660 this.load( 661 this.retry( 662 async() => { 663 const r = await window.fetch(url, fetchOpts); 664 if (!r.ok) { 665 if (fetchOpts.method === 'POST' && r.status === 422) { 666 toast.open(); 667 throw r.status; 668 } 669 throw 'Failed to fetch results data.'; 670 } 671 return r.json(); 672 }, 673 err => err === 422, 674 5, 675 5000 676 ).then( 677 json => { 678 this.searchResults = json.results.sort((a, b) => a.test.localeCompare(b.test)); 679 this.refreshDisplayedNodes(); 680 }, 681 (e) => { 682 toast.close(); 683 // eslint-disable-next-line no-console 684 console.log(`Failed to load: ${e}`); 685 this.resultsLoadFailed = true; 686 } 687 ) 688 ); 689 } 690 691 fetchDiff() { 692 if (!this.diffFromAPI) { 693 return; 694 } 695 this.load( 696 window.fetch(this.diffURL) 697 .then(r => { 698 if (!r.ok || r.status !== 200) { 699 return Promise.reject('Failed to fetch diff data.'); 700 } 701 return r.json(); 702 }) 703 .then(json => { 704 this.diffResults = json; 705 this.refreshDisplayedNodes(); 706 }) 707 ); 708 } 709 710 pathUpdated(path) { 711 this.refreshDisplayedNodes(); 712 if (this.testRuns) { 713 this.sortCol = new Array(this.testRuns.length).fill(false); 714 this.isPathSorted = false; 715 } 716 this.showHistory = false 717 } 718 719 aggregateTestTotals(nodes, row, rs, diffRun) { 720 // Aggregation is done by test aggregation and subtest aggregation. 721 const aggregateTotalsBySubtest = (rs, i, diffRun) => { 722 const status = rs[i].status; 723 let passes = rs[i].passes; 724 let total = rs[i].total; 725 if (status) { 726 // Increment 'OK' status totals specifically for diff views. 727 // Diff views will still take harness status into account. 728 if (diffRun) { 729 total++; 730 if (status === 'O') passes++; 731 } else if (rs[i].total === 0) { 732 // If we're in subtest view and we have a test with no subtests, 733 // we should NOT ignore the test status and add it to the subtest count. 734 total++; 735 if (status === 'P') passes++; 736 } 737 } 738 return [passes, total]; 739 }; 740 741 const aggregateTotalsByTest = (rs, i) => { 742 const passingStatus = PASSING_STATUSES.includes(rs[i].status); 743 let passes = 0; 744 // If this is an old summary, aggregate using the old process. 745 if (!rs[i].newAggProcess) { 746 // Ignore aggregating test if there are no results. 747 if (rs[i].total === 0) { 748 return [0, 0]; 749 } 750 // Take the passes / total subtests to get a percentage passing. 751 passes = rs[i].passes / rs[i].total; 752 // If we have a total of 0 subtests but the status is passing, 753 // mark as 100% passing. 754 } else if (passingStatus && rs[i].total === 0) { 755 passes = 1; 756 // Otherwise, the passing percentage is the number of passes divided by the total. 757 } else if (rs[i].total > 0) { 758 passes = rs[i].passes / rs[i].total; 759 } 760 761 return [passes, 1]; 762 }; 763 764 for (let i = 0; i < rs.length; i++) { 765 const status = rs[i].status; 766 const isMissing = status === '' && rs[i].total === 0; 767 row.results[i].singleSubtest = (rs[i].total === 0 && status && status !== 'O') || isMissing; 768 row.results[i].status = status; 769 let passes, total = 0; 770 [passes, total] = aggregateTotalsByTest(rs, i); 771 // Add the results to the total count of tests. 772 row.results[i].passes += passes; 773 nodes.totals[i].passes += passes; 774 row.results[i].total += total; 775 nodes.totals[i].total+= total; 776 777 [passes, total] = aggregateTotalsBySubtest(rs, i, diffRun); 778 // Initialize subtest counts to zero if not started. 779 if (!('subtest_total' in row.results[i])) { 780 row.results[i].subtest_passes = 0; 781 row.results[i].subtest_total = 0; 782 row.results[i].test_view_passes = 0; 783 row.results[i].test_view_total = 0; 784 } 785 row.results[i].subtest_passes += passes; 786 nodes.totals[i].subtest_passes += passes; 787 row.results[i].subtest_total += total; 788 nodes.totals[i].subtest_total += total; 789 const test_view_pass = (passes === total && PASSING_STATUSES.includes(status)) ? 1: 0; 790 row.results[i].test_view_passes += test_view_pass; 791 nodes.totals[i].test_view_passes += test_view_pass; 792 row.results[i].test_view_total++; 793 nodes.totals[i].test_view_total++; 794 } 795 } 796 797 refreshDisplayedNodes() { 798 if (!this.searchResults || !this.searchResults.length) { 799 this.displayedNodes = []; 800 return; 801 } 802 // Prefix: includes trailing slash. 803 const prefix = this.path === '/' ? '/' : `${this.path}/`; 804 const collapsePathOnto = (testPath, nodes) => { 805 const suffix = testPath.substring(prefix.length); 806 const slashIdx = suffix.split('?')[0].indexOf('/'); 807 const isDir = slashIdx !== -1; 808 const name = isDir ? suffix.substring(0, slashIdx) : suffix; 809 // Either add new node to acc, or add passes, total to an 810 // existing node. 811 if (!nodes.hasOwnProperty(name)) { 812 nodes[name] = { 813 path: `${prefix}${name}`, 814 isDir, 815 results: this.testRuns.map(() => ({ 816 passes: 0, 817 total: 0, 818 })), 819 }; 820 } 821 return name; 822 }; 823 824 const aggregateTestTotals = this.aggregateTestTotals; 825 const diffRun = this.diffRun 826 827 const resultsByPath = this.searchResults 828 // Filter out files not in this directory. 829 .filter(r => r.test.startsWith(prefix)) 830 // Accumulate displayedNodes from remaining files. 831 .reduce((nodes, r) => { 832 // Compute dir/file name that is direct descendant of this.path. 833 let testPath = r.test; 834 let previousTestPath; 835 if (this.diffResults && this.diffResults.renames) { 836 if (testPath in this.diffResults.renames) { 837 // This path was renamed; ignore. 838 return nodes; 839 } 840 const rename = Object.entries(this.diffResults.renames).find(e => e[1] === testPath); 841 if (rename) { 842 // This is the new path name; store the old one. 843 previousTestPath = rename[0]; 844 } 845 } 846 const name = collapsePathOnto(testPath, nodes); 847 848 const rs = r.legacy_status; 849 const row = nodes[name]; 850 if (!rs) { 851 return nodes; 852 } 853 854 // Keep track of overall total. 855 if (!('totals' in nodes)) { 856 nodes['totals'] = this.testRuns.map(() => { 857 return { passes: 0, total: 0, subtest_passes: 0, subtest_total: 0, test_view_passes: 0, test_view_total: 0 }; 858 }); 859 } 860 // Accumulate the sums. 861 aggregateTestTotals(nodes, row, r.legacy_status, diffRun); 862 863 if (previousTestPath) { 864 const previous = this.searchResults.find(r => r.test === previousTestPath); 865 if (previous) { 866 row.results[0].subtest_passes += previous.legacy_status[0].passes; 867 row.results[0].subtest_total += previous.legacy_status[0].total; 868 } 869 } 870 if (this.diff && rs.length === 2) { 871 let diff; 872 if (this.diffResults) { 873 diff = this.diffResults.diff[r.test]; 874 } else if (r.diff) { 875 diff = r.diff; 876 } else { 877 const [before, after] = rs; 878 diff = this.computeDifferences(before, after); 879 } 880 if (diff) { 881 row.diff = row.diff || { 882 passes: 0, 883 regressions: 0, 884 total: 0, 885 }; 886 row.diff.passes += diff[0]; 887 row.diff.regressions += diff[1]; 888 row.diff.total += diff[2]; 889 } 890 } 891 return nodes; 892 }, {}); 893 894 // Take the calculated totals to be displayed at bottom of results page. 895 // Delete key after reassignment. 896 this.displayedTotals = resultsByPath.totals; 897 delete resultsByPath.totals; 898 899 this.displayedNodes = Object.values(resultsByPath) 900 .filter(row => { 901 if (!this.onlyShowDifferences) { 902 return true; 903 } 904 return row.diff; 905 }); 906 } 907 908 computeDifferences(before, after) { 909 // Count statuses for diff views. 910 let beforePasses = before.passes; 911 let beforeTotal = before.total; 912 if (before.status) { 913 beforeTotal++; 914 if (PASSING_STATUSES.includes(before.status)) beforePasses++; 915 } 916 let afterPasses = after.passes; 917 let afterTotal = after.total; 918 if (after.status) { 919 afterTotal++; 920 if (PASSING_STATUSES.includes(after.status)) afterPasses++; 921 } 922 923 const deleted = beforeTotal > 0 && afterTotal === 0; 924 const added = afterTotal > 0 && beforeTotal === 0; 925 if (deleted && !this.diffFilter.includes('D') 926 || added && !this.diffFilter.includes('A')) { 927 return; 928 } 929 const failingBefore = beforeTotal - beforePasses; 930 const failingAfter = afterTotal - afterPasses; 931 const diff = [ 932 Math.max(afterPasses - beforePasses, 0), // passes 933 Math.max(failingAfter - failingBefore, 0), // regressions 934 afterTotal - beforeTotal // total 935 ]; 936 const hasChanges = diff.some(v => v !== 0); 937 if ((this.diffFilter.includes('A') && added) 938 || (this.diffFilter.includes('D') && deleted) 939 || (this.diffFilter.includes('C') && hasChanges) 940 || (this.diffFilter.includes('U') && !hasChanges)) { 941 return diff; 942 } 943 } 944 945 platformID({ browser_name, browser_version, os_name, os_version }) { 946 return `${browser_name}-${browser_version}-${os_name}-${os_version}`; 947 } 948 949 canAmendMetadata(node, index, testRun) { 950 // It is always possible in triage mode to amend metadata for a problem 951 // with a test file itself. 952 if (index === undefined) { 953 return !node.isDir && this.triageMetadataUI && this.isTriageMode; 954 } 955 956 // Triage can occur if a status doesn't pass. 957 const status = this.getNodeResultDataByPropertyName(node, index, testRun, 'status'); 958 const failStatus = status && !PASSING_STATUSES.includes(status); 959 const totalTests = this.getNodeResultDataByPropertyName(node, index, testRun, 'total'); 960 const passedTests = this.getNodeResultDataByPropertyName(node, index, testRun, 'passes'); 961 return ((totalTests - passedTests) > 0 || failStatus) && this.triageMetadataUI && this.isTriageMode; 962 } 963 964 testResultClass(node, index, testRun, prop) { 965 // Guard against incomplete data. 966 if (!node || !testRun) { 967 return 'none'; 968 } 969 970 const result = node.results[index]; 971 const isDiff = this.isDiff(testRun); 972 if (isDiff) { 973 if (!node.diff) { 974 return 'none'; 975 } 976 // Diff case: 'delta [positive|negative|<nothing>]' based on delta 977 // value; 978 const delta = this.getDiffDelta(node, prop); 979 if (delta === 0) { 980 return 'delta'; 981 } 982 983 return `delta ${delta > 0 ? 'positive' : 'negative'}`; 984 } else { 985 // Change prop by view. 986 let prefix = ''; 987 if (this.isDefaultView()) { 988 prefix = 'subtest_' 989 } else if (this.isTestView()) { 990 prefix = 'test_view_'; 991 } 992 // Non-diff case: result=undefined -> 'none'; path='/' -> 'top'; 993 // result.passes=0 && result.total=0 -> 'top'; 994 // otherwise -> 'passes-[colouring-by-percent]'. 995 if (typeof result === 'undefined' && prop === 'total') { 996 return 'none'; 997 } 998 // Percent view (interop-202*) will allow the home results to be colorized. 999 if (this.path === '/' && !this.colorHomepage && !this.isInteropView()) { 1000 return 'top'; 1001 } 1002 if (result[`${prefix}passes`] === 0 && result[`${prefix}total`] === 0) { 1003 return 'top'; 1004 } 1005 return this.passRateClass(result[`${prefix}passes`], result[`${prefix}total`]); 1006 } 1007 } 1008 1009 shouldDisplayToggle(canViewInteropScores, pathIsATestFile) { 1010 return canViewInteropScores && !pathIsATestFile; 1011 } 1012 1013 testViewButtonClass(view) { 1014 return (view === VIEW_ENUM.Test) ? 'selected' : 'unselected'; 1015 } 1016 1017 interopButtonClass(view) { 1018 return (view === VIEW_ENUM.Interop) ? 'selected' : 'unselected'; 1019 } 1020 1021 defaultButtonClass(view) { 1022 return (view !== VIEW_ENUM.Interop && view !== VIEW_ENUM.Test) ? 'selected' : 'unselected'; 1023 } 1024 1025 clickInterop() { 1026 if (this.isInteropView()) { 1027 return; 1028 } 1029 this.view = VIEW_ENUM.Interop; 1030 } 1031 1032 clickTestView() { 1033 if (!this.showViewEqTest) { 1034 // Do nothing if the `showViewEqTest` feature flag is not enabled. 1035 return; 1036 } 1037 1038 if (this.isTestView()) { 1039 return; 1040 } 1041 this.view = VIEW_ENUM.Test; 1042 } 1043 1044 clickDefault() { 1045 if (this.isDefaultView()) { 1046 return; 1047 } 1048 this.view = VIEW_ENUM.Subtest; 1049 } 1050 1051 changeView(view) { 1052 if (!view) { 1053 return; 1054 } 1055 // Change query string to display correct view. 1056 let query = location.search; 1057 if (query.length > 0) { 1058 query = query.substring(1) 1059 } 1060 let viewStr = `view=${view}`; 1061 const params = query.split('&'); 1062 let viewFound = false; 1063 for(let i = 0; i < params.length; i++) { 1064 if (params[i].includes('view=')) { 1065 viewFound = true; 1066 params[i] = viewStr; 1067 } 1068 } 1069 if (!viewFound) { 1070 params.push(viewStr) 1071 } 1072 1073 let url = location.pathname; 1074 url += `?${params.join('&')}`; 1075 history.pushState('', '', url) 1076 } 1077 1078 isDefaultView() { 1079 // Checks if a special view is active. 1080 return !this.isInteropView() && !this.isTestView(); 1081 } 1082 1083 isInteropView() { 1084 return this.view === VIEW_ENUM.Interop; 1085 } 1086 1087 isTestView() { 1088 // If the `showViewEqTest` feature flag is not active, return false immediately. 1089 return this.showViewEqTest && this.view === VIEW_ENUM.Test; 1090 } 1091 1092 getTotalsClass(totalInfo) { 1093 if ((this.path === '/' && !this.colorHomepage && this.isDefaultView()) 1094 || totalInfo.subtest_total === 0) { 1095 return 'top'; 1096 } 1097 if (this.isTestView()) { 1098 return this.passRateClass(totalInfo.test_view_passes, totalInfo.test_view_total); 1099 } 1100 if (!this.isDefaultView()) { 1101 return this.passRateClass(totalInfo.passes, totalInfo.total); 1102 } 1103 return this.passRateClass(totalInfo.subtest_passes, totalInfo.subtest_total); 1104 } 1105 1106 getDiffDelta(node, prop) { 1107 let val = 0; 1108 if (!prop) { 1109 val = Object.values(node.diff).forEach(v => val += Math.abs(v)); 1110 } else { 1111 val = node.diff[prop]; 1112 } 1113 return prop === 'regressions' ? -val : val; 1114 } 1115 1116 getDiffDeltaStr(node, prop) { 1117 const delta = this.getDiffDelta(node, prop); 1118 if (delta === 0) { 1119 return '0'; 1120 } 1121 const posOrNeg = delta > 0 ? '+' : ''; 1122 return `${posOrNeg}${delta}`; 1123 } 1124 1125 hasResults(node, testRun) { 1126 return typeof node.results[testRun.results_url] !== 'undefined'; 1127 } 1128 1129 isDiff(testRun) { 1130 return testRun && testRun.revision === 'diff'; 1131 } 1132 1133 getNodeResultDataByPropertyName(node, index, testRun, property) { 1134 if (this.isDiff(testRun)) { 1135 return this.getDiffDeltaStr(node, property); 1136 } 1137 if (index >= 0 && index < node.results.length) { 1138 return node.results[index][property]; 1139 } 1140 } 1141 1142 shouldDisplayHarnessWarning(node, index) { 1143 // Determine if a warning sign should be displayed next to subtest counts. 1144 const status = node.results[index].status; 1145 return !node.isDir && status && !PASSING_STATUSES.includes(status) 1146 && !node.results.every(testInfo => testInfo.singleSubtest); 1147 } 1148 1149 getStatusDisplay(node, index) { 1150 let status = node.results[index].status; 1151 if (status in STATUS_ABBREVIATIONS) { 1152 status = STATUS_ABBREVIATIONS[status]; 1153 } 1154 return status; 1155 } 1156 1157 // Formats the numbers shown on the results page for test aggregation. 1158 getTestNumbersDisplay(passes, total, isDir=true) { 1159 const formatPasses = parseFloat(passes.toFixed(2)); 1160 let cellDisplay = ''; 1161 1162 // To differentiate subtests from tests, a different separator is used. 1163 let separator = ' / '; 1164 if (!isDir) { 1165 separator = ' of '; 1166 } 1167 1168 // Show flat '0 / total' or 'total / total' only if none or all tests/subtests pass. 1169 // Display in parentheses if representing subtests. 1170 if (passes === 0) { 1171 cellDisplay = `0${separator}${total}`; 1172 } else if (passes === total) { 1173 cellDisplay = `${total}${separator}${total}`; 1174 } else if (formatPasses < 0.01) { 1175 // If there are passing tests, but only enough to round to 0.00, 1176 // show 0.01 rather than 0.00 to differentiate between possible error states. 1177 cellDisplay = `0.01${separator}${total}`; 1178 } else if (formatPasses === parseFloat(total)) { 1179 // If almost every test is passing, but there are some failures, 1180 // don't round up to 'total / total' so that it's clear some failure exists. 1181 cellDisplay = `${formatPasses - 0.01}`; 1182 } else { 1183 cellDisplay = `${formatPasses}${separator}${total}`; 1184 } 1185 return `${cellDisplay}`; 1186 } 1187 1188 // Formats the numbers shown on the results page for the interop view. 1189 formatCellDisplayInterop(passes, total, isDir) { 1190 1191 // Just show subtest numbers if we're at a single test view. 1192 if (!isDir) { 1193 return `${this.getTestNumbersDisplay(passes, total, isDir)} subtests`; 1194 } 1195 1196 const formatPercent = parseFloat((passes / total * 100).toFixed(0)); 1197 let cellDisplay = ''; 1198 // Show flat 0% or 100% only if none or all tests/subtests pass. 1199 if (passes === 0) { 1200 cellDisplay = '0'; 1201 } else if (passes === total) { 1202 cellDisplay = '100'; 1203 } else if (formatPercent === 0.0) { 1204 // If there are passing tests, but only enough to round to 0.00, 1205 // show 0.01 rather than 0.00 to differentiate between possible error states. 1206 cellDisplay = '0.1'; 1207 } else if (formatPercent === 100.0) { 1208 // If almost every test is passing, but there are some failures, 1209 // don't round up to 'total / total' so that it's clear some failure exists. 1210 cellDisplay = '99.9'; 1211 } else { 1212 cellDisplay = `${formatPercent}`; 1213 } 1214 return `${this.getTestNumbersDisplay(passes, total, isDir)} (${cellDisplay}%)`; 1215 } 1216 1217 // Formats the numbers shown on the results page for the test view. 1218 formatCellDisplayTestView(passes, total, status, isDir) { 1219 1220 // At the test level: 1221 // 1. Show PASS is passes == total for subtests AND (status is undefined (legacy) OR isPassingStatus (v2)). 1222 // 2. Show FAIL if status is undefined (legacy summaries) or 'O' (because showing OK would be misleading). 1223 // 3. Show FAIL otherwise. 1224 if (!isDir) { 1225 if (passes === total && ((status === undefined) || (PASSING_STATUSES.includes(status)))) { 1226 return "PASS" 1227 } else if ((status === undefined) || (status === 'O')) { 1228 return "FAIL"; 1229 } else if (status in STATUS_ABBREVIATIONS) { 1230 return STATUS_ABBREVIATIONS[status]; 1231 } else { 1232 return "FAIL"; 1233 } 1234 } 1235 1236 // Only display the the numbers without percentages. 1237 return `${this.getTestNumbersDisplay(passes, total, isDir)}`; 1238 } 1239 1240 // Formats the numbers that will be shown in each cell on the results page. 1241 formatCellDisplay(passes, total, status=undefined, isDir=true) { 1242 // Display 'Missing' text if there are no tests or subtests. 1243 if (total === 0 && !status) { 1244 return 'Missing'; 1245 } 1246 1247 // If the view is not the default view (subtest), then check for the 'interop' view. 1248 // If view is 'interop', use that format instead. 1249 if (this.isInteropView()) { 1250 return this.formatCellDisplayInterop(passes, total, isDir); 1251 } 1252 1253 // If the view is not the default view (subtest), then check for the 'test' view. 1254 // If view is 'test', use that format instead. 1255 if (this.isTestView()) { 1256 return this.formatCellDisplayTestView(passes, total, status, isDir); 1257 } 1258 1259 // If we're in the subtest view and there are no subtests but a status exists, 1260 // we should count the status as the test total. 1261 if (total === 0) { 1262 if (status === 'P') return `${passes + 1} / ${total + 1}`; 1263 return `${passes} / ${total + 1}`; 1264 } 1265 return `${passes} / ${total}`; 1266 } 1267 1268 isSubtestView(node) { 1269 return this.isDefaultView() || !node.isDir; 1270 } 1271 1272 getNodeTotalProp(node) { 1273 if (this.isTestView()) { 1274 return 'test_view_total'; 1275 } 1276 // Display test numbers at directory level, but subtest numbers when showing a single test. 1277 return this.isSubtestView(node) ? 'subtest_total': 'total'; 1278 } 1279 1280 getNodePassProp(node) { 1281 if (this.isTestView()) { 1282 return 'test_view_passes'; 1283 } 1284 // Display test numbers at directory level, but subtest numbers when showing a single test. 1285 return this.isSubtestView(node) ? 'subtest_passes': 'passes'; 1286 } 1287 1288 getNodeResult(node, index) { 1289 const status = node.results[index].status; 1290 const passesProp = this.getNodePassProp(node); 1291 const totalProp = this.getNodeTotalProp(node); 1292 // Calculate what should be displayed in a given results row. 1293 let passes = node.results[index][passesProp]; 1294 let total = node.results[index][totalProp]; 1295 return this.formatCellDisplay(passes, total, status, node.isDir); 1296 } 1297 1298 // Format and display the information shown in the totals cells. 1299 getTotalDisplay(totalInfo) { 1300 let passes = totalInfo.subtest_passes; 1301 let total = totalInfo.subtest_total; 1302 if (this.isInteropView()) { 1303 passes = totalInfo.passes; 1304 total = totalInfo.total; 1305 } 1306 if (this.isTestView()) { 1307 passes = totalInfo.test_view_passes; 1308 total = totalInfo.test_view_total; 1309 } 1310 return this.formatCellDisplay(passes, total); 1311 } 1312 1313 getTotalText() { 1314 if (this.isDefaultView()) { 1315 return 'Subtest Total'; 1316 } 1317 return 'Test Total'; 1318 } 1319 1320 /* Function for getting total numbers. 1321 * Intentionally not exposed in UI. 1322 * To generate, open your console and run: 1323 * document.querySelector('wpt-results').generateTotalPassNumbers() 1324 */ 1325 generateTotalPassNumbers() { 1326 const totals = {}; 1327 1328 this.testRuns.forEach(testRun => { 1329 const testRunID = this.platformID(testRun); 1330 totals[testRunID] = { passes: 0, total: 0 }; 1331 1332 Object.keys(this.specDirs).forEach(specKey => { 1333 let { passes, total } = this.specDirs[specKey].results[testRun.results_url]; 1334 1335 totals[testRunID].passes += passes; 1336 totals[testRunID].total += total; 1337 }); 1338 }); 1339 1340 Object.keys(totals).forEach(key => { 1341 totals[key].percent = (totals[key].passes / totals[key].total) * 100; 1342 }); 1343 1344 // eslint-disable-next-line no-console 1345 console.table(Object.keys(totals).map(k => ({ 1346 platformID: k, 1347 passes: totals[k].passes, 1348 total: totals[k].total, 1349 percent: totals[k].percent 1350 }))); 1351 1352 // eslint-disable-next-line no-console 1353 console.log('JSON version:', JSON.stringify(totals)); 1354 } 1355 1356 showHistoryClicked() { 1357 return () => { 1358 this.showHistory = true; 1359 }; 1360 } 1361 1362 queryChanged(query, queryBefore) { 1363 super.queryChanged(query, queryBefore); 1364 // TODO (danielrsmith): fix the query logic so that this statement isn't needed 1365 // to avoid duplicate calls. Hacky fix here that will not reload the data if 1366 // 'view' is the only query string param. 1367 if (query.includes('view') && query.split('=').length === 2) { 1368 return; 1369 } 1370 1371 if (this._fetchedQuery === query) { 1372 return; 1373 } 1374 this._fetchedQuery = query; // Debounce. 1375 this.reloadData(); 1376 } 1377 1378 moveToNext() { 1379 this._move(true); 1380 } 1381 1382 moveToPrev() { 1383 this._move(false); 1384 } 1385 1386 _move(forward) { 1387 if (!this.searchResults || !this.searchResults.length) { 1388 return; 1389 } 1390 const n = this.searchResults.length; 1391 let next = this.searchResults.findIndex(r => r.test.startsWith(this.path)); 1392 if (next < 0) { 1393 next = (forward ? 0 : -1); 1394 } else if (this.searchResults[next].test === this.path) { // Only advance 1 for exact match. 1395 next = next + (forward ? 1 : -1); 1396 } 1397 // % in js is not modulo, it's remainder. Ensure it's positive. 1398 this.path = this.searchResults[(n + next) % n].test; 1399 } 1400 1401 sortTestName() { 1402 if (!this.displayedNodes) { 1403 return; 1404 } 1405 1406 this.isPathSorted = !this.isPathSorted; 1407 this.sortCol = new Array(this.testRuns.length).fill(false); 1408 const sortedNodes = this.displayedNodes.slice(); 1409 sortedNodes.sort((a, b) => { 1410 if (this.isPathSorted) { 1411 return this.compareTestNameDefaultOrder(a, b); 1412 } 1413 return this.compareTestNameDefaultOrder(b, a); 1414 }); 1415 this.displayedNodes = sortedNodes; 1416 } 1417 1418 compareTestName(a, b) { 1419 if (this.isPathSorted) { 1420 return this.compareTestNameDefaultOrder(a, b); 1421 } 1422 return this.compareTestNameDefaultOrder(b, a); 1423 } 1424 1425 compareTestNameDefaultOrder(a, b) { 1426 const pathA = a.path.toLowerCase(); 1427 const pathB = b.path.toLowerCase(); 1428 if (pathA < pathB) { 1429 return -1; 1430 } 1431 1432 if (pathA > pathB) { 1433 return 1; 1434 } 1435 return 0; 1436 } 1437 1438 sortTestResults(index) { 1439 return () => { 1440 if (!this.displayedNodes) { 1441 return; 1442 } 1443 1444 const sortedNodes = this.displayedNodes.slice(); 1445 sortedNodes.sort((a, b) => { 1446 if (this.sortCol[index]) { 1447 // Switch a and b to reverse the order; 1448 const c = a; 1449 a = b; 1450 b = c; 1451 } 1452 // Use numbers based on view. 1453 let passesParam = 'passes'; 1454 let totalParam = 'total'; 1455 if (this.isDefaultView()) { 1456 passesParam = 'subtest_passes'; 1457 totalParam = 'subtest_total'; 1458 } else if (this.isTestView()) { 1459 passesParam = 'test_view_passes'; 1460 totalParam = 'test_view_total'; 1461 } 1462 1463 // Both 0/0 cases; compare test names. 1464 if (a.results[index][totalParam] === 0 && b.results[index][totalParam] === 0) { 1465 return this.compareTestNameDefaultOrder(a, b); 1466 } 1467 1468 // One of them is 0/0; compare passes; 1469 if (a.results[index][totalParam] === 0 || b.results[index][totalParam] === 0) { 1470 return a.results[index][totalParam] - b.results[index][totalParam]; 1471 } 1472 const percentageA = a.results[index][passesParam] / a.results[index][totalParam]; 1473 const percentageB = b.results[index][passesParam] / b.results[index][totalParam]; 1474 if (percentageA === percentageB) { 1475 return this.compareTestNameDefaultOrder(a, b); 1476 } 1477 return percentageA - percentageB; 1478 }); 1479 1480 const newSortCol = new Array(this.sortCol.length).fill(false); 1481 newSortCol[index] = !this.sortCol[index]; 1482 this.sortCol = newSortCol; 1483 this.isPathSorted = false; 1484 this.displayedNodes = sortedNodes; 1485 }; 1486 } 1487 1488 getSortIcon(isSorted) { 1489 if (isSorted) { 1490 return '/static/expand_more.svg'; 1491 } 1492 return '/static/expand_less.svg'; 1493 } 1494 1495 handleTriageMode(isTriageMode) { 1496 if (isTriageMode && this.pathIsATestFile) { 1497 return; 1498 } 1499 this.handleTriageModeChange(isTriageMode, this.$['selected-toast']); 1500 } 1501 1502 clearSelectedCells(selectedMetadata) { 1503 this.handleClear(selectedMetadata); 1504 } 1505 1506 handleTriageHover() { 1507 const [index, node, testRun] = arguments; 1508 return (e) => { 1509 this.handleHover(e.target.closest('td'), this.canAmendMetadata(node, index, testRun)); 1510 }; 1511 } 1512 1513 handleTriageSelect() { 1514 const [index, node, testRun] = arguments; 1515 return (e) => { 1516 if (!this.canAmendMetadata(node, index, testRun)) { 1517 return; 1518 } 1519 1520 const product = index === undefined ? '' : this.displayedProducts[index].browser_name; 1521 this.handleSelect(e.target.closest('td'), product, node.path, this.$['selected-toast']); 1522 }; 1523 } 1524 1525 handleReloadPendingMetadata() { 1526 this.triageNotifier = !this.triageNotifier; 1527 } 1528 1529 openAmendMetadata() { 1530 this.$.amend.open(); 1531 } 1532 1533 shouldDisplayTestLabel(testname, labelMap) { 1534 return this.displayMetadata && this.getTestLabel(testname, labelMap) !== ''; 1535 } 1536 1537 shouldDisplayTotals(displayedTotals, diffRun) { 1538 return !diffRun && displayedTotals && displayedTotals.length > 0; 1539 } 1540 1541 getTestLabelTitle(testname, labelMap) { 1542 const labels = this.getTestLabel(testname, labelMap); 1543 if (labels.includes(',')) { 1544 return 'labels: ' + labels; 1545 } 1546 return 'label: ' + labels; 1547 } 1548 1549 getTestLabel(testname, labelMap) { 1550 if (!labelMap) { 1551 return ''; 1552 } 1553 1554 if (this.computePathIsASubfolder(testname)) { 1555 testname = testname + '/*'; 1556 } 1557 1558 if (testname in labelMap) { 1559 return labelMap[testname]; 1560 } 1561 1562 return ''; 1563 } 1564 1565 shouldDisplayMetadata(index, testname, metadataMap) { 1566 return !this.pathIsRootDir && this.displayMetadata && this.getMetadataUrl(index, testname, metadataMap) !== ''; 1567 } 1568 1569 getMetadataUrl(index, testname, metadataMap) { 1570 if (!metadataMap) { 1571 return ''; 1572 } 1573 1574 if (this.computePathIsASubfolder(testname)) { 1575 testname = testname + '/*'; 1576 } 1577 1578 const browserName = index === undefined ? '' : this.displayedProducts[index].browser_name; 1579 const key = testname + browserName; 1580 if (key in metadataMap) { 1581 if ('/' in metadataMap[key]) { 1582 return metadataMap[key]['/']; 1583 } 1584 1585 // If a URL link does not exist on a test level, return the first subtest link. 1586 const subtestMap = metadataMap[key]; 1587 return subtestMap[Object.keys(subtestMap)[0]]; 1588 } 1589 return ''; 1590 } 1591 } 1592 1593 window.customElements.define(WPTResults.is, WPTResults); 1594 1595 export { WPTResults };