Skip to content
Source
Sign inSign up

atomic-file.test.ts

102 lines3.4 KB

1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2​
3const files = new Map<string, { text: string; deleted: boolean }>();
4const failMode = vi.hoisted(() => ({
5 current: "none" as "none" | "write" | "move",
6 cleanupFails: false,
7}));
8​
9vi.mock("expo-file-system", () => ({
10 File: class {
11 readonly uri: string;
12 readonly name: string;
13 readonly parentDirectory: { readonly uri: string };
14​
15 constructor(parent: string | { readonly uri: string }, name?: string) {
16 const parentUri = typeof parent === "string" ? parent : parent.uri;
17 this.uri = name ? `${parentUri}/${name}` : parentUri;
18 this.name = name ?? this.uri.split("/").at(-1) ?? "file";
19 this.parentDirectory = { uri: parentUri };
20 }
21​
22 get exists(): boolean {
23 return files.has(this.uri) && files.get(this.uri)?.deleted === false;
24 }
25​
26 create(): void {
27 files.set(this.uri, { text: "", deleted: false });
28 }
29​
30 async write(text: string): Promise<void> {
31 await Promise.resolve(); // Exercise asynchronous file-write rejection.
32 if (failMode.current === "write") throw new Error("disk full");
33 files.set(this.uri, { text, deleted: false });
34 }
35​
36 moveSync(destination: { readonly uri: string }): void {
37 if (failMode.current === "move") throw new Error("rename failed");
38 const entry = files.get(this.uri);
39 if (!entry) throw new Error("missing staged file");
40 files.set(destination.uri, entry);
41 files.delete(this.uri);
42 }
43​
44 delete(): void {
45 if (failMode.cleanupFails) throw new Error("cleanup failed");
46 if (!files.has(this.uri)) throw new Error("missing file");
47 files.delete(this.uri);
48 }
49 },
50}));
51​
52import { writeFileAtomically } from "./atomic-file";
53import { File as ExpoFile } from "expo-file-system";
54​
55const DIRECTORY_URI = "file:///documents/thread-outbox";
56​
57function tempFileUris(): Array<string> {
58 return [...files.keys()].filter(
59 (uri) => uri.startsWith(`${DIRECTORY_URI}/`) && uri.endsWith(".tmp"),
60 );
62​
63function makeFile(name: string): ExpoFile {
64 return new ExpoFile(DIRECTORY_URI, name);
66​
67describe("writeFileAtomically", () => {
68 beforeEach(() => {
69 files.clear();
70 failMode.current = "none";
71 failMode.cleanupFails = false;
72 });
73​
74 it("lands contents at the final path and leaves no temp file behind", async () => {
75 const file = makeFile("message.json");
76 await writeFileAtomically(file, '{"ok":true}');
77 expect(files.get(file.uri)?.text).toBe('{"ok":true}');
78 expect(tempFileUris()).toEqual([]);
79 });
80​
81 it("removes its staging file when the write fails", async () => {
82 failMode.current = "write";
83 const file = makeFile("message.json");
84 await expect(writeFileAtomically(file, '{"ok":true}')).rejects.toThrow("disk full");
85 expect(tempFileUris()).toEqual([]);
86 });
87​
88 it("removes its staging file when the rename fails", async () => {
89 failMode.current = "move";
90 const file = makeFile("message.json");
91 await expect(writeFileAtomically(file, '{"ok":true}')).rejects.toThrow("rename failed");
92 expect(tempFileUris()).toEqual([]);
93 expect(files.has(file.uri)).toBe(false);
94 });
95​
96 it("preserves the original error when cleanup also fails", async () => {
97 failMode.current = "write";
98 failMode.cleanupFails = true;
99 const file = makeFile("message.json");
100 await expect(writeFileAtomically(file, "{}")).rejects.toThrow("disk full");
101 });
102});