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
24 changes: 2 additions & 22 deletions packages/cli/src/commands/results/pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { realpath } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import { exit } from "node:process";

import { formatByteSize } from "@allurereport/core-api";
import AdmZip from "adm-zip";
import { Command, Option } from "clipanion";
import { glob } from "glob";
Expand Down Expand Up @@ -38,27 +39,6 @@ export class ResultsPackCommand extends Command {
description: "The working directory for the command to run (default: current working directory)",
});

/**
* Formats a size in bytes to a human-readable string with appropriate unit (B, KB, MB, GB)
* @param bytes
*/
#formatSize(bytes: number): string {
const units = ["bytes", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;

while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}

if (bytes === 0) {
return "0 bytes";
}

return unitIndex === 0 ? `${Math.round(size)} ${units[unitIndex]}` : `${size.toFixed(2)} ${units[unitIndex]}`;
}

async execute() {
const cwd = await realpath(this.cwd ?? process.cwd());
const resultsDir = (this.resultsDir ?? "./**/allure-results").replace(/[\\/]$/, "");
Expand Down Expand Up @@ -116,7 +96,7 @@ export class ResultsPackCommand extends Command {

console.log(green(`Archive created successfully: ${outputPath}`));
console.log(
green(`Total size: ${this.#formatSize(stats.size)}. ${resultsFiles.size} results files have been collected`),
green(`Total size: ${formatByteSize(stats.size)}. ${resultsFiles.size} results files have been collected`),
);
} catch (err) {
console.log(red(`Error creating archive: ${(err as Error).message}`));
Expand Down
1 change: 1 addition & 0 deletions packages/core-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ export * from "./utils/strings.js";
export * from "./utils/dictionary.js";
export * from "./utils/path.js";
export * from "./utils/url.js";
export * from "./utils/size.js";
73 changes: 73 additions & 0 deletions packages/core-api/src/utils/size.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/** Per-attachment raw size at or above which single-file mode writes externally. */
export const HEAVY_ATTACHMENT_BYTES = 1_048_576; // 1 MiB

/** Soft warning when single-file HTML is this large or larger. */
export const LARGE_REPORT_WARN_BYTES = 50 * 1024 * 1024; // 50 MiB

/** Stronger warning when single-file HTML is this large or larger. */
export const LARGE_REPORT_SEVERE_BYTES = 100 * 1024 * 1024; // 100 MiB

/**
* Formats a size in bytes to a human-readable string (B, KB, MB, GB) using 1024-based units.
*/
export const formatByteSize = (bytes: number): string => {
const units = ["bytes", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;

while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}

if (bytes === 0) {
return "0 bytes";
}

return unitIndex === 0 ? `${Math.round(size)} ${units[unitIndex]}` : `${size.toFixed(2)} ${units[unitIndex]}`;
};

/**
* Whether an attachment raw size should be treated as heavy for single-file hybrid mode.
* Missing / non-finite sizes are not heavy (caller should re-check after reading the buffer).
*/
export const isHeavyAttachment = (
sizeBytes: number | undefined | null,
thresholdBytes: number = HEAVY_ATTACHMENT_BYTES,
): boolean => {
if (sizeBytes == null || !Number.isFinite(sizeBytes)) {
return false;
}

return sizeBytes >= thresholdBytes;
};

/** Rough base64-in-HTML size estimate (raw * 4/3). */
export const estimateBase64EmbeddedSize = (rawBytes: number): number => Math.ceil((rawBytes * 4) / 3);

/**
* Warn when a single-file HTML payload is large enough to hurt load performance.
* Does not throw; hard generation failures remain the caller's RangeError handling.
*/
export const warnIfLargeSingleFileReport = (
htmlByteLength: number,
log: (message: string) => void = console.warn,
): void => {
if (htmlByteLength >= LARGE_REPORT_SEVERE_BYTES) {
log(
`Single-file report is very large (${formatByteSize(htmlByteLength)}). ` +
`Performance issues are expected beyond ~${formatByteSize(LARGE_REPORT_SEVERE_BYTES)}. ` +
"Prefer multi-file mode or ensure heavy attachments are written as external files. " +
"Serve the report over HTTP (e.g. `allure open`) rather than opening the HTML via file://.",
);
return;
}

if (htmlByteLength >= LARGE_REPORT_WARN_BYTES) {
log(
`Single-file report is large (${formatByteSize(htmlByteLength)}). ` +
`Load performance may degrade beyond ~${formatByteSize(LARGE_REPORT_WARN_BYTES)}. ` +
"Consider multi-file mode for reports with heavy attachments.",
);
}
};
88 changes: 88 additions & 0 deletions packages/core-api/test/utils/size.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { epic, feature, label, story } from "allure-js-commons";
import { beforeEach, describe, expect, it, vi } from "vitest";

import {
HEAVY_ATTACHMENT_BYTES,
LARGE_REPORT_SEVERE_BYTES,
LARGE_REPORT_WARN_BYTES,
estimateBase64EmbeddedSize,
formatByteSize,
isHeavyAttachment,
warnIfLargeSingleFileReport,
} from "../../src/index.js";

beforeEach(async () => {
await epic("coverage");
await feature("report-data-model");
await story("size");
await label("coverage", "report-data-model");
});

describe("formatByteSize", () => {
it("formats zero and sub-kilobyte sizes", () => {
expect(formatByteSize(0)).toBe("0 bytes");
expect(formatByteSize(512)).toBe("512 bytes");
});

it("formats KB, MB, and GB with 1024-based units", () => {
expect(formatByteSize(1024)).toBe("1.00 KB");
expect(formatByteSize(HEAVY_ATTACHMENT_BYTES)).toBe("1.00 MB");
expect(formatByteSize(LARGE_REPORT_WARN_BYTES)).toBe("50.00 MB");
expect(formatByteSize(1024 ** 3)).toBe("1.00 GB");
});
});

describe("isHeavyAttachment", () => {
it("returns false for missing or non-finite sizes", () => {
expect(isHeavyAttachment(undefined)).toBe(false);
expect(isHeavyAttachment(null)).toBe(false);
expect(isHeavyAttachment(Number.NaN)).toBe(false);
});

it("uses the default 1 MiB threshold", () => {
expect(isHeavyAttachment(HEAVY_ATTACHMENT_BYTES - 1)).toBe(false);
expect(isHeavyAttachment(HEAVY_ATTACHMENT_BYTES)).toBe(true);
});

it("accepts a custom threshold", () => {
expect(isHeavyAttachment(100, 100)).toBe(true);
expect(isHeavyAttachment(99, 100)).toBe(false);
});
});

describe("estimateBase64EmbeddedSize", () => {
it("estimates 4/3 expansion", () => {
expect(estimateBase64EmbeddedSize(3)).toBe(4);
expect(estimateBase64EmbeddedSize(HEAVY_ATTACHMENT_BYTES)).toBe(Math.ceil((HEAVY_ATTACHMENT_BYTES * 4) / 3));
});
});

describe("warnIfLargeSingleFileReport", () => {
it("does not warn below the soft threshold", () => {
const log = vi.fn();

warnIfLargeSingleFileReport(LARGE_REPORT_WARN_BYTES - 1, log);

expect(log).not.toHaveBeenCalled();
});

it("warns at the soft threshold", () => {
const log = vi.fn();

warnIfLargeSingleFileReport(LARGE_REPORT_WARN_BYTES, log);

expect(log).toHaveBeenCalledTimes(1);
expect(log.mock.calls[0][0]).toContain("is large");
expect(log.mock.calls[0][0]).toContain("50.00 MB");
});

it("uses the severe message at the hard threshold", () => {
const log = vi.fn();

warnIfLargeSingleFileReport(LARGE_REPORT_SEVERE_BYTES, log);

expect(log).toHaveBeenCalledTimes(1);
expect(log.mock.calls[0][0]).toContain("very large");
expect(log.mock.calls[0][0]).toContain("100.00 MB");
});
});
2 changes: 1 addition & 1 deletion packages/plugin-awesome/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ The plugin accepts the following options:
| Option | Description | Type | Default |
|----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------|-------------------------------|
| `reportName` | Name of the report | `string` | `Allure Report` |
| `singleFile` | Writes the report as a single `index.html` file | `boolean` | `false` |
| `singleFile` | Writes the report as a single `index.html` file. Attachments of **1 MiB or larger** are written next to the report under `data/attachments/` (hybrid mode) so the HTML stays loadable; open via `allure open` / HTTP rather than `file://` when external attachments exist. The CLI warns if the HTML exceeds ~50–100 MiB. | `boolean` | `false` |
| `logo` | Path to the logo image | `string` | `null` |
| `theme` | Default color theme of the report | `light \| dark \| auto` | `auto` OS theme |
| `reportLanguage` | Default language of the report | `string` | OS language |
Expand Down
90 changes: 85 additions & 5 deletions packages/plugin-awesome/src/generators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ import {
stringifyForInlineScript,
createScriptTag,
createStylesLinkTag,
HEAVY_ATTACHMENT_BYTES,
incrementStatistic,
isHeavyAttachment,
joinPosixPath,
nullsLast,
ordinal,
warnIfLargeSingleFileReport,
} from "@allurereport/core-api";
import type {
AllureStore,
Expand Down Expand Up @@ -520,12 +523,37 @@ export const generateStatistic = async (
}
};

export type GenerateAttachmentsFilesOptions = {
/**
* When set, attachments at or above the heavy threshold are written here
* (sibling files) instead of into the in-memory single-file embed map.
*/
externalWriter?: AwesomeDataWriter;
/**
* Raw size threshold for hybrid single-file externalization.
* @default HEAVY_ATTACHMENT_BYTES (1 MiB)
*/
heavyAttachmentBytes?: number;
};

export type GenerateAttachmentsFilesResult = {
byId: Map<string, string>;
/** Attachments written next to the report instead of embedded in index.html */
externalCount: number;
externalBytes: number;
};

export const generateAttachmentsFiles = async (
writer: AwesomeDataWriter,
attachmentLinks: AttachmentLink[],
contentFunction: (id: string) => Promise<ResultFile | undefined>,
) => {
options: GenerateAttachmentsFilesOptions = {},
): Promise<GenerateAttachmentsFilesResult> => {
const { externalWriter, heavyAttachmentBytes = HEAVY_ATTACHMENT_BYTES } = options;
const result = new Map<string, string>();
let externalCount = 0;
let externalBytes = 0;

for (const { id, ext, ...link } of attachmentLinks) {
if (link.missed) {
continue;
Expand All @@ -536,10 +564,31 @@ export const generateAttachmentsFiles = async (
continue;
}
const src = `${id}${ext}`;
await writer.writeAttachment(src, content);
let targetWriter = writer;

if (externalWriter) {
const sizeFromMeta =
"contentLength" in link && typeof link.contentLength === "number" ? link.contentLength : undefined;
let size = sizeFromMeta ?? content.getContentLength?.();

// Metadata can be missing or wrong; verify against the buffer when needed.
if (!isHeavyAttachment(size, heavyAttachmentBytes)) {
const buffer = await content.asBuffer();
size = buffer?.byteLength ?? size;
}

if (isHeavyAttachment(size, heavyAttachmentBytes)) {
targetWriter = externalWriter;
externalCount += 1;
externalBytes += size ?? 0;
}
}

await targetWriter.writeAttachment(src, content);
result.set(id, src);
}
return result;

return { byId: result, externalCount, externalBytes };
};

export const generateHistoryDataPoints = async (writer: AwesomeDataWriter, store: AllureStore) => {
Expand All @@ -562,6 +611,11 @@ export const generateGlobals = async (
globalErrors?: PluginGlobalError[];
globalErrorsByEnv?: Record<string, PluginGlobalError[]>;
contentFunction: (id: string) => Promise<ResultFile | undefined>;
/**
* When set, heavy global attachments are written here instead of the in-memory embed map.
*/
externalWriter?: AwesomeDataWriter;
heavyAttachmentBytes?: number;
},
) => {
const {
Expand All @@ -582,6 +636,8 @@ export const generateGlobals = async (
globals.exitCode = globalExitCode;
}

const { externalWriter, heavyAttachmentBytes = HEAVY_ATTACHMENT_BYTES } = payload;

for (const attachment of globalAttachments) {
const src = `${attachment.id}${attachment.ext}`;
const content = await contentFunction(attachment.id);
Expand All @@ -590,7 +646,25 @@ export const generateGlobals = async (
continue;
}

await writer.writeAttachment(src, content);
let targetWriter = writer;

if (externalWriter) {
let size =
"contentLength" in attachment && typeof attachment.contentLength === "number"
? attachment.contentLength
: content.getContentLength();

if (!isHeavyAttachment(size, heavyAttachmentBytes)) {
const buffer = await content.asBuffer();
size = buffer?.byteLength ?? size;
}

if (isHeavyAttachment(size, heavyAttachmentBytes)) {
targetWriter = externalWriter;
}
}

await targetWriter.writeAttachment(src, content);

globals.attachments.push(attachment);
}
Expand Down Expand Up @@ -736,7 +810,13 @@ export const generateStaticFiles = async (
singleFile: payload.singleFile,
});

await reportFiles.addFile("index.html", Buffer.from(html, "utf8"));
const htmlBuffer = Buffer.from(html, "utf8");

if (payload.singleFile) {
warnIfLargeSingleFileReport(htmlBuffer.byteLength);
}

await reportFiles.addFile("index.html", htmlBuffer);
} catch (err) {
if (err instanceof RangeError) {
// eslint-disable-next-line no-console
Expand Down
Loading