1import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import {
3 type EnvironmentId,
4 type PreviewEvent,
5 type PreviewSessionSnapshot,
6 ThreadId,
7} from "@t3tools/contracts";
8import { beforeEach, describe, expect, it } from "vite-plus/test";
9
10import {
11 __testing,
12 applyPreviewDesktopState,
13 applyPreviewServerEvent as applyPreviewServerEventImpl,
14 applyPreviewServerSnapshot,
15 beginPreviewSessionClose,
16 cancelPreviewSessionClose,
17 previewStateAtom,
18 readThreadPreviewState,
19 reconcilePreviewServerSessions,
20 rememberPreviewUrl,
21 resetPreviewStateForTests,
22 setActivePreviewTab,
23 updatePreviewServerSnapshot,
24} from "./previewStateStore";
25import { appAtomRegistry } from "./rpc/atomRegistry";
26
27const environmentId = "env-1" as EnvironmentId;
28const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1"));
29const otherRef = scopeThreadRef(environmentId, ThreadId.make("thread-2"));
30
31const makeSnapshot = (overrides: Partial<PreviewSessionSnapshot> = {}): PreviewSessionSnapshot => ({
32 threadId: "thread-1",
33 tabId: "tab_a",
34 navStatus: { _tag: "Loading", url: "http://localhost:5173/", title: "" },
35 canGoBack: false,
36 canGoForward: false,
37 updatedAt: "2026-01-01T00:00:00.000Z",
38 ...overrides,
39});
40
41type PreviewEventDraft = PreviewEvent extends infer Event
42 ? Event extends { readonly revision: number }
43 ? Omit<Event, "revision" | "serverEpoch">
44 : never
45 : never;
46
47const serverEpoch = "server-a";
48let nextServerRevision = 0;
49const applyPreviewServerEvent = (eventRef: typeof ref, event: PreviewEventDraft): void => {
50 nextServerRevision += 1;
51 applyPreviewServerEventImpl(eventRef, {
52 ...event,
53 serverEpoch,
54 revision: nextServerRevision,
55 } as PreviewEvent);
56};
57
58beforeEach(() => {
59 nextServerRevision = 0;
60 resetPreviewStateForTests();
61});
62
63describe("previewStateStore (single-tab)", () => {
64 it("keeps independent state atoms for each thread", () => {
65 expect(previewStateAtom(scopedThreadKey(ref))).toBe(previewStateAtom(scopedThreadKey(ref)));
66 expect(previewStateAtom(scopedThreadKey(ref))).not.toBe(
67 previewStateAtom(scopedThreadKey(otherRef)),
68 );
69
70 applyPreviewServerSnapshot(ref, makeSnapshot());
71 expect(readThreadPreviewState(ref).snapshot?.tabId).toBe("tab_a");
72 expect(readThreadPreviewState(otherRef)).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE);
73 });
74
75 it("opened event seeds the snapshot and remembers the URL", () => {
76 const snapshot = makeSnapshot();
77 applyPreviewServerEvent(ref, {
78 type: "opened",
79 threadId: "thread-1",
80 tabId: snapshot.tabId,
81 createdAt: snapshot.updatedAt,
82 snapshot,
83 });
84 const state = readThreadPreviewState(ref);
85 expect(state.snapshot?.tabId).toBe(snapshot.tabId);
86 expect(state.recentlySeenUrls).toContain("http://localhost:5173/");
87 });
88
89 it("a second `opened` for a different tab replaces the rendered snapshot", () => {
90 const a = makeSnapshot({ tabId: "tab_a" });
91 const b = makeSnapshot({ tabId: "tab_b" });
92 applyPreviewServerEvent(ref, {
93 type: "opened",
94 threadId: "thread-1",
95 tabId: a.tabId,
96 createdAt: a.updatedAt,
97 snapshot: a,
98 });
99 applyPreviewServerEvent(ref, {
100 type: "opened",
101 threadId: "thread-1",
102 tabId: b.tabId,
103 createdAt: b.updatedAt,
104 snapshot: b,
105 });
106 const state = readThreadPreviewState(ref);
107 expect(state.snapshot?.tabId).toBe(b.tabId);
108 });
109
110 it("navigated event updates the snapshot URL", () => {
111 const snapshot = makeSnapshot();
112 applyPreviewServerEvent(ref, {
113 type: "opened",
114 threadId: "thread-1",
115 tabId: snapshot.tabId,
116 createdAt: snapshot.updatedAt,
117 snapshot,
118 });
119 applyPreviewServerEvent(ref, {
120 type: "navigated",
121 threadId: "thread-1",
122 tabId: snapshot.tabId,
123 createdAt: "2026-01-01T00:00:01.000Z",
124 snapshot: {
125 ...snapshot,
126 navStatus: { _tag: "Success", url: "http://localhost:5173/about", title: "About" },
127 },
128 });
129 const state = readThreadPreviewState(ref);
130 expect(state.snapshot?.navStatus._tag).toBe("Success");
131 if (state.snapshot?.navStatus._tag === "Success") {
132 expect(state.snapshot.navStatus.url).toBe("http://localhost:5173/about");
133 }
134 });
135
136 it("resized event updates tab viewport without changing the active tab", () => {
137 const active = makeSnapshot({ tabId: "tab_a" });
138 const background = makeSnapshot({ tabId: "tab_b" });
139 applyPreviewServerSnapshot(ref, background);
140 applyPreviewServerSnapshot(ref, active);
141
142 applyPreviewServerEvent(ref, {
143 type: "resized",
144 threadId: "thread-1",
145 tabId: background.tabId,
146 createdAt: "2026-01-01T00:00:01.000Z",
147 snapshot: {
148 ...background,
149 viewport: { _tag: "preset", presetId: "pixel-8", width: 412, height: 915 },
150 updatedAt: "2026-01-01T00:00:01.000Z",
151 },
152 });
153
154 const state = readThreadPreviewState(ref);
155 expect(state.activeTabId).toBe(active.tabId);
156 expect(state.sessions[background.tabId]?.viewport).toEqual({
157 _tag: "preset",
158 presetId: "pixel-8",
159 width: 412,
160 height: 915,
161 });
162 });
163
164 it("failed event flips the snapshot to LoadFailed when tabId matches", () => {
165 const snapshot = makeSnapshot();
166 applyPreviewServerEvent(ref, {
167 type: "opened",
168 threadId: "thread-1",
169 tabId: snapshot.tabId,
170 createdAt: snapshot.updatedAt,
171 snapshot,
172 });
173 applyPreviewServerEvent(ref, {
174 type: "failed",
175 threadId: "thread-1",
176 tabId: snapshot.tabId,
177 createdAt: "2026-01-01T00:00:01.000Z",
178 url: "http://localhost:5173/",
179 title: "",
180 code: -105,
181 description: "ERR_NAME_NOT_RESOLVED",
182 });
183 const state = readThreadPreviewState(ref);
184 expect(state.snapshot?.navStatus._tag).toBe("LoadFailed");
185 });
186
187 it("failed event for a non-active tab is ignored", () => {
188 const snapshot = makeSnapshot({ tabId: "tab_a" });
189 applyPreviewServerEvent(ref, {
190 type: "opened",
191 threadId: "thread-1",
192 tabId: snapshot.tabId,
193 createdAt: snapshot.updatedAt,
194 snapshot,
195 });
196 applyPreviewServerEvent(ref, {
197 type: "failed",
198 threadId: "thread-1",
199 tabId: "tab_b",
200 createdAt: "2026-01-01T00:00:01.000Z",
201 url: "http://localhost:9999/",
202 title: "",
203 code: -105,
204 description: "ERR_NAME_NOT_RESOLVED",
205 });
206 const state = readThreadPreviewState(ref);
207 expect(state.snapshot?.navStatus._tag).toBe("Loading");
208 });
209
210 it("closed event clears snapshot but retains recently-seen URLs", () => {
211 const snapshot = makeSnapshot();
212 applyPreviewServerEvent(ref, {
213 type: "opened",
214 threadId: "thread-1",
215 tabId: snapshot.tabId,
216 createdAt: snapshot.updatedAt,
217 snapshot,
218 });
219 applyPreviewServerEvent(ref, {
220 type: "closed",
221 threadId: "thread-1",
222 tabId: snapshot.tabId,
223 createdAt: "2026-01-01T00:00:01.000Z",
224 });
225 const state = readThreadPreviewState(ref);
226 expect(state.snapshot).toBeNull();
227 expect(state.recentlySeenUrls).toContain("http://localhost:5173/");
228 });
229
230 it("optimistically removes a session before the server close event arrives", () => {
231 const first = makeSnapshot({ tabId: "tab_a" });
232 const second = makeSnapshot({
233 tabId: "tab_b",
234 updatedAt: "2026-01-01T00:00:01.000Z",
235 });
236 applyPreviewServerSnapshot(ref, first);
237 applyPreviewServerSnapshot(ref, second);
238
239 beginPreviewSessionClose(ref, second.tabId);
240
241 const state = readThreadPreviewState(ref);
242 expect(Object.keys(state.sessions)).toEqual([first.tabId]);
243 expect(state.activeTabId).toBe(first.tabId);
244 expect(state.snapshot?.tabId).toBe(first.tabId);
245 });
246
247 it("treats a late server close event after optimistic removal as a no-op", () => {
248 const snapshot = makeSnapshot();
249 applyPreviewServerSnapshot(ref, snapshot);
250 beginPreviewSessionClose(ref, snapshot.tabId);
251
252 applyPreviewServerEvent(ref, {
253 type: "closed",
254 threadId: "thread-1",
255 tabId: snapshot.tabId,
256 createdAt: "2026-01-01T00:00:01.000Z",
257 });
258
259 const state = readThreadPreviewState(ref);
260 expect(state.sessions).toEqual({});
261 expect(state.snapshot).toBeNull();
262 });
263
264 it("does not resurrect an intentionally closed tab from a stale list snapshot", () => {
265 const snapshot = makeSnapshot();
266 applyPreviewServerSnapshot(ref, snapshot);
267 beginPreviewSessionClose(ref, snapshot.tabId);
268
269 applyPreviewServerSnapshot(ref, snapshot);
270
271 const state = readThreadPreviewState(ref);
272 expect(state.sessions).toEqual({});
273 expect(state.snapshot).toBeNull();
274 });
275
276 it("can restore a suppressed tab after a failed close", () => {
277 const snapshot = makeSnapshot();
278 applyPreviewServerSnapshot(ref, snapshot);
279 beginPreviewSessionClose(ref, snapshot.tabId);
280
281 cancelPreviewSessionClose(ref, snapshot, snapshot.tabId);
282
283 const state = readThreadPreviewState(ref);
284 expect(state.sessions).toEqual({ [snapshot.tabId]: snapshot });
285 expect(state.snapshot).toEqual(snapshot);
286 });
287
288 it("closed event for a different tab is a no-op", () => {
289 const snapshot = makeSnapshot({ tabId: "tab_a" });
290 applyPreviewServerEvent(ref, {
291 type: "opened",
292 threadId: "thread-1",
293 tabId: snapshot.tabId,
294 createdAt: snapshot.updatedAt,
295 snapshot,
296 });
297 applyPreviewServerEvent(ref, {
298 type: "closed",
299 threadId: "thread-1",
300 tabId: "tab_b",
301 createdAt: "2026-01-01T00:00:01.000Z",
302 });
303 const state = readThreadPreviewState(ref);
304 expect(state.snapshot?.tabId).toBe(snapshot.tabId);
305 });
306
307 it("desktopOverlay updates independently of snapshot", () => {
308 const snapshot = makeSnapshot();
309 applyPreviewServerEvent(ref, {
310 type: "opened",
311 threadId: "thread-1",
312 tabId: snapshot.tabId,
313 createdAt: snapshot.updatedAt,
314 snapshot,
315 });
316 applyPreviewDesktopState(ref, snapshot.tabId, {
317 hasWebContents: true,
318 canGoBack: true,
319 canGoForward: false,
320 loading: false,
321 zoomFactor: 1,
322 pictureInPicture: false,
323 colorScheme: "system",
324 audioMuted: false,
325 audible: false,
326 controller: "none",
327 favicon: null,
328 });
329 const state = readThreadPreviewState(ref);
330 expect(state.desktopOverlay?.canGoBack).toBe(true);
331 expect(state.snapshot?.canGoBack).toBe(false);
332 });
333
334 it("does not publish duplicate desktop browser state", () => {
335 const snapshot = makeSnapshot();
336 applyPreviewServerSnapshot(ref, snapshot);
337 const overlay = {
338 hasWebContents: true,
339 canGoBack: true,
340 canGoForward: false,
341 loading: false,
342 zoomFactor: 1,
343 pictureInPicture: false,
344 colorScheme: "system" as const,
345 audioMuted: false,
346 audible: false,
347 controller: "none" as const,
348 favicon: {
349 dataUrl: "data:image/png;base64,AA==",
350 pageUrl: "https://example.com",
351 capturedAt: 1,
352 },
353 };
354 let updateCount = 0;
355 const unsubscribe = appAtomRegistry.subscribe(previewStateAtom(scopedThreadKey(ref)), () => {
356 updateCount += 1;
357 });
358
359 applyPreviewDesktopState(ref, snapshot.tabId, overlay);
360 applyPreviewDesktopState(ref, snapshot.tabId, { ...overlay, favicon: { ...overlay.favicon } });
361 unsubscribe();
362
363 expect(updateCount).toBe(1);
364 });
365
366 it("retains multiple tabs and switches active desktop state", () => {
367 const first = makeSnapshot();
368 const second = { ...makeSnapshot(), tabId: "tab_2", updatedAt: "2026-01-02T00:00:00.000Z" };
369 applyPreviewServerSnapshot(ref, first);
370 applyPreviewServerSnapshot(ref, second);
371 applyPreviewDesktopState(ref, first.tabId, {
372 hasWebContents: true,
373 canGoBack: true,
374 canGoForward: false,
375 loading: false,
376 zoomFactor: 1,
377 pictureInPicture: false,
378 colorScheme: "system",
379 audioMuted: false,
380 audible: false,
381 controller: "none",
382 favicon: null,
383 });
384 setActivePreviewTab(ref, first.tabId);
385
386 const state = readThreadPreviewState(ref);
387 expect(Object.keys(state.sessions)).toEqual([first.tabId, second.tabId]);
388 expect(state.snapshot?.tabId).toBe(first.tabId);
389 expect(state.desktopOverlay?.canGoBack).toBe(true);
390 });
391
392 it("updates a background snapshot without changing the active tab", () => {
393 const background = makeSnapshot({ tabId: "tab_a" });
394 const active = makeSnapshot({
395 tabId: "tab_b",
396 updatedAt: "2026-01-01T00:00:01.000Z",
397 });
398 applyPreviewServerSnapshot(ref, background);
399 applyPreviewServerSnapshot(ref, active);
400
401 const resized = {
402 ...background,
403 viewport: { _tag: "freeform" as const, width: 900, height: 700 },
404 updatedAt: "2026-01-01T00:00:02.000Z",
405 };
406 updatePreviewServerSnapshot(ref, resized);
407
408 const state = readThreadPreviewState(ref);
409 expect(state.activeTabId).toBe(active.tabId);
410 expect(state.snapshot?.tabId).toBe(active.tabId);
411 expect(state.sessions[background.tabId]).toEqual(resized);
412 });
413
414 it("reconciles an authoritative session list without focusing a background tab", () => {
415 const active = makeSnapshot({ tabId: "tab_a" });
416 const stale = makeSnapshot({
417 tabId: "tab_stale",
418 updatedAt: "2026-01-01T00:00:01.000Z",
419 });
420 applyPreviewServerSnapshot(ref, stale);
421 applyPreviewServerSnapshot(ref, active);
422 applyPreviewDesktopState(ref, stale.tabId, {
423 hasWebContents: true,
424 canGoBack: false,
425 canGoForward: false,
426 loading: false,
427 zoomFactor: 1,
428 pictureInPicture: false,
429 colorScheme: "system",
430 audioMuted: false,
431 audible: false,
432 controller: "none",
433 favicon: null,
434 });
435
436 reconcilePreviewServerSessions(ref, { sessions: [active], serverEpoch, revision: 1 });
437
438 const state = readThreadPreviewState(ref);
439 expect(Object.keys(state.sessions)).toEqual([active.tabId]);
440 expect(state.activeTabId).toBe(active.tabId);
441 expect(state.snapshot).toEqual(active);
442 expect(state.desktopByTabId[stale.tabId]).toBeUndefined();
443 });
444
445 it("clears stale sessions when an authoritative list is empty", () => {
446 applyPreviewServerSnapshot(ref, makeSnapshot());
447
448 reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 1 });
449
450 const state = readThreadPreviewState(ref);
451 expect(state.sessions).toEqual({});
452 expect(state.activeTabId).toBeNull();
453 expect(state.snapshot).toBeNull();
454 });
455
456 it("ignores a list response older than the latest server event", () => {
457 const snapshot = makeSnapshot();
458 applyPreviewServerEvent(ref, {
459 type: "opened",
460 threadId: "thread-1",
461 tabId: snapshot.tabId,
462 createdAt: snapshot.updatedAt,
463 snapshot,
464 });
465
466 reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 0 });
467
468 expect(readThreadPreviewState(ref).sessions).toEqual({ [snapshot.tabId]: snapshot });
469 expect(readThreadPreviewState(ref).listLoaded).toBe(false);
470
471 const olderTab = makeSnapshot({ tabId: "tab_before_event" });
472 reconcilePreviewServerSessions(ref, {
473 sessions: [olderTab, snapshot],
474 serverEpoch,
475 revision: 2,
476 });
477 expect(readThreadPreviewState(ref).listLoaded).toBe(true);
478 expect(readThreadPreviewState(ref).sessions).toEqual({
479 [olderTab.tabId]: olderTab,
480 [snapshot.tabId]: snapshot,
481 });
482 });
483
484 it("does not resurrect a tab from an event older than its close", () => {
485 const snapshot = makeSnapshot();
486 applyPreviewServerEvent(ref, {
487 type: "opened",
488 threadId: "thread-1",
489 tabId: snapshot.tabId,
490 createdAt: snapshot.updatedAt,
491 snapshot,
492 });
493 applyPreviewServerEvent(ref, {
494 type: "closed",
495 threadId: "thread-1",
496 tabId: snapshot.tabId,
497 createdAt: "2026-01-01T00:00:01.000Z",
498 });
499
500 applyPreviewServerEventImpl(ref, {
501 type: "opened",
502 threadId: "thread-1",
503 tabId: snapshot.tabId,
504 createdAt: snapshot.updatedAt,
505 serverEpoch,
506 revision: 1,
507 snapshot,
508 });
509
510 expect(readThreadPreviewState(ref).sessions).toEqual({});
511 });
512
513 it("accepts a lower revision from a newly restarted server", () => {
514 const snapshot = makeSnapshot();
515 applyPreviewServerEventImpl(ref, {
516 type: "opened",
517 threadId: "thread-1",
518 tabId: snapshot.tabId,
519 createdAt: snapshot.updatedAt,
520 serverEpoch,
521 revision: 12,
522 snapshot,
523 });
524
525 reconcilePreviewServerSessions(ref, {
526 sessions: [],
527 serverEpoch: "server-b",
528 revision: 0,
529 });
530
531 const state = readThreadPreviewState(ref);
532 expect(state.sessions).toEqual({});
533 expect(state.serverEpoch).toBe("server-b");
534 expect(state.serverRevision).toBe(0);
535 });
536
537 it("does not carry raw-tab state across a server restart", () => {
538 const previous = makeSnapshot({
539 navStatus: { _tag: "Success", url: "https://old.example", title: "Old" },
540 updatedAt: "2026-01-01T00:00:02.000Z",
541 });
542 applyPreviewServerEventImpl(ref, {
543 type: "opened",
544 threadId: "thread-1",
545 tabId: previous.tabId,
546 createdAt: previous.updatedAt,
547 serverEpoch,
548 revision: 12,
549 snapshot: previous,
550 });
551 beginPreviewSessionClose(ref, previous.tabId);
552 applyPreviewDesktopState(ref, previous.tabId, {
553 hasWebContents: true,
554 canGoBack: false,
555 canGoForward: false,
556 loading: false,
557 zoomFactor: 1,
558 pictureInPicture: false,
559 colorScheme: "system",
560 audioMuted: false,
561 audible: false,
562 controller: "none",
563 favicon: null,
564 });
565 const restarted = makeSnapshot({
566 navStatus: { _tag: "Success", url: "https://new.example", title: "New" },
567 updatedAt: "2026-01-01T00:00:01.000Z",
568 });
569 reconcilePreviewServerSessions(ref, {
570 sessions: [restarted],
571 serverEpoch: "server-b",
572 revision: 0,
573 });
574
575 const state = readThreadPreviewState(ref);
576 expect(state.sessions[restarted.tabId]).toEqual(restarted);
577 expect(state.suppressedTabIds).toEqual(new Set());
578 expect(state.desktopByTabId).toEqual({});
579 expect(state.desktopOverlay).toBeNull();
580 });
581
582 it("applyServerSnapshot null clears snapshot for a thread that had one", () => {
583 const snapshot = makeSnapshot();
584 applyPreviewServerSnapshot(ref, snapshot);
585 applyPreviewServerSnapshot(ref, null);
586 const state = readThreadPreviewState(ref);
587 expect(state.snapshot).toBeNull();
588 });
589
590 it("does not replace a streamed snapshot with older SWR data", () => {
591 applyPreviewServerSnapshot(
592 ref,
593 makeSnapshot({
594 navStatus: { _tag: "Success", url: "http://localhost:5173/new", title: "New" },
595 updatedAt: "2026-01-01T00:00:02.000Z",
596 }),
597 );
598 applyPreviewServerSnapshot(
599 ref,
600 makeSnapshot({
601 navStatus: { _tag: "Success", url: "http://localhost:5173/old", title: "Old" },
602 updatedAt: "2026-01-01T00:00:01.000Z",
603 }),
604 );
605
606 const state = readThreadPreviewState(ref);
607 expect(state.snapshot?.navStatus).toEqual({
608 _tag: "Success",
609 url: "http://localhost:5173/new",
610 title: "New",
611 });
612 });
613
614 it("rememberUrl dedupes and caps at limit", () => {
615 for (let i = 0; i < __testing.RECENT_URL_LIMIT + 5; i += 1) {
616 rememberPreviewUrl(ref, `http://localhost:${5000 + i}/`);
617 }
618 const state = readThreadPreviewState(ref);
619 expect(state.recentlySeenUrls.length).toBeLessThanOrEqual(__testing.RECENT_URL_LIMIT);
620 expect(state.recentlySeenUrls[0]).toBe(
621 `http://localhost:${5000 + __testing.RECENT_URL_LIMIT + 4}/`,
622 );
623 });
624});