Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
6 changes: 3 additions & 3 deletions .github/allure-bundle-size.baseline.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"rootPackage": "allure",
"measuredAt": "2026-09-02T07:44:48.293Z",
"measuredAt": "2026-09-03T07:55:54.137Z",
"internalClosureCount": 34,
"selfBytes": 31776768,
"depsBytes": 65581056,
"totalBytes": 97357824
"depsBytes": 65585152,
"totalBytes": 97361920
}
39 changes: 39 additions & 0 deletions docs/quality_gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,45 @@ If `qualityGate` is configured and `--rerun` is enabled, Allure runs the test co

Use `--rerun=0` or remove `--rerun` when the quality gate should validate the run.

## Generated artifacts

When quality gates are enabled, Allure writes `quality-gate.json` to the generated report root. The file contains a flat
list of rule results and keeps related tests as report-scoped IDs in `testResults`.

```json
[
{
"success": false,
"expected": 0,
"actual": 1,
"rule": "maxFailures",
"message": "The number of failed tests 1 exceeds the allowed threshold value 0",
"environment": "chrome",
"testResults": ["4f1c2d"]
}
]
```

Use the generated report root `test-results.json` to resolve these IDs. The registry contains compact test details that
are enough to list and link related tests.

```json
{
"byId": {
"4f1c2d": {
"id": "4f1c2d",
"name": "checkout rejects expired card",
"status": "failed",
"duration": 1234,
"environment": "chrome"
}
}
}
```

Together, these files allow CI integrations to show which rule failed and which tests are related to it without reading
the full per-test result files.

## Using external rules

You can use external quality gate rules implemented by the community – just provide them to the `use` field in the quality gate configuration:
Expand Down
39 changes: 26 additions & 13 deletions packages/core/src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ const INIT_REQUIRED_ERROR_MESSAGE = "report is not initialised. Call the start()
const DEFAULT_READ_CONCURRENCY = 64;
const MAX_READ_CONCURRENCY = 256;
const TEST_RESULTS_REGISTRY_FILENAME = "test-results.json";
const QUALITY_GATE_RESULTS_FILENAME = "quality-gate.json";
const ROOT_INTEGRATION_FILENAMES = new Set([TEST_RESULTS_REGISTRY_FILENAME, QUALITY_GATE_RESULTS_FILENAME]);

const readConcurrency = () => {
const parsed = Number.parseInt(process.env.ALLURE_READ_CONCURRENCY ?? "", 10);
Expand Down Expand Up @@ -127,6 +129,7 @@ export class AllureReport {
#historyDataPoint?: HistoryDataPoint;
#summaryPath?: string;
#testResultsRegistryPath?: string;
#qualityGateResultsPath?: string;
#summariesByPluginId: Map<string, PluginSummary> = new Map();
#publishedRemoteHrefs: Set<string> = new Set();
#published = false;
Expand Down Expand Up @@ -263,6 +266,9 @@ export class AllureReport {
}

await this.#writeTestResultRegistry();
if (this.#qualityGate) {
await this.#writeQualityGateFiles();
}
await this.#writeSummaryFiles();
await this.#generateRootSummary();

Expand All @@ -285,9 +291,10 @@ export class AllureReport {
const summariesSnapshot = this.#cloneSummariesByPluginId();
const uploadProgressMessage =
reportsToPublish.length === 1 ? `Publishing "${reportsToPublish[0].pluginId}" report` : "Publishing reports";
const rootReportFiles = this.#getRootReportFiles();
const totalFilesToUpload =
reportsToPublish.reduce((acc, report) => acc + Object.keys(report.files).length, 0) +
(this.#testResultsRegistryPath ? 1 : 0);
Object.keys(rootReportFiles).length;
let summariesMutated = false;
let reportCreated = false;
let publishErrorMessage = "Report upload has failed, the report won't be published";
Expand Down Expand Up @@ -333,12 +340,12 @@ export class AllureReport {
}
}

if (this.#testResultsRegistryPath) {
publishErrorMessage = "Test results registry upload has failed, the report won't be published";
if (Object.keys(rootReportFiles).length > 0) {
publishErrorMessage = "Report integration artifacts upload has failed, the report won't be published";

await client.uploadReport({
reportUuid: this.reportUuid,
files: { [TEST_RESULTS_REGISTRY_FILENAME]: this.#testResultsRegistryPath },
files: rootReportFiles,
onProgress: incrementUploadProgress,
});
}
Expand Down Expand Up @@ -1026,6 +1033,20 @@ export class AllureReport {
);
};

#getRootReportFiles = (): Record<string, string> => ({
...(this.#testResultsRegistryPath ? { [TEST_RESULTS_REGISTRY_FILENAME]: this.#testResultsRegistryPath } : {}),
...(this.#qualityGateResultsPath ? { [QUALITY_GATE_RESULTS_FILENAME]: this.#qualityGateResultsPath } : {}),
});

#writeQualityGateFiles = async (): Promise<void> => {
const qualityGateResults = await this.#store.qualityGateResults();

this.#qualityGateResultsPath = await this.#reportFiles.addFile(
QUALITY_GATE_RESULTS_FILENAME,
Buffer.from(JSON.stringify(qualityGateResults)),
);
};

#generateRootSummary = async (): Promise<void> => {
const summaries = [...this.#summariesByPluginId.values()].map(clonePluginSummary);

Expand Down Expand Up @@ -1126,7 +1147,7 @@ export class AllureReport {
const reportContent = await readdir(reportPath);

for (const entry of reportContent) {
if (entry === TEST_RESULTS_REGISTRY_FILENAME) {
if (ROOT_INTEGRATION_FILENAMES.has(entry)) {
continue;
}

Expand Down Expand Up @@ -1168,14 +1189,6 @@ export class AllureReport {
console.info(`- ${href}`);
});
}

if (!this.#qualityGate) {
return;
}

const qualityGateResults = await this.#store.qualityGateResultsByEnv();

await writeFile(join(this.#output, "quality-gate.json"), JSON.stringify(qualityGateResults));
} finally {
await this.#finishPerfMetrics();
}
Expand Down
75 changes: 74 additions & 1 deletion packages/core/test/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ describe("report", () => {
it("should write root test result registry and keep a single plugin report at the root", async () => {
const output = await mkdtemp(join(tmpdir(), "allure3-test-results-registry-"));
const p1 = createPlugin("p1");
const config = await resolveConfig({ name: "Allure Report", output });
const config = await resolveConfig({ name: "Allure Report", output, environment: "chrome" });

config.plugins = [p1];
(p1.plugin.done as Mock).mockImplementation(async (context) => {
Expand Down Expand Up @@ -151,13 +151,44 @@ describe("report", () => {
id,
name: "failed test",
duration: 123,
environment: "chrome",
status: "failed",
},
},
});
await expect(readFile(join(output, "index.html"), "utf8")).resolves.toBe("index");
});

it("should write quality gate results with related test ids", async () => {
const output = await mkdtemp(join(tmpdir(), "allure3-quality-gate-resolved-"));
const config = await resolveConfig({
name: "Allure Report",
output,
qualityGate: {
rules: [],
},
});
const allureReport = new AllureReport(config);

await allureReport.start();
const testResultId = "result-1";
const qualityGateResult = {
success: false,
expected: 0,
actual: 1,
rule: "maxFailures",
message: "Failed tests exceed threshold",
testResults: [testResultId],
};

allureReport.realtimeDispatcher.sendQualityGateResults([qualityGateResult]);
await allureReport.done();

const qualityGateResults = JSON.parse(await readFile(join(output, "quality-gate.json"), "utf8"));

expect(qualityGateResults).toEqual([qualityGateResult]);
});

it("should not allow call done() before start()", async () => {
const config = await resolveConfig({
name: "Allure Report",
Expand Down Expand Up @@ -784,6 +815,48 @@ describe("report", () => {
);
});

it("should upload quality gate artifacts with root report files", async () => {
const p1 = createPlugin("p1", true, { publish: true });
const config = await resolveConfig({
name: "Allure Report",
qualityGate: {
rules: [],
},
});

config.plugins = [p1];
(p1.plugin.done as Mock).mockImplementation(async (context) => {
await context.reportFiles.addFile("index.html", Buffer.from("index"));
});

const allureReport = new AllureReport({
...config,
allureService: allureServiceConfig(),
});

await allureReport.start();
allureReport.realtimeDispatcher.sendQualityGateResults([
{
success: false,
expected: 0,
actual: 1,
rule: "maxFailures",
message: "Failed tests exceed threshold",
testResults: [],
},
]);
await allureReport.done();

expect(AllureServiceClientMock.prototype.uploadReport).toHaveBeenCalledWith(
expect.objectContaining({
files: {
"test-results.json": expect.any(String),
"quality-gate.json": expect.any(String),
},
}),
);
});

const verifyUploadOptionsForwarding = async (uploadConcurrency?: number) => {
const p1 = createPlugin("p1", true, { publish: true });
const config = await resolveConfig({ name: "Allure Report" });
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-api/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export interface PluginContext {
/**
* Reduced test result information shared by report integrations.
*/
export type TestResultSummary = Pick<TestResult, "id" | "name" | "duration" | "status">;
export type TestResultSummary = Pick<TestResult, "id" | "name" | "duration" | "environment" | "status">;

export interface TestResultRegistry {
byId: Record<string, TestResultSummary>;
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-api/src/utils/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const convertToTestResultSummary = (tr: TestResult): TestResultSummary =>
name: tr.name,
duration: tr.duration,
status: tr.status,
...(tr.environment ? { environment: tr.environment } : {}),
});

export const createTestResultRegistry = (testResults: TestResult[]): TestResultRegistry => ({
Expand Down
31 changes: 26 additions & 5 deletions packages/plugin-api/test/summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,25 +30,46 @@ const testResult = (args: Partial<TestResult> = {}): TestResult => ({
describe("summary utils", () => {
it("convertToTestResultSummary maps fields", () => {
expect(
convertToTestResultSummary(testResult({ id: "id-1", name: "name-1", status: "failed", duration: 123 })),
convertToTestResultSummary(
testResult({
id: "id-1",
name: "name-1",
status: "failed",
duration: 123,
environment: "chrome",
}),
),
).toEqual({
id: "id-1",
name: "name-1",
status: "failed",
duration: 123,
environment: "chrome",
});
});

it("createTestResultRegistry indexes reduced test results by id", () => {
expect(
createTestResultRegistry([
testResult({ id: "id-1", name: "name-1", status: "failed", duration: 123 }),
testResult({ id: "id-2", name: "name-2", status: "passed", duration: 456 }),
testResult({
id: "id-1",
name: "name-1",
status: "failed",
duration: 123,
environment: "chrome",
}),
testResult({ id: "id-2", name: "name-2", status: "passed", duration: 456, environment: "firefox" }),
]),
).toEqual({
byId: {
"id-1": { id: "id-1", name: "name-1", duration: 123, status: "failed" },
"id-2": { id: "id-2", name: "name-2", duration: 456, status: "passed" },
"id-1": {
id: "id-1",
name: "name-1",
duration: 123,
environment: "chrome",
status: "failed",
},
"id-2": { id: "id-2", name: "name-2", duration: 456, environment: "firefox", status: "passed" },
},
});
});
Expand Down
2 changes: 1 addition & 1 deletion packages/web-awesome/test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ describe("App", () => {
await waitFor(() => {
expect(screen.queryByTestId("loader")).not.toBeInTheDocument();
});
});
}, 30_000);

it("should fetch env-scoped data when an environment is selected", async () => {
await selectEnvironment("");
Expand Down
Loading