| // Copyright 2020 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import {AuthorizationHeader, getAuthorizationHeader} from './auth'; |
| import {getEarliestArtifactExpiration, getParentInv} from './resultdb-utils'; |
| import {LRUMemoryCacheObject} from './lru-memory-cache-object'; |
| import {Build} from './checks-fetcher'; |
| import {FaultAttributeProperties} from './failure-analysis'; |
| |
| export enum TestStatus { |
| PASS = 'PASS', |
| FAIL = 'FAIL', |
| CRASH = 'CRASH', |
| ABORT = 'ABORT', |
| SKIP = 'SKIP', |
| } |
| |
| export enum TestVariantStatus { |
| UNEXPECTED = 'UNEXPECTED', |
| UNEXPECTEDLY_SKIPPED = 'UNEXPECTEDLY_SKIPPED', |
| FLAKY = 'FLAKY', |
| EXONERATED = 'EXONERATED', |
| UNEXPECTED_MASK = 'UNEXPECTED_MASK', |
| EXPECTED = 'EXPECTED', |
| } |
| |
| export declare interface TestVariant { |
| variant: any; |
| results: {result: TestResult}[]; |
| exonerations: any[]; |
| status: TestVariantStatus; |
| testId?: string; |
| variantHash?: string; |
| testMetadata?: { |
| name: any; |
| }; |
| faultAttribute?: FaultAttributeProperties; |
| } |
| |
| export declare interface TestResult { |
| name: string; |
| testId?: string; |
| resultId?: string; |
| tags: {[key: string]: string}[]; |
| status?: TestStatus; |
| summaryHtml?: string; |
| expected?: boolean; |
| } |
| |
| export declare interface Artifact { |
| name: string; |
| artifactId: string; |
| fetchUrl: string; |
| fetchUrlExpiration?: string; |
| contentType?: string; |
| sizeBytes: number; |
| contents?: any; |
| } |
| |
| declare interface TestVariantsQuery { |
| invocations: any[]; |
| predicate: { |
| status: TestVariantStatus; |
| }; |
| pageSize: number; |
| } |
| |
| declare interface listAllArtifactsQuery { |
| parent: string; |
| pageSize: number; |
| pageToken: string; |
| } |
| |
| // TODO(gavinmak): Use IndexedDB. |
| const artifactCache = new LRUMemoryCacheObject(256); |
| const variantCache = new LRUMemoryCacheObject(512); |
| |
| /** |
| * Client for ResultDB v1 API. |
| * Requests and responses to and from the RPC methods are defined in: |
| * https://osscs.corp.google.com/chromium/infra/infra/+/master:go/src/go.chromium.org/luci/resultdb/proto/v1/resultdb.proto |
| * For more help see: |
| * https://goto.google.com/resultdb-rpc |
| */ |
| export class ResultDbV1Client { |
| host: string; |
| |
| changeId: string; |
| |
| constructor(host: string, changeId: string) { |
| this.host = host; |
| this.changeId = changeId; |
| } |
| |
| /** |
| * Calls a ResultDB v1 API method. |
| * |
| * @param method RPC service method name, e.g. "GetInvocation". |
| * @param request Request body. |
| * @param signal An AbortSignal object. |
| * @return Response body. |
| */ |
| private async call( |
| service: string, |
| method: string, |
| request: TestVariantsQuery | listAllArtifactsQuery, |
| signal: AbortSignal | undefined = undefined |
| ): Promise<any> { |
| // Miniature implementation of pRPC protocol with JSONPB and auth. |
| const authHeader = await this.getAuthorizationHeader(); |
| const headers = { |
| accept: 'application/json', |
| 'content-type': 'application/json', |
| ...authHeader, |
| }; |
| |
| const url = `https://${this.host}/prpc/${service}/${method}`; |
| const response = await fetch(url, { |
| method: 'POST', |
| headers, |
| body: JSON.stringify(request), |
| signal, |
| }).catch(e => { |
| if (e.name === 'AbortError') { |
| console.warn('ResultDB request aborted'); |
| } else { |
| throw e; |
| } |
| }); |
| // If fetch is aborted, return an empty object. |
| if (!response) { |
| return {}; |
| } |
| if (!response.ok) { |
| throw new Error('ResultDB request failed'); |
| } |
| |
| const rawResponseText = await response.text(); |
| const xssiPrefix = ")]}'"; |
| if (!rawResponseText.startsWith(xssiPrefix)) { |
| throw new Error( |
| `Response body does not start with XSSI prefix: ${xssiPrefix}` |
| ); |
| } |
| return JSON.parse(rawResponseText.substr(xssiPrefix.length)); |
| } |
| |
| queryTestVariants( |
| request: TestVariantsQuery, |
| signal = undefined |
| ): Promise<{testVariants: TestVariant[]}> { |
| return this.call( |
| 'luci.resultdb.v1.ResultDB', |
| 'QueryTestVariants', |
| request, |
| signal |
| ); |
| } |
| |
| listArtifacts( |
| request: listAllArtifactsQuery |
| ): Promise<{artifacts: Artifact[]; nextPageToken?: string}> { |
| return this.call('luci.resultdb.v1.ResultDB', 'ListArtifacts', request); |
| } |
| |
| /** |
| * Returns test variants corresponding to the given build. This does not |
| * return expected variants. |
| * |
| * @param build Buildbucket build. |
| * @param limit Number of test variants to return. ResultDB coerces |
| * any number above 1000 to 1000. |
| * @param inclAdditional Whether to fetch flaky/exonerated results. |
| */ |
| async fetchTestVariants( |
| build: Build, |
| limit: number, |
| inclAdditional: boolean |
| ): Promise<TestVariant[]> { |
| const cacheKey = build.id + (inclAdditional ? '-additional' : ''); |
| const cachedVariants = variantCache.read(cacheKey); |
| if (cachedVariants) { |
| return cachedVariants; |
| } |
| |
| const response = await this.queryTestVariants({ |
| invocations: [build.infra?.resultdb?.invocation], |
| predicate: { |
| status: inclAdditional |
| ? TestVariantStatus.UNEXPECTED_MASK |
| : TestVariantStatus.UNEXPECTED, |
| }, |
| pageSize: limit, |
| }); |
| const testVariants: TestVariant[] = response.testVariants || []; |
| |
| // Cache only if build is finalized. |
| if (build.endTime) { |
| // TODO(gavinmak): Move try logic to CacheObject. |
| try { |
| variantCache.write(cacheKey, testVariants); |
| } catch (e) { |
| console.warn('Failed to write to test variant cache:', e); |
| } |
| } |
| return testVariants; |
| } |
| |
| /** |
| * Returns all artifacts for the given artifact parent. |
| * |
| * @param parent Invocation or test result name. |
| */ |
| async listAllArtifacts(parent: string): Promise<Artifact[]> { |
| const artifacts: Artifact[] = []; |
| let pageToken = ''; |
| while (true) { |
| const response = await this.listArtifacts({ |
| parent, |
| // Artifact entries are small - just pointers to artifact content, |
| // so reduce the probability of sequential RPCs by using a large |
| // pageSize. |
| pageSize: 10000, |
| pageToken, |
| }); |
| |
| artifacts.push(...(response.artifacts || [])); |
| |
| // Exit if there are no more artifacts to fetch. |
| if (!response.nextPageToken) { |
| break; |
| } |
| pageToken = response.nextPageToken; |
| } |
| return artifacts; |
| } |
| |
| /** |
| * Returns artifacts for the given test result and its parent invocation. If |
| * allowFetch is true, then artifacts will be fetched on cache miss. The |
| * returned object will contain a fromCache property that is true iff the |
| * results are fetched from cache. |
| * |
| * @param resultName The result name. |
| * @param allowFetch Whether to query ResultDB. |
| */ |
| async fetchArtifacts( |
| resultName: string, |
| allowFetch: boolean |
| ): Promise<{ |
| fromCache: boolean; |
| resArtifacts: Artifact[]; |
| invArtifacts: Artifact[]; |
| }> { |
| const cacheKey = `${this.host}-${resultName}`; |
| const cachedArtifacts = artifactCache.read(cacheKey); |
| if (cachedArtifacts) { |
| const {expiry, resArtifacts, invArtifacts} = cachedArtifacts; |
| // Check if the cached results will expire in the next minute. |
| if (expiry > Date.now() + 1000 * 60) { |
| return { |
| fromCache: true, |
| resArtifacts, |
| invArtifacts, |
| }; |
| } |
| artifactCache.delete(cacheKey); |
| } |
| |
| if (!allowFetch) { |
| return { |
| fromCache: false, |
| resArtifacts: [], |
| invArtifacts: [], |
| }; |
| } |
| |
| // Query ResultDB. |
| // TODO(gavinmak): Don't refetch invocation-level artifacts when processing |
| // multiple test results. |
| let allSucceeded = true; |
| const [resArtifacts, invArtifacts] = await Promise.all([ |
| this.listAllArtifacts(resultName).catch(e => { |
| allSucceeded = false; |
| console.warn('Failed to fetch result artifacts:', e); |
| return []; |
| }), |
| this.listAllArtifacts(getParentInv(resultName)).catch(e => { |
| allSucceeded = false; |
| console.warn('Failed to fetch invocation-level artifacts:', e); |
| return []; |
| }), |
| ]); |
| |
| if (allSucceeded) { |
| // TODO(gavinmak): Move try logic to CacheObject. |
| try { |
| artifactCache.write(cacheKey, { |
| expiry: getEarliestArtifactExpiration([ |
| ...resArtifacts, |
| ...invArtifacts, |
| ]), |
| resArtifacts, |
| invArtifacts, |
| }); |
| } catch (e) { |
| console.warn('Failed to write to artifact cache:', e); |
| } |
| } |
| return { |
| fromCache: false, |
| resArtifacts, |
| invArtifacts, |
| }; |
| } |
| |
| /** |
| * Mockable equivalent of window.buildbucket.getAuthorizationHeader. |
| * |
| * @returns authorization header to use in requests. |
| */ |
| getAuthorizationHeader(): Promise<AuthorizationHeader> { |
| return getAuthorizationHeader(this.changeId); |
| } |
| } |