From 87ed590859583e8ec7412cdb3de0e3a5be022663 Mon Sep 17 00:00:00 2001 From: "David A. Ramos" Date: Tue, 6 Sep 2022 22:54:24 -0700 Subject: [PATCH] Upload test runs to presigned S3 URLs This change leverages a recent backend API change to support larger test runs. Inline test run submissions are subject to a 6MB due to AWS Lambda's payload limits. --- .../test/integration/src/runTestCase.ts | 73 +++++++++-- packages/js-api/src/index.ts | 118 +++++++++++++----- yarn.lock | 6 +- 3 files changed, 153 insertions(+), 44 deletions(-) diff --git a/packages/jest-plugin/test/integration/src/runTestCase.ts b/packages/jest-plugin/test/integration/src/runTestCase.ts index b2ece45..8a35626 100644 --- a/packages/jest-plugin/test/integration/src/runTestCase.ts +++ b/packages/jest-plugin/test/integration/src/runTestCase.ts @@ -12,7 +12,8 @@ import { import { run } from "jest"; import escapeStringRegexp from "escape-string-regexp"; import { - CreateTestSuiteRunRequest, + CreateTestSuiteRunFromUploadRequest, + CreateTestSuiteRunInlineRequest, TEST_NAME_ENTRY_MAX_LENGTH, TestAttemptResult, TestRunAttemptRecord, @@ -26,6 +27,7 @@ import deepEqual from "deep-equal"; import * as cosmiconfig from "cosmiconfig"; import { CosmiconfigResult } from "cosmiconfig/dist/types"; import { UnflakableConfig } from "../../../src/types"; +import { gunzipSync } from "zlib"; const userAgentRegex = new RegExp( "unflakable-js-api/(?:[-0-9.]|alpha|beta)+ unflakable-jest-plugin/(?:[-0-9.]|alpha|beta)+ \\(Jest [0-9]+\\.[0-9]+\\.[0-9]+; Node v[0-9]+\\.[0-9]+\\.[0-9]\\)" @@ -487,7 +489,9 @@ const uploadResultsMatcher = results: ResultCounts ): MockMatcher => (_url, { body, headers }) => { - const parsedBody = JSON.parse(body as string) as CreateTestSuiteRunRequest; + const parsedBody = JSON.parse( + gunzipSync(body as string).toString() + ) as CreateTestSuiteRunInlineRequest; expect((headers as { [key in string]: string })["User-Agent"]).toMatch( userAgentRegex @@ -802,26 +806,77 @@ const addFetchMockExpectations = ( } ); if (expectResultsToBeUploaded) { + const uploadUrl = + "https://s3.mock.amazonaws.com/unflakable-backend-mock-test-uploads/teams/MOCK_TEAM_ID/" + + `suites/${expectedSuiteId}/runs/upload/MOCK_UPLOAD_ID?X-Amz-Signature=MOCK_SIGNATURE`; fetchMock.postOnce( { - url: `https://app.unflakable.com/api/v1/test-suites/${expectedSuiteId}/runs`, + url: `https://app.unflakable.com/api/v1/test-suites/${expectedSuiteId}/runs/upload`, headers: { Authorization: `Bearer ${expectedApiKey}`, "Content-Type": "application/json", }, + matcher: (_url, { body }) => { + expect(body).toBe(undefined); + return true; + }, + }, + (): MockResponse => ({ + body: { + upload_id: "MOCK_UPLOAD_ID", + }, + headers: { + Location: uploadUrl, + }, + status: 201, + }) + ); + + let runRequest: CreateTestSuiteRunInlineRequest | null = null; + fetchMock.putOnce( + { + url: uploadUrl, + headers: { + "Content-Encoding": "gzip", + "Content-Type": "application/json", + }, matcher: uploadResultsMatcher(params, results), }, (_url: string, { body }: MockRequest): MockResponse => { + runRequest = JSON.parse( + gunzipSync(body as string).toString() + ) as CreateTestSuiteRunInlineRequest; + + return { + status: 200, + }; + } + ); + fetchMock.postOnce( + { + url: `https://app.unflakable.com/api/v1/test-suites/${expectedSuiteId}/runs`, + headers: { + Authorization: `Bearer ${expectedApiKey}`, + "Content-Type": "application/json", + }, + matcher: (_url, { body }) => { + const parsedBody = JSON.parse( + body as string + ) as CreateTestSuiteRunFromUploadRequest; + expect(parsedBody.upload_id).toBe("MOCK_UPLOAD_ID"); + return true; + }, + }, + (): MockResponse => { + expect(runRequest).not.toBeNull(); + const parsedRequest = runRequest as CreateTestSuiteRunInlineRequest; + if (failToUploadResults) { return { throws: new Error("mock request failure"), }; } - const parsedBody = JSON.parse( - body as string - ) as CreateTestSuiteRunRequest; - return { body: { run_id: "MOCK_RUN_ID", @@ -836,8 +891,8 @@ const addFetchMockExpectations = ( commit: expectedCommit, } : {}), - start_time: parsedBody.start_time, - end_time: parsedBody.end_time, + start_time: parsedRequest.start_time, + end_time: parsedRequest.end_time, num_tests: results.failedTests + results.flakyTests + diff --git a/packages/js-api/src/index.ts b/packages/js-api/src/index.ts index 9b0486f..3de7b52 100644 --- a/packages/js-api/src/index.ts +++ b/packages/js-api/src/index.ts @@ -1,7 +1,9 @@ // Copyright (c) 2022 Developer Innovations, LLC -import fetch from "node-fetch"; +import fetch, { Response } from "node-fetch"; import _debug = require("debug"); +import { gzip } from "zlib"; +import { promisify } from "util"; const debug = _debug("unflakable:api"); @@ -34,13 +36,16 @@ export type TestRunRecord = { name: string[]; attempts: TestRunAttemptRecord[]; }; -export type CreateTestSuiteRunRequest = { +export type CreateTestSuiteRunInlineRequest = { branch?: string; commit?: string; start_time: string; end_time: string; test_runs: TestRunRecord[]; }; +export declare type CreateTestSuiteRunFromUploadRequest = { + upload_id: string; +}; export type TestSuiteRunSummary = { run_id: string; suite_id: string; @@ -54,12 +59,42 @@ export type TestSuiteRunSummary = { num_flake: number; num_quarantined: number; }; +export type CreateTestSuiteRunUploadUrlResponse = { + upload_id: string; +}; const userAgent = (clientDescription?: string) => `unflakable-js-api/${JS_API_VERSION}${ clientDescription !== undefined ? ` ${clientDescription}` : "" }`; +const requestHeaders = ({ + apiKey, + clientDescription, +}: { + apiKey: string; + clientDescription?: string; +}) => ({ + Authorization: "Bearer " + apiKey, + "User-Agent": userAgent(clientDescription), +}); + +const expectResponse = + (expectedStatus: number, expectedStatusText: string) => + async (res: Response): Promise => { + if (res.status !== expectedStatus) { + const body = await res.text(); + throw new Error( + `received HTTP response \`${res.status} ${ + res.statusText + }\` (expected \`${expectedStatusText}\`)${ + body.length > 0 ? `: ${body}` : "" + }` + ); + } + return res; + }; + export const createTestSuiteRun = async ({ request, testSuiteId, @@ -67,7 +102,7 @@ export const createTestSuiteRun = async ({ clientDescription, baseUrl, }: { - request: CreateTestSuiteRunRequest; + request: CreateTestSuiteRunInlineRequest; testSuiteId: string; apiKey: string; clientDescription?: string; @@ -75,31 +110,62 @@ export const createTestSuiteRun = async ({ }): Promise => { const requestJson = JSON.stringify(request); debug(`Creating test suite run: ${requestJson}`); - return await fetch( + const gzippedRequest = await promisify(gzip)(requestJson); + + const { uploadId, uploadUrl } = await fetch( `${ baseUrl !== undefined ? baseUrl : BASE_URL - }/api/v1/test-suites/${testSuiteId}/runs`, + }/api/v1/test-suites/${testSuiteId}/runs/upload`, { method: "post", - body: requestJson, headers: { - Authorization: "Bearer " + apiKey, "Content-Type": "application/json", - "User-Agent": userAgent(clientDescription), + ...requestHeaders({ apiKey, clientDescription }), }, } ) + .then(expectResponse(201, "201 Created")) .then(async (res) => { - if (res.status !== 201) { - const body = await res.text(); - throw new Error( - `received HTTP response \`${res.status} ${ - res.statusText - }\` (expected \`201 Created\`)${body.length > 0 ? `: ${body}` : ""}` - ); + const location = res.headers.get("Location"); + if (location === null) { + throw new Error("no Location response header found"); } - return res.json() as Promise; - }) + const body = (await res.json()) as CreateTestSuiteRunUploadUrlResponse; + return { + uploadId: body.upload_id, + uploadUrl: location, + }; + }); + + await fetch(uploadUrl, { + method: "put", + body: gzippedRequest, + headers: { + "Content-Encoding": "gzip", + "Content-Type": "application/json", + "User-Agent": userAgent(clientDescription), + }, + }).then(expectResponse(200, "200 OK")); + + const requestBody: CreateTestSuiteRunFromUploadRequest = { + upload_id: uploadId, + }; + + return await fetch( + `${ + baseUrl !== undefined ? baseUrl : BASE_URL + }/api/v1/test-suites/${testSuiteId}/runs`, + { + method: "post", + body: JSON.stringify(requestBody), + headers: { + "Content-Type": "application/json", + ...requestHeaders({ apiKey, clientDescription }), + }, + } + ) + .then(expectResponse(201, "201 Created")) + .then((res) => res.json() as Promise) .then((parsedResponse: TestSuiteRunSummary) => { debug(`Received response: ${JSON.stringify(parsedResponse)}`); return parsedResponse; @@ -124,23 +190,11 @@ export const getTestSuiteManifest = async ({ }/api/v1/test-suites/${testSuiteId}/manifest`, { method: "get", - headers: { - Authorization: "Bearer " + apiKey, - "User-Agent": userAgent(clientDescription), - }, + headers: requestHeaders({ apiKey, clientDescription }), } ) - .then(async (res) => { - if (res.status !== 200) { - const body = await res.text(); - throw new Error( - `received HTTP response \`${res.status} ${ - res.statusText - }\` (expected \`200 OK\`)${body.length > 0 ? `: ${body}` : ""}` - ); - } - return res.json() as Promise; - }) + .then(expectResponse(200, "200 OK")) + .then((res) => res.json() as Promise) .then((parsedResponse: TestSuiteManifest) => { debug(`Received response: ${JSON.stringify(parsedResponse)}`); return parsedResponse; diff --git a/yarn.lock b/yarn.lock index 081d5e7..53c1bfc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2586,9 +2586,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.30001313": - version: 1.0.30001313 - resolution: "caniuse-lite@npm:1.0.30001313" - checksum: 49f2dcd1fa493a09a5247dcf3a4da3b9df355131b1fc1fd08b67ae7683c300ed9b9eef6a5424b4ac7e5d1ff0e129d2a0b4adf2a6a5a04ab5c2c0b2c590e935be + version: 1.0.30001390 + resolution: "caniuse-lite@npm:1.0.30001390" + checksum: 5ba4ae64e27c61e1c7d7125223159d6cf7fa3cdbf8f00b9ec83a06f274ff45ddcbfebe509716fa31ae2664b70ef9e1d1c4a5b9430e717852992358121d9ee9be languageName: node linkType: hard