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 };