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  }