Skip to content
Source
Sign inSign up

fileSaveSessions.test.ts

261 lines9.1 KB

1import { EnvironmentId } from "@t3tools/contracts";
2import { Cause } from "effect";
3import { AsyncResult } from "effect/reactivity";
4import { describe, expect, it, vi } from "vite-plus/test";
5​
6import {
7 createFileSaveSessions,
8 type FileSaveSessionDeps,
9 type FileSaveTarget,
10} from "./fileSaveSessions";
11​
12const target: FileSaveTarget = {
13 environmentId: EnvironmentId.make("file-save-sessions"),
14 cwd: "/workspace",
15 relativePath: "file.txt",
16};
17​
18function setup() {
19 vi.useFakeTimers();
20 const state = {
21 writable: true,
22 unsaved: null as string | null,
23 onWritable: [] as Array<() => void>,
24 };
25 const persist = vi.fn<FileSaveSessionDeps["persist"]>(async () => AsyncResult.success(undefined));
26 const confirm = vi.fn((_target: FileSaveTarget, _contents: string) => true);
27 const onPendingChange = vi.fn();
28 const sessions = createFileSaveSessions({
29 debounceMs: 500,
30 canPersist: () => state.writable,
31 subscribeWritable: (_target, onWritable) => {
32 state.onWritable.push(onWritable);
33 return () => {
34 state.onWritable = state.onWritable.filter((listener) => listener !== onWritable);
35 };
36 },
37 persist,
38 confirm,
39 readUnsaved: () => state.unsaved,
40 });
41 const writes = () => persist.mock.calls.map(([written, contents]) => [written, contents]);
42 return {
43 state,
44 sessions,
45 persist,
46 confirm,
47 onPendingChange,
48 writes,
49 [Symbol.dispose]: () => vi.useRealTimers(),
50 };
52​
53describe("file save sessions", () => {
54 it("debounces edits and clears the pending marker once confirmed", async () => {
55 using h = setup();
56 h.sessions.acquire(target, h.onPendingChange);
57 h.sessions.change(target, "a");
58 h.sessions.change(target, "ab");
59 expect(h.onPendingChange).toHaveBeenLastCalledWith("file.txt", true);
60 await vi.advanceTimersByTimeAsync(500);
61 expect(h.writes()).toEqual([[target, "ab"]]);
62 expect(h.onPendingChange).toHaveBeenLastCalledWith("file.txt", false);
63 });
64​
65 it("flushes on the last release and drops edits from retired editors", async () => {
66 using h = setup();
67 const releaseFirst = h.sessions.acquire(target, h.onPendingChange);
68 const releaseSecond = h.sessions.acquire(target, h.onPendingChange);
69 h.sessions.change(target, "pending edit");
70 releaseFirst();
71 expect(h.persist).not.toHaveBeenCalled();
72 releaseSecond();
73 h.sessions.change(target, "stale editor contents");
74 await vi.runAllTimersAsync();
75 expect(h.writes()).toEqual([[target, "pending edit"]]);
76 expect(h.onPendingChange).toHaveBeenLastCalledWith("file.txt", false);
77 });
78​
79 it("gives a replaced file a fresh session and flushes the old one", async () => {
80 using h = setup();
81 const other = { ...target, relativePath: "other.txt" };
82 const release = h.sessions.acquire(target, h.onPendingChange);
83 h.sessions.change(target, "old file edit");
84 release();
85 h.sessions.acquire(other, h.onPendingChange);
86 h.sessions.change(target, "stale editor contents");
87 h.sessions.change(other, "new file edit");
88 await vi.runAllTimersAsync();
89 expect(h.writes()).toEqual([
90 [target, "old file edit"],
91 [other, "new file edit"],
92 ]);
93 });
94​
95 it("resumes an unsaved draft when a session is created", async () => {
96 using h = setup();
97 h.state.unsaved = "pending draft";
98 h.sessions.acquire(target, h.onPendingChange);
99 expect(h.onPendingChange).toHaveBeenLastCalledWith("file.txt", true);
100 await vi.advanceTimersByTimeAsync(500);
101 expect(h.writes()).toEqual([[target, "pending draft"]]);
102 });
103​
104 it("does not queue a draft that a retired session is still writing", async () => {
105 using h = setup();
106 let finishWrite = () => {};
107 h.persist.mockImplementationOnce(
108 () =>
109 new Promise((resolve) => {
110 finishWrite = () => resolve(AsyncResult.success(undefined));
111 }),
112 );
113 const release = h.sessions.acquire(target, h.onPendingChange);
114 h.sessions.change(target, "draft");
115 h.state.unsaved = "draft";
116 release();
117 // A StrictMode ref replay reacquires while the flush is in flight.
118 h.sessions.acquire(target, h.onPendingChange);
119 finishWrite();
120 await vi.runAllTimersAsync();
121 expect(h.writes()).toEqual([[target, "draft"]]);
122 });
123​
124 it("writes a rejoining surface's edit last, over the released session's queued draft", async () => {
125 using h = setup();
126 let finishWrite = () => {};
127 h.persist.mockImplementationOnce(
128 () =>
129 new Promise((resolve) => {
130 finishWrite = () => resolve(AsyncResult.success(undefined));
131 }),
132 );
133 const release = h.sessions.acquire(target, h.onPendingChange);
134 h.sessions.change(target, "X");
135 await vi.advanceTimersByTimeAsync(500);
136 h.sessions.change(target, "Y");
137 release();
138 h.sessions.acquire(target, h.onPendingChange);
139 h.sessions.change(target, "Z");
140 await vi.advanceTimersByTimeAsync(500);
141 finishWrite();
142 await vi.runAllTimersAsync();
143 expect(h.writes().at(-1)).toEqual([target, "Z"]);
144 });
145​
146 it("tells a surface joining a session with an unsaved edit that the file is pending", () => {
147 using h = setup();
148 h.sessions.acquire(target, h.onPendingChange);
149 h.sessions.change(target, "edit");
150 const joiningListener = vi.fn();
151 h.sessions.acquire(target, joiningListener);
152 expect(joiningListener).toHaveBeenCalledWith("file.txt", true);
153 });
154​
155 it("lets go of a released session that cannot write and recovers its draft later", async () => {
156 using h = setup();
157 const release = h.sessions.acquire(target, h.onPendingChange);
158 h.state.writable = false;
159 h.sessions.change(target, "draft");
160 h.state.unsaved = "draft";
161 release();
162 expect(h.state.onWritable).toEqual([]);
163 h.sessions.change(target, "retired editor contents");
164 h.state.writable = true;
165 h.sessions.acquire(target, h.onPendingChange);
166 await vi.runAllTimersAsync();
167 expect(h.writes()).toEqual([[target, "draft"]]);
168 });
169​
170 it("recovers a draft that a retired session gave up on when write access dropped", async () => {
171 using h = setup();
172 let finishWrite = () => {};
173 h.persist.mockImplementationOnce(
174 () =>
175 new Promise((resolve) => {
176 finishWrite = () => resolve(AsyncResult.success(undefined));
177 }),
178 );
179 const release = h.sessions.acquire(target, h.onPendingChange);
180 h.sessions.change(target, "X");
181 await vi.advanceTimersByTimeAsync(500);
182 h.sessions.change(target, "Y");
183 release();
184 h.state.writable = false;
185 finishWrite();
186 await vi.runAllTimersAsync();
187 h.state.unsaved = "Y";
188 h.sessions.acquire(target, h.onPendingChange);
189 h.state.writable = true;
190 for (const onWritable of h.state.onWritable) onWritable();
191 await vi.runAllTimersAsync();
192 expect(h.writes()).toEqual([
193 [target, "X"],
194 [target, "Y"],
195 ]);
196 });
197​
198 it("stops notifying a surface's listener once it releases a shared session", async () => {
199 using h = setup();
200 const retiredListener = vi.fn();
201 const release = h.sessions.acquire(target, retiredListener);
202 h.sessions.acquire(target, h.onPendingChange);
203 release();
204 h.sessions.change(target, "edit");
205 await vi.runAllTimersAsync();
206 expect(retiredListener).not.toHaveBeenCalled();
207 expect(h.onPendingChange).toHaveBeenLastCalledWith("file.txt", false);
208 });
209​
210 it("requeues a draft whose retired flush failed on the live session", async () => {
211 using h = setup();
212 let failWrite = () => {};
213 h.persist.mockImplementationOnce(
214 () =>
215 new Promise((resolve) => {
216 failWrite = () => resolve(AsyncResult.failure(Cause.fail("offline")));
217 }),
218 );
219 const release = h.sessions.acquire(target, h.onPendingChange);
220 h.sessions.change(target, "draft");
221 h.state.unsaved = "draft";
222 release();
223 h.sessions.acquire(target, h.onPendingChange);
224 failWrite();
225 await vi.runAllTimersAsync();
226 expect(h.writes()).toEqual([
227 [target, "draft"],
228 [target, "draft"],
229 ]);
230 expect(h.onPendingChange).toHaveBeenLastCalledWith("file.txt", false);
231 });
232​
233 it("keeps edits pending while unwritable and resumes them when access returns", async () => {
234 using h = setup();
235 h.sessions.acquire(target, h.onPendingChange);
236 h.state.writable = false;
237 h.sessions.change(target, "pending edit");
238 h.state.unsaved = "pending edit";
239 await vi.runAllTimersAsync();
240 expect(h.persist).not.toHaveBeenCalled();
241 expect(h.onPendingChange).toHaveBeenLastCalledWith("file.txt", true);
242​
243 h.state.writable = true;
244 for (const onWritable of h.state.onWritable) onWritable();
245 await vi.advanceTimersByTimeAsync(500);
246 expect(h.writes()).toEqual([[target, "pending edit"]]);
247 expect(h.onPendingChange).toHaveBeenLastCalledWith("file.txt", false);
248 });
249​
250 it("skips recovery while unwritable and stops watching access after release", async () => {
251 using h = setup();
252 h.state.writable = false;
253 h.state.unsaved = "pending draft";
254 const release = h.sessions.acquire(target, h.onPendingChange);
255 expect(h.onPendingChange).not.toHaveBeenCalled();
256 release();
257 expect(h.state.onWritable).toEqual([]);
258 await vi.runAllTimersAsync();
259 expect(h.persist).not.toHaveBeenCalled();
260 });
261});