github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/webapp/components/test-file-results-table.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 '../node_modules/@polymer/polymer/lib/elements/dom-if.js';
     8  import '../node_modules/@polymer/polymer/lib/elements/dom-repeat.js';
     9  import '../node_modules/@polymer/iron-icon/iron-icon.js';
    10  import '../node_modules/@polymer/iron-icons/image-icons.js';
    11  import '../node_modules/@polymer/paper-button/paper-button.js';
    12  import '../node_modules/@polymer/paper-toast/paper-toast.js';
    13  import { html } from '../node_modules/@polymer/polymer/polymer-element.js';
    14  import { TestRunsBase } from './test-runs.js';
    15  import { WPTColors } from './wpt-colors.js';
    16  import { PathInfo } from './path.js';
    17  import { Pluralizer } from './pluralize.js';
    18  import { WPTFlags } from './wpt-flags.js';
    19  import { AmendMetadataMixin } from './wpt-amend-metadata.js';
    20  import { productFromRun } from './product-info.js';
    21  
    22  class TestFileResultsTable extends WPTFlags(Pluralizer(AmendMetadataMixin(WPTColors(PathInfo(TestRunsBase))))) {
    23    static get is() {
    24      return 'test-file-results-table';
    25    }
    26  
    27    static get template() {
    28      return html`
    29  <style include="wpt-colors">
    30    table {
    31      width: 100%;
    32      border-collapse: collapse;
    33    }
    34    th {
    35      background: white;
    36      position: sticky;
    37      top: 0;
    38      z-index: 1;
    39    }
    40    td {
    41      padding: 0.25em;
    42      height: 1.5em;
    43    }
    44    td.diff {
    45      border-left: 8px solid white;
    46    }
    47    td code {
    48      color: black;
    49      line-height: 1.6em;
    50      white-space: pre-wrap;
    51      word-break: break-all;
    52    }
    53    td.sub-test-name, .ref-button {
    54      font-family: monospace;
    55    }
    56    td.result {
    57      background-color: #eee;
    58    }
    59    td[selected] {
    60      border: 2px solid #000000;
    61    }
    62    td[triage] {
    63      cursor: pointer;
    64    }
    65    td[triage]:hover {
    66      opacity: 0.7;
    67      box-shadow: 5px 5px 5px;
    68    }
    69    .ref-button {
    70      color: #333;
    71      text-decoration: none;
    72      display: block;
    73      float: right;
    74    }
    75    table[verbose] .ref-button {
    76      display: none;
    77    }
    78    tbody tr:nth-child(2){
    79      border-bottom: 8px solid white;
    80      padding: 8px;
    81    }
    82    table td img {
    83      width: 100%;
    84    }
    85    table[terse] td {
    86      position: relative;
    87    }
    88    table[terse] td.sub-test-name {
    89      font-family: monospace;
    90      background-color: white;
    91    }
    92    table[terse] td.sub-test-name code {
    93      box-sizing: border-box;
    94      height: 100%;
    95      left: 0;
    96      overflow: hidden;
    97      position: absolute;
    98      text-overflow: ellipsis;
    99      top: 0;
   100      white-space: nowrap;
   101      width: 100%;
   102    }
   103    table[terse] td.sub-test-name code:hover {
   104      z-index: 1;
   105      text-overflow: initial;
   106      background-color: inherit;
   107      width: -moz-max-content;
   108      width: max-content;
   109    }
   110    .totals-row {
   111      border-top: 8px solid white;
   112      padding: 8px;
   113    }
   114    .view-triage {
   115      margin-left: 30px;
   116    }
   117  </style>
   118  
   119  <paper-toast id="selected-toast" duration="0">
   120    <span>[[triageToastMsg(selectedMetadata.length)]]</span>
   121    <paper-button class="view-triage" on-click="openAmendMetadata" raised="[[hasSelections]]" disabled="[[!hasSelections]]">TRIAGE</paper-button>
   122  </paper-toast>
   123  
   124  <table terse$="[[!verbose]]" verbose$="[[verbose]]">
   125    <thead>
   126      <tr>
   127        <th width="[[computeSubtestThWidth(testRuns, diffRun)]]">Subtest</th>
   128        <template is="dom-repeat" items="[[testRuns]]" as="testRun">
   129          <th width="[[computeRunThWidth(testRuns, diffRun)]]">
   130            <test-run test-run="[[testRun]]"></test-run>
   131          </th>
   132        </template>
   133        <template is="dom-if" if="[[diffRun]]">
   134          <th>
   135            <test-run test-run="[[diffRun]]"></test-run>
   136            <paper-icon-button icon="filter-list" onclick="[[toggleDiffFilter]]" title="Toggle filtering to only show differences"></paper-icon-button>
   137          </th>
   138        </template>
   139      </tr>
   140    </thead>
   141    <tbody>
   142      <template is="dom-repeat" items="[[rows]]" as="row">
   143        <tr>
   144          <td class="sub-test-name"><code>[[ row.name ]]</code></td>
   145  
   146          <template is="dom-repeat" items="[[row.results]]" as="result">
   147            <td class$="[[ colorClass(result.status) ]]" onclick="[[handleTriageSelect(index, row.name, result.status)]]" onmouseover="[[handleTriageHover(result.status)]]">
   148              <code>[[ subtestMessage(result, verbose) ]]</code>
   149  
   150              <template is="dom-if" if="[[shouldDisplayMetadata(index, row.name, metadataMap, result.status, isTriageMode)]]">
   151                <a href="[[ getMetadataUrlForSubtest(index, row.name, metadataMap) ]]" target="_blank"><iron-icon class="bug" icon="bug-report"></iron-icon></a>
   152              </template>
   153  
   154              <template is="dom-if" if="[[result.screenshots]]">
   155                <a class="ref-button" href="[[ computeAnalyzerURL(result.screenshots) ]]">
   156                  <iron-icon icon="image:compare"></iron-icon>
   157                  COMPARE
   158                </a>
   159              </template>
   160            </td>
   161          </template>
   162  
   163          <template is="dom-if" if="[[diffRun]]">
   164            <td class$="diff [[ diffClass(row.results) ]]">
   165              [[ diffDisplay(row.results) ]]
   166            </td>
   167          </template>
   168        </tr>
   169      </template>
   170      <template is="dom-if" if="[[shouldShowTotals(totals)]]">
   171        <tr class="totals-row">
   172          <td class="sub-test-name"><code><strong>Subtest Total</strong></code></td>
   173          <template is="dom-repeat" items="[[totals]]" as="columnTotal">
   174            <td class$="[[ totalsColorClass(columnTotal.passes, columnTotal.total) ]]">
   175              <code>[[ columnTotal.passes ]]/[[ columnTotal.total ]]</code>
   176            </td>
   177          </template>
   178        </tr>
   179      </template>
   180      <template is="dom-if" if="[[verbose]]">
   181        <template is="dom-if" if="[[anyScreenshots(firstRow)]]">
   182          <tr>
   183            <td class="sub-test-name"><code>Screenshot</code></td>
   184            <template is="dom-repeat" items="[[firstRow.results]]" as="result">
   185              <td>
   186                <template is="dom-if" if="[[ testScreenshot(result.screenshots) ]]">
   187                  <a href="[[ computeAnalyzerURL(result.screenshots) ]]">
   188                    <img src="[[ testScreenshot(result.screenshots) ]]" />
   189                  </a>
   190                </template>
   191              </td>
   192            </template>
   193          </tr>
   194        </template>
   195      </template>
   196    </tbody>
   197  </table>
   198  <wpt-amend-metadata id="amend" selected-metadata="{{selectedMetadata}}" path="[[path]]"></wpt-amend-metadata>
   199  `;
   200    }
   201  
   202    static get properties() {
   203      return {
   204        diffRun: {
   205          type: Object,
   206          value: null,
   207        },
   208        onlyShowDifferences: {
   209          type: Boolean,
   210          value: false,
   211          notify: true,
   212        },
   213        statusesAsMessage: {
   214          type: Array,
   215          value: ['OK', 'PASS', 'TIMEOUT'],
   216        },
   217        rows: {
   218          type: Array,
   219          value: [],
   220        },
   221        firstRow: {
   222          type: Object,
   223          computed: 'computeFirstRow(rows)',
   224        },
   225        verbose: {
   226          type: Boolean,
   227          value: false,
   228        },
   229        displayedProducts: {
   230          type: Array,
   231          computed: 'computeDisplayedProducts(testRuns)',
   232        },
   233        totals: {
   234          type: Array,
   235          computed: 'computeTotals(rows)'
   236        },
   237        metadataMap: Object,
   238        matchers: {
   239          type: Array,
   240          value: [
   241            {
   242              re: /^assert_equals:.* expected ("(\\"|[^"])*"|[^ ]*) but got ("(\\"|[^"])*"|[^ ]*)$/,
   243              getMessage: match => `!EQ(${match[1]}, ${match[3]})`,
   244            },
   245            {
   246              re: /^assert_approx_equals:.* expected ("(\\"|[^"])*"| [+][/][-] |[^:]*) but got ("(\\"|[^"])*"| [+][/][-] |[^:]*):.*$/,
   247              getMessage: match => `!~EQ(${match[1]}, ${match[3]})`,
   248            },
   249            {
   250              re: /^assert ("(\\"|[^"])*"|[^ ]*) == ("(\\"|[^"])*"|[^ ]*)$/,
   251              getMessage: match => `!EQ(${match[1]}, ${match[3]})`,
   252            },
   253            {
   254              re: /^assert_array_equals:.*$/,
   255              getMessage: () => '!ARRAY_EQ(a, b)',
   256            },
   257            {
   258              re: /^Uncaught [^ ]*Error:.*$/,
   259              getMessage: () => 'UNCAUGHT_ERROR',
   260            },
   261            {
   262              re: /^([^ ]*) is not ([a-zA-Z0-9 ]*)$/,
   263              getMessage: match => `NOT_${match[2].toUpperCase().replace(/\s/g, '_')}(${match[1]})`,
   264            },
   265            {
   266              re: /^promise_test: Unhandled rejection with value: (.*)$/,
   267              getMessage: match => `PROMISE_REJECT(${match[1]})`,
   268            },
   269            {
   270              re: /^assert_true: .*$/,
   271              getMessage: () => '!TRUE',
   272            },
   273            {
   274              re: /^assert_own_property: [^"]*"([^"]*)".*$/,
   275              getMessage: match => `!OWN_PROPERTY(${match[1]})`,
   276            },
   277            {
   278              re: /^assert_inherits: [^"]*"([^"]*)".*$/,
   279              getMessage: match => `!INHERITS(${match[1]})`,
   280            },
   281          ],
   282        },
   283      };
   284    }
   285  
   286    static get observers() {
   287      return [
   288        'clearSelectedCells(selectedMetadata)',
   289        'handleTriageMode(isTriageMode)',
   290      ];
   291    }
   292  
   293    constructor() {
   294      super();
   295      this.toggleDiffFilter = () => {
   296        this.onlyShowDifferences = !this.onlyShowDifferences;
   297      };
   298    }
   299  
   300    computeDisplayedProducts(testRuns) {
   301      if (!testRuns) {
   302        return [];
   303      }
   304  
   305      return testRuns.map(productFromRun);
   306    }
   307  
   308    subtestMessage(result, verbose) {
   309      // Return status string for messageless status or "status-as-message".
   310      if ((result.status && !result.message) ||
   311        this.statusesAsMessage.includes(result.status)) {
   312        return result.status;
   313      } else if (!result.status) {
   314        return 'MISSING';
   315      }
   316      if (verbose) {
   317        return `${result.status} message: ${result.message}`;
   318      }
   319      // Terse table only: Display "ERROR" without message on harness error.
   320      if (result.status === 'ERROR') {
   321        return 'ERROR';
   322      }
   323      return this.parseFailureMessage(result);
   324    }
   325  
   326    computeAnalyzerURL(screenshots) {
   327      if (!screenshots) {
   328        throw 'empty screenshots';
   329      }
   330      const url = new URL('/analyzer', window.location);
   331      for (const sha of screenshots.values()) {
   332        url.searchParams.append('screenshot', sha);
   333      }
   334      return url.href;
   335    }
   336  
   337    computeSubtestThWidth(testRuns, diffRun) {
   338      const runs = testRuns && testRuns.length || 0;
   339      const plusOne = diffRun && 1 || 0;
   340      return `${200 / (runs + 2 + plusOne)}%`;
   341    }
   342  
   343    computeRunThWidth(testRuns, diffRun) {
   344      const runs = testRuns && testRuns.length || 0;
   345      const plusOne = diffRun && 1 || 0;
   346      return `${100 / (runs + 2 + plusOne)}%`;
   347    }
   348  
   349    computeFirstRow(rows) {
   350      return rows && rows.length && rows[0];
   351    }
   352  
   353    computeTotals(rows) {
   354      // The first two rows display TestHarness status and duration,
   355      // so we don't need to count them. If only these rows exist,
   356      // there is no need to show totals.
   357      if (rows.length <= 2) {
   358        return [];
   359      }
   360  
   361      // Keep a total for each browser.
   362      const totals = new Array(rows[0].results.length);
   363      for (let i = 0; i < totals.length; i++) {
   364        totals[i] = {passes: 0, total: 0};
   365      }
   366  
   367      // Tally the number of passes and total tests.
   368      for (let i = 2; i < rows.length; i++) {
   369        rows[i].results.forEach((result, index) => {
   370          if (result.status === 'PASS') {
   371            totals[index].passes++;
   372          }
   373          // If the test status is missing, it's not counted toward the total.
   374          if (result.status) {
   375            totals[index].total++;
   376          }
   377        });
   378      }
   379      return totals;
   380    }
   381  
   382    colorClass(status) {
   383      if (['PASS'].includes(status)) {
   384        return this.passRateClass(1, 1);
   385      } else if (['FAIL', 'ERROR', 'TIMEOUT', 'NOTRUN', 'CRASH'].includes(status)) {
   386        return this.passRateClass(0, 1);
   387      }
   388      return 'result';
   389    }
   390  
   391    totalsColorClass(passes, total) {
   392      // Gray cell color if no tests were run.
   393      if (total === 0) {
   394        return 'result';
   395      }
   396      // If tests were run, choose a color based on the % of tests passed.
   397      return this.passRateClass(passes, total);
   398    }
   399  
   400    parseFailureMessage(result) {
   401      const msg = result.message;
   402      let matchedMsg = '';
   403      for (const matcher of this.matchers) {
   404        const match = msg.match(matcher.re);
   405        if (match !== null) {
   406          matchedMsg = matcher.getMessage(match);
   407          break;
   408        }
   409      }
   410      return matchedMsg ? matchedMsg : result.status;
   411    }
   412  
   413    anyScreenshots(row) {
   414      return row && row.results && row.results.find(r => r.screenshots);
   415    }
   416  
   417    testScreenshot(screenshots) {
   418      if (!screenshots) {
   419        return;
   420      }
   421      let shot;
   422      if (screenshots.has(this.path)) {
   423        shot = screenshots.get(this.path);
   424      } else {
   425        shot = screenshots.values()[0];
   426      }
   427      return `/api/screenshot/${shot}`;
   428    }
   429  
   430    diffDisplay(results) {
   431      if (results[0].status !== results[1].status) {
   432        const passed = results.map(r => ['OK', 'PASS'].includes(r.status));
   433        if (passed[0] && !passed[1]) {
   434          return '-1';
   435        } else if (passed[1] && !passed[0]) {
   436          return '+1';
   437        }
   438        return '0';
   439      }
   440    }
   441  
   442    diffClass(results) {
   443      const passed = results.map(r => ['OK', 'PASS'].includes(r.status));
   444      if (passed[0] && !passed[1]) {
   445        return this.passRateClass(0, 1);
   446      } else if (passed[1] && !passed[0]) {
   447        return this.passRateClass(1, 1);
   448      }
   449    }
   450  
   451    canAmendMetadata(status) {
   452      return this.hasFailed(status) && this.triageMetadataUI && this.isTriageMode;
   453    }
   454  
   455    hasFailed(status) {
   456      return ['FAIL', 'ERROR', 'TIMEOUT'].includes(status);
   457    }
   458  
   459    clearSelectedCells(selectedMetadata) {
   460      this.handleClear(selectedMetadata);
   461    }
   462  
   463    handleTriageMode(isTriageMode) {
   464      this.handleTriageModeChange(isTriageMode, this.$['selected-toast']);
   465    }
   466  
   467    handleTriageHover() {
   468      const [status] = arguments;
   469      return (e) => {
   470        this.handleHover(e.target.closest('td'), this.canAmendMetadata(status));
   471      };
   472    }
   473  
   474    handleTriageSelect() {
   475      const [index, test, status] = arguments;
   476      return (e) => {
   477        if (!this.canAmendMetadata(status)) {
   478          return;
   479        }
   480  
   481        this.handleSelect(e.target.closest('td'), this.displayedProducts[index].browser_name, test, this.$['selected-toast']);
   482      };
   483    }
   484  
   485    openAmendMetadata() {
   486      this.$.amend.open();
   487    }
   488  
   489    shouldDisplayMetadata(index, subtestname, metadataMap, status, isTriageMode) {
   490      if (!metadataMap) {
   491        return false;
   492      }
   493  
   494      // Show icons for passing subtests when triageMode is enabled.
   495      // See https://github.com/web-platform-tests/wpt.fyi/issues/2300
   496      if (!this.hasFailed(status) && !isTriageMode) {
   497        return false;
   498      }
   499  
   500      return this.displayMetadata && this.getMetadataUrlForSubtest(index, subtestname, metadataMap) !== '';
   501    }
   502  
   503    shouldShowTotals(totals) {
   504      return totals && totals.length > 0;
   505    }
   506  
   507    getMetadataUrlForSubtest(index, subtestname, metadataMap) {
   508      if (subtestname === 'Duration') {
   509        return '';
   510      }
   511  
   512      const key = this.path + this.displayedProducts[index].browser_name;
   513      if (key in metadataMap) {
   514        if (subtestname in metadataMap[key]) {
   515          return metadataMap[key][subtestname];
   516        }
   517  
   518        // If there is no subtest URL, falls back to the test-level URL.
   519        if ('/' in metadataMap[key]) {
   520          return metadataMap[key]['/'];
   521        }
   522      }
   523      return '';
   524    }
   525  }
   526  window.customElements.define(TestFileResultsTable.is, TestFileResultsTable);
   527  
   528  export { TestFileResultsTable };