go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/services/resultdb/resultdb.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 stableStringify from 'fast-json-stable-stringify';
    16  
    17  import { BuilderID } from '@/common/services/buildbucket';
    18  import {
    19    GerritChange,
    20    GitilesCommit,
    21    StringPair,
    22  } from '@/common/services/common';
    23  import { logging } from '@/common/tools/logging';
    24  import { cached, CacheOption } from '@/generic_libs/tools/cached_fn';
    25  import { PrpcClientExt } from '@/generic_libs/tools/prpc_client_ext';
    26  import { sha256 } from '@/generic_libs/tools/utils';
    27  
    28  /* eslint-disable max-len */
    29  /**
    30   * Manually coded type definition and classes for resultdb service.
    31   * TODO(weiweilin): To be replaced by code generated version once we have one.
    32   * source: https://chromium.googlesource.com/infra/luci/luci-go/+/4525018bc0953bfa8597bd056f814dcf5e765142/resultdb/proto/rpc/v1/resultdb.proto
    33   */
    34  /* eslint-enable max-len */
    35  
    36  export const PERM_INVOCATIONS_GET = 'resultdb.invocations.get';
    37  export const PERM_TEST_EXONERATIONS_LIST = 'resultdb.testExonerations.list';
    38  export const PERM_TEST_RESULTS_LIST = 'resultdb.testResults.list';
    39  export const PERM_TEST_EXONERATIONS_LIST_LIMITED =
    40    'resultdb.testExonerations.listLimited';
    41  export const PERM_TEST_RESULTS_LIST_LIMITED =
    42    'resultdb.testResults.listLimited';
    43  
    44  export enum TestStatus {
    45    Unspecified = 'STATUS_UNSPECIFIED',
    46    Pass = 'PASS',
    47    Fail = 'FAIL',
    48    Crash = 'CRASH',
    49    Abort = 'ABORT',
    50    Skip = 'SKIP',
    51  }
    52  
    53  export enum InvocationState {
    54    Unspecified = 'STATE_UNSPECIFIED',
    55    Active = 'ACTIVE',
    56    Finalizing = 'FINALIZING',
    57    Finalized = 'FINALIZED',
    58  }
    59  
    60  export interface Invocation {
    61    readonly interrupted: boolean;
    62    readonly name: string;
    63    readonly realm: string;
    64    readonly state: InvocationState;
    65    readonly createTime: string;
    66    readonly finalizeTime: string;
    67    readonly deadline: string;
    68    readonly includedInvocations?: string[];
    69    readonly tags?: StringPair[];
    70  }
    71  
    72  export interface TestResult {
    73    readonly name: string;
    74    readonly testId: string;
    75    readonly resultId: string;
    76    readonly variant?: Variant;
    77    readonly variantHash?: string;
    78    readonly expected?: boolean;
    79    readonly status: TestStatus;
    80    readonly summaryHtml: string;
    81    readonly startTime: string;
    82    readonly duration?: string;
    83    readonly tags?: StringPair[];
    84    readonly failureReason?: FailureReason;
    85  }
    86  
    87  export interface TestLocation {
    88    readonly repo: string;
    89    readonly fileName: string;
    90    readonly line?: number;
    91  }
    92  
    93  export interface TestExoneration {
    94    readonly name: string;
    95    readonly testId: string;
    96    readonly variant?: Variant;
    97    readonly variantHash?: string;
    98    readonly exonerationId: string;
    99    readonly explanationHtml?: string;
   100  }
   101  
   102  export interface Artifact {
   103    readonly name: string;
   104    readonly artifactId: string;
   105    readonly fetchUrl: string;
   106    readonly fetchUrlExpiration: string;
   107    readonly contentType: string;
   108    readonly sizeBytes: number;
   109  }
   110  
   111  export type TestVariantDef = { [key: string]: string };
   112  
   113  export interface Variant {
   114    readonly def: TestVariantDef;
   115  }
   116  
   117  export interface FailureReason {
   118    readonly primaryErrorMessage: string;
   119  }
   120  
   121  export interface GetInvocationRequest {
   122    readonly name: string;
   123  }
   124  
   125  export interface QueryTestResultsRequest {
   126    readonly invocations: string[];
   127    readonly readMask?: string;
   128    readonly predicate?: TestResultPredicate;
   129    readonly pageSize?: number;
   130    readonly pageToken?: string;
   131  }
   132  
   133  export interface QueryTestExonerationsRequest {
   134    readonly invocations: string[];
   135    readonly predicate?: TestExonerationPredicate;
   136    readonly pageSize?: number;
   137    readonly pageToken?: string;
   138  }
   139  
   140  export interface ListArtifactsRequest {
   141    readonly parent: string;
   142    readonly pageSize?: number;
   143    readonly pageToken?: string;
   144  }
   145  
   146  export interface EdgeTypeSet {
   147    readonly includedInvocations: boolean;
   148    readonly testResults: boolean;
   149  }
   150  
   151  export interface QueryArtifactsRequest {
   152    readonly invocations: string[];
   153    readonly followEdges?: EdgeTypeSet;
   154    readonly testResultPredicate?: TestResultPredicate;
   155    readonly maxStaleness?: string;
   156    readonly pageSize?: number;
   157    readonly pageToken?: string;
   158  }
   159  
   160  export interface GetArtifactRequest {
   161    readonly name: string;
   162  }
   163  
   164  export interface TestResultPredicate {
   165    readonly testIdRegexp?: string;
   166    readonly variant?: VariantPredicate;
   167    readonly expectancy?: Expectancy;
   168  }
   169  
   170  export interface TestExonerationPredicate {
   171    readonly testIdRegexp?: string;
   172    readonly variant?: VariantPredicate;
   173  }
   174  
   175  export type VariantPredicate =
   176    | { readonly equals: Variant }
   177    | { readonly contains: Variant };
   178  
   179  export const enum Expectancy {
   180    All = 'ALL',
   181    VariantsWithUnexpectedResults = 'VARIANTS_WITH_UNEXPECTED_RESULTS',
   182  }
   183  
   184  export interface QueryTestResultsResponse {
   185    readonly testResults?: TestResult[];
   186    readonly nextPageToken?: string;
   187  }
   188  
   189  export interface QueryTestExonerationsResponse {
   190    readonly testExonerations?: TestExoneration[];
   191    readonly nextPageToken?: string;
   192  }
   193  
   194  export interface ListArtifactsResponse {
   195    readonly artifacts?: Artifact[];
   196    readonly nextPageToken?: string;
   197  }
   198  
   199  export interface QueryArtifactsResponse {
   200    readonly artifacts?: Artifact[];
   201    readonly nextPageToken?: string;
   202  }
   203  
   204  export interface QueryTestVariantsRequest {
   205    readonly invocations: readonly string[];
   206    readonly pageSize?: number;
   207    readonly pageToken?: string;
   208    readonly resultLimit?: number;
   209  }
   210  
   211  export interface QueryTestVariantsResponse {
   212    readonly testVariants?: readonly TestVariant[];
   213    readonly nextPageToken?: string;
   214  }
   215  
   216  export interface TestVariant {
   217    readonly testId: string;
   218    readonly variant?: Variant;
   219    readonly variantHash: string;
   220    readonly status: TestVariantStatus;
   221    readonly results?: readonly TestResultBundle[];
   222    readonly exonerations?: readonly TestExoneration[];
   223    readonly testMetadata?: TestMetadata;
   224    readonly sourcesId: string;
   225  }
   226  
   227  export interface Sources {
   228    readonly gitilesCommit?: GitilesCommit;
   229    readonly changelists?: GerritChange[];
   230  }
   231  
   232  export const enum TestVariantStatus {
   233    TEST_VARIANT_STATUS_UNSPECIFIED = 'TEST_VARIANT_STATUS_UNSPECIFIED',
   234    UNEXPECTED = 'UNEXPECTED',
   235    UNEXPECTEDLY_SKIPPED = 'UNEXPECTEDLY_SKIPPED',
   236    FLAKY = 'FLAKY',
   237    EXONERATED = 'EXONERATED',
   238    EXPECTED = 'EXPECTED',
   239  }
   240  
   241  // Note: once we have more than 9 statuses, we need to add '0' prefix so '10'
   242  // won't appear before '2' after sorting.
   243  export const TEST_VARIANT_STATUS_CMP_STRING = {
   244    [TestVariantStatus.TEST_VARIANT_STATUS_UNSPECIFIED]: '0',
   245    [TestVariantStatus.UNEXPECTED]: '1',
   246    [TestVariantStatus.UNEXPECTEDLY_SKIPPED]: '2',
   247    [TestVariantStatus.FLAKY]: '3',
   248    [TestVariantStatus.EXONERATED]: '4',
   249    [TestVariantStatus.EXPECTED]: '5',
   250  };
   251  
   252  export interface TestMetadata {
   253    readonly name?: string;
   254    readonly location?: TestLocation;
   255    readonly propertiesSchema?: string;
   256    readonly properties?: { [key: string]: unknown };
   257  }
   258  
   259  export interface TestResultBundle {
   260    readonly result: TestResult;
   261  }
   262  
   263  export interface TestVariantIdentifier {
   264    readonly testId: string;
   265    readonly variantHash: string;
   266  }
   267  
   268  export interface BatchGetTestVariantsRequest {
   269    readonly invocation: string;
   270    readonly testVariants: readonly TestVariantIdentifier[];
   271    readonly resultLimit?: number;
   272  }
   273  
   274  export interface BatchGetTestVariantsResponse {
   275    readonly testVariants?: readonly TestVariant[];
   276    readonly sources: { [key: string]: Sources };
   277  }
   278  
   279  export interface QueryTestMetadataRequest {
   280    readonly project: string;
   281    readonly predicate?: TestMetadataPredicate;
   282    readonly pageSize?: number;
   283    readonly pageToken?: string;
   284  }
   285  
   286  export interface TestMetadataPredicate {
   287    readonly testIds?: string[];
   288  }
   289  
   290  export interface QueryTestMetadataResponse {
   291    readonly testMetadata?: TestMetadataDetail[];
   292    readonly nextPageToken?: string;
   293  }
   294  
   295  export interface TestMetadataDetail {
   296    readonly name: string;
   297    readonly project: string;
   298    readonly testId: string;
   299    readonly refHash: string;
   300    readonly sourceRef: SourceRef;
   301    readonly testMetadata?: TestMetadata;
   302  }
   303  
   304  export type SourceRef = { readonly gitiles?: GitilesRef };
   305  export interface GitilesRef {
   306    readonly host: string;
   307    readonly project: string;
   308    readonly ref: string;
   309  }
   310  
   311  // The maximum number of results that can be included in a test variant returned
   312  // from the RPC.
   313  export const RESULT_LIMIT = 100;
   314  
   315  export class ResultDb {
   316    static readonly SERVICE = 'luci.resultdb.v1.ResultDB';
   317  
   318    private readonly cachedCallFn: (
   319      opt: CacheOption,
   320      method: string,
   321      message: object,
   322    ) => Promise<unknown>;
   323  
   324    constructor(client: PrpcClientExt) {
   325      this.cachedCallFn = cached(
   326        (method: string, message: object) =>
   327          client.call(ResultDb.SERVICE, method, message),
   328        {
   329          key: (method, message) => `${method}-${stableStringify(message)}`,
   330        },
   331      );
   332    }
   333  
   334    async getInvocation(
   335      req: GetInvocationRequest,
   336      cacheOpt: CacheOption = {},
   337    ): Promise<Invocation> {
   338      return (await this.cachedCallFn(
   339        cacheOpt,
   340        'GetInvocation',
   341        req,
   342      )) as Invocation;
   343    }
   344  
   345    async queryTestResults(
   346      req: QueryTestResultsRequest,
   347      cacheOpt: CacheOption = {},
   348    ) {
   349      return (await this.cachedCallFn(
   350        cacheOpt,
   351        'QueryTestResults',
   352        req,
   353      )) as QueryTestResultsResponse;
   354    }
   355  
   356    async queryTestExonerations(
   357      req: QueryTestExonerationsRequest,
   358      cacheOpt: CacheOption = {},
   359    ) {
   360      return (await this.cachedCallFn(
   361        cacheOpt,
   362        'QueryTestExonerations',
   363        req,
   364      )) as QueryTestExonerationsResponse;
   365    }
   366  
   367    async listArtifacts(req: ListArtifactsRequest, cacheOpt: CacheOption = {}) {
   368      return (await this.cachedCallFn(
   369        cacheOpt,
   370        'ListArtifacts',
   371        req,
   372      )) as ListArtifactsResponse;
   373    }
   374  
   375    async queryArtifacts(req: QueryArtifactsRequest, cacheOpt: CacheOption = {}) {
   376      return (await this.cachedCallFn(
   377        cacheOpt,
   378        'QueryArtifacts',
   379        req,
   380      )) as QueryArtifactsResponse;
   381    }
   382  
   383    async getArtifact(req: GetArtifactRequest, cacheOpt: CacheOption = {}) {
   384      return (await this.cachedCallFn(cacheOpt, 'GetArtifact', req)) as Artifact;
   385    }
   386  
   387    async queryTestVariants(
   388      req: QueryTestVariantsRequest,
   389      cacheOpt: CacheOption = {},
   390    ) {
   391      return (await this.cachedCallFn(
   392        cacheOpt,
   393        'QueryTestVariants',
   394        req,
   395      )) as QueryTestVariantsResponse;
   396    }
   397  
   398    async batchGetTestVariants(
   399      req: BatchGetTestVariantsRequest,
   400      cacheOpt: CacheOption = {},
   401    ) {
   402      return (await this.cachedCallFn(
   403        cacheOpt,
   404        'BatchGetTestVariants',
   405        req,
   406      )) as BatchGetTestVariantsResponse;
   407    }
   408  
   409    async queryTestMetadata(
   410      req: QueryTestMetadataRequest,
   411      cacheOpt: CacheOption = {},
   412    ) {
   413      return (await this.cachedCallFn(
   414        cacheOpt,
   415        'QueryTestMetadata',
   416        req,
   417      )) as QueryTestMetadataResponse;
   418    }
   419  }
   420  
   421  export interface TestResultIdentifier {
   422    readonly invocationId: string;
   423    readonly testId: string;
   424    readonly resultId: string;
   425  }
   426  
   427  /**
   428   * Parses the artifact name and get the individual components.
   429   */
   430  export function parseArtifactName(artifactName: string): ArtifactIdentifier {
   431    const match = artifactName.match(
   432      /^invocations\/(.*?)\/(?:tests\/(.*?)\/results\/(.*?)\/)?artifacts\/(.*)$/,
   433    );
   434    if (!match) {
   435      throw new Error(`invalid artifact name: ${artifactName}`);
   436    }
   437  
   438    const [, invocationId, testId, resultId, artifactId] = match as string[];
   439  
   440    return {
   441      invocationId,
   442      testId: testId ? decodeURIComponent(testId) : undefined,
   443      resultId: resultId ? resultId : undefined,
   444      artifactId,
   445    };
   446  }
   447  
   448  export type ArtifactIdentifier =
   449    | InvocationArtifactIdentifier
   450    | TestResultArtifactIdentifier;
   451  
   452  export interface InvocationArtifactIdentifier {
   453    readonly invocationId: string;
   454    readonly testId?: string;
   455    readonly resultId?: string;
   456    readonly artifactId: string;
   457  }
   458  
   459  export interface TestResultArtifactIdentifier {
   460    readonly invocationId: string;
   461    readonly testId: string;
   462    readonly resultId: string;
   463    readonly artifactId: string;
   464  }
   465  
   466  /**
   467   * Constructs the name of the artifact.
   468   */
   469  export function constructArtifactName(identifier: ArtifactIdentifier) {
   470    if (identifier.testId && identifier.resultId) {
   471      return `invocations/${identifier.invocationId}/tests/${encodeURIComponent(
   472        identifier.testId,
   473      )}/results/${identifier.resultId}/artifacts/${identifier.artifactId}`;
   474    } else {
   475      return `invocations/${identifier.invocationId}/artifacts/${identifier.artifactId}`;
   476    }
   477  }
   478  
   479  /**
   480   * Computes invocation ID for the build from the given build ID.
   481   */
   482  export function getInvIdFromBuildId(buildId: string): string {
   483    return `build-${buildId}`;
   484  }
   485  
   486  /**
   487   * Computes invocation ID for the build from the given builder ID and build number.
   488   */
   489  export async function getInvIdFromBuildNum(
   490    builder: BuilderID,
   491    buildNum: number,
   492  ): Promise<string> {
   493    const builderId = `${builder.project}/${builder.bucket}/${builder.builder}`;
   494    return `build-${await sha256(builderId)}-${buildNum}`;
   495  }
   496  
   497  /**
   498   * Create a test variant property getter for the given property key.
   499   *
   500   * A property key must be one of the following:
   501   * 1. 'status': status of the test variant.
   502   * 2. 'name': test_metadata.name of the test variant.
   503   * 3. 'v.{variant_key}': variant.def[variant_key] of the test variant (e.g.
   504   * v.gpu).
   505   */
   506  export function createTVPropGetter(
   507    propKey: string,
   508  ): (v: TestVariant) => ToString {
   509    if (propKey.match(/^v[.]/i)) {
   510      const variantKey = propKey.slice(2);
   511      return (v) => v.variant?.def[variantKey] || '';
   512    }
   513    propKey = propKey.toLowerCase();
   514    switch (propKey) {
   515      case 'name':
   516        return (v) => v.testMetadata?.name || v.testId;
   517      case 'status':
   518        return (v) => v.status;
   519      default:
   520        logging.warn('invalid property key', propKey);
   521        return () => '';
   522    }
   523  }
   524  
   525  /**
   526   * Create a test variant compare function for the given sorting key list.
   527   *
   528   * A sorting key must be one of the following:
   529   * 1. '{property_key}': sort by property_key in ascending order.
   530   * 2. '-{property_key}': sort by property_key in descending order.
   531   */
   532  export function createTVCmpFn(
   533    sortingKeys: readonly string[],
   534  ): (v1: TestVariant, v2: TestVariant) => number {
   535    const sorters: Array<[number, (v: TestVariant) => { toString(): string }]> =
   536      sortingKeys.map((key) => {
   537        const [mul, propKey] = key.startsWith('-')
   538          ? [-1, key.slice(1)]
   539          : [1, key];
   540        const propGetter = createTVPropGetter(propKey);
   541  
   542        // Status should be be sorted by their significance not by their string
   543        // representation.
   544        if (propKey.toLowerCase() === 'status') {
   545          return [
   546            mul,
   547            (v) =>
   548              TEST_VARIANT_STATUS_CMP_STRING[propGetter(v) as TestVariantStatus],
   549          ];
   550        }
   551        return [mul, propGetter];
   552      });
   553    return (v1, v2) => {
   554      for (const [mul, propGetter] of sorters) {
   555        const cmp =
   556          propGetter(v1).toString().localeCompare(propGetter(v2).toString()) *
   557          mul;
   558        if (cmp !== 0) {
   559          return cmp;
   560        }
   561      }
   562      return 0;
   563    };
   564  }
   565  
   566  /**
   567   * Computes the display label for a given property key.
   568   */
   569  export function getPropKeyLabel(key: string) {
   570    // If the key has the format of '{type}.{value}', hide the '{type}.' prefix.
   571    // It's safe to cast here because the 2nd capture group must match something.
   572    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   573    return key.match(/^([^.]*\.)?(.*)$/)![2];
   574  }