Skip to content

Commit a493f85

Browse files
committed
reuse files when generate report within hardlinks(#545)
1 parent f210cb0 commit a493f85

2 files changed

Lines changed: 128 additions & 3 deletions

File tree

packages/core/src/plugin.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { mkdir, writeFile } from "node:fs/promises";
1+
import { createHash, randomUUID } from "node:crypto";
2+
import { link, mkdir, rename, rm, writeFile } from "node:fs/promises";
23
import { dirname, resolve } from "node:path";
34
import { join as joinPosix } from "node:path/posix";
45

@@ -56,6 +57,8 @@ export class InMemoryReportFiles implements ReportFiles {
5657

5758
export class FileSystemReportFiles implements ReportFiles {
5859
readonly #output: string;
60+
readonly #contentHashToPath = new Map<string, string>();
61+
readonly #pathToContentHash = new Map<string, string>();
5962

6063
constructor(output: string) {
6164
this.#output = resolve(output);
@@ -64,10 +67,68 @@ export class FileSystemReportFiles implements ReportFiles {
6467
addFile = async (path: string, data: Buffer): Promise<string> => {
6568
const targetPath = resolve(this.#output, path);
6669
const targetDirPath = dirname(targetPath);
70+
const contentHash = createHash("sha256").update(data).digest("hex");
71+
const targetPathHash = this.#pathToContentHash.get(targetPath);
72+
const canonicalPath = this.#contentHashToPath.get(contentHash);
6773

6874
await mkdir(targetDirPath, { recursive: true });
69-
await writeFile(targetPath, data, { encoding: "utf-8" });
75+
76+
if (targetPathHash === contentHash) {
77+
return targetPath;
78+
}
79+
80+
if (canonicalPath && canonicalPath !== targetPath) {
81+
try {
82+
await this.#replaceWithHardlink(canonicalPath, targetPath);
83+
this.#pathToContentHash.set(targetPath, contentHash);
84+
return targetPath;
85+
} catch (error) {
86+
if (!this.#isRecoverableHardlinkError(error)) {
87+
throw error;
88+
}
89+
}
90+
}
91+
92+
await this.#replaceWithFile(targetPath, data);
93+
this.#contentHashToPath.set(contentHash, targetPath);
94+
this.#pathToContentHash.set(targetPath, contentHash);
7095

7196
return targetPath;
7297
};
98+
99+
#replaceWithFile = async (targetPath: string, data: Buffer): Promise<void> => {
100+
const tempPath = `${targetPath}.${randomUUID()}.tmp`;
101+
102+
try {
103+
await writeFile(tempPath, data, { encoding: "utf-8" });
104+
await rename(tempPath, targetPath);
105+
} finally {
106+
await rm(tempPath, { force: true });
107+
}
108+
};
109+
110+
#replaceWithHardlink = async (canonicalPath: string, targetPath: string): Promise<void> => {
111+
const tempPath = `${targetPath}.${randomUUID()}.tmp`;
112+
113+
try {
114+
await link(canonicalPath, tempPath);
115+
await rename(tempPath, targetPath);
116+
} finally {
117+
await rm(tempPath, { force: true });
118+
}
119+
};
120+
121+
#isRecoverableHardlinkError = (error: unknown): boolean => {
122+
if (!error || typeof error !== "object" || !("code" in error)) {
123+
return false;
124+
}
125+
126+
return (
127+
error.code === "EXDEV" ||
128+
error.code === "EPERM" ||
129+
error.code === "EEXIST" ||
130+
error.code === "ENOENT" ||
131+
error.code === "EACCES"
132+
);
133+
};
73134
}

packages/e2e/test/commons/output.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { readdir } from "node:fs/promises";
1+
import { readFile, readdir, stat } from "node:fs/promises";
2+
import { resolve } from "node:path";
23

34
import AwesomePlugin from "@allurereport/plugin-awesome";
45
import { expect, test } from "@playwright/test";
@@ -130,4 +131,67 @@ test.describe("output", () => {
130131
expect(reportDirFiles.find((dirent) => dirent.name === "awesome1" && !dirent.isFile())).not.toBeUndefined();
131132
expect(reportDirFiles.find((dirent) => dirent.name === "awesome2" && !dirent.isFile())).not.toBeUndefined();
132133
});
134+
135+
test("should keep attachment and static assets accessible for every awesome report without duplicating inode", async () => {
136+
bootstrap = await bootstrapReport({
137+
reportConfig: {
138+
name: "Sample allure report",
139+
appendHistory: false,
140+
knownIssuesPath: undefined,
141+
plugins: [
142+
{
143+
id: "awesome1",
144+
enabled: true,
145+
plugin: new AwesomePlugin({}),
146+
options: {},
147+
},
148+
{
149+
id: "awesome2",
150+
enabled: true,
151+
plugin: new AwesomePlugin({}),
152+
options: {},
153+
},
154+
],
155+
},
156+
testResults: [
157+
{
158+
name: "0 sample passed test",
159+
fullName: "sample.js#0 sample passed test",
160+
status: Status.PASSED,
161+
stage: Stage.FINISHED,
162+
start: 1000,
163+
},
164+
],
165+
globals: {
166+
attachments: {
167+
"global-shared.txt": Buffer.from("global-shared-content", "utf8"),
168+
},
169+
},
170+
});
171+
172+
const reportOneAssets = await readdir(resolve(bootstrap.reportDir, "awesome1"));
173+
const reportTwoAssets = await readdir(resolve(bootstrap.reportDir, "awesome2"));
174+
const reportOneAttachmentFiles = await readdir(resolve(bootstrap.reportDir, "awesome1", "data", "attachments"));
175+
const reportTwoAttachmentFiles = await readdir(resolve(bootstrap.reportDir, "awesome2", "data", "attachments"));
176+
const staticAssets = reportOneAssets.filter((assetName) => assetName.endsWith(".js") || assetName.endsWith(".css"));
177+
178+
expect(staticAssets.length).toBeGreaterThan(0);
179+
expect(reportTwoAssets).toEqual(expect.arrayContaining(staticAssets));
180+
expect(reportOneAttachmentFiles).toHaveLength(1);
181+
expect(reportTwoAttachmentFiles).toHaveLength(1);
182+
expect(reportOneAttachmentFiles[0]).toBe(reportTwoAttachmentFiles[0]);
183+
184+
const reportOneAttachmentPath = resolve(bootstrap.reportDir, "awesome1", "data", "attachments", reportOneAttachmentFiles[0]);
185+
const reportTwoAttachmentPath = resolve(bootstrap.reportDir, "awesome2", "data", "attachments", reportTwoAttachmentFiles[0]);
186+
187+
expect(await readFile(reportOneAttachmentPath, "utf8")).toBe("global-shared-content");
188+
expect(await readFile(reportTwoAttachmentPath, "utf8")).toBe("global-shared-content");
189+
190+
if (process.platform !== "win32") {
191+
const firstAttachmentStat = await stat(reportOneAttachmentPath);
192+
const secondAttachmentStat = await stat(reportTwoAttachmentPath);
193+
194+
expect(firstAttachmentStat.ino).toBe(secondAttachmentStat.ino);
195+
}
196+
});
133197
});

0 commit comments

Comments
 (0)