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