Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c07b6bb
fix(plugin-testops): correlate CI uploads with TestOps job runs end-t…
todti Aug 27, 2026
e2598ca
fix(plugin-testops): clear stale UploadQueue entries on a later direc…
todti Aug 28, 2026
a81b8be
refactor(plugin-testops): split out free functions from client.ts and…
todti Aug 28, 2026
84add79
fix(plugin-testops): stop sending reporterType/reporterName on /api/l…
todti Aug 28, 2026
65c5f7d
refactor(plugin-testops): dedupe retry-hook and failure-logging code …
todti Aug 28, 2026
40c3874
fix(plugin-testops): align the upload payload with what TestOps accepts
todti Sep 1, 2026
e938c01
fix(plugin-testops): attach to TestOps-triggered job runs on an undet…
todti Sep 1, 2026
49d273c
refactor(plugin-testops): name UploadPacer's rate-limit resolution cases
todti Sep 1, 2026
f8e8c6d
mute more xcresults flaky tests
epszaw Sep 2, 2026
8820a87
fix(plugin-testops): fall back to the requested job run id when TestO…
todti Sep 2, 2026
916366c
fix(plugin-testops): don't autoclose a launch that still has pending …
todti Sep 2, 2026
8aed51f
style(plugin-testops): drop remaining rationale comments and single-l…
todti Sep 2, 2026
9b14c7c
fix(plugin-testops): don't disable the plugin when TestOps set ALLURE…
todti Sep 2, 2026
1b5a98d
fix(cli): remove a stale test plan file when the TestOps request fails
todti Sep 2, 2026
60a0317
fix(plugin-testops): charge oversized batches their full cost in Uplo…
todti Sep 2, 2026
3c3f486
fix(plugin-testops): stop the finalization retry from re-uploading al…
todti Sep 2, 2026
f3310fe
fix(plugin-testops): count real attachment sizes toward the upload by…
todti Sep 2, 2026
eb384f0
fix(plugin-testops): stop retrying a deliberately canceled upload req…
todti Sep 2, 2026
115bec5
refactor(plugin-testops): dedupe result-upload error logging, use 0 a…
todti Sep 2, 2026
1ff6729
fix(plugin-testops): honor Retry-After in withUploadRetry's backoff
todti Sep 2, 2026
1294a31
fix(plugin-testops): tighten id validation and bound the Retry-After …
todti Sep 2, 2026
68c5e8d
fix(plugin-testops): don't close the launch when a session failure sk…
todti Sep 2, 2026
0e55446
fix(plugin-testops): surface a failed launch-categories sync instead …
todti Sep 2, 2026
3e8194a
perf(plugin-testops): look named envs up by key and stop re-creating …
todti Sep 2, 2026
cf20157
test(ci): pin ALLURE_CI_ENV taking precedence over an already-set ALL…
todti Sep 2, 2026
5a44e9a
Merge branch 'main' into feat/testops-attach-existing-job-run
todti Sep 3, 2026
47e7401
chore: mute flaky xcresult reader tests via quality-gate resolutions
todti Sep 3, 2026
cd1765b
chore(plugin-testops): remove dead namedEnvs getter
todti Sep 3, 2026
ae85fc5
Merge branch 'main' into feat/testops-attach-existing-job-run
todti Sep 3, 2026
ca6b0b8
fix: drop the wrong id from the xcresult resolutions rule
todti Sep 3, 2026
d8ef48b
test(reader): skip a flaky xcresult parameter test in CI
todti Sep 3, 2026
4d95428
test(reader): retry the xcresulttool suite in CI instead of skipping …
todti Sep 3, 2026
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
9 changes: 8 additions & 1 deletion allurerc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,14 @@ const config = {
rules: [
{
resolution: "accepted",
testCaseId: ["85d28c01c71394fbdfa81e84cfd7e751", "49dcb3bdd6479da760dd2d91c30a9baa", "0a83faa11f37b5ec6dd119680e00b7c5"],
testCaseId: [
"85d28c01c71394fbdfa81e84cfd7e751",
"49dcb3bdd6479da760dd2d91c30a9baa",
"0a83faa11f37b5ec6dd119680e00b7c5",
"aca386ffeb0e3195d3296f035de6b214",
"8fb61126e49f99342262db3ac2a85c22",
"d41ec9abd4ce6884b4b1da1ed54359f1",
],
comment: "Flaky tests that can't be fixed entirely for CI. On local machine they always pass",
},
],
Expand Down
24 changes: 24 additions & 0 deletions packages/ci/src/ciEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { env } from "node:process";

export const applyAllureCiEnv = (): void => {
const encodedAllureCiEnv = env.ALLURE_CI_ENV;

if (!encodedAllureCiEnv) {
return;
}

try {
const decodedAllureCiEnv = JSON.parse(Buffer.from(encodedAllureCiEnv, "base64").toString("utf8")) as Record<
string,
string
>;

for (const [variableName, variableValue] of Object.entries(decodedAllureCiEnv)) {
if (variableName.startsWith("ALLURE_")) {
env[variableName] = variableValue;
}
}
} catch {
return;
}
};
2 changes: 1 addition & 1 deletion packages/ci/src/detectors/azure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const REF_PREFIX = "refs/";
const BRANCH_REF_PREFIX = "refs/heads/";
const TAG_REF_PREFIX = "refs/tags/";

export const getRootURL = (): string => getEnv("SYSTEM_COLLECTIONURI");
export const getRootURL = (): string => getEnv("SYSTEM_COLLECTIONURI").replace(/\/+$/, "");

export const getBuildID = (): string => getEnv("BUILD_BUILDID");

Expand Down
1 change: 1 addition & 0 deletions packages/ci/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { applyAllureCiEnv } from "./ciEnv.js";
export { detect } from "./detect.js";
export { isLocalCiDescriptor } from "./detectors/local.js";
68 changes: 68 additions & 0 deletions packages/ci/test/ciEnv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { epic, feature, label, story } from "allure-js-commons";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { applyAllureCiEnv } from "../src/ciEnv.js";

beforeEach(async () => {
await epic("coverage");
await feature("testops-integration");
await story("ciEnv");
await label("coverage", "testops-integration");
});

afterEach(() => {
vi.unstubAllEnvs();
delete process.env.ALLURE_DECODED_VAR;
delete process.env.ALLURE_OTHER_DECODED_VAR;
});

const encode = (vars: Record<string, string>): string => Buffer.from(JSON.stringify(vars), "utf8").toString("base64");

describe("applyAllureCiEnv", () => {
it("does nothing when ALLURE_CI_ENV is not set", () => {
applyAllureCiEnv();

expect(process.env.ALLURE_DECODED_VAR).toBeUndefined();
});

it("decodes ALLURE_CI_ENV and applies its ALLURE_* entries to process.env", () => {
vi.stubEnv("ALLURE_CI_ENV", encode({ ALLURE_DECODED_VAR: "from-ci-env" }));

applyAllureCiEnv();

expect(process.env.ALLURE_DECODED_VAR).toBe("from-ci-env");
});

it("applies every ALLURE_* entry from the bundle", () => {
vi.stubEnv("ALLURE_CI_ENV", encode({ ALLURE_DECODED_VAR: "one", ALLURE_OTHER_DECODED_VAR: "two" }));

applyAllureCiEnv();

expect(process.env.ALLURE_DECODED_VAR).toBe("one");
expect(process.env.ALLURE_OTHER_DECODED_VAR).toBe("two");
});

it("lets the bundle win over an ALLURE_ variable already set in the environment", () => {
vi.stubEnv("ALLURE_DECODED_VAR", "set-directly-on-the-step");
vi.stubEnv("ALLURE_CI_ENV", encode({ ALLURE_DECODED_VAR: "from-ci-env" }));

applyAllureCiEnv();

expect(process.env.ALLURE_DECODED_VAR).toBe("from-ci-env");
});

it("ignores non-ALLURE_ keys in the decoded bundle", () => {
vi.stubEnv("ALLURE_CI_ENV", encode({ NOT_ALLURE_PREFIXED: "should-be-ignored" }));

applyAllureCiEnv();

expect(process.env.NOT_ALLURE_PREFIXED).toBeUndefined();
});

it("does not throw and leaves env untouched when ALLURE_CI_ENV is malformed", () => {
vi.stubEnv("ALLURE_CI_ENV", "not-valid-base64-json");

expect(() => applyAllureCiEnv()).not.toThrow();
expect(process.env.ALLURE_DECODED_VAR).toBeUndefined();
});
});
10 changes: 10 additions & 0 deletions packages/ci/test/detectors/azure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ describe("azure", () => {
expect(getRootURL()).toBe("https://dev.azure.com/organization");
});

it("should drop the trailing slash Azure always adds", () => {
(getEnv as Mock).mockImplementation((key: string) => {
if (key === "SYSTEM_COLLECTIONURI") {
return "https://dev.azure.com/organization/";
}
});

expect(getRootURL()).toBe("https://dev.azure.com/organization");
});

it("should return empty string when environment variable is not set", () => {
(getEnv as Mock).mockImplementation((key: string) => {
if (key === "SYSTEM_COLLECTIONURI") {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from "./history.js";
export * from "./run.js";
export * from "./slack.js";
export * from "./testplan.js";
export * from "./testopsPlan.js";
export * from "./watch.js";
export * from "./open.js";
export * from "./qualityGate.js";
Expand Down
83 changes: 83 additions & 0 deletions packages/cli/src/commands/testopsPlan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import * as console from "node:console";
import { rm, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { env, exit } from "node:process";

import { applyAllureCiEnv } from "@allurereport/ci";
import type { TestPlan } from "@allurereport/core-api";
import { createServiceHttpClient } from "@allurereport/service";
import { Command, Option } from "clipanion";
import { red } from "yoctocolors";

type TestOpsTestCaseInfo = {
id?: number;
selector?: string;
};

export class TestOpsPlanCommand extends Command {
static paths = [["testops-plan"]];

static usage = Command.Usage({
description: "Fetches a testplan.json for the current TestOps job run",
details:
"Reads ALLURE_JOB_RUN_ID and, when it's set, downloads the test cases selected for that job run " +
"from TestOps and writes them as a testplan.json compatible with ALLURE_TESTPLAN_PATH. " +
"Does nothing when ALLURE_JOB_RUN_ID isn't set, which covers every run TestOps didn't trigger itself.",
examples: [
["testops-plan", "Write ./testplan.json from the current job run, if any"],
["testops-plan --output custom-testplan.json", "Write to a custom path instead"],
],
});

output = Option.String("--output,-o", {
description: "The output file name. Absolute paths are accepted as well (default: ./testplan.json)",
});

async execute() {
applyAllureCiEnv();

const jobRunId = Number(env.ALLURE_JOB_RUN_ID);

if (!Number.isInteger(jobRunId) || jobRunId <= 0) {
console.log("ALLURE_JOB_RUN_ID isn't set, skipping test plan generation");
return;
}

const endpoint = env.ALLURE_ENDPOINT;
const accessToken = env.ALLURE_TOKEN;

if (!endpoint || !accessToken) {
console.error(red("ALLURE_ENDPOINT and ALLURE_TOKEN are required to fetch a test plan from TestOps"));
exit(1);
return;
}

const client = createServiceHttpClient(endpoint, { apiToken: accessToken });
const output = resolve(this.output ?? "./testplan.json");
let tests: TestOpsTestCaseInfo[] | undefined;

try {
tests = await client.get<TestOpsTestCaseInfo[]>(`/api/rs/jobrun/${jobRunId}/plan`, {
params: { expected: "true" },
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);

await rm(output, { force: true });
console.error(red(`Could not fetch the test plan for job run ${jobRunId}, continuing without one: ${message}`));
return;
}

const testPlan: TestPlan = {
version: "1.0",
tests: (tests ?? []).map(({ id, selector }) => ({
...(id !== undefined ? { id: String(id) } : {}),
...(selector !== undefined ? { selector } : {}),
})),
};

await writeFile(output, JSON.stringify(testPlan), "utf-8");

console.log(`test plan for job run ${jobRunId} written to ${output} (${testPlan.tests.length} test(s))`);
}
}
2 changes: 2 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
ResultsUnpackCommand,
RunCommand,
SlackCommand,
TestOpsPlanCommand,
TestPlanCommand,
WatchCommand,
isAgentTaskMapHelpRequest,
Expand Down Expand Up @@ -66,6 +67,7 @@ cli.register(OpenCommand);
cli.register(QualityGateCommand);
cli.register(RunCommand);
cli.register(SlackCommand);
cli.register(TestOpsPlanCommand);
cli.register(TestPlanCommand);
cli.register(WatchCommand);
cli.register(ResultsPackCommand);
Expand Down
130 changes: 130 additions & 0 deletions packages/cli/test/commands/testopsPlan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import * as console from "node:console";
import { readFile, rm, writeFile } from "node:fs/promises";
import { exit } from "node:process";

import { epic, feature, label, story } from "allure-js-commons";
import { run } from "clipanion";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { TestOpsPlanCommand } from "../../src/commands/testopsPlan.js";

const fixtures = {
jobRunId: "491277",
endpoint: "http://testops.example.com",
token: "test-token",
output: "./.tmp-testops-plan.json",
};

const getMock = vi.fn();

vi.mock("node:console", async (importOriginal) => ({
...(await importOriginal()),
log: vi.fn(),
error: vi.fn(),
}));
vi.mock("node:process", async (importOriginal) => ({
...(await importOriginal()),
exit: vi.fn(),
}));
vi.mock("@allurereport/service", () => ({
createServiceHttpClient: vi.fn(() => ({ get: getMock })),
}));

beforeEach(async () => {
await epic("coverage");
await feature("cli-commands");
await story("testops-plan");
await label("coverage", "cli-commands");
vi.clearAllMocks();
});

afterEach(async () => {
vi.unstubAllEnvs();
await rm(fixtures.output, { force: true });
});

describe("testops-plan command", () => {
it("should skip generation when ALLURE_JOB_RUN_ID isn't set", async () => {
vi.stubEnv("ALLURE_JOB_RUN_ID", "");

await run(TestOpsPlanCommand, ["testops-plan"]);

expect(console.log).toHaveBeenCalledWith(expect.stringContaining("ALLURE_JOB_RUN_ID isn't set"));
expect(getMock).not.toHaveBeenCalled();
});

it("should exit with code 1 when TestOps credentials are missing", async () => {
vi.stubEnv("ALLURE_JOB_RUN_ID", fixtures.jobRunId);
vi.stubEnv("ALLURE_ENDPOINT", "");
vi.stubEnv("ALLURE_TOKEN", "");

await run(TestOpsPlanCommand, ["testops-plan"]);

expect(exit).toHaveBeenCalledWith(1);
expect(getMock).not.toHaveBeenCalled();
});

it("should fetch the job run's test plan and write testplan.json", async () => {
vi.stubEnv("ALLURE_JOB_RUN_ID", fixtures.jobRunId);
vi.stubEnv("ALLURE_ENDPOINT", fixtures.endpoint);
vi.stubEnv("ALLURE_TOKEN", fixtures.token);
getMock.mockResolvedValueOnce([
{ id: 123, selector: "suite.spec.ts#test one" },
{ id: 456, selector: "suite.spec.ts#test two" },
]);

await run(TestOpsPlanCommand, ["testops-plan", "--output", fixtures.output]);

expect(getMock).toHaveBeenCalledWith(`/api/rs/jobrun/${fixtures.jobRunId}/plan`, {
params: { expected: "true" },
});

const written = JSON.parse(await readFile(fixtures.output, "utf-8"));

expect(written).toEqual({
version: "1.0",
tests: [
{ id: "123", selector: "suite.spec.ts#test one" },
{ id: "456", selector: "suite.spec.ts#test two" },
],
});
});

it("should continue without a test plan when the TestOps request fails", async () => {
vi.stubEnv("ALLURE_JOB_RUN_ID", fixtures.jobRunId);
vi.stubEnv("ALLURE_ENDPOINT", fixtures.endpoint);
vi.stubEnv("ALLURE_TOKEN", fixtures.token);
getMock.mockRejectedValueOnce(new Error("network error"));

await run(TestOpsPlanCommand, ["testops-plan", "--output", fixtures.output]);

expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Could not fetch the test plan"));
expect(exit).not.toHaveBeenCalled();
await expect(readFile(fixtures.output, "utf-8")).rejects.toThrow();
});

it("should remove a stale test plan left over from an earlier run when the request fails", async () => {
vi.stubEnv("ALLURE_JOB_RUN_ID", fixtures.jobRunId);
vi.stubEnv("ALLURE_ENDPOINT", fixtures.endpoint);
vi.stubEnv("ALLURE_TOKEN", fixtures.token);
await writeFile(fixtures.output, JSON.stringify({ version: "1.0", tests: [{ id: "stale" }] }), "utf-8");
getMock.mockRejectedValueOnce(new Error("network error"));

await run(TestOpsPlanCommand, ["testops-plan", "--output", fixtures.output]);

await expect(readFile(fixtures.output, "utf-8")).rejects.toThrow();
});

it("should write an empty test plan when the job run has no selected tests", async () => {
vi.stubEnv("ALLURE_JOB_RUN_ID", fixtures.jobRunId);
vi.stubEnv("ALLURE_ENDPOINT", fixtures.endpoint);
vi.stubEnv("ALLURE_TOKEN", fixtures.token);
getMock.mockResolvedValueOnce([]);

await run(TestOpsPlanCommand, ["testops-plan", "--output", fixtures.output]);

const written = JSON.parse(await readFile(fixtures.output, "utf-8"));

expect(written).toEqual({ version: "1.0", tests: [] });
});
});
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
Loading
Loading