1import type { ProjectReadFileResult } from "@t3tools/contracts";
2import { EnvironmentId } from "@t3tools/contracts";
3import { AsyncResult } from "effect/reactivity";
4import { afterEach, describe, expect, it, vi } from "vite-plus/test";
5
6const registryTasks = vi.hoisted(() => new Set<() => void>());
7
8vi.mock("~/rpc/atomRegistry", async () => {
9 const { AtomRegistry } = await import("effect/reactivity");
10 return {
11 appAtomRegistry: AtomRegistry.make({
12 scheduleTask: (task) => {
13 registryTasks.add(task);
14 return () => {
15 registryTasks.delete(task);
16 };
17 },
18 }),
19 };
20});
21
22import { appAtomRegistry } from "~/rpc/atomRegistry";
23import { projectEnvironment } from "~/state/projects";
24import { FileSaveCoordinator } from "./fileSaveCoordinator";
25import {
26 clearProjectFileQueryData,
27 confirmProjectFileQueryData,
28 getOptimisticProjectFileQueryData,
29 getUnsavedProjectFileQueryData,
30 resolveProjectFileQueryData,
31 setProjectFileQueryData,
32} from "./projectFilesQueryState";
33
34const environmentId = EnvironmentId.make("environment-project-files-query-test");
35const optimisticFile = projectEnvironment.optimisticFile({
36 environmentId,
37 cwd: "/repo",
38 relativePath: "convex.json",
39});
40
41function drainRegistryTasks(): void {
42 while (registryTasks.size > 0) {
43 const tasks = [...registryTasks];
44 registryTasks.clear();
45 for (const task of tasks) task();
46 }
47}
48
49describe("project files queries", () => {
50 afterEach(() => {
51 clearProjectFileQueryData(environmentId, "/repo", "convex.json");
52 drainRegistryTasks();
53 vi.useRealTimers();
54 vi.unstubAllGlobals();
55 });
56
57 it("resumes an unsaved draft after closing the preview and restoring write access", async () => {
58 vi.stubGlobal("window", {});
59 vi.useFakeTimers();
60 const closePreview = appAtomRegistry.mount(optimisticFile);
61 let canWrite = true;
62 const persist = vi.fn().mockResolvedValue(AsyncResult.success(undefined));
63 const onPendingChange = vi.fn();
64 const makeCoordinator = () =>
65 new FileSaveCoordinator({
66 debounceMs: 500,
67 canPersist: () => canWrite,
68 persist,
69 onPendingChange,
70 onConfirmed: (contents) =>
71 confirmProjectFileQueryData(environmentId, "/repo", "convex.json", contents),
72 });
73 const initial = makeCoordinator();
74 setProjectFileQueryData(environmentId, "/repo", "convex.json", "unsaved draft");
75 initial.change("unsaved draft");
76 canWrite = false;
77 initial.dispose();
78 closePreview();
79 await vi.runAllTimersAsync();
80 drainRegistryTasks();
81
82 expect(persist).not.toHaveBeenCalled();
83 expect(onPendingChange).toHaveBeenLastCalledWith(true);
84 const unsaved = getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json");
85 expect(unsaved?.contents).toBe("unsaved draft");
86
87 canWrite = true;
88 const reopened = makeCoordinator();
89 reopened.change(unsaved!.contents);
90 await vi.advanceTimersByTimeAsync(500);
91
92 expect(persist).toHaveBeenCalledOnce();
93 expect(persist).toHaveBeenCalledWith("unsaved draft");
94 expect(onPendingChange).toHaveBeenLastCalledWith(false);
95 expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")).toBeNull();
96 reopened.dispose();
97 drainRegistryTasks();
98 expect(appAtomRegistry.getNodes().has(optimisticFile)).toBe(false);
99 });
100
101 it("releases a retained unsaved draft when explicitly cleared", () => {
102 setProjectFileQueryData(environmentId, "/repo", "convex.json", "first draft");
103 setProjectFileQueryData(environmentId, "/repo", "convex.json", "latest draft");
104 drainRegistryTasks();
105 expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")?.contents).toBe(
106 "latest draft",
107 );
108
109 clearProjectFileQueryData(environmentId, "/repo", "convex.json");
110 drainRegistryTasks();
111 expect(appAtomRegistry.getNodes().has(optimisticFile)).toBe(false);
112 });
113
114 it("keeps a reopened editor's newer draft pending when the old editor's write finishes", async () => {
115 vi.stubGlobal("window", {});
116 vi.useFakeTimers();
117 let canWrite = true;
118 const saved = AsyncResult.success(undefined);
119 let finishFirstWrite!: (result: typeof saved) => void;
120 const firstWrite = new Promise<typeof saved>((resolve) => {
121 finishFirstWrite = resolve;
122 });
123 const persist = vi.fn().mockReturnValueOnce(firstWrite).mockResolvedValue(saved);
124 const onPendingChange = vi.fn();
125 const makeCoordinator = () =>
126 new FileSaveCoordinator({
127 debounceMs: 500,
128 canPersist: () => canWrite,
129 persist,
130 onPendingChange,
131 onConfirmed: (contents) =>
132 confirmProjectFileQueryData(environmentId, "/repo", "convex.json", contents),
133 });
134
135 const initial = makeCoordinator();
136 setProjectFileQueryData(environmentId, "/repo", "convex.json", "first draft");
137 initial.change("first draft");
138 await vi.advanceTimersByTimeAsync(500);
139 initial.dispose();
140
141 const reopened = makeCoordinator();
142 setProjectFileQueryData(environmentId, "/repo", "convex.json", "newer draft");
143 reopened.change("newer draft");
144 canWrite = false;
145 finishFirstWrite(saved);
146 await vi.runAllTimersAsync();
147
148 expect(persist).toHaveBeenCalledOnce();
149 expect(onPendingChange).toHaveBeenLastCalledWith(true);
150 expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")?.contents).toBe(
151 "newer draft",
152 );
153
154 canWrite = true;
155 reopened.change("newer draft");
156 await vi.advanceTimersByTimeAsync(500);
157 expect(persist).toHaveBeenLastCalledWith("newer draft");
158 expect(onPendingChange).toHaveBeenLastCalledWith(false);
159 expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")).toBeNull();
160 reopened.dispose();
161 });
162
163 it("keeps the latest optimistic draft when an older write finishes", () => {
164 vi.stubGlobal("window", {});
165 const initial = {
166 relativePath: "convex.json",
167 contents: '{"nodeVersion":"20"}',
168 byteLength: 20,
169 truncated: false,
170 } satisfies ProjectReadFileResult;
171 setProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"220"}');
172 setProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"22"}');
173
174 expect(getOptimisticProjectFileQueryData(environmentId, "/repo", "convex.json")?.contents).toBe(
175 '{"nodeVersion":"22"}',
176 );
177
178 expect(
179 confirmProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"220"}'),
180 ).toBe(false);
181
182 expect(resolveProjectFileQueryData(environmentId, "/repo", "convex.json", initial)).toEqual({
183 relativePath: "convex.json",
184 contents: '{"nodeVersion":"22"}',
185 byteLength: 20,
186 truncated: false,
187 });
188
189 expect(
190 confirmProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"22"}'),
191 ).toBe(true);
192 });
193});