test(artifacts): verify extraction races with real filesystem changes
Co-authored-by: dakjdakd <164857476+dakjdakd@users.noreply.github.com>
This commit is contained in:
parent
f6cfbfcc55
commit
1ac8389c5c
|
|
@ -415,6 +415,7 @@ jobs:
|
|||
tests/boundary/filesystem/binaryTarget.test.ts
|
||||
tests/boundary/filesystem/safeOutputTree.test.ts
|
||||
tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts
|
||||
tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts
|
||||
tests/boundary/filesystem/setupUnavailableProvider.test.ts
|
||||
tests/boundary/filesystem/windowsNpxRegistration.test.ts
|
||||
tests/boundary/filesystem/webScriptManifestPaths.test.ts
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { watch } from "node:fs";
|
||||
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
|
||||
import { createPackage, createPackageWithOptions } from "@electron/asar";
|
||||
import { expect, it, onTestFinished, vi } from "vitest";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import { SafeOutputTree } from "../../../../src/artifacts/SafeOutputTree.js";
|
||||
import { artifactExtractionResultSchema } from "../../../../src/domain/artifactGraph.js";
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
|
@ -17,10 +18,11 @@ it.each(["file", "directory"] as const)(
|
|||
const source = join(root, "source");
|
||||
await mkdir(join(source, "z"), { recursive: true });
|
||||
// Equal bytes share an artifact node, but both occurrences must be copied.
|
||||
await writeFile(join(source, "a.txt"), "same bytes\n");
|
||||
await writeFile(join(source, "z", "missing.txt"), "same bytes\n");
|
||||
const bytes = Buffer.alloc(8 * 1024 * 1024, "same bytes\n");
|
||||
await writeFile(join(source, "a.txt"), bytes);
|
||||
await writeFile(join(source, "z", "missing.txt"), bytes);
|
||||
const output = join(root, "output");
|
||||
afterInventory(async () => {
|
||||
const changed = afterInventory(output, async () => {
|
||||
await rm(
|
||||
removedKind === "file"
|
||||
? join(source, "z", "missing.txt")
|
||||
|
|
@ -30,18 +32,36 @@ it.each(["file", "directory"] as const)(
|
|||
});
|
||||
|
||||
const result = await extract(source, output);
|
||||
await changed();
|
||||
if (result.ok) throw new Error("Incomplete extraction must fail");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected).toMatchObject({
|
||||
code: "artifact_operation_failed",
|
||||
details: {
|
||||
operation: "extract_artifact",
|
||||
},
|
||||
});
|
||||
// A directory already returned by readdir can disappear before its stat.
|
||||
// That earlier reader failure retains ENOENT instead of reaching the final
|
||||
// completeness check. Both paths must reject publication and roll back.
|
||||
if (removedKind === "directory" && projected.details?.reason === "io") {
|
||||
expect(projected.details).toMatchObject({
|
||||
reason: "io",
|
||||
detail: `Could not inspect entry at ${join(source, "z")} (ENOENT)`,
|
||||
});
|
||||
} else {
|
||||
expect(projected.details).toMatchObject({
|
||||
reason: "integrity",
|
||||
detail:
|
||||
"Inventoried regular artifact entries were not materialized: z/missing.txt",
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
await expect(access(output)).rejects.toThrow();
|
||||
expect(await readFile(join(source, "a.txt"), "utf8")).toBe("same bytes\n");
|
||||
expect(
|
||||
createHash("sha256")
|
||||
.update(await readFile(join(source, "a.txt")))
|
||||
.digest("hex"),
|
||||
).toBe(createHash("sha256").update(bytes).digest("hex"));
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -49,12 +69,16 @@ it("still refuses a regular file added after inventory and rolls back", async ()
|
|||
const root = await createTestTempDirectory("rea-extract-added-");
|
||||
const source = join(root, "source");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "a.txt"), "original\n");
|
||||
await writeFile(
|
||||
join(source, "a.txt"),
|
||||
Buffer.alloc(8 * 1024 * 1024, "original\n"),
|
||||
);
|
||||
const output = join(root, "output");
|
||||
afterInventory(async () => {
|
||||
const changed = afterInventory(output, async () => {
|
||||
await writeFile(join(source, "z.txt"), "new\n");
|
||||
});
|
||||
const result = await extract(source, output);
|
||||
await changed();
|
||||
if (result.ok) throw new Error("Changed inventory must fail");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
details: {
|
||||
|
|
@ -136,18 +160,27 @@ it("ignores unavailable members of a nested ASAR but refuses them as active entr
|
|||
await expect(access(activeOutput)).rejects.toThrow();
|
||||
});
|
||||
|
||||
// Synchronize a real filesystem change after the actual inventory scan. The
|
||||
// production reader, writes, failure translation and rollback all remain real.
|
||||
const afterInventory = (change: () => Promise<void>): void => {
|
||||
const create = SafeOutputTree.create;
|
||||
const hook = vi
|
||||
.spyOn(SafeOutputTree, "create")
|
||||
.mockImplementationOnce(async (...args) => {
|
||||
const output = await create(...args);
|
||||
await change();
|
||||
return output;
|
||||
});
|
||||
onTestFinished(() => hook.mockRestore());
|
||||
// The real destination is created only after inventory. A streamed first file
|
||||
// leaves time to mutate a later entry without replacing any production method.
|
||||
const afterInventory = (output: string, change: () => Promise<void>) => {
|
||||
let mutation:
|
||||
| Promise<{ ok: true } | { ok: false; cause: unknown }>
|
||||
| undefined;
|
||||
const watcher = watch(dirname(output), (_event, filename) => {
|
||||
if (filename !== basename(output) || mutation !== undefined) return;
|
||||
watcher.close();
|
||||
mutation = change().then(
|
||||
() => ({ ok: true as const }),
|
||||
(cause: unknown) => ({ ok: false as const, cause }),
|
||||
);
|
||||
});
|
||||
onTestFinished(() => watcher.close());
|
||||
return async () => {
|
||||
if (mutation === undefined)
|
||||
throw new Error("Extraction destination was not observed");
|
||||
const result = await mutation;
|
||||
if (!result.ok) throw result.cause;
|
||||
};
|
||||
};
|
||||
|
||||
const extract = (source: string, output: string) =>
|
||||
|
|
|
|||
Loading…
Reference in New Issue