diff --git a/packages/core/src/report.ts b/packages/core/src/report.ts index 937f43f870d..2539a9d2ad4 100644 --- a/packages/core/src/report.ts +++ b/packages/core/src/report.ts @@ -63,6 +63,7 @@ export class AllureReport { #stageTempDirs: string[] = []; #state?: Record; + #storeFiles: Record = {}; #executionStage: "init" | "running" | "done" = "init"; readonly reportUuid: string; @@ -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 @@ -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) { @@ -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 , 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, }; diff --git a/packages/core/test/report.test.ts b/packages/core/test/report.test.ts index 9c159a99ce6..e69229c2bee 100644 --- a/packages/core/test/report.test.ts +++ b/packages/core/test/report.test.ts @@ -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"; @@ -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(); }); @@ -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); @@ -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 }); + } + }); }); diff --git a/packages/plugin-allure2/src/generators.ts b/packages/plugin-allure2/src/generators.ts index 16fa104a03f..cd826354f21 100644 --- a/packages/plugin-allure2/src/generators.ts +++ b/packages/plugin-allure2/src/generators.ts @@ -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 { diff --git a/packages/plugin-allure2/src/plugin.ts b/packages/plugin-allure2/src/plugin.ts index 72dd1faf125..bb732dc404d 100644 --- a/packages/plugin-allure2/src/plugin.ts +++ b/packages/plugin-allure2/src/plugin.ts @@ -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), diff --git a/packages/plugin-allure2/src/writer.ts b/packages/plugin-allure2/src/writer.ts index e7ca0a885b7..af1a20b167f 100644 --- a/packages/plugin-allure2/src/writer.ts +++ b/packages/plugin-allure2/src/writer.ts @@ -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")); @@ -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"), ); diff --git a/packages/plugin-api/src/plugin.ts b/packages/plugin-api/src/plugin.ts index 261f4a00540..b76c98d7107 100644 --- a/packages/plugin-api/src/plugin.ts +++ b/packages/plugin-api/src/plugin.ts @@ -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; } diff --git a/packages/plugin-awesome/src/generators.ts b/packages/plugin-awesome/src/generators.ts index 4192b503ae3..4f083212f8c 100644 --- a/packages/plugin-awesome/src/generators.ts +++ b/packages/plugin-awesome/src/generators.ts @@ -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, diff --git a/packages/plugin-awesome/src/plugin.ts b/packages/plugin-awesome/src/plugin.ts index 9e31b8bd940..8c9f88d18b3 100644 --- a/packages/plugin-awesome/src/plugin.ts +++ b/packages/plugin-awesome/src/plugin.ts @@ -21,7 +21,6 @@ import { generateQualityGateResults, generateStaticFiles, generateStatistic, - generateTestCases, generateTestEnvGroups, generateTestResults, generateTree, @@ -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); @@ -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(); }; diff --git a/packages/plugin-awesome/src/writer.ts b/packages/plugin-awesome/src/writer.ts index 3cce7997d6a..e77a08a0905 100644 --- a/packages/plugin-awesome/src/writer.ts +++ b/packages/plugin-awesome/src/writer.ts @@ -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> { @@ -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"), ); diff --git a/packages/plugin-classic/src/generators.ts b/packages/plugin-classic/src/generators.ts index c4edb4054b4..d7a1d6b8ce7 100644 --- a/packages/plugin-classic/src/generators.ts +++ b/packages/plugin-classic/src/generators.ts @@ -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(), }; diff --git a/packages/plugin-classic/src/plugin.ts b/packages/plugin-classic/src/plugin.ts index 4cb2ddb09ed..1d75239e953 100644 --- a/packages/plugin-classic/src/plugin.ts +++ b/packages/plugin-classic/src/plugin.ts @@ -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(); }; diff --git a/packages/plugin-classic/src/writer.ts b/packages/plugin-classic/src/writer.ts index 1bfc31c5849..5ffb722ee0b 100644 --- a/packages/plugin-classic/src/writer.ts +++ b/packages/plugin-classic/src/writer.ts @@ -86,7 +86,14 @@ export class InMemoryReportDataWriter implements ClassicDataWriter { } export class ReportFileDataWriter implements ClassicDataWriter { - 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")); @@ -104,11 +111,12 @@ export class ReportFileDataWriter implements ClassicDataWriter { 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"), ); diff --git a/packages/plugin-jira/test/index.test.ts b/packages/plugin-jira/test/index.test.ts index 9c2e5a0dce7..ce40f5dc6fd 100644 --- a/packages/plugin-jira/test/index.test.ts +++ b/packages/plugin-jira/test/index.test.ts @@ -22,6 +22,7 @@ const defaultPluginContext = { reportUrl: "http://example.com/report", reportUuid: "test-uuid", reportName: "Test Report", + reportStoreFiles: undefined, ci: { jobUrl: "http://ci.example.com/job/123", jobName: "Test Job", diff --git a/packages/summary/src/generators.ts b/packages/summary/src/generators.ts index 43c74dae66b..49e18b072b8 100644 --- a/packages/summary/src/generators.ts +++ b/packages/summary/src/generators.ts @@ -8,6 +8,12 @@ const require = createRequire(import.meta.url); export type TemplateManifest = Record<string, string>; +const escapeJsonForInlineScript = (value: unknown): string => { + // Prevent breaking out of a <script> tag when JSON contains "</script>". + // See https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements + return JSON.stringify(value).replaceAll("</script>", "<\\/script>"); +}; + const template = `<!DOCTYPE html> <html dir="ltr" lang="en"> <head> @@ -21,10 +27,10 @@ const template = `<!DOCTYPE html> <script> window.allure = window.allure || {}; </script> - {{{ bodyTags }}} <script> window.reportSummaries = {{{ reportSummaries }}} </script> + {{{ bodyTags }}} {{{ reportFilesScript }}} </body> </html> @@ -51,6 +57,6 @@ export const generateSummaryStaticFiles = async (payload: { summaries: PluginSum return compile({ bodyTags: bodyTags.join("\n"), analyticsEnable: true, - reportSummaries: JSON.stringify(payload.summaries), + reportSummaries: escapeJsonForInlineScript(payload.summaries), }); }; diff --git a/packages/summary/test/generators.test.ts b/packages/summary/test/generators.test.ts new file mode 100644 index 00000000000..1c873d3e212 --- /dev/null +++ b/packages/summary/test/generators.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import type { PluginSummary } from "@allurereport/plugin-api"; + +import { generateSummaryStaticFiles } from "../src/generators.js"; + +const makeSummary = (i: number): PluginSummary => ({ + name: `Report ${i}`, + stats: { + total: 1, + passed: 1, + failed: 0, + broken: 0, + skipped: 0, + unknown: 0, + }, + status: "passed", + duration: 1, + href: `${i}/`, +}); + +describe("summary generators", () => { + it("should generate HTML with reportSummaries defined before main script when there are >20 summaries", async () => { + const summaries = Array.from({ length: 21 }, (_, i) => makeSummary(i)); + const html = await generateSummaryStaticFiles({ summaries }); + + // Ensure all summaries are embedded (no truncation) + expect(html).toContain("window.reportSummaries"); + expect(html).toContain("Report 20"); + + // Ensure ordering: data comes before app bundle script + const reportSummariesPos = html.indexOf("window.reportSummaries"); + const mainScriptPos = html.indexOf("data:text/javascript;base64,"); + expect(reportSummariesPos).toBeGreaterThan(-1); + expect(mainScriptPos).toBeGreaterThan(-1); + expect(reportSummariesPos).toBeLessThan(mainScriptPos); + }); +}); diff --git a/packages/web-allure2/types.d.ts b/packages/web-allure2/types.d.ts index 6ccb6e49444..6a768c76efe 100644 --- a/packages/web-allure2/types.d.ts +++ b/packages/web-allure2/types.d.ts @@ -2,4 +2,9 @@ export type Allure2ReportOptions = { reportName?: string; reportLanguage?: string; createdAt: number; + /** + * Base URL for shared report store assets (e.g. `data/attachments/*`). + * Useful when the UI is served from a plugin subdirectory. + */ + storeBaseUrl?: string; } diff --git a/packages/web-awesome/types.d.ts b/packages/web-awesome/types.d.ts index 4b1954ab2a1..e10c335bde2 100644 --- a/packages/web-awesome/types.d.ts +++ b/packages/web-awesome/types.d.ts @@ -22,6 +22,11 @@ export type AwesomeReportOptions = { reportLanguage?: "en"; createdAt: number; reportUuid: string; + /** + * Base URL for shared report store assets (e.g. `data/attachments/*`). + * Useful when the UI is served from a plugin subdirectory. + */ + storeBaseUrl?: string; layout?: Layout; defaultSection?: string; sections?: string[]; diff --git a/packages/web-classic/types.d.ts b/packages/web-classic/types.d.ts index cc5f4245735..7fe38d3f782 100644 --- a/packages/web-classic/types.d.ts +++ b/packages/web-classic/types.d.ts @@ -24,6 +24,11 @@ export type ClassicReportOptions = { reportLanguage?: "en"; createdAt: number; reportUuid: string; + /** + * Base URL for shared report store assets (e.g. `data/attachments/*`). + * Useful when the UI is served from a plugin subdirectory. + */ + storeBaseUrl?: string; allureVersion?: string; cacheKey?: string; }; diff --git a/packages/web-commons/src/data.ts b/packages/web-commons/src/data.ts index 49a48ec72ec..dd0821041be 100644 --- a/packages/web-commons/src/data.ts +++ b/packages/web-commons/src/data.ts @@ -40,6 +40,14 @@ export const reportDataUrl = async ( return `data:${contentType};base64,${value}`; } + // When a report is generated into a plugin subdirectory (e.g. `awesome/`), we may want + // to fetch shared store files (e.g. `data/attachments/*`) from the report root. + // `storeBaseUrl` is expected to be a relative URL such as `../`. + if (path.startsWith("data/") && globalThis.allureReportOptions?.storeBaseUrl) { + const base = globalThis.allureReportOptions.storeBaseUrl as string; + path = `${base.replace(/\/+$/, "")}/${path}`; + } + const baseEl = globalThis.document.head.querySelector("base")?.href ?? "https://localhost"; const url = new URL(path, baseEl); const liveReloadHash = globalThis.localStorage.getItem(ALLURE_LIVE_RELOAD_HASH_STORAGE_KEY);