diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97025afe7..6d03dc87e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts index e5994c79a..778a80f33 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.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 => { - 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) => { + 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) =>