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
39 changes: 38 additions & 1 deletion packages/core/src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export class AllureReport {

#stageTempDirs: string[] = [];
#state?: Record<string, PluginState>;
#storeFiles: Record<string, string> = {};
#executionStage: "init" | "running" | "done" = "init";

readonly reportUuid: string;
Expand Down Expand Up @@ -574,6 +575,33 @@ export class AllureReport {
await context.reportFiles.addFile("summary.json", Buffer.from(JSON.stringify(summary)));
});

// publish shared store files (non-namespaced) once per report
if (this.#allureServiceClient && this.#publish) {
const storeFilesEntries = Object.entries(this.#storeFiles);
const progressBar =
storeFilesEntries?.length > 0
? new ProgressBar(`Publishing shared report store [:bar] :current/:total`, {
total: storeFilesEntries.length,
width: 20,
})
: undefined;
const limitFn = pLimit(50);
const fns = storeFilesEntries.map(([filename, filepath]) =>
limitFn(async () => {
// shared store files are global assets (not tied to a particular plugin)
await this.#allureServiceClient!.addReportAsset({
filename,
filepath,
});

progressBar?.tick?.();
}),
);

progressBar?.render?.();
await Promise.all(fns);
}

if (summaries.length > 1) {
const summaryPath = await generateSummary(this.#output, summaries);
const publishedReports = this.#plugins
Expand Down Expand Up @@ -670,6 +698,7 @@ export class AllureReport {
if (initState) {
// reset state on start;
this.#state = {};
this.#storeFiles = {};
}

for (const { enabled, id, plugin, options } of this.#plugins) {
Expand Down Expand Up @@ -698,14 +727,22 @@ export class AllureReport {

files[key] = filepath;
});
const reportStoreFiles = new PluginFiles(this.#reportFiles, "", (key, filepath) => {
this.#storeFiles[key] = filepath;
});

// Allow plugins to override the report name used in their generated UI (e.g. HTML <title>, report header).
// Fallback to the global report name.
const pluginReportName = (options as { reportName?: string } | undefined)?.reportName ?? this.#reportName;
const pluginContext: PluginContext = {
id,
publish: !!options?.publish,
allureVersion: version,
reportUuid: this.reportUuid,
reportName: this.#reportName,
reportName: pluginReportName,
state: pluginState,
reportFiles: pluginFiles,
reportStoreFiles,
reportUrl: this.reportUrl,
ci: this.#ci,
};
Expand Down
112 changes: 112 additions & 0 deletions packages/core/test/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { BufferResultFile } from "@allurereport/reader-api";
import { generateSummary } from "@allurereport/summary";
import type { Mock, Mocked } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { access, mkdtemp, readdir, realpath, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveConfig } from "../src/index.js";
import { AllureReport } from "../src/report.js";
import { AllureServiceClientMock } from "./utils.js";
Expand Down Expand Up @@ -44,6 +47,19 @@ const createPlugin = (id: string, enabled: boolean = true, options: Record<strin
};
};

const resolveStoreFilePath = async (reportRoot: string, storeRelativePath: string): Promise<string> => {
// Expected layout: `<reportRoot>/data/...`
const underData = join(reportRoot, "data", storeRelativePath);
try {
await access(underData);
return underData;
} catch {
// When report output contains only `data/`, `AllureReport.done()` flattens it to the report root.
// In that case `data/attachments/*` becomes `<reportRoot>/attachments/*`, etc.
return join(reportRoot, storeRelativePath);
}
};

beforeEach(() => {
vi.clearAllMocks();
});
Expand Down Expand Up @@ -125,6 +141,24 @@ describe("report", () => {
expect(p2.plugin.start.mock.invocationCallOrder[0]).toBeLessThan(p3.plugin.start.mock.invocationCallOrder[0]);
});

it("should pass plugin reportName option into plugin context", async () => {
const p1 = createPlugin("p1", true, { reportName: "My Plugin Report" });
const config = await resolveConfig({
name: "Global Report Name",
});
config.plugins?.push(p1);

const allureReport = new AllureReport(config);
await allureReport.start();

expect(p1.plugin.start).toBeCalledTimes(1);
expect(p1.plugin.start).toBeCalledWith(
expect.objectContaining({ reportName: "My Plugin Report" }),
expect.anything(),
expect.anything(),
);
});

it("should not call disabled plugins on start()", async () => {
const p1 = createPlugin("p1");
const p2 = createPlugin("p2", false);
Expand Down Expand Up @@ -248,4 +282,82 @@ describe("report", () => {
},
]);
});

it("should expose a single shared report store for all plugins (no duplicated attachments)", async () => {
const output = await realpath(await mkdtemp(join(tmpdir(), "allure3-report-test-")));

try {
const p1 = createPlugin("p1");
const p2 = createPlugin("p2");

// both plugins write the same attachment into the shared store
p1.plugin.done.mockImplementation(async (context) => {
await context.reportStoreFiles.addFile("data/attachments/shared.txt", Buffer.from("p1", "utf8"));
});
p2.plugin.done.mockImplementation(async (context) => {
await context.reportStoreFiles.addFile("data/attachments/shared.txt", Buffer.from("p2", "utf8"));
});

const config = await resolveConfig({
name: "Allure Report",
output,
});
config.plugins = [p1, p2];

const allureReport = new AllureReport(config);
await allureReport.start();
await allureReport.done();

const reportRoot = output;

// shared store must exist at report root
const sharedAttachmentPath = await resolveStoreFilePath(reportRoot, join("attachments", "shared.txt"));
await expect(access(sharedAttachmentPath)).resolves.toBeUndefined();

// and must not be duplicated inside each plugin directory
await expect(access(join(reportRoot, "p1", "data", "attachments", "shared.txt"))).rejects.toThrow();
await expect(access(join(reportRoot, "p2", "data", "attachments", "shared.txt"))).rejects.toThrow();
} finally {
await rm(output, { recursive: true, force: true });
}
});

it("should expose a single shared report store for all plugins (no duplicated test case json)", async () => {
const output = await realpath(await mkdtemp(join(tmpdir(), "allure3-report-test-")));

try {
const p1 = createPlugin("p1");
const p2 = createPlugin("p2");

// both plugins write the same test case into the shared store
p1.plugin.done.mockImplementation(async (context) => {
await context.reportStoreFiles.addFile("data/test-results/shared.json", Buffer.from("p1", "utf8"));
});
p2.plugin.done.mockImplementation(async (context) => {
await context.reportStoreFiles.addFile("data/test-results/shared.json", Buffer.from("p2", "utf8"));
});

const config = await resolveConfig({
name: "Allure Report",
output,
});
config.plugins = [p1, p2];

const allureReport = new AllureReport(config);
await allureReport.start();
await allureReport.done();

const reportRoot = output;

// shared store must exist at report root
const sharedTrPath = await resolveStoreFilePath(reportRoot, join("test-results", "shared.json"));
await expect(access(sharedTrPath)).resolves.toBeUndefined();

// and must not be duplicated inside each plugin directory
await expect(access(join(reportRoot, "p1", "data", "test-results", "shared.json"))).rejects.toThrow();
await expect(access(join(reportRoot, "p2", "data", "test-results", "shared.json"))).rejects.toThrow();
} finally {
await rm(output, { recursive: true, force: true });
}
});
});
3 changes: 3 additions & 0 deletions packages/plugin-allure2/src/generators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ export const generateStaticFiles = async (payload: {
reportName: reportName ?? "Allure Report",
reportLanguage: reportLanguage ?? "en",
createdAt: Date.now(),
// UI is served from `<report>/<pluginId>/`, while shared store assets are placed at report root
// (e.g. `<report>/data/attachments/*`).
storeBaseUrl: "../",
};

try {
Expand Down
4 changes: 3 additions & 1 deletion packages/plugin-allure2/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export class Allure2Plugin implements Plugin {

#generate = async (context: PluginContext, store: AllureStore) => {
const { reportName = "Allure Report", singleFile = false, reportLanguage = "en" } = this.options ?? {};
const writer = singleFile ? new InMemoryReportDataWriter() : new ReportFileDataWriter(context.reportFiles);
const writer = singleFile
? new InMemoryReportDataWriter()
: new ReportFileDataWriter(context.reportFiles, context.reportStoreFiles);
const attachmentLinks = await store.allAttachments();
const attachmentMap = await generateAttachmentsData(writer, attachmentLinks, (id) =>
store.attachmentContentById(id),
Expand Down
14 changes: 11 additions & 3 deletions packages/plugin-allure2/src/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,14 @@ export class InMemoryReportDataWriter implements Allure2DataWriter {
}

export class ReportFileDataWriter implements Allure2DataWriter {
constructor(readonly reportFiles: ReportFiles) {}
constructor(
readonly reportFiles: ReportFiles,
/**
* Root-level store files shared across all plugins.
* Attachments should go here to avoid duplicating them per plugin.
*/
readonly reportStoreFiles: ReportFiles = reportFiles,
) {}

async writeData(fileName: string, data: any): Promise<void> {
await this.reportFiles.addFile(joinPosix("data", fileName), Buffer.from(JSON.stringify(data), "utf-8"));
Expand All @@ -104,11 +111,12 @@ export class ReportFileDataWriter implements Allure2DataWriter {
return;
}

await this.reportFiles.addFile(joinPosix("data", "attachments", source), contentBuffer);
await this.reportStoreFiles.addFile(joinPosix("data", "attachments", source), contentBuffer);
}

async writeTestCase(test: Allure2TestResult): Promise<void> {
await this.reportFiles.addFile(
// Store-derived test cases are shared across plugins; write them once at report root.
await this.reportStoreFiles.addFile(
joinPosix("data", "test-cases", `${test.uid}.json`),
Buffer.from(JSON.stringify(test), "utf8"),
);
Expand Down
7 changes: 7 additions & 0 deletions packages/plugin-api/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export interface PluginContext {
reportUuid: string;
reportName: string;
reportFiles: ReportFiles;
/**
* Root-level (non-namespaced) report files.
*
* Use this for shared artifacts that must be generated once for the whole report
* (e.g. shared `data/attachments/*`) to avoid duplicating large files per plugin.
*/
reportStoreFiles: ReportFiles;
reportUrl?: string;
ci?: CiDescriptor;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/plugin-awesome/src/generators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,9 @@ export const generateStaticFiles = async (
createdAt: now,
reportUuid,
groupBy: groupBy?.length ? groupBy : [],
// UI is served from `<report>/<pluginId>/`, while shared store assets are placed at report root
// (e.g. `<report>/data/attachments/*`).
storeBaseUrl: "../",
cacheKey: now.toString(),
ci,
layout,
Expand Down
4 changes: 1 addition & 3 deletions packages/plugin-awesome/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
generateQualityGateResults,
generateStaticFiles,
generateStatistic,
generateTestCases,
generateTestEnvGroups,
generateTestResults,
generateTree,
Expand Down Expand Up @@ -71,7 +70,6 @@ export class AwesomePlugin implements Plugin {
: [];

await generateHistoryDataPoints(this.#writer!, store);
await generateTestCases(this.#writer!, convertedTrs);
await generateTree(this.#writer!, "tree.json", treeLabels, convertedTrs, { appendTitlePath });
await generateNav(this.#writer!, convertedTrs, "nav.json");
await generateTestEnvGroups(this.#writer!, allTestEnvGroups);
Expand Down Expand Up @@ -126,7 +124,7 @@ export class AwesomePlugin implements Plugin {
return;
}

this.#writer = new ReportFileDataWriter(context.reportFiles);
this.#writer = new ReportFileDataWriter(context.reportFiles, context.reportStoreFiles);

await Promise.resolve();
};
Expand Down
21 changes: 17 additions & 4 deletions packages/plugin-awesome/src/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,22 @@ export class InMemoryReportDataWriter implements AwesomeDataWriter {
}

export class ReportFileDataWriter implements AwesomeDataWriter {
constructor(readonly reportFiles: ReportFiles) {}
constructor(
readonly reportFiles: ReportFiles,
/**
* Root-level store files shared across all plugins.
* Attachments should go here to avoid duplicating them per plugin.
*/
readonly reportStoreFiles: ReportFiles = reportFiles,
) {}

async writeData(fileName: string, data: any): Promise<void> {
await this.reportFiles.addFile(joinPosix("data", fileName), Buffer.from(JSON.stringify(data), "utf-8"));
// Some data files are store-derived and can be shared across all plugins.
// Currently, this includes:
// - `data/test-env-groups/*` (generated from store environments/groups)
const target = fileName.startsWith("test-env-groups/") ? this.reportStoreFiles : this.reportFiles;

await target.addFile(joinPosix("data", fileName), Buffer.from(JSON.stringify(data), "utf-8"));
}

async writeWidget(fileName: string, data: any): Promise<void> {
Expand All @@ -104,11 +116,12 @@ export class ReportFileDataWriter implements AwesomeDataWriter {
return;
}

await this.reportFiles.addFile(joinPosix("data", "attachments", source), contentBuffer);
await this.reportStoreFiles.addFile(joinPosix("data", "attachments", source), contentBuffer);
}

async writeTestCase(test: AwesomeTestResult): Promise<void> {
await this.reportFiles.addFile(
// Store-derived test cases are shared across plugins; write them once at report root.
await this.reportStoreFiles.addFile(
joinPosix("data", "test-results", `${test.id}.json`),
Buffer.from(JSON.stringify(test), "utf8"),
);
Expand Down
3 changes: 3 additions & 0 deletions packages/plugin-classic/src/generators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,9 @@ export const generateStaticFiles = async (
createdAt: now,
reportUuid,
groupBy: groupBy?.length ? groupBy : ["parentSuite", "suite", "subSuite"],
// UI is served from `<report>/<pluginId>/`, while shared store assets are placed at report root
// (e.g. `<report>/data/attachments/*`).
storeBaseUrl: "../",
allureVersion,
cacheKey: now.toString(),
};
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-classic/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export class ClassicPlugin implements Plugin {
return;
}

this.#writer = new ReportFileDataWriter(context.reportFiles);
this.#writer = new ReportFileDataWriter(context.reportFiles, context.reportStoreFiles);

await Promise.resolve();
};
Expand Down
Loading