github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/webapp/components/test-file-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 '../node_modules/@polymer/paper-toggle-button/paper-toggle-button.js';
     8  import '../node_modules/@polymer/polymer/lib/elements/dom-if.js';
     9  import { html, PolymerElement } from '../node_modules/@polymer/polymer/polymer-element.js';
    10  import { LoadingState } from './loading-state.js';
    11  import './test-file-results-table.js';
    12  import { TestRunsUIQuery } from './test-runs-query.js';
    13  import { TestRunsQueryLoader } from './test-runs.js';
    14  import './wpt-colors.js';
    15  import { timeTaken } from './utils.js';
    16  import { WPTFlags } from './wpt-flags.js';
    17  import { PathInfo } from './path.js';
    18  
    19  class TestFileResults extends WPTFlags(LoadingState(PathInfo(
    20    TestRunsQueryLoader(TestRunsUIQuery(PolymerElement))))) {
    21    static get template() {
    22      return html`
    23      <style include="wpt-colors">
    24        :host {
    25          display: block;
    26          font-size: 16px;
    27        }
    28        h1 {
    29          font-size: 1.5em;
    30        }
    31        .right {
    32          display: flex;
    33          justify-content: flex-end;
    34        }
    35        .right paper-toggle-button {
    36          padding: 8px;
    37        }
    38        paper-toggle-button {
    39          --paper-toggle-button-checked-bar-color:  var(--paper-blue-500);
    40          --paper-toggle-button-checked-button-color:  var(--paper-blue-700);
    41          --paper-toggle-button-checked-ink-color: var(--paper-blue-300);
    42        }
    43      </style>
    44  
    45      <div class="right">
    46        <paper-toggle-button checked="{{isVerbose}}">
    47          Show Details
    48        </paper-toggle-button>
    49      </div>
    50  
    51      <test-file-results-table test-runs="[[testRuns]]"
    52                               diff-run="[[diffRun]]"
    53                               only-show-differences="{{onlyShowDifferences}}"
    54                               path="[[path]]"
    55                               rows="[[rows]]"
    56                               verbose="[[isVerbose]]"
    57                               is-triage-mode="[[isTriageMode]]"
    58                               metadata-map="[[metadataMap]]">
    59      </test-file-results-table>
    60  `;
    61    }
    62  
    63    static get is() {
    64      return 'test-file-results';
    65    }
    66  
    67    static get properties() {
    68      return {
    69        diffRun: Object,
    70        onlyShowDifferences: {
    71          type: Boolean,
    72          value: false,
    73        },
    74        structuredSearch: Object,
    75        resultsTable: {
    76          type: Array,
    77          value: [],
    78        },
    79        isVerbose: {
    80          type: Boolean,
    81          value: false,
    82        },
    83        rows: {
    84          type: Array,
    85          computed: 'computeRows(resultsTable, onlyShowDifferences)',
    86        },
    87        subtestRowCount: {
    88          type: Number,
    89          value: 0,
    90          notify: true
    91        },
    92        isTriageMode: Boolean,
    93        metadataMap: Object,
    94      };
    95    }
    96  
    97    async connectedCallback() {
    98      await super.connectedCallback();
    99      console.assert(this.path);
   100      console.assert(this.path[0] === '/');
   101    }
   102  
   103    static get observers() {
   104      return ['loadData(path, testRuns, structuredSearch)'];
   105    }
   106  
   107    async loadData(path, testRuns, structuredSearch) {
   108      // Run a search query, including subtests, as well as fetching the results file.
   109      let [searchResults, resultsTable] = await Promise.all([
   110        this.fetchSearchResults(path, testRuns, structuredSearch),
   111        this.fetchTestFile(path, testRuns),
   112      ]);
   113  
   114      this.resultsTable = this.filterResultsTableBySearch(path, resultsTable, searchResults);
   115    }
   116  
   117    async fetchSearchResults(path, testRuns, structuredSearch) {
   118      if (!testRuns || !testRuns.length || !this.structuredQueries || !structuredSearch) {
   119        return;
   120      }
   121  
   122      // Combine the query with " and [path]".
   123      const q = {
   124        and: [
   125          {pattern: path},
   126          structuredSearch,
   127        ]
   128      };
   129  
   130      const url = new URL('/api/search', window.location);
   131      url.searchParams.set('subtests', '');
   132      if (this.diffRun) {
   133        url.searchParams.set('diff', true);
   134      }
   135      const fetchOpts = {
   136        method: 'POST',
   137        body: JSON.stringify({
   138          run_ids: testRuns.map(r => r.id),
   139          query: q,
   140        }),
   141      };
   142      return await this.retry(
   143        async() => {
   144          const r = await window.fetch(url, fetchOpts);
   145          if (!r.ok) {
   146            if (fetchOpts.method === 'POST' && r.status === 422) {
   147              throw r.status;
   148            }
   149            throw 'Failed to fetch results data.';
   150          }
   151          return r.json();
   152        },
   153        err => err === 422,
   154        testRuns.length + 1,
   155        5000
   156      );
   157    }
   158  
   159    async fetchTestFile(path, testRuns) {
   160      this.resultsTable = []; // Clear any existing rows.
   161      if (!path || !testRuns) {
   162        return;
   163      }
   164      const resultsPerTestRun = await Promise.all(
   165        testRuns.map(tr => this.loadResultFile(tr)));
   166  
   167      // Special setup for the first two rows (status + duration).
   168      const resultsTable = this.resultsTableHeaders(resultsPerTestRun);
   169  
   170      // Setup test name order according to when they appear in run results.
   171      let allNames = [];
   172      for (const runResults of resultsPerTestRun) {
   173        if (runResults && runResults.subtests) {
   174          this.mergeNamesInto(runResults.subtests.map(s => s.name), allNames);
   175        }
   176      }
   177  
   178      // Copy results into resultsTable.
   179      for (const name of allNames) {
   180        let results = [];
   181        for (const runResults of resultsPerTestRun) {
   182          const result = runResults && runResults.subtests &&
   183            runResults.subtests.find(sub => sub.name === name);
   184          results.push(result ? {
   185            status: result.status,
   186            message: result.message,
   187          } : {status: null, message: null});
   188        }
   189        resultsTable.push({
   190          name,
   191          results,
   192        });
   193      }
   194  
   195      // Set name for test-level status entry after subtests discovered.
   196      // Parameter is number of subtests.
   197      resultsTable[0].name = this.statusName(resultsTable.length - 2);
   198      return resultsTable;
   199    }
   200  
   201    async loadResultFile(testRun) {
   202      const url = this.resultsURL(testRun, this.path);
   203      const response = await window.fetch(url);
   204      if (!response.ok) {
   205        return null;
   206      }
   207      return response.json();
   208    }
   209  
   210    resultsTableHeaders(resultsPerTestRun) {
   211      return [
   212        {
   213          // resultsTable[0].name will be set later depending on the number of subtests.
   214          name: '',
   215          results: resultsPerTestRun.map(data => {
   216            const result = {
   217              status: data && data.status,
   218              message: data && data.message,
   219            };
   220            if (data && data.screenshots) {
   221              result.screenshots = this.shuffleScreenshots(this.path, data.screenshots);
   222            }
   223            return result;
   224          })
   225        },
   226        {
   227          name: 'Duration',
   228          results: resultsPerTestRun.map(data => ({status: data && timeTaken(data.duration), message: null}))
   229        }
   230      ];
   231    }
   232  
   233    filterResultsTableBySearch(path, resultsTable, searchResults) {
   234      if (!resultsTable || !searchResults) {
   235        return resultsTable;
   236      }
   237      const test = searchResults.results.find(r => r.test === path);
   238      if (!test) {
   239        return resultsTable;
   240      }
   241      const subtests = new Set(test.subtests);
   242      const [status, duration, ...others] = resultsTable;
   243      const matches = others.filter(t => subtests.has(t.name));
   244      return [status, duration, ...matches];
   245    }
   246  
   247    mergeNamesInto(names, allNames) {
   248      if (!allNames.length) {
   249        allNames.splice(0, 0, ...names);
   250        return;
   251      }
   252      let lastOffset = 0;
   253      let lastMatch = 0;
   254      names.forEach((name, i) => {
   255        // Optimization for "next item matches too".
   256        let offset;
   257        if (i === lastMatch + 1 && allNames[lastOffset + 1] === name) {
   258          offset = lastOffset + 1;
   259        } else {
   260          offset = allNames.findIndex(n => n === name);
   261        }
   262        if (offset >= 0) {
   263          lastOffset = offset;
   264          lastMatch = i;
   265        } else {
   266          allNames.splice(lastOffset + i - lastMatch, 0, name);
   267        }
   268      });
   269    }
   270  
   271    // Slice summary file URL to infer the URL path to get single test data.
   272    resultsURL(testRun, path) {
   273      path = this.encodeTestPath(path);
   274      // This is relying on the assumption that result
   275      // files end with '-summary.json.gz' or '-summary_v2.json.gz'.
   276      let resultsSuffix = '-summary.json.gz';
   277      if (!testRun.results_url.includes(resultsSuffix)) {
   278        resultsSuffix = '-summary_v2.json.gz';
   279      }
   280      const resultsBase = testRun.results_url.slice(0, testRun.results_url.lastIndexOf(resultsSuffix));
   281      return `${resultsBase}${path}`;
   282    }
   283  
   284    statusName(numSubtests) {
   285      return numSubtests > 0 ? 'Harness status' : 'Test status';
   286    }
   287  
   288    shuffleScreenshots(path, rawScreenshots) {
   289      // Clone the data because we might modify it.
   290      const screenshots = Object.assign({}, rawScreenshots);
   291      // Make sure the test itself appears first in the Map to follow the
   292      // convention of reftest-analyzer (actual, expected).
   293      const firstScreenshot = [];
   294      if (path in screenshots) {
   295        firstScreenshot.push([path, screenshots[path]]);
   296        delete screenshots[path];
   297      }
   298      return new Map([...firstScreenshot, ...Object.entries(screenshots)]);
   299    }
   300  
   301    computeRows(resultsTable, onlyShowDifferences) {
   302      let rows = resultsTable;
   303      if (resultsTable && resultsTable.length && onlyShowDifferences) {
   304        const [first, ...others] = resultsTable;
   305        rows = [first, ...others.filter(r => {
   306          return r.results[0].status !== r.results[1].status;
   307        })];
   308      }
   309  
   310      // If displaying subtests of a single test, the first two rows will
   311      // reflect TestHarness status and duration, so we don't count them
   312      // when displaying the number of subtests in the blue banner.
   313      if (rows.length > 2 && rows[1].name === 'Duration') {
   314        this.subtestRowCount = rows.length - 2;
   315      } else {
   316        this.subtestRowCount = 0;
   317      }
   318  
   319      this._fireEvent('subtestrows', { rows });
   320      return rows;
   321    }
   322  
   323    _fireEvent(eventName, detail) {
   324      const event = new CustomEvent(eventName, {
   325        bubbles: true,
   326        composed: true,
   327        detail,
   328      });
   329      this.dispatchEvent(event);
   330    }
   331  }
   332  
   333  window.customElements.define(TestFileResults.is, TestFileResults);
   334  
   335  export { TestFileResults };