1import { EnvironmentId } from "@t3tools/contracts";
2import { AsyncResult } from "effect/reactivity";
3import { act, StrictMode } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7const { writeFile, confirmFile, readScope, getUnsavedFile } = vi.hoisted(() => ({
8 writeFile: vi.fn(),
9 confirmFile: vi.fn(),
10 readScope: vi.fn(),
11 getUnsavedFile: vi.fn(),
12}));
13vi.mock("~/state/projects", () => ({ projectEnvironment: { writeFile: {} } }));
14vi.mock("~/state/session", () => ({
15 readEnvironmentScope: readScope,
16 useEnvironmentScope: readScope,
17}));
18vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => writeFile }));
19vi.mock("./projectFilesQueryState", () => ({
20 confirmProjectFileQueryData: confirmFile,
21 getUnsavedProjectFileQueryData: getUnsavedFile,
22}));
23
24import { useFileSaveCoordinator } from "./useFileSaveCoordinator";
25
26const environmentId = EnvironmentId.make("save-lifecycle-audit");
27const onPendingChange = vi.fn();
28const defaultProps = {
29 environmentId,
30 cwd: "/workspace",
31 relativePath: "file.txt",
32 onPendingChange,
33};
34let renderer: ReactTestRenderer | null;
35
36function ChangeSource(_props: { onChange: (contents: string) => void }) {
37 return null;
38}
39
40function FileSurface(props: Parameters<typeof useFileSaveCoordinator>[0]) {
41 const coordinator = useFileSaveCoordinator(props);
42 return <ChangeSource onChange={(contents) => coordinator.change(contents)} />;
43}
44
45function mount(props = defaultProps) {
46 act(() => {
47 renderer = create(
48 <StrictMode>
49 <FileSurface {...props} />
50 </StrictMode>,
51 );
52 });
53}
54
55function changeHandler(): (contents: string) => void {
56 return renderer!.root.findByType(ChangeSource).props.onChange;
57}
58
59beforeEach(() => {
60 renderer = null;
61 vi.useFakeTimers();
62 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
63 writeFile.mockReset().mockResolvedValue(AsyncResult.success(undefined));
64 confirmFile.mockReset();
65 readScope.mockReset().mockReturnValue(true);
66 getUnsavedFile.mockReset().mockReturnValue(null);
67 onPendingChange.mockReset();
68});
69
70afterEach(async () => {
71 await act(async () => renderer?.unmount());
72 vi.useRealTimers();
73 vi.unstubAllGlobals();
74});
75
76describe("file-save React lifecycle", () => {
77 it("persists editor model changes after StrictMode setup replay", async () => {
78 mount();
79 changeHandler()("AUDIT7907NATIVE\n");
80 expect(onPendingChange).toHaveBeenCalledWith("file.txt", true);
81 await vi.advanceTimersByTimeAsync(500);
82 expect(writeFile).toHaveBeenCalledExactlyOnceWith({
83 environmentId,
84 input: { cwd: "/workspace", relativePath: "file.txt", contents: "AUDIT7907NATIVE\n" },
85 });
86 expect(confirmFile).toHaveBeenCalledExactlyOnceWith(
87 environmentId,
88 "/workspace",
89 "file.txt",
90 "AUDIT7907NATIVE\n",
91 );
92 expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false);
93 });
94
95 it("persists Markdown edits after StrictMode setup replay", async () => {
96 mount({ ...defaultProps, relativePath: "README.md" });
97 changeHandler()("- [x] task\n");
98 await vi.advanceTimersByTimeAsync(500);
99 expect(writeFile).toHaveBeenCalledExactlyOnceWith({
100 environmentId,
101 input: { cwd: "/workspace", relativePath: "README.md", contents: "- [x] task\n" },
102 });
103 });
104
105 it("keeps the debounce across rerenders of the same file", async () => {
106 mount();
107 changeHandler()("first");
108 await vi.advanceTimersByTimeAsync(300);
109 act(() =>
110 renderer!.update(
111 <StrictMode>
112 <FileSurface {...defaultProps} />
113 </StrictMode>,
114 ),
115 );
116 changeHandler()("latest");
117 await vi.advanceTimersByTimeAsync(499);
118 expect(writeFile).not.toHaveBeenCalled();
119 await vi.advanceTimersByTimeAsync(1);
120 expect(writeFile).toHaveBeenCalledTimes(1);
121 expect(writeFile.mock.calls[0]![0].input.contents).toBe("latest");
122 });
123
124 it("flushes on unmount and ignores a retired editor callback", async () => {
125 mount();
126 const retiredChange = changeHandler();
127 retiredChange("pending edit");
128 await act(async () => renderer!.unmount());
129 renderer = null;
130 retiredChange("stale editor contents");
131 await vi.runAllTimersAsync();
132 expect(writeFile).toHaveBeenCalledTimes(1);
133 expect(writeFile.mock.calls[0]![0].input.contents).toBe("pending edit");
134 });
135
136 it.each([false, true])(
137 "keeps edits pending after permission is revoked before a React update (unmount: %s)",
138 async (unmount) => {
139 mount();
140 changeHandler()("pending edit");
141 readScope.mockReturnValue(false);
142 if (unmount) {
143 await act(async () => renderer!.unmount());
144 renderer = null;
145 }
146 await vi.runAllTimersAsync();
147 expect(writeFile).not.toHaveBeenCalled();
148 expect(confirmFile).not.toHaveBeenCalled();
149 expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", true);
150 },
151 );
152
153 it("resumes an unsaved draft when write permission returns after effect replay", async () => {
154 readScope.mockReturnValue(false);
155 getUnsavedFile.mockReturnValue({ contents: "pending draft" });
156 mount();
157 await vi.runAllTimersAsync();
158 expect(writeFile).not.toHaveBeenCalled();
159
160 readScope.mockReturnValue(true);
161 act(() =>
162 renderer!.update(
163 <StrictMode>
164 <FileSurface {...defaultProps} />
165 </StrictMode>,
166 ),
167 );
168 await vi.advanceTimersByTimeAsync(500);
169 expect(writeFile).toHaveBeenCalledExactlyOnceWith({
170 environmentId,
171 input: { cwd: "/workspace", relativePath: "file.txt", contents: "pending draft" },
172 });
173 expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false);
174 });
175
176 it("recovers an existing draft once after StrictMode setup replay", async () => {
177 getUnsavedFile.mockReturnValue({ contents: "reopened draft" });
178 mount();
179 await vi.runAllTimersAsync();
180 expect(writeFile).toHaveBeenCalledExactlyOnceWith({
181 environmentId,
182 input: { cwd: "/workspace", relativePath: "file.txt", contents: "reopened draft" },
183 });
184 expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false);
185 });
186
187 it.each([
188 { relativePath: "other.txt" },
189 { cwd: "/other-workspace" },
190 { environmentId: EnvironmentId.make("other-environment") },
191 ])("retires callbacks when the file identity changes: %j", async (change) => {
192 mount();
193 const retiredChange = changeHandler();
194 retiredChange("old file edit");
195 const nextProps = { ...defaultProps, ...change };
196 act(() =>
197 renderer!.update(
198 <StrictMode>
199 <FileSurface {...nextProps} />
200 </StrictMode>,
201 ),
202 );
203 retiredChange("stale editor contents");
204 changeHandler()("new file edit");
205 await vi.runAllTimersAsync();
206 expect(writeFile.mock.calls.map(([request]) => request)).toEqual([
207 {
208 environmentId,
209 input: { cwd: "/workspace", relativePath: "file.txt", contents: "old file edit" },
210 },
211 {
212 environmentId: nextProps.environmentId,
213 input: {
214 cwd: nextProps.cwd,
215 relativePath: nextProps.relativePath,
216 contents: "new file edit",
217 },
218 },
219 ]);
220 });
221
222 it("does not reactivate a retired callback when the same file mounts again", async () => {
223 mount();
224 const retiredChange = changeHandler();
225 await act(async () => renderer!.unmount());
226 renderer = null;
227 mount();
228 retiredChange("stale contents");
229 changeHandler()("current contents");
230 await vi.runAllTimersAsync();
231 expect(writeFile).toHaveBeenCalledTimes(1);
232 expect(writeFile.mock.calls[0]![0].input.contents).toBe("current contents");
233 });
234});