Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
32 changes: 20 additions & 12 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 @@ -263,6 +265,9 @@ export class AllureReport {
}

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

Expand All @@ -285,9 +290,10 @@ export class AllureReport {
const summariesSnapshot = this.#cloneSummariesByPluginId();
const uploadProgressMessage =
reportsToPublish.length === 1 ? `Publishing "${reportsToPublish[0].pluginId}" report` : "Publishing reports";
const rootRemoteReportFiles = this.#getRootRemoteReportFiles();
const totalFilesToUpload =
reportsToPublish.reduce((acc, report) => acc + Object.keys(report.files).length, 0) +
(this.#testResultsRegistryPath ? 1 : 0);
Object.keys(rootRemoteReportFiles).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 +339,12 @@ export class AllureReport {
}
}

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

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

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

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

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 +1142,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 +1184,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
110 changes: 101 additions & 9 deletions packages/core/test/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,17 +145,51 @@ describe("report", () => {
const registry = JSON.parse(await readFile(join(output, "test-results.json"), "utf8"));
const id = md5("result-1");

expect(registry).toEqual({
byId: {
[id]: {
id,
name: "failed test",
duration: 123,
status: "failed",
},
const registryEntry = registry.byId[id];

expect(registryEntry).toEqual(
expect.objectContaining({
id,
name: "failed test",
duration: 123,
status: "failed",
}),
);
expect(registryEntry).not.toHaveProperty("labels");
expect(registryEntry).not.toHaveProperty("steps");
expect(registryEntry).not.toHaveProperty("attachments");
expect(registryEntry).not.toHaveProperty("error");
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: [],
},
});
await expect(readFile(join(output, "index.html"), "utf8")).resolves.toBe("index");
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 () => {
Expand Down Expand Up @@ -784,6 +818,64 @@ describe("report", () => {
);
});

it("should keep quality gate results local and omit them from remote root uploads", async () => {
const output = await mkdtemp(join(tmpdir(), "allure3-quality-gate-local-only-"));
const p1 = createPlugin("p1", true, { publish: true });
const config = await resolveConfig({
name: "Allure Report",
output,
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();

const qualityGateResults = JSON.parse(await readFile(join(output, "quality-gate.json"), "utf8"));
const uploadedRootFiles = (AllureServiceClientMock.prototype.uploadReport as Mock).mock.calls
.map(([options]) => options as { pluginId?: string; files: Record<string, string> })
.filter(({ pluginId }) => pluginId === undefined)
.map(({ files }) => files);

expect(qualityGateResults).toEqual([
{
success: false,
expected: 0,
actual: 1,
rule: "maxFailures",
message: "Failed tests exceed threshold",
testResults: [],
},
]);
expect(AllureServiceClientMock.prototype.uploadReport).toHaveBeenCalledWith(
expect.objectContaining({
files: { "test-results.json": expect.any(String) },
}),
);
expect(uploadedRootFiles).toEqual([{ "test-results.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