| 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { |
| 3 | type DesktopPendingSnapShot, |
| 4 | EnvironmentId, |
| 5 | ProjectId, |
| 6 | ThreadId, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 9 | |
| 10 | import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; |
| 11 | import type { DesktopSnapShotBridge } from "../../lib/desktopSnapShot"; |
| 12 | import { |
| 13 | beginSnapShotAnimationWhenReady, |
| 14 | deliverSnapShot, |
| 15 | dismissFailedSnapShot, |
| 16 | resolveExistingSnapShotTarget, |
| 17 | resolveSnapShotTargetOnce, |
| 18 | resolveSnapShotDeliveryTarget, |
| 19 | } from "./SnapShotCoordinator"; |
| 20 | import { |
| 21 | beginSnapShotAnimation, |
| 22 | dismissAllSnapShotAnimations, |
| 23 | getPendingSnapShotAnimations, |
| 24 | setSnapShotAnimationDestination, |
| 25 | scheduleSnapShotAnimationDestination, |
| 26 | } from "../../lib/snapShotAnimation"; |
| 27 | |
| 28 | const 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 | |
| 44 | const environmentId = EnvironmentId.make("snap-shot-environment"); |
| 45 | const projectRef = scopeProjectRef(environmentId, ProjectId.make("snap-shot-project")); |
| 46 | |
| 47 | beforeEach(() => { |
| 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 | |
| 59 | afterEach(() => { |
| 60 | dismissAllSnapShotAnimations(); |
| 61 | vi.useRealTimers(); |
| 62 | vi.unstubAllGlobals(); |
| 63 | }); |
| 64 | |
| 65 | describe("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 | |
| 136 | describe("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 | |
| 253 | describe("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 | |
| 294 | describe("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 | |
| 354 | describe("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 | }); |