1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import {
3 type DesktopPendingSnapShot,
4 EnvironmentId,
5 ProjectId,
6 ThreadId,
7} from "@t3tools/contracts";
8import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
9
10import { DraftId, useComposerDraftStore } from "../../composerDraftStore";
11import type { DesktopSnapShotBridge } from "../../lib/desktopSnapShot";
12import {
13 beginSnapShotAnimationWhenReady,
14 deliverSnapShot,
15 dismissFailedSnapShot,
16 resolveExistingSnapShotTarget,
17 resolveSnapShotTargetOnce,
18 resolveSnapShotDeliveryTarget,
19} from "./SnapShotCoordinator";
20import {
21 beginSnapShotAnimation,
22 dismissAllSnapShotAnimations,
23 getPendingSnapShotAnimations,
24 setSnapShotAnimationDestination,
25 scheduleSnapShotAnimationDestination,
26} from "../../lib/snapShotAnimation";
27
28const storage = vi.hoisted(() => {
29 const values = new Map<string, string>();
30 const storage = {
31 getItem: (key: string) => values.get(key) ?? null,
32 setItem: vi.fn((key: string, value: string) => {
33 values.set(key, value);
34 }),
35 removeItem: (key: string) => {
36 values.delete(key);
37 },
38 clear: () => values.clear(),
39 };
40 vi.stubGlobal("localStorage", storage);
41 return storage;
42});
43
44const environmentId = EnvironmentId.make("snap-shot-environment");
45const projectRef = scopeProjectRef(environmentId, ProjectId.make("snap-shot-project"));
46
47beforeEach(() => {
48 storage.clear();
49 vi.stubGlobal("localStorage", storage);
50 useComposerDraftStore.setState({
51 draftsByThreadKey: {},
52 draftThreadsByThreadKey: {},
53 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
54 stickyModelSelectionByProvider: {},
55 stickyActiveProvider: null,
56 });
57});
58
59afterEach(() => {
60 dismissAllSnapShotAnimations();
61 vi.useRealTimers();
62 vi.unstubAllGlobals();
63});
64
65describe("window capture failures", () => {
66 it("dismisses an older failed capture without disturbing a newer capture", async () => {
67 const target = DraftId.make("snap-shot-draft");
68 const pendingStarts = new Set<string>();
69 const soundedIds = new Set(["older", "newer"]);
70 const dismissSnapShotAnimation = vi.fn(async () => undefined);
71 vi.stubGlobal("window", {
72 localStorage: storage,
73 desktopBridge: {
74 requestSnapShotPermissions: vi.fn(),
75 getSnapShotState: vi.fn(),
76 checkSnapShotShortcut: vi.fn(),
77 setSnapShotShortcutSuppressed: vi.fn(),
78 listPendingSnapShots: vi.fn(),
79 readSnapShot: vi.fn(),
80 acknowledgeSnapShot: vi.fn(),
81 onSnapShotEvent: vi.fn(() => () => undefined),
82 dismissSnapShotAnimation,
83 },
84 });
85 await beginSnapShotAnimationWhenReady("older", Promise.resolve(target), pendingStarts);
86 await beginSnapShotAnimationWhenReady("newer", Promise.resolve(target), pendingStarts);
87
88 dismissFailedSnapShot("older", soundedIds, pendingStarts);
89
90 expect(getPendingSnapShotAnimations().map(({ id }) => id)).toEqual(["newer"]);
91 expect(soundedIds).toEqual(new Set(["newer"]));
92 expect(dismissSnapShotAnimation).toHaveBeenCalledExactlyOnceWith("older");
93 });
94
95 it("does not resurrect a failed capture when its draft becomes ready later", async () => {
96 const target = DraftId.make("snap-shot-draft");
97 let resolveTarget: ((target: DraftId) => void) | undefined;
98 const targetReady = new Promise<DraftId>((resolve) => {
99 resolveTarget = resolve;
100 });
101 const pendingStarts = new Set<string>();
102 const soundedIds = new Set(["older", "newer"]);
103 const olderStart = beginSnapShotAnimationWhenReady("older", targetReady, pendingStarts);
104 await beginSnapShotAnimationWhenReady("newer", Promise.resolve(target), pendingStarts);
105
106 dismissFailedSnapShot("older", soundedIds, pendingStarts);
107 resolveTarget?.(target);
108 await olderStart;
109
110 expect(getPendingSnapShotAnimations().map(({ id }) => id)).toEqual(["newer"]);
111 expect(soundedIds).toEqual(new Set(["newer"]));
112 expect(pendingStarts.size).toBe(0);
113 });
114
115 it("keeps global failures dismissing all active and pending captures", async () => {
116 const target = DraftId.make("snap-shot-draft");
117 let resolveTarget: ((target: DraftId) => void) | undefined;
118 const targetReady = new Promise<DraftId>((resolve) => {
119 resolveTarget = resolve;
120 });
121 const pendingStarts = new Set<string>();
122 const soundedIds = new Set(["older", "newer"]);
123 await beginSnapShotAnimationWhenReady("older", Promise.resolve(target), pendingStarts);
124 const newerStart = beginSnapShotAnimationWhenReady("newer", targetReady, pendingStarts);
125
126 dismissFailedSnapShot(undefined, soundedIds, pendingStarts);
127 resolveTarget?.(target);
128 await newerStart;
129
130 expect(getPendingSnapShotAnimations()).toEqual([]);
131 expect(soundedIds.size).toBe(0);
132 expect(pendingStarts.size).toBe(0);
133 });
134});
135
136describe("window capture delivery", () => {
137 it.each([
138 { target: DraftId.make("snap-shot-draft"), accessibleText: undefined },
139 { target: DraftId.make("snap-shot-draft"), accessibleText: "const answer = 42;" },
140 {
141 target: scopeThreadRef(environmentId, ThreadId.make("snap-shot-thread")),
142 accessibleText: undefined,
143 },
144 {
145 target: scopeThreadRef(environmentId, ThreadId.make("snap-shot-thread")),
146 accessibleText: "const answer = 42;",
147 },
148 ])(
149 "preserves capture contents for $target before a stalled animation finishes ($accessibleText)",
150 async ({ target, accessibleText }) => {
151 vi.useFakeTimers();
152 const never = new Promise<void>(() => undefined);
153 const animationFrames: Array<FrameRequestCallback> = [];
154 const acknowledgeSnapShot = vi.fn(async () => undefined);
155 const bridge = {
156 requestSnapShotPermissions: vi.fn(async () => undefined),
157 getSnapShotState: vi.fn(),
158 checkSnapShotShortcut: vi.fn(),
159 setSnapShotShortcutSuppressed: vi.fn(async () => undefined),
160 listPendingSnapShots: vi.fn(async () => []),
161 readSnapShot: vi.fn(async () => ({
162 id: "12345678-1234-1234-1234-123456789abc",
163 name: "window.png",
164 mimeType: "image/png",
165 sizeBytes: 3,
166 dataUrl: "data:image/png;base64,AQID",
167 source: {
168 kind: "snap-shot" as const,
169 capturedAt: "2026-09-01T00:00:00.000Z",
170 appName: "Editor",
171 windowTitle: "main.ts",
172 ...(accessibleText ? { accessibleText } : {}),
173 },
174 })),
175 acknowledgeSnapShot,
176 setSnapShotAnimationDestination: vi.fn(() => never),
177 onMenuAction: vi.fn(() => () => undefined),
178 onSnapShotEvent: vi.fn(() => () => undefined),
179 } as unknown as DesktopSnapShotBridge;
180 const item: DesktopPendingSnapShot = {
181 id: "12345678-1234-1234-1234-123456789abc",
182 name: "window.png",
183 mimeType: "image/png",
184 sizeBytes: 3,
185 source: {
186 kind: "snap-shot",
187 capturedAt: "2026-09-01T00:00:00.000Z",
188 appName: "Editor",
189 windowTitle: "main.ts",
190 ...(accessibleText ? { accessibleText } : {}),
191 },
192 };
193 vi.stubGlobal("window", {
194 localStorage: storage,
195 desktopBridge: bridge,
196 setTimeout,
197 clearTimeout,
198 matchMedia: () => ({ matches: false }),
199 getComputedStyle: () => ({
200 backgroundColor: "rgb(0, 0, 0)",
201 borderTopColor: "rgb(255, 255, 255)",
202 borderTopLeftRadius: "8px",
203 borderTopWidth: "1px",
204 }),
205 dispatchEvent: vi.fn(),
206 });
207 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
208 animationFrames.push(callback);
209 return animationFrames.length;
210 });
211
212 if (typeof target === "string") {
213 useComposerDraftStore.getState().setProjectDraftThreadId(projectRef, target, {
214 threadId: ThreadId.make("snap-shot-draft-thread"),
215 });
216 }
217 beginSnapShotAnimation(item.id, target);
218 setSnapShotAnimationDestination(
219 item.id,
220 {
221 isConnected: true,
222 getBoundingClientRect: () => ({ x: 0, y: 0, width: 208, height: 112 }),
223 } as HTMLElement,
224 item.source,
225 );
226
227 const delivery = deliverSnapShot(bridge, item, target);
228 await vi.advanceTimersByTimeAsync(0);
229
230 const draft = useComposerDraftStore.getState().getComposerDraft(target);
231 expect(draft?.images).toHaveLength(1);
232 expect(draft?.images[0]?.source).toEqual(item.source);
233 expect(getPendingSnapShotAnimations()).toHaveLength(1);
234 expect(acknowledgeSnapShot).not.toHaveBeenCalled();
235
236 expect(animationFrames).toHaveLength(1);
237 animationFrames.shift()?.(0);
238 animationFrames.shift()?.(0);
239 await vi.advanceTimersByTimeAsync(0);
240 await vi.advanceTimersByTimeAsync(2_000);
241 expect(getPendingSnapShotAnimations()).toHaveLength(0);
242 expect(acknowledgeSnapShot).not.toHaveBeenCalled();
243 expect(animationFrames).toHaveLength(1);
244 animationFrames.shift()?.(0);
245 expect(acknowledgeSnapShot).not.toHaveBeenCalled();
246 animationFrames.shift()?.(0);
247 await delivery;
248 expect(acknowledgeSnapShot).toHaveBeenCalledWith(item.id);
249 },
250 );
251});
252
253describe("window capture target resolution", () => {
254 it("shares bare-route draft creation between animation start and capture drain", async () => {
255 const draftId = DraftId.make("snap-shot-draft");
256 let finishResolution: ((target: DraftId) => void) | undefined;
257 const resolveTarget = vi.fn(
258 () =>
259 new Promise<DraftId>((resolve) => {
260 finishResolution = resolve;
261 }),
262 );
263 const resolutionRef: { current: Promise<DraftId | null> | null } = { current: null };
264
265 const animationTarget = resolveSnapShotTargetOnce(resolutionRef, resolveTarget);
266 const attachmentTarget = resolveSnapShotTargetOnce(resolutionRef, resolveTarget);
267
268 expect(animationTarget).toBe(attachmentTarget);
269 expect(resolveTarget).toHaveBeenCalledTimes(1);
270 finishResolution?.(draftId);
271 await expect(animationTarget).resolves.toBe(draftId);
272 expect(resolutionRef.current).toBeNull();
273 });
274
275 it("follows a draft to its promoted server thread", () => {
276 const draftId = DraftId.make("snap-shot-draft");
277 const promotedRef = scopeThreadRef(environmentId, ThreadId.make("snap-shot-promoted-thread"));
278 const store = useComposerDraftStore.getState();
279 store.setProjectDraftThreadId(projectRef, draftId, {
280 threadId: ThreadId.make("snap-shot-draft-thread"),
281 });
282 store.markDraftThreadPromoting(draftId, promotedRef);
283
284 expect(resolveExistingSnapShotTarget(draftId, null)).toEqual(promotedRef);
285 });
286
287 it("keeps the routed server thread before its shell loads", () => {
288 const routeThreadRef = scopeThreadRef(environmentId, ThreadId.make("snap-shot-routed-thread"));
289
290 expect(resolveExistingSnapShotTarget(routeThreadRef, routeThreadRef)).toEqual(routeThreadRef);
291 });
292});
293
294describe("durable snapshot delivery", () => {
295 it.each([false, true])(
296 "retains a capture on quota failure and retries without duplicates (staged: %s)",
297 async (staged) => {
298 const target = scopeThreadRef(environmentId, ThreadId.make("quota-thread"));
299 const capture = {
300 id: "12345678-1234-1234-1234-123456789abc",
301 name: "window.png",
302 mimeType: "image/png" as const,
303 sizeBytes: 3,
304 dataUrl: "data:image/png;base64,AQID",
305 source: {
306 kind: "snap-shot" as const,
307 capturedAt: "2026-09-01T00:00:00.000Z",
308 appName: "Editor",
309 windowTitle: "main.ts",
310 },
311 };
312 const acknowledgeSnapShot = vi.fn(async () => undefined);
313 const bridge = {
314 readSnapShot: async () => capture,
315 acknowledgeSnapShot,
316 } as unknown as DesktopSnapShotBridge;
317 vi.stubGlobal("window", { localStorage: storage, dispatchEvent: vi.fn() });
318 const write = storage.setItem.getMockImplementation()!;
319 storage.setItem.mockImplementation(() => {
320 throw new Error("QuotaExceededError");
321 });
322 try {
323 if (staged) {
324 const store = useComposerDraftStore.getState();
325 store.addImage(target, {
326 type: "image",
327 ...capture,
328 previewUrl: capture.dataUrl,
329 file: new File([new Uint8Array([1, 2, 3])], capture.name, { type: capture.mimeType }),
330 });
331 void store.syncPersistedAttachments(target, [capture]);
332 }
333 await expect(deliverSnapShot(bridge, capture, target)).rejects.toThrow(
334 "could not be saved",
335 );
336 expect(acknowledgeSnapShot).not.toHaveBeenCalled();
337 expect(
338 useComposerDraftStore.getState().getComposerDraft(target)?.nonPersistedImageIds,
339 ).toContain(capture.id);
340 storage.setItem.mockImplementation(write);
341 await deliverSnapShot(bridge, capture, target);
342 expect(acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id);
343 expect(useComposerDraftStore.getState().getComposerDraft(target)?.images).toHaveLength(1);
344 expect(
345 useComposerDraftStore.getState().getComposerDraft(target)?.persistedAttachments,
346 ).toHaveLength(1);
347 } finally {
348 storage.setItem.mockImplementation(write);
349 }
350 },
351 );
352});
353
354describe("snapshot destination ownership", () => {
355 it.each(["unmount", "blur", "disabled animations"])(
356 "keeps the original environment and thread after %s",
357 async (reason) => {
358 const original = scopeThreadRef(environmentId, ThreadId.make("original"));
359 const next = scopeThreadRef(EnvironmentId.make("another-environment"), ThreadId.make("next"));
360 const targets = new Map<string, Promise<typeof original | null>>();
361 let current = original;
362 const resolveTarget = async () => current;
363 const requested = resolveSnapShotDeliveryTarget(targets, "capture", resolveTarget);
364 if (reason !== "disabled animations") {
365 beginSnapShotAnimation("capture", original);
366 if (reason === "unmount") {
367 const unmount = scheduleSnapShotAnimationDestination("capture", () => undefined);
368 unmount();
369 } else dismissAllSnapShotAnimations();
370 }
371 current = next;
372 await requested;
373 expect(getPendingSnapShotAnimations()).toHaveLength(0);
374 expect(await resolveSnapShotDeliveryTarget(targets, "capture", resolveTarget)).toEqual(
375 original,
376 );
377 expect(await resolveSnapShotDeliveryTarget(targets, "later-capture", resolveTarget)).toEqual(
378 next,
379 );
380 },
381 );
382});