go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/components/result_entry/result_entry.ts (about) 1 // Copyright 2020 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 import '@material/mwc-icon'; 16 import { MobxLitElement } from '@adobe/lit-mobx'; 17 import { GrpcError, RpcCode } from '@chopsui/prpc-client'; 18 import { css, html } from 'lit'; 19 import { customElement } from 'lit/decorators.js'; 20 import { unsafeHTML } from 'lit/directives/unsafe-html.js'; 21 import { computed, makeObservable, observable } from 'mobx'; 22 import { fromPromise, IPromiseBasedObservable } from 'mobx-utils'; 23 24 import '@/common/components/associated_bugs_badge'; 25 import '@/generic_libs/components/expandable_entry'; 26 import '@/common/components/tags_entry'; 27 import { makeClusterLink } from '@/analysis/tools/utils'; 28 import { ON_TEST_RESULT_DATA_READY } from '@/common/constants/event'; 29 import { TEST_STATUS_DISPLAY_MAP } from '@/common/constants/legacy'; 30 import { Cluster } from '@/common/services/luci_analysis'; 31 import { 32 Artifact, 33 ListArtifactsResponse, 34 TestResult, 35 } from '@/common/services/resultdb'; 36 import { consumeStore, StoreInstance } from '@/common/store'; 37 import { colorClasses, commonStyles } from '@/common/styles/stylesheets'; 38 import { logging } from '@/common/tools/logging'; 39 import { 40 displayCompactDuration, 41 displayDuration, 42 parseProtoDurationStr, 43 } from '@/common/tools/time_utils'; 44 import { 45 getInvURLPath, 46 getRawArtifactURLPath, 47 getSwarmingTaskURL, 48 } from '@/common/tools/url_utils'; 49 import { parseSwarmingTaskFromInvId } from '@/common/tools/utils'; 50 import { reportRenderError } from '@/generic_libs/tools/error_handler'; 51 import { consumer } from '@/generic_libs/tools/lit_context'; 52 import { unwrapObservable } from '@/generic_libs/tools/mobx_utils'; 53 import { unwrapOrElse } from '@/generic_libs/tools/utils'; 54 import { parseTestResultName } from '@/test_verdict/tools/utils'; 55 56 import './image_diff_artifact'; 57 import './link_artifact'; 58 import '../text_artifact'; 59 import './text_diff_artifact'; 60 import { TextArtifactEvent } from '../text_artifact'; 61 62 /** 63 * Renders an expandable entry of the given test result. 64 */ 65 @customElement('milo-result-entry') 66 @consumer 67 export class ResultEntryElement extends MobxLitElement { 68 @observable.ref 69 @consumeStore() 70 store!: StoreInstance; 71 72 @observable.ref id = ''; 73 @observable.ref testResult!: TestResult; 74 75 @observable.ref project = ''; 76 @observable.ref clusters: readonly Cluster[] = []; 77 78 @observable.ref private _expanded = false; 79 @computed get expanded() { 80 return this._expanded; 81 } 82 set expanded(newVal: boolean) { 83 this._expanded = newVal; 84 // Always render the content once it was expanded so the descendants' states 85 // don't get reset after the node is collapsed. 86 this.shouldRenderContent = this.shouldRenderContent || newVal; 87 } 88 89 @observable.ref private shouldRenderContent = false; 90 91 @computed 92 private get duration() { 93 const durationStr = this.testResult.duration; 94 if (!durationStr) { 95 return null; 96 } 97 return parseProtoDurationStr(durationStr); 98 } 99 100 @computed 101 private get parentInvId() { 102 return parseTestResultName(this.testResult.name).invocationId; 103 } 104 105 @computed 106 private get resultArtifacts$(): IPromiseBasedObservable<ListArtifactsResponse> { 107 const resultdb = this.store.services.resultDb; 108 if (!resultdb) { 109 // Returns a promise that never resolves when resultDb isn't ready. 110 return fromPromise(Promise.race([])); 111 } 112 // TODO(weiweilin): handle pagination. 113 return fromPromise( 114 resultdb.listArtifacts({ parent: this.testResult.name }), 115 ); 116 } 117 118 @computed private get resultArtifacts() { 119 return unwrapOrElse( 120 () => unwrapObservable(this.resultArtifacts$, {}).artifacts || [], 121 // Optional resource, users may not have access to the artifacts. 122 (e) => { 123 if (!(e instanceof GrpcError && e.code === RpcCode.PERMISSION_DENIED)) { 124 logging.error(e); 125 } 126 return []; 127 }, 128 ); 129 } 130 131 @computed private get invArtifacts$() { 132 const resultdb = this.store.services.resultDb; 133 if (!resultdb) { 134 // Returns a promise that never resolves when resultDb isn't ready. 135 return fromPromise(Promise.race([])); 136 } 137 // TODO(weiweilin): handle pagination. 138 return fromPromise( 139 resultdb.listArtifacts({ parent: 'invocations/' + this.parentInvId }), 140 ); 141 } 142 143 @computed private get invArtifacts() { 144 return unwrapOrElse( 145 () => unwrapObservable(this.invArtifacts$, {}).artifacts || [], 146 // Optional resource, users may not have access to the artifacts. 147 (e) => { 148 if (!(e instanceof GrpcError && e.code === RpcCode.PERMISSION_DENIED)) { 149 logging.error(e); 150 } 151 return []; 152 }, 153 ); 154 } 155 156 @computed private get stainlessLogArtifact() { 157 return this.invArtifacts.find((a) => a.artifactId === 'stainless_logs'); 158 } 159 160 @computed private get testhausLogArtifact() { 161 // Check for Testhaus logs at the test result level first. 162 const log = this.resultArtifacts.find( 163 (a) => a.artifactId === 'testhaus_logs', 164 ); 165 if (log) { 166 return log; 167 } 168 169 // Now check at the parent invocation level. 170 return this.invArtifacts.find((a) => a.artifactId === 'testhaus_logs'); 171 } 172 173 @computed private get textDiffArtifact() { 174 return this.resultArtifacts.find((a) => a.artifactId === 'text_diff'); 175 } 176 177 @computed private get imageDiffArtifactGroup() { 178 return { 179 expected: this.resultArtifacts.find( 180 (a) => a.artifactId === 'expected_image', 181 ), 182 actual: this.resultArtifacts.find((a) => a.artifactId === 'actual_image'), 183 diff: this.resultArtifacts.find((a) => a.artifactId === 'image_diff'), 184 }; 185 } 186 187 @computed private get clusterLink() { 188 if (!this.project) { 189 return null; 190 } 191 192 // There can be at most one failureReason cluster. 193 const reasonCluster = this.clusters.filter((c) => 194 c.clusterId.algorithm.startsWith('reason-'), 195 )?.[0]; 196 if (!reasonCluster) { 197 return null; 198 } 199 200 return makeClusterLink(this.project, reasonCluster.clusterId); 201 } 202 203 constructor() { 204 super(); 205 makeObservable(this); 206 } 207 208 private handleResultDataReady = (e: Event) => { 209 (e as CustomEvent<TextArtifactEvent>).detail.setData( 210 this.parentInvId, 211 this.testResult.name, 212 ); 213 }; 214 215 connectedCallback(): void { 216 super.connectedCallback(); 217 this.addEventListener( 218 ON_TEST_RESULT_DATA_READY, 219 this.handleResultDataReady, 220 ); 221 } 222 223 disconnectedCallback(): void { 224 super.connectedCallback(); 225 this.removeEventListener( 226 ON_TEST_RESULT_DATA_READY, 227 this.handleResultDataReady, 228 ); 229 } 230 231 private renderFailureReason() { 232 const errMsg = this.testResult.failureReason?.primaryErrorMessage; 233 if (!errMsg) { 234 return html``; 235 } 236 237 return html` 238 <milo-expandable-entry .contentRuler="none" .expanded=${true}> 239 <span slot="header" 240 >Failure 241 Reason${this.clusterLink 242 ? html` (<a 243 href=${this.clusterLink} 244 target="_blank" 245 @click=${(e: Event) => e.stopImmediatePropagation()} 246 >similar failures</a 247 >)` 248 : ''}: 249 </span> 250 <pre id="failure-reason" class="info-block" slot="content"> 251 ${errMsg}</pre 252 > 253 </milo-expandable-entry> 254 `; 255 } 256 257 private renderLogLinkArtifacts() { 258 if (this.testhausLogArtifact || this.stainlessLogArtifact) { 259 let testhausLink = null; 260 if (this.testhausLogArtifact) { 261 testhausLink = html`<milo-link-artifact 262 .artifact=${this.testhausLogArtifact} 263 .label="Testhaus" 264 ></milo-link-artifact>`; 265 } 266 let delimiter = null; 267 if (this.testhausLogArtifact && this.stainlessLogArtifact) { 268 delimiter = ', '; 269 } 270 let stainlessLink = null; 271 if (this.stainlessLogArtifact) { 272 stainlessLink = html`<milo-link-artifact 273 .artifact=${this.stainlessLogArtifact} 274 .label="Stainless" 275 ></milo-link-artifact>`; 276 } 277 278 return html` 279 <div class="summary-log-link"> 280 View logs in: ${testhausLink}${delimiter}${stainlessLink} 281 </div> 282 `; 283 } 284 285 return null; 286 } 287 288 private renderSummaryHtml() { 289 if (!this.testResult.summaryHtml) { 290 return html``; 291 } 292 293 return html` 294 <milo-expandable-entry .contentRuler="none" .expanded=${true}> 295 <span slot="header">Summary:</span> 296 <div slot="content"> 297 <div id="summary-html" class="info-block"> 298 ${unsafeHTML(this.testResult.summaryHtml)} 299 </div> 300 ${this.renderLogLinkArtifacts()} 301 </div> 302 </milo-expandable-entry> 303 `; 304 } 305 306 private renderArtifactLink(artifact: Artifact) { 307 if (artifact.contentType === 'text/x-uri') { 308 return html`<milo-link-artifact 309 .artifact=${artifact} 310 ></milo-link-artifact>`; 311 } 312 return html`<a href=${getRawArtifactURLPath(artifact.name)} target="_blank" 313 >${artifact.artifactId}</a 314 >`; 315 } 316 317 private renderInvocationLevelArtifacts() { 318 if (this.invArtifacts.length === 0) { 319 return html``; 320 } 321 322 return html` 323 <div id="inv-artifacts-header"> 324 From the parent inv <a href=${getInvURLPath(this.parentInvId)}></a>: 325 </div> 326 <ul> 327 ${this.invArtifacts.map( 328 (artifact) => html` <li>${this.renderArtifactLink(artifact)}</li> `, 329 )} 330 </ul> 331 `; 332 } 333 334 private renderArtifacts() { 335 const artifactCount = 336 this.resultArtifacts.length + this.invArtifacts.length; 337 if (artifactCount === 0) { 338 return html``; 339 } 340 341 return html` 342 <milo-expandable-entry .contentRuler="invisible"> 343 <span slot="header"> 344 Artifacts: <span class="greyed-out">${artifactCount}</span> 345 </span> 346 <div slot="content"> 347 <ul> 348 ${this.resultArtifacts.map( 349 (artifact) => html` 350 <li>${this.renderArtifactLink(artifact)}</li> 351 `, 352 )} 353 </ul> 354 ${this.renderInvocationLevelArtifacts()} 355 </div> 356 </milo-expandable-entry> 357 `; 358 } 359 360 private renderContent() { 361 if (!this.shouldRenderContent) { 362 return html``; 363 } 364 365 return html` 366 ${this.renderFailureReason()}${this.renderSummaryHtml()} 367 ${this.textDiffArtifact && 368 html` 369 <milo-text-diff-artifact .artifact=${this.textDiffArtifact}> 370 </milo-text-diff-artifact> 371 `} 372 ${this.imageDiffArtifactGroup.diff && 373 html` 374 <milo-image-diff-artifact 375 .expected=${this.imageDiffArtifactGroup.expected} 376 .actual=${this.imageDiffArtifactGroup.actual} 377 .diff=${this.imageDiffArtifactGroup.diff} 378 > 379 </milo-image-diff-artifact> 380 `} 381 ${this.renderArtifacts()} 382 ${this.testResult.tags?.length 383 ? html`<milo-tags-entry 384 .tags=${this.testResult.tags} 385 ></milo-tags-entry>` 386 : ''} 387 `; 388 } 389 390 protected render = reportRenderError(this, () => { 391 let duration = 'No duration'; 392 let compactDuration = 'N/A'; 393 let durationUnits = ''; 394 if (this.duration) { 395 duration = displayDuration(this.duration); 396 [compactDuration, durationUnits] = displayCompactDuration(this.duration); 397 } 398 return html` 399 <milo-expandable-entry 400 .expanded=${this.expanded} 401 .onToggle=${(expanded: boolean) => (this.expanded = expanded)} 402 > 403 <span id="header" slot="header"> 404 <div class="duration ${durationUnits}" title=${duration}> 405 ${compactDuration} 406 </div> 407 result #${this.id} 408 <span class=${this.testResult.expected ? 'expected' : 'unexpected'}> 409 ${this.testResult.expected ? 'expectedly' : 'unexpectedly'} 410 ${TEST_STATUS_DISPLAY_MAP[this.testResult.status]} 411 </span> 412 ${this.renderParentLink()} 413 ${this.clusters.length && this.project 414 ? html`<milo-associated-bugs-badge 415 .project=${this.project} 416 .clusters=${this.clusters} 417 ></milo-associated-bugs-badge>` 418 : ''} 419 </span> 420 <div slot="content">${this.renderContent()}</div> 421 </milo-expandable-entry> 422 `; 423 }); 424 425 private renderParentLink() { 426 const swarmingTaskId = parseSwarmingTaskFromInvId(this.parentInvId); 427 if (swarmingTaskId !== null) { 428 return html` 429 in task: 430 <a 431 href="${getSwarmingTaskURL( 432 swarmingTaskId.swarmingHost, 433 swarmingTaskId.taskId, 434 )}" 435 target="_blank" 436 @click=${(e: Event) => e.stopPropagation()} 437 > 438 ${swarmingTaskId.taskId} 439 </a> 440 `; 441 } 442 443 // There's an alternative format for build invocation: 444 // `build-${builderIdHash}-${buildNum}`. 445 // We don't match that because: 446 // 1. we can't get back the build link because the builderID is hashed, and 447 // 2. typically those invocations are only used as wrapper invocations that 448 // points to the `build-${buildId}` for the same build for speeding up 449 // queries when buildId is not yet known to the client. We don't expect them 450 // to be used here. 451 const matchBuild = this.parentInvId.match(/^build-([0-9]+)$/); 452 if (matchBuild) { 453 return html` 454 in build: 455 <a 456 href="/ui/b/${matchBuild[1]}" 457 target="_blank" 458 @click=${(e: Event) => e.stopPropagation()} 459 > 460 ${matchBuild[1]} 461 </a> 462 `; 463 } 464 465 return null; 466 } 467 468 static styles = [ 469 commonStyles, 470 colorClasses, 471 css` 472 :host { 473 display: block; 474 } 475 476 #header { 477 display: inline-block; 478 font-size: 14px; 479 letter-spacing: 0.1px; 480 font-weight: 500; 481 } 482 483 .info-block { 484 background-color: var(--block-background-color); 485 padding: 5px; 486 } 487 488 pre { 489 margin: 0; 490 font-size: 12px; 491 white-space: pre-wrap; 492 overflow-wrap: break-word; 493 } 494 495 #summary-html p:first-child { 496 margin-top: 0; 497 } 498 #summary-html p:last-child { 499 margin-bottom: 0; 500 } 501 502 .summary-log-link { 503 padding-top: 5px; 504 } 505 506 ul { 507 margin: 3px 0; 508 padding-inline-start: 28px; 509 } 510 511 #inv-artifacts-header { 512 margin-top: 12px; 513 } 514 515 milo-associated-bugs-badge { 516 max-width: 300px; 517 margin-left: 4px; 518 } 519 `, 520 ]; 521 }