PageSourceSearch

https://wpt.fyi/views/wpt-results.js

js wpt.fyi collected 2026-09-24 08:45:54 UTC 52,175 bytes, 1,600 lines download raw bytes

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

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.