391 lines
12 KiB
TypeScript
391 lines
12 KiB
TypeScript
import {
|
|
mkdtemp,
|
|
open,
|
|
readFile,
|
|
readdir,
|
|
rename,
|
|
rm,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { constants } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import {
|
|
createValidatedJsonArtifactWriter,
|
|
writeValidatedJsonArtifact,
|
|
} from "../../scripts/lib/validated-json-artifact.ts";
|
|
|
|
const artifactSchema = z
|
|
.object({ schemaVersion: z.literal(1), name: z.string().min(1) })
|
|
.strict();
|
|
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
async function temporaryDirectory(): Promise<string> {
|
|
const directory = await mkdtemp(
|
|
path.join(tmpdir(), "validated-json-artifact-"),
|
|
);
|
|
temporaryDirectories.push(directory);
|
|
return directory;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) =>
|
|
rm(directory, { force: true, recursive: true }),
|
|
),
|
|
);
|
|
});
|
|
|
|
describe("validated JSON artifact writer", () => {
|
|
it("syncs an O_NOFOLLOW exclusive temp and its directory around rename", async () => {
|
|
const events: string[] = [];
|
|
let openFlags = 0;
|
|
let openMode = 0;
|
|
const writer = createValidatedJsonArtifactWriter({
|
|
createNonce: () => "durable",
|
|
fileSystem: {
|
|
open: async (_target, flags, mode) => {
|
|
openFlags = flags;
|
|
openMode = mode;
|
|
events.push(`open:${flags}`);
|
|
return {
|
|
writeFile: async () => {
|
|
events.push("write");
|
|
},
|
|
sync: async () => {
|
|
events.push("file-sync");
|
|
},
|
|
close: async () => {
|
|
events.push("file-close");
|
|
},
|
|
};
|
|
},
|
|
openDirectory: async () => ({
|
|
sync: async () => {
|
|
events.push("directory-sync");
|
|
},
|
|
close: async () => {
|
|
events.push("directory-close");
|
|
},
|
|
}),
|
|
rename: async () => {
|
|
events.push("rename");
|
|
},
|
|
rm: async () => {},
|
|
},
|
|
});
|
|
|
|
await writer({
|
|
path: "/tmp/report.json",
|
|
schema: artifactSchema,
|
|
value: { schemaVersion: 1, name: "valid" },
|
|
});
|
|
|
|
expect(events).toHaveLength(7);
|
|
expect(events[0]).toMatch(/^open:\d+$/);
|
|
expect(openFlags & constants.O_WRONLY).toBe(constants.O_WRONLY);
|
|
expect(openFlags & constants.O_CREAT).toBe(constants.O_CREAT);
|
|
expect(openFlags & constants.O_EXCL).toBe(constants.O_EXCL);
|
|
expect(openFlags & constants.O_NOFOLLOW).toBe(constants.O_NOFOLLOW);
|
|
expect(openMode).toBe(0o600);
|
|
expect(events.slice(1)).toEqual([
|
|
"write",
|
|
"file-sync",
|
|
"file-close",
|
|
"rename",
|
|
"directory-sync",
|
|
"directory-close",
|
|
]);
|
|
});
|
|
|
|
it.each(["existing", "missing"] as const)(
|
|
"rejects invalid %s artifacts before changing destination state",
|
|
async (destinationState) => {
|
|
const directory = await temporaryDirectory();
|
|
const destination = path.join(directory, "artifact.json");
|
|
if (destinationState === "existing") {
|
|
await writeFile(destination, "previous-bytes\n", "utf8");
|
|
}
|
|
const entriesBefore = await readdir(directory);
|
|
|
|
await expect(
|
|
writeValidatedJsonArtifact({
|
|
path: destination,
|
|
schema: artifactSchema,
|
|
value: { schemaVersion: 1, name: "" },
|
|
}),
|
|
).rejects.toThrow();
|
|
|
|
expect(await readdir(directory)).toEqual(entriesBefore);
|
|
if (destinationState === "existing") {
|
|
await expect(readFile(destination, "utf8")).resolves.toBe(
|
|
"previous-bytes\n",
|
|
);
|
|
} else {
|
|
await expect(readFile(destination, "utf8")).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
it("publishes complete formatted bytes through a sibling rename", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const destination = path.join(directory, "artifact.json");
|
|
|
|
await writeValidatedJsonArtifact({
|
|
path: destination,
|
|
schema: artifactSchema,
|
|
value: { schemaVersion: 1, name: "valid" },
|
|
});
|
|
|
|
await expect(readFile(destination, "utf8")).resolves.toBe(
|
|
'{\n "schemaVersion": 1,\n "name": "valid"\n}\n',
|
|
);
|
|
expect(await readdir(directory)).toEqual(["artifact.json"]);
|
|
});
|
|
|
|
it("writes the schema's defaulted and transformed parsed output", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const destination = path.join(directory, "artifact.json");
|
|
const parsedOutputSchema = z
|
|
.object({
|
|
name: z.string().default("generated"),
|
|
count: z.string().transform((value) => Number(value)),
|
|
})
|
|
.strict();
|
|
|
|
await writeValidatedJsonArtifact({
|
|
path: destination,
|
|
schema: parsedOutputSchema,
|
|
value: { count: "3" },
|
|
});
|
|
|
|
expect(JSON.parse(await readFile(destination, "utf8"))).toEqual({
|
|
name: "generated",
|
|
count: 3,
|
|
});
|
|
});
|
|
|
|
it("reports a close-only failure and cleans its owned temp", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const destination = path.join(directory, "artifact.json");
|
|
const ownedTemp = path.join(directory, ".artifact.json.close-only.tmp");
|
|
const closeError = new Error("injected close-only failure");
|
|
const writer = createValidatedJsonArtifactWriter({
|
|
createNonce: () => "close-only",
|
|
fileSystem: {
|
|
open: async (target, flags) => {
|
|
const handle = await open(target, flags);
|
|
return {
|
|
writeFile: async (data, encoding) =>
|
|
handle.writeFile(data, encoding),
|
|
sync: async () => handle.sync(),
|
|
close: async () => {
|
|
await handle.close();
|
|
throw closeError;
|
|
},
|
|
};
|
|
},
|
|
openDirectory: async (target) => {
|
|
const handle = await open(target, constants.O_RDONLY);
|
|
return {
|
|
sync: async () => handle.sync(),
|
|
close: async () => handle.close(),
|
|
};
|
|
},
|
|
rename,
|
|
rm,
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
writer({
|
|
path: destination,
|
|
schema: artifactSchema,
|
|
value: { schemaVersion: 1, name: "valid" },
|
|
}),
|
|
).rejects.toBe(closeError);
|
|
await expect(readFile(destination, "utf8")).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
await expect(readFile(ownedTemp, "utf8")).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
});
|
|
|
|
it("preserves the write error when write and close both fail", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const destination = path.join(directory, "artifact.json");
|
|
const ownedTemp = path.join(directory, ".artifact.json.double-failure.tmp");
|
|
const writeError = new Error("injected primary write failure");
|
|
const closeError = new Error("injected secondary close failure");
|
|
const writer = createValidatedJsonArtifactWriter({
|
|
createNonce: () => "double-failure",
|
|
fileSystem: {
|
|
open: async (target, flags) => {
|
|
const handle = await open(target, flags);
|
|
return {
|
|
writeFile: async (data, encoding) => {
|
|
await handle.writeFile(data, encoding);
|
|
throw writeError;
|
|
},
|
|
sync: async () => handle.sync(),
|
|
close: async () => {
|
|
await handle.close();
|
|
throw closeError;
|
|
},
|
|
};
|
|
},
|
|
openDirectory: async (target) => {
|
|
const handle = await open(target, constants.O_RDONLY);
|
|
return {
|
|
sync: async () => handle.sync(),
|
|
close: async () => handle.close(),
|
|
};
|
|
},
|
|
rename,
|
|
rm,
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
writer({
|
|
path: destination,
|
|
schema: artifactSchema,
|
|
value: { schemaVersion: 1, name: "valid" },
|
|
}),
|
|
).rejects.toBe(writeError);
|
|
await expect(readFile(destination, "utf8")).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
await expect(readFile(ownedTemp, "utf8")).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
});
|
|
|
|
it("publishes one complete document when two valid writers race", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const destination = path.join(directory, "artifact.json");
|
|
const nonces = ["first", "second"];
|
|
const writer = createValidatedJsonArtifactWriter({
|
|
createNonce: () => {
|
|
const nonce = nonces.shift();
|
|
if (nonce === undefined) throw new Error("nonce fixture exhausted");
|
|
return nonce;
|
|
},
|
|
});
|
|
|
|
await Promise.all([
|
|
writer({
|
|
path: destination,
|
|
schema: artifactSchema,
|
|
value: { schemaVersion: 1, name: "first" },
|
|
}),
|
|
writer({
|
|
path: destination,
|
|
schema: artifactSchema,
|
|
value: { schemaVersion: 1, name: "second" },
|
|
}),
|
|
]);
|
|
|
|
expect([
|
|
{ schemaVersion: 1, name: "first" },
|
|
{ schemaVersion: 1, name: "second" },
|
|
]).toContainEqual(JSON.parse(await readFile(destination, "utf8")));
|
|
expect(await readdir(directory)).toEqual(["artifact.json"]);
|
|
});
|
|
|
|
it.each(["write", "rename"] as const)(
|
|
"cleans only its owned sibling temp when %s fails",
|
|
async (failurePoint) => {
|
|
const directory = await temporaryDirectory();
|
|
const destination = path.join(directory, "artifact.json");
|
|
const unrelatedTemp = path.join(directory, ".artifact.json.unrelated.tmp");
|
|
const ownedTemp = path.join(directory, ".artifact.json.owned.tmp");
|
|
await writeFile(destination, "previous-bytes\n", "utf8");
|
|
await writeFile(unrelatedTemp, "unrelated\n", "utf8");
|
|
const writer = createValidatedJsonArtifactWriter({
|
|
createNonce: () => "owned",
|
|
fileSystem: {
|
|
open: async (target, flags) => {
|
|
const handle = await open(target, flags);
|
|
return {
|
|
writeFile: async (data, encoding) => {
|
|
await handle.writeFile(data, encoding);
|
|
if (failurePoint === "write") {
|
|
throw new Error("injected write failure");
|
|
}
|
|
},
|
|
sync: async () => handle.sync(),
|
|
close: async () => handle.close(),
|
|
};
|
|
},
|
|
openDirectory: async (target) => {
|
|
const handle = await open(target, constants.O_RDONLY);
|
|
return {
|
|
sync: async () => handle.sync(),
|
|
close: async () => handle.close(),
|
|
};
|
|
},
|
|
rename: async (source, target) => {
|
|
if (failurePoint === "rename") {
|
|
throw new Error("injected rename failure");
|
|
}
|
|
await rename(source, target);
|
|
},
|
|
rm,
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
writer({
|
|
path: destination,
|
|
schema: artifactSchema,
|
|
value: { schemaVersion: 1, name: "valid" },
|
|
}),
|
|
).rejects.toThrow(`injected ${failurePoint} failure`);
|
|
|
|
await expect(readFile(destination, "utf8")).resolves.toBe(
|
|
"previous-bytes\n",
|
|
);
|
|
await expect(readFile(unrelatedTemp, "utf8")).resolves.toBe(
|
|
"unrelated\n",
|
|
);
|
|
await expect(readFile(ownedTemp, "utf8")).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("does not delete a pre-existing colliding sibling temp", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const destination = path.join(directory, "artifact.json");
|
|
const collidingTemp = path.join(directory, ".artifact.json.collision.tmp");
|
|
await writeFile(collidingTemp, "another-writer\n", "utf8");
|
|
const writer = createValidatedJsonArtifactWriter({
|
|
createNonce: () => "collision",
|
|
});
|
|
|
|
await expect(
|
|
writer({
|
|
path: destination,
|
|
schema: artifactSchema,
|
|
value: { schemaVersion: 1, name: "valid" },
|
|
}),
|
|
).rejects.toMatchObject({ code: "EEXIST" });
|
|
|
|
await expect(readFile(collidingTemp, "utf8")).resolves.toBe(
|
|
"another-writer\n",
|
|
);
|
|
await expect(readFile(destination, "utf8")).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
});
|
|
});
|