Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/cli/src/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Command, Option } from "clipanion";
import { red } from "yoctocolors";

import { findFilesByGlobs } from "./../utils/fileSystem.js";
import { notifySignals, waitForAbort } from "./../utils/signals.js";
import { generate } from "./commons/generate.js";

export class OpenCommand extends Command {
Expand Down Expand Up @@ -83,12 +84,14 @@ export class OpenCommand extends Command {
});

// clean up temp report directory on ctrl-c
process.on("SIGINT", async () => {
const notifier = notifySignals(["SIGINT", "SIGTERM"]);

void waitForAbort(notifier.signal).then(async () => {
try {
await rm(config.output, { recursive: true });
} catch {}

process.exit(0);
process.exit(notifier.info()?.code ?? 0);
});

await serve({
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from "./terminal.js";
export * from "./logs.js";
export * from "./execution-context.js";
export * from "./fileSystem.js";
export * from "./signals.js";
92 changes: 92 additions & 0 deletions packages/cli/src/utils/signals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import process from "node:process";

export const SIGNAL_EXIT_CODES: Partial<Record<NodeJS.Signals, number>> = {
SIGINT: 130,
SIGTERM: 143,
};

export const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 60_000;
export const COMMAND_TERMINATION_GRACE_MS = 5_000;

export type SignalInfo = {
signal: NodeJS.Signals;
code: number;
receivedAt: number;
deadline: number;
};

const exitCodeForSignal = (signal: NodeJS.Signals): number => SIGNAL_EXIT_CODES[signal] ?? 1;

export type SignalNotifier = {
/** aborts the moment the first SIGINT/SIGTERM is received */
signal: AbortSignal;
/** the signal that triggered the abort, once received */
info: () => SignalInfo | undefined;
/** stop listening for signals */
dispose: () => void;
};

export const notifySignals = (
signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"],
onRepeat?: (signal: NodeJS.Signals) => void,
): SignalNotifier => {
const controller = new AbortController();
let info: SignalInfo | undefined;

const handler = (signal: NodeJS.Signals) => {
if (info) {
onRepeat?.(signal);
return;
}

const receivedAt = Date.now();

info = {
signal,
code: exitCodeForSignal(signal),
receivedAt,
deadline: receivedAt + GRACEFUL_SHUTDOWN_TIMEOUT_MS,
};

controller.abort();
};

for (const signal of signals) {
process.on(signal, handler);
}

return {
signal: controller.signal,
info: () => info,
dispose: () => {
for (const signal of signals) {
process.off(signal, handler);
}
},
};
};

/** time left until the graceful-shutdown deadline, floored at 0; undefined if no signal received yet */
export const gracefulShutdownRemaining = (info: SignalInfo | undefined): number | undefined => {
if (!info) {
return undefined;
}

return Math.max(0, info.deadline - Date.now());
};

export const boundedTerminationSignal = (
info: SignalInfo | undefined,
graceMs: number = COMMAND_TERMINATION_GRACE_MS,
): AbortSignal => {
const remaining = gracefulShutdownRemaining(info);
const timeoutMs = remaining === undefined ? graceMs : Math.min(remaining, graceMs);

return AbortSignal.timeout(timeoutMs);
};

/** resolves once the given signal aborts */
export const waitForAbort = (signal: AbortSignal): Promise<void> =>
signal.aborted
? Promise.resolve()
: new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
2 changes: 2 additions & 0 deletions packages/plugin-testops/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ The plugin accepts the following options:
| `autocloseLaunch` | When `true` (default), the launch is closed automatically when the plugin finishes; set to `false` to keep the launch open | `boolean` | `true` |
| `gitFlow` | When `true`, collect Git metadata for TestOps Git Flow on CI uploads (opt-in) | `boolean` | `false` |
| `ancestorLimit` | How many ancestor commits to attach to the launch for history linking in TestOps | `number` | `100` |
| `uploadRateLimit` | Caps how fast uploads are sent to TestOps within a rolling time window (requests/files/bytes per window); pass `false` to disable pacing entirely | `{ windowMs: number; maxRequestsPerWindow?: number; maxFilesPerWindow?: number; maxBytesPerWindow?: number } \| false` | `20` req/s, `1000` files/s, `1 GiB`/s |
| `reopenClosedLaunch` | When `true`, a launch that TestOps reports as closed is reopened automatically instead of failing the upload | `boolean` | `false` |

### Using options from environment variables

Expand Down
137 changes: 91 additions & 46 deletions packages/plugin-testops/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
UploadResultsResponseDto,
} from "./model.js";
import type { TestOpsFixtureResult } from "./model.js";
import { UploadPacer } from "./uploadPacer.js";
import { toUploadFixturesResultsDto } from "./utils/fixtures.js";
import { testStatusToLaunchStatus } from "./utils/launches.js";
import { normalizeTestStepsResults, toUploadResultsDto } from "./utils/testResults.js";
Expand All @@ -58,6 +59,20 @@ class TestOpsClientError extends AxiosError<{
const CHUNK_SIZE = 100;
const BULK_UPLOAD_CHUNK_SIZE = 1000;

// best-effort: a Buffer/Blob's size is known upfront, a stream's isn't without consuming it,
// so streamed attachments just don't contribute to the byte budget
const attachmentByteLength = (content: AttachmentForUpload["content"]): number => {
if (Buffer.isBuffer(content)) {
return content.length;
}

if (typeof Blob !== "undefined" && content instanceof Blob) {
return content.size;
}

return 0;
};

export class TestOpsClient {
#baseUrl: string;
#logger = new Logger("TestOpsClient");
Expand All @@ -68,6 +83,7 @@ export class TestOpsClient {
#session?: TestOpsSession;
#uploadInProgress: boolean = false;
#uploadLimit: number = 1;
#uploadPacer: UploadPacer;
#namedEnvsIdsByEnv: Map<string, TestOpsNamedEnv> = new Map();

constructor(params: TestOpsClientParams) {
Expand Down Expand Up @@ -98,6 +114,8 @@ export class TestOpsClient {
if (params.limit) {
this.#uploadLimit = params.limit;
}

this.#uploadPacer = new UploadPacer(params.uploadRateLimit);
}

isTestOpsClientError(error: unknown): error is TestOpsClientError {
Expand Down Expand Up @@ -126,6 +144,12 @@ export class TestOpsClient {
this.#logger.verbose("Launch closed");
}

async reopenLaunch(launchId: number): Promise<void> {
this.#logger.verbose("Reopening closed launch…");
await this.#client.post(`/api/launch/${launchId}/reopen`);
this.#logger.verbose("Launch reopened");
}

async createLaunchCategoriesBulk(
launchId: number,
items: LaunchCategoryBulkItem[],
Expand Down Expand Up @@ -339,6 +363,7 @@ export class TestOpsClient {
}

const formData = new FormData();
let totalBytes = 0;

for (const attachmentLink of attachments) {
const attachment = await attachmentsResolver(attachmentLink);
Expand All @@ -347,12 +372,16 @@ export class TestOpsClient {
continue;
}

totalBytes += attachmentByteLength(attachment.content);

formData.append("file", attachment.content, {
filename: attachment.originalFileName,
contentType: attachment.contentType,
});
}

await this.#uploadPacer.wait({ requests: 1, files: attachments.length, bytes: totalBytes });

await this.#client.post("/api/launch/attachment", {
body: formData,
onUploadProgress(progressEvent) {
Expand All @@ -374,6 +403,8 @@ export class TestOpsClient {
throw new Error("Launch isn't created! Call createLaunch first");
}

await this.#uploadPacer.wait({ requests: 1 });

await this.#client.post("/api/launch/error/bulk", {
body: {
launchId: this.#launch.id,
Expand Down Expand Up @@ -408,51 +439,42 @@ export class TestOpsClient {
const uploadedTrs: TestResult[] = [];
const envNamesById = new Map(environments.map(({ id, name }) => [id, name]));

try {
for (const trsChunk of trsChunks) {
const chunkEnvs = new Map<string, EnvironmentIdentity>();
for (const trsChunk of trsChunks) {
const chunkEnvs = new Map<string, EnvironmentIdentity>();

for (const tr of trsChunk) {
const environmentId = tr.environment;
for (const tr of trsChunk) {
const environmentId = tr.environment;

if (environmentId && !this.#namedEnvsIdsByEnv.has(environmentId)) {
chunkEnvs.set(environmentId, {
id: environmentId,
name: envNamesById.get(environmentId) ?? environmentId,
});
}
if (environmentId && !this.#namedEnvsIdsByEnv.has(environmentId)) {
chunkEnvs.set(environmentId, {
id: environmentId,
name: envNamesById.get(environmentId) ?? environmentId,
});
}
}

if (chunkEnvs.size > 0) {
await this.createNamedEnvs(Array.from(chunkEnvs.values()));
}
if (chunkEnvs.size > 0) {
await this.createNamedEnvs(Array.from(chunkEnvs.values()));
}

const reportIdsToTestOpsIds = await this.#postTestResultsChunk(trsChunk);
await this.#uploadPacer.wait({ requests: 1, files: trsChunk.length });

uploadedTrs.push(...trsChunk.filter((tr) => typeof reportIdsToTestOpsIds[tr.id] === "number"));
const reportIdsToTestOpsIds = await this.#postTestResultsChunk(trsChunk);

await this.#uploadChunkAttachmentsAndFixtures(
trsChunk,
reportIdsToTestOpsIds,
attachmentsResolver,
fixturesResolver,
uploadLimitFn,
onProgress,
);
}
uploadedTrs.push(...trsChunk.filter((tr) => typeof reportIdsToTestOpsIds[tr.id] === "number"));

this.#logger.verbose("Test results upload completed");
} catch (error) {
if (this.isTestOpsClientError(error)) {
this.#logger.error(`Failed to upload test results: ${error.response?.data.message}`);
this.#logger.debug(error.response.data);
} else if (error instanceof Error) {
this.#logger.error(`Failed to upload test results: ${error.message}`);
} else {
this.#logger.error("Failed to upload test results");
}
await this.#uploadChunkAttachmentsAndFixtures(
trsChunk,
reportIdsToTestOpsIds,
attachmentsResolver,
fixturesResolver,
uploadLimitFn,
onProgress,
);
}

this.#logger.verbose("Test results upload completed");

return uploadedTrs;
}

Expand Down Expand Up @@ -519,17 +541,30 @@ export class TestOpsClient {
return;
}

const attachments = await attachmentsResolver(tr);
const fixtures = (await fixturesResolver(tr))
.filter((fixture) => validateExecutableName(fixture.name))
.map((fixture) => ({
...fixture,
...(fixture.steps ? { steps: normalizeTestStepsResults(fixture.steps) } : {}),
}));

await this.#uploadAttachmentsForResult(testOpsId, attachments as AttachmentForUpload[]);
await this.#uploadFixturesForResult(testOpsId, fixtures);
onProgress?.();
try {
const attachments = await attachmentsResolver(tr);
const fixtures = (await fixturesResolver(tr))
.filter((fixture) => validateExecutableName(fixture.name))
.map((fixture) => ({
...fixture,
...(fixture.steps ? { steps: normalizeTestStepsResults(fixture.steps) } : {}),
}));

await this.#uploadAttachmentsForResult(testOpsId, attachments as AttachmentForUpload[]);
await this.#uploadFixturesForResult(testOpsId, fixtures);
} catch (error) {
// a subordinate failure (resolver or fixture upload) shouldn't invalidate the test
// result itself, which TestOps has already acknowledged by this point
if (this.isTestOpsClientError(error)) {
this.#logger.error(`Failed to upload fixtures for result ${testOpsId}: ${error.response?.data.message}`);
} else if (error instanceof Error) {
this.#logger.error(`Failed to upload fixtures for result ${testOpsId}: ${error.message}`);
} else {
this.#logger.error(`Failed to upload fixtures for result ${testOpsId}`);
}
} finally {
onProgress?.();
}
}),
),
);
Expand All @@ -545,14 +580,20 @@ export class TestOpsClient {
for (const attachmentsChunk of attachmentsChunks) {
const formData = new FormData();

let chunkBytes = 0;

for (const att of attachmentsChunk) {
chunkBytes += attachmentByteLength(att.content);

formData.append("file", att.content, {
filename: att.originalFileName,
contentType: att.contentType,
});
}

try {
await this.#uploadPacer.wait({ requests: 1, files: attachmentsChunk.length, bytes: chunkBytes });

await this.#client.post(`/api/upload/test-result/${testOpsResultId}/attachment`, {
body: formData,
headers: formData.getHeaders(),
Expand All @@ -578,6 +619,8 @@ export class TestOpsClient {

const body = toUploadFixturesResultsDto(fixtures);

await this.#uploadPacer.wait({ requests: 1 });

await this.#client.post(`/api/upload/test-result/${testOpsResultId}/test-fixture-result`, {
body,
});
Expand Down Expand Up @@ -610,6 +653,8 @@ export class TestOpsClient {
return item;
});

await this.#uploadPacer.wait({ requests: 1 });

await this.#client.post("/api/launch/quality-gate/bulk", {
body: {
launchId: this.#launch.id,
Expand Down
Loading
Loading