Skip to content
This repository has been archived by the owner on Sep 1, 2024. It is now read-only.

Commit

Permalink
Upload test runs to presigned S3 URLs
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ramosbugs committed Sep 7, 2022
1 parent 4daf067 commit 87ed590
Show file tree
Hide file tree
Showing 3 changed files with 153 additions and 44 deletions.
73 changes: 64 additions & 9 deletions packages/jest-plugin/test/integration/src/runTestCase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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]\\)"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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 +
Expand Down
118 changes: 86 additions & 32 deletions packages/js-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -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");

Expand Down Expand Up @@ -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;
Expand All @@ -54,52 +59,113 @@ 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<Response> => {
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,
apiKey,
clientDescription,
baseUrl,
}: {
request: CreateTestSuiteRunRequest;
request: CreateTestSuiteRunInlineRequest;
testSuiteId: string;
apiKey: string;
clientDescription?: string;
baseUrl?: string;
}): Promise<TestSuiteRunSummary> => {
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<TestSuiteRunSummary>;
})
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<TestSuiteRunSummary>)
.then((parsedResponse: TestSuiteRunSummary) => {
debug(`Received response: ${JSON.stringify(parsedResponse)}`);
return parsedResponse;
Expand All @@ -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<TestSuiteManifest>;
})
.then(expectResponse(200, "200 OK"))
.then((res) => res.json() as Promise<TestSuiteManifest>)
.then((parsedResponse: TestSuiteManifest) => {
debug(`Received response: ${JSON.stringify(parsedResponse)}`);
return parsedResponse;
Expand Down
6 changes: 3 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

0 comments on commit 87ed590

Please sign in to comment.