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 packages/core-api/src/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ export const createFaviconLinkTag = (src: string) => {
export const createBaseUrlScript = () => {
return `
<script>
const { origin, pathname } = window.location;
const { origin, pathname } = window.location;
const url = new URL(pathname, origin);
const baseEl = document.createElement("base");

baseEl.href = url.toString();

window.document.head.appendChild(baseEl);
</script>
`;
Expand Down
81 changes: 75 additions & 6 deletions packages/core/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { mkdir, writeFile } from "node:fs/promises";
import { createHash, randomUUID } from "node:crypto";
import { link, mkdir, rename, rm, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { join as joinPosix } from "node:path/posix";

Expand Down Expand Up @@ -58,6 +59,8 @@ export class InMemoryReportFiles implements ReportFiles {

export class FileSystemReportFiles implements ReportFiles {
readonly #output: string;
readonly #contentHashToPath = new Map<string, string>();
readonly #pathToContentHash = new Map<string, string>();
readonly #createdDirs = new Map<string, Promise<string | undefined>>();

constructor(output: string) {
Expand All @@ -67,17 +70,83 @@ export class FileSystemReportFiles implements ReportFiles {
addFile = async (path: string, data: Buffer): Promise<string> => {
const targetPath = resolvePathUnderOutputRoot(this.#output, path);
const targetDirPath = dirname(targetPath);
const contentHash = createHash("sha256").update(data).digest("hex");
const targetPathHash = this.#pathToContentHash.get(targetPath);
const canonicalPath = this.#contentHashToPath.get(contentHash);

let createdDir = this.#createdDirs.get(targetDirPath);
await this.#ensureDir(targetDirPath);

if (targetPathHash === contentHash) {
return targetPath;
}

if (canonicalPath && canonicalPath !== targetPath) {
try {
await this.#replaceWithHardlink(canonicalPath, targetPath);
this.#pathToContentHash.set(targetPath, contentHash);
return targetPath;
} catch (error) {
if (!this.#isRecoverableHardlinkError(error)) {
throw error;
}
}
}

if (targetPathHash && this.#contentHashToPath.get(targetPathHash) === targetPath) {
this.#contentHashToPath.delete(targetPathHash);
}

await this.#replaceWithFile(targetPath, data);
this.#contentHashToPath.set(contentHash, targetPath);
this.#pathToContentHash.set(targetPath, contentHash);

return targetPath;
};

#ensureDir = async (dirPath: string): Promise<void> => {
let createdDir = this.#createdDirs.get(dirPath);

if (!createdDir) {
createdDir = mkdir(targetDirPath, { recursive: true });
this.#createdDirs.set(targetDirPath, createdDir);
createdDir = mkdir(dirPath, { recursive: true });
this.#createdDirs.set(dirPath, createdDir);
}

await createdDir;
await writeFile(targetPath, data, { encoding: "utf-8" });
};

return targetPath;
#replaceWithFile = async (targetPath: string, data: Buffer): Promise<void> => {
const tempPath = `${targetPath}.${randomUUID()}.tmp`;

try {
await writeFile(tempPath, data, { encoding: "utf-8" });
await rename(tempPath, targetPath);
} finally {
await rm(tempPath, { force: true });
}
};

#replaceWithHardlink = async (canonicalPath: string, targetPath: string): Promise<void> => {
const tempPath = `${targetPath}.${randomUUID()}.tmp`;

try {
await link(canonicalPath, tempPath);
await rename(tempPath, targetPath);
} finally {
await rm(tempPath, { force: true });
}
};

#isRecoverableHardlinkError = (error: unknown): boolean => {
if (!error || typeof error !== "object" || !("code" in error)) {
return false;
}

return (
error.code === "EXDEV" ||
error.code === "EPERM" ||
error.code === "EEXIST" ||
error.code === "ENOENT" ||
error.code === "EACCES"
);
};
}
104 changes: 104 additions & 0 deletions packages/core/test/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { mkdtemp, readFile, rm, stat, unlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterEach, describe, expect, it } from "vitest";

import { FileSystemReportFiles } from "../src/plugin.js";

describe("FileSystemReportFiles", () => {
const temporaryDirectories: string[] = [];

afterEach(async () => {
for (const directoryPath of temporaryDirectories) {
await rm(directoryPath, { recursive: true, force: true });
}
temporaryDirectories.length = 0;
});

const createWriter = async () => {
const outputDirectory = await mkdtemp(join(tmpdir(), "allure-core-plugin-test-"));

temporaryDirectories.push(outputDirectory);

return {
outputDirectory,
writer: new FileSystemReportFiles(outputDirectory),
};
};

it("uses hardlinks for equal payloads written into different paths", async () => {
const { writer } = await createWriter();

const sourcePath = await writer.addFile("report1/main.js", Buffer.from("shared-content", "utf8"));
const targetPath = await writer.addFile("report2/main.js", Buffer.from("shared-content", "utf8"));
const sourceStat = await stat(sourcePath);
const targetStat = await stat(targetPath);

expect(await readFile(sourcePath, "utf8")).toBe("shared-content");
expect(await readFile(targetPath, "utf8")).toBe("shared-content");

if (process.platform !== "win32") {
expect(sourceStat.ino).toBe(targetStat.ino);
}
});

it("rewrites only a target file when shared link receives a different payload", async () => {
const { writer } = await createWriter();

const sourcePath = await writer.addFile("report1/main.css", Buffer.from("same-content", "utf8"));
const targetPath = await writer.addFile("report2/main.css", Buffer.from("same-content", "utf8"));

await writer.addFile("report2/main.css", Buffer.from("updated-content", "utf8"));

expect(await readFile(sourcePath, "utf8")).toBe("same-content");
expect(await readFile(targetPath, "utf8")).toBe("updated-content");

if (process.platform !== "win32") {
const sourceStat = await stat(sourcePath);
const targetStat = await stat(targetPath);

expect(sourceStat.ino).not.toBe(targetStat.ino);
}
});

it("falls back to regular write when canonical hardlink source disappears", async () => {
const { writer } = await createWriter();

const canonicalPath = await writer.addFile("report1/asset.js", Buffer.from("content-to-share", "utf8"));

await unlink(canonicalPath);

const fallbackPath = await writer.addFile("report2/asset.js", Buffer.from("content-to-share", "utf8"));

expect(await readFile(fallbackPath, "utf8")).toBe("content-to-share");
});

it("does not rewrite file when path receives identical content again", async () => {
const { writer } = await createWriter();

const targetPath = await writer.addFile("awesome/main.js", Buffer.from("stable-shared-asset", "utf8"));
const firstStat = await stat(targetPath);

await writer.addFile("awesome/main.js", Buffer.from("stable-shared-asset", "utf8"));

if (process.platform !== "win32") {
const secondStat = await stat(targetPath);

expect(firstStat.ino).toBe(secondStat.ino);
}
});

it("does not reuse rewritten canonical path for the old payload", async () => {
const { writer } = await createWriter();

const canonicalPath = await writer.addFile("report1/asset.txt", Buffer.from("old-content", "utf8"));

await writer.addFile("report1/asset.txt", Buffer.from("new-content", "utf8"));

const targetPath = await writer.addFile("report2/asset.txt", Buffer.from("old-content", "utf8"));

expect(await readFile(canonicalPath, "utf8")).toBe("new-content");
expect(await readFile(targetPath, "utf8")).toBe("old-content");
});
});
78 changes: 77 additions & 1 deletion packages/e2e/test/commons/output.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readdir } from "node:fs/promises";
import { readFile, readdir, stat } from "node:fs/promises";
import { resolve } from "node:path";

import AwesomePlugin from "@allurereport/plugin-awesome";
import { expect, test } from "@playwright/test";
Expand Down Expand Up @@ -134,4 +135,79 @@ test.describe("output", () => {
expect(reportDirFiles.find((dirent) => dirent.name === "awesome1" && !dirent.isFile())).not.toBeUndefined();
expect(reportDirFiles.find((dirent) => dirent.name === "awesome2" && !dirent.isFile())).not.toBeUndefined();
});

test("should keep attachment and static assets accessible for every awesome report without duplicating inode", async () => {
bootstrap = await bootstrapReport({
reportConfig: {
name: "Sample allure report",
appendHistory: false,
knownIssuesPath: undefined,
plugins: [
{
id: "awesome1",
enabled: true,
plugin: new AwesomePlugin({}),
options: {},
},
{
id: "awesome2",
enabled: true,
plugin: new AwesomePlugin({}),
options: {},
},
],
},
testResults: [
{
name: "0 sample passed test",
fullName: "sample.js#0 sample passed test",
status: Status.PASSED,
stage: Stage.FINISHED,
start: 1000,
},
],
globals: {
attachments: {
"global-shared.txt": Buffer.from("global-shared-content", "utf8"),
},
},
});

const reportOneAssets = await readdir(resolve(bootstrap.reportDir, "awesome1"));
const reportTwoAssets = await readdir(resolve(bootstrap.reportDir, "awesome2"));
const reportOneAttachmentFiles = await readdir(resolve(bootstrap.reportDir, "awesome1", "data", "attachments"));
const reportTwoAttachmentFiles = await readdir(resolve(bootstrap.reportDir, "awesome2", "data", "attachments"));
const staticAssets = reportOneAssets.filter((assetName) => assetName.endsWith(".js") || assetName.endsWith(".css"));

expect(staticAssets.length).toBeGreaterThan(0);
expect(reportTwoAssets).toEqual(expect.arrayContaining(staticAssets));
expect(reportOneAttachmentFiles).toHaveLength(1);
expect(reportTwoAttachmentFiles).toHaveLength(1);
expect(reportOneAttachmentFiles[0]).toBe(reportTwoAttachmentFiles[0]);

const reportOneAttachmentPath = resolve(
bootstrap.reportDir,
"awesome1",
"data",
"attachments",
reportOneAttachmentFiles[0],
);
const reportTwoAttachmentPath = resolve(
bootstrap.reportDir,
"awesome2",
"data",
"attachments",
reportTwoAttachmentFiles[0],
);

expect(await readFile(reportOneAttachmentPath, "utf8")).toBe("global-shared-content");
expect(await readFile(reportTwoAttachmentPath, "utf8")).toBe("global-shared-content");

if (process.platform !== "win32") {
const firstAttachmentStat = await stat(reportOneAttachmentPath);
const secondAttachmentStat = await stat(reportTwoAttachmentPath);

expect(firstAttachmentStat.ino).toBe(secondAttachmentStat.ino);
}
});
});
Loading