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 }