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