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 resetPreviewStateForTests,
21 setActivePreviewTab,
22} from "./previewStateStore";
23import { appAtomRegistry } from "./rpc/atomRegistry";
24
25const environmentId = "env-1" as EnvironmentId;
26const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1"));
27
28const makeSnapshot = (overrides: Partial<PreviewSessionSnapshot> = {}): PreviewSessionSnapshot => ({
29 threadId: "thread-1",
30 tabId: "tab_a",
31 navStatus: { _tag: "Loading", url: "http://localhost:5173/", title: "" },
32 canGoBack: false,
33 canGoForward: false,
34 updatedAt: "2026-01-01T00:00:00.000Z",
35 ...overrides,
36});
37
38type PreviewEventDraft = PreviewEvent extends infer Event
39 ? Event extends { readonly revision: number }
40 ? Omit<Event, "revision" | "serverEpoch">
41 : never
42 : never;
43
44const serverEpoch = "server-a";
45let nextServerRevision = 0;
46const applyPreviewServerEvent = (eventRef: typeof ref, event: PreviewEventDraft): void => {
47 nextServerRevision += 1;
48 applyPreviewServerEventImpl(eventRef, {
49 ...event,
50 serverEpoch,
51 revision: nextServerRevision,
52 } as PreviewEvent);
53};
54
55beforeEach(() => {
56 nextServerRevision = 0;
57 resetPreviewStateForTests();
58});
59
60describe("previewStateStore (single-tab)", () => {
61 it("resized event updates tab viewport without changing the active tab", () => {
62 const active = makeSnapshot({ tabId: "tab_a" });
63 const background = makeSnapshot({ tabId: "tab_b" });
64 applyPreviewServerSnapshot(ref, background);
65 applyPreviewServerSnapshot(ref, active);
66
67 applyPreviewServerEvent(ref, {
68 type: "resized",
69 threadId: "thread-1",
70 tabId: background.tabId,
71 createdAt: "2026-01-01T00:00:01.000Z",
72 snapshot: {
73 ...background,
74 viewport: { _tag: "preset", presetId: "pixel-8", width: 412, height: 915 },
75 updatedAt: "2026-01-01T00:00:01.000Z",
76 },
77 });
78
79 const state = readThreadPreviewState(ref);
80 expect(state.activeTabId).toBe(active.tabId);
81 expect(state.sessions[background.tabId]?.viewport).toEqual({
82 _tag: "preset",
83 presetId: "pixel-8",
84 width: 412,
85 height: 915,
86 });
87 });
88
89 it("failed event for a non-active tab is ignored", () => {
90 const snapshot = makeSnapshot({ tabId: "tab_a" });
91 applyPreviewServerEvent(ref, {
92 type: "opened",
93 threadId: "thread-1",
94 tabId: snapshot.tabId,
95 createdAt: snapshot.updatedAt,
96 snapshot,
97 });
98 applyPreviewServerEvent(ref, {
99 type: "failed",
100 threadId: "thread-1",
101 tabId: "tab_b",
102 createdAt: "2026-01-01T00:00:01.000Z",
103 url: "http://localhost:9999/",
104 title: "",
105 code: -105,
106 description: "ERR_NAME_NOT_RESOLVED",
107 });
108 const state = readThreadPreviewState(ref);
109 expect(state.snapshot?.navStatus._tag).toBe("Loading");
110 });
111
112 it("optimistically removes a session before the server close event arrives", () => {
113 const first = makeSnapshot({ tabId: "tab_a" });
114 const second = makeSnapshot({
115 tabId: "tab_b",
116 updatedAt: "2026-01-01T00:00:01.000Z",
117 });
118 applyPreviewServerSnapshot(ref, first);
119 applyPreviewServerSnapshot(ref, second);
120
121 beginPreviewSessionClose(ref, second.tabId);
122
123 const state = readThreadPreviewState(ref);
124 expect(Object.keys(state.sessions)).toEqual([first.tabId]);
125 expect(state.activeTabId).toBe(first.tabId);
126 expect(state.snapshot?.tabId).toBe(first.tabId);
127 });
128
129 it("treats a late server close event after optimistic removal as a no-op", () => {
130 const snapshot = makeSnapshot();
131 applyPreviewServerSnapshot(ref, snapshot);
132 beginPreviewSessionClose(ref, snapshot.tabId);
133
134 applyPreviewServerEvent(ref, {
135 type: "closed",
136 threadId: "thread-1",
137 tabId: snapshot.tabId,
138 createdAt: "2026-01-01T00:00:01.000Z",
139 });
140
141 const state = readThreadPreviewState(ref);
142 expect(state.sessions).toEqual({});
143 expect(state.snapshot).toBeNull();
144 });
145
146 it("does not resurrect an intentionally closed tab from a stale list snapshot", () => {
147 const snapshot = makeSnapshot();
148 applyPreviewServerSnapshot(ref, snapshot);
149 beginPreviewSessionClose(ref, snapshot.tabId);
150
151 applyPreviewServerSnapshot(ref, snapshot);
152
153 const state = readThreadPreviewState(ref);
154 expect(state.sessions).toEqual({});
155 expect(state.snapshot).toBeNull();
156 });
157
158 it("can restore a suppressed tab after a failed close", () => {
159 const snapshot = makeSnapshot();
160 applyPreviewServerSnapshot(ref, snapshot);
161 beginPreviewSessionClose(ref, snapshot.tabId);
162
163 cancelPreviewSessionClose(ref, snapshot, snapshot.tabId);
164
165 const state = readThreadPreviewState(ref);
166 expect(state.sessions).toEqual({ [snapshot.tabId]: snapshot });
167 expect(state.snapshot).toEqual(snapshot);
168 });
169
170 it("does not publish duplicate desktop browser state", () => {
171 const snapshot = makeSnapshot();
172 applyPreviewServerSnapshot(ref, snapshot);
173 const overlay = {
174 hasWebContents: true,
175 canGoBack: true,
176 canGoForward: false,
177 loading: false,
178 zoomFactor: 1,
179 pictureInPicture: false,
180 colorScheme: "system" as const,
181 audioMuted: false,
182 audible: false,
183 controller: "none" as const,
184 favicon: {
185 dataUrl: "data:image/png;base64,AA==",
186 pageUrl: "https://example.com",
187 capturedAt: 1,
188 },
189 };
190 let updateCount = 0;
191 const unsubscribe = appAtomRegistry.subscribe(previewStateAtom(scopedThreadKey(ref)), () => {
192 updateCount += 1;
193 });
194
195 applyPreviewDesktopState(ref, snapshot.tabId, overlay);
196 applyPreviewDesktopState(ref, snapshot.tabId, { ...overlay, favicon: { ...overlay.favicon } });
197 unsubscribe();
198
199 expect(updateCount).toBe(1);
200 });
201
202 it("retains multiple tabs and switches active desktop state", () => {
203 const first = makeSnapshot();
204 const second = { ...makeSnapshot(), tabId: "tab_2", updatedAt: "2026-01-02T00:00:00.000Z" };
205 applyPreviewServerSnapshot(ref, first);
206 applyPreviewServerSnapshot(ref, second);
207 applyPreviewDesktopState(ref, first.tabId, {
208 hasWebContents: true,
209 canGoBack: true,
210 canGoForward: false,
211 loading: false,
212 zoomFactor: 1,
213 pictureInPicture: false,
214 colorScheme: "system",
215 audioMuted: false,
216 audible: false,
217 controller: "none",
218 favicon: null,
219 });
220 setActivePreviewTab(ref, first.tabId);
221
222 const state = readThreadPreviewState(ref);
223 expect(Object.keys(state.sessions)).toEqual([first.tabId, second.tabId]);
224 expect(state.snapshot?.tabId).toBe(first.tabId);
225 expect(state.desktopOverlay?.canGoBack).toBe(true);
226 });
227
228 it("reconciles an authoritative session list without focusing a background tab", () => {
229 const active = makeSnapshot({ tabId: "tab_a" });
230 const stale = makeSnapshot({
231 tabId: "tab_stale",
232 updatedAt: "2026-01-01T00:00:01.000Z",
233 });
234 applyPreviewServerSnapshot(ref, stale);
235 applyPreviewServerSnapshot(ref, active);
236 applyPreviewDesktopState(ref, stale.tabId, {
237 hasWebContents: true,
238 canGoBack: false,
239 canGoForward: false,
240 loading: false,
241 zoomFactor: 1,
242 pictureInPicture: false,
243 colorScheme: "system",
244 audioMuted: false,
245 audible: false,
246 controller: "none",
247 favicon: null,
248 });
249
250 reconcilePreviewServerSessions(ref, { sessions: [active], serverEpoch, revision: 1 });
251
252 const state = readThreadPreviewState(ref);
253 expect(Object.keys(state.sessions)).toEqual([active.tabId]);
254 expect(state.activeTabId).toBe(active.tabId);
255 expect(state.snapshot).toEqual(active);
256 expect(state.desktopByTabId[stale.tabId]).toBeUndefined();
257 });
258
259 it("keeps the state object when a refetched list changes nothing", () => {
260 reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 1 });
261 const loaded = readThreadPreviewState(ref);
262
263 reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 1 });
264 expect(readThreadPreviewState(ref)).toBe(loaded);
265
266 const snapshot = makeSnapshot();
267 reconcilePreviewServerSessions(ref, { sessions: [snapshot], serverEpoch, revision: 2 });
268 expect(readThreadPreviewState(ref).sessions).toEqual({ [snapshot.tabId]: snapshot });
269 });
270
271 it("ignores a list response older than the latest server event", () => {
272 const snapshot = makeSnapshot();
273 applyPreviewServerEvent(ref, {
274 type: "opened",
275 threadId: "thread-1",
276 tabId: snapshot.tabId,
277 createdAt: snapshot.updatedAt,
278 snapshot,
279 });
280
281 reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 0 });
282
283 expect(readThreadPreviewState(ref).sessions).toEqual({ [snapshot.tabId]: snapshot });
284 expect(readThreadPreviewState(ref).listLoaded).toBe(false);
285
286 const olderTab = makeSnapshot({ tabId: "tab_before_event" });
287 reconcilePreviewServerSessions(ref, {
288 sessions: [olderTab, snapshot],
289 serverEpoch,
290 revision: 2,
291 });
292 expect(readThreadPreviewState(ref).listLoaded).toBe(true);
293 expect(readThreadPreviewState(ref).sessions).toEqual({
294 [olderTab.tabId]: olderTab,
295 [snapshot.tabId]: snapshot,
296 });
297 });
298
299 it("does not resurrect a tab from an event older than its close", () => {
300 const snapshot = makeSnapshot();
301 applyPreviewServerEvent(ref, {
302 type: "opened",
303 threadId: "thread-1",
304 tabId: snapshot.tabId,
305 createdAt: snapshot.updatedAt,
306 snapshot,
307 });
308 applyPreviewServerEvent(ref, {
309 type: "closed",
310 threadId: "thread-1",
311 tabId: snapshot.tabId,
312 createdAt: "2026-01-01T00:00:01.000Z",
313 });
314
315 applyPreviewServerEventImpl(ref, {
316 type: "opened",
317 threadId: "thread-1",
318 tabId: snapshot.tabId,
319 createdAt: snapshot.updatedAt,
320 serverEpoch,
321 revision: 1,
322 snapshot,
323 });
324
325 expect(readThreadPreviewState(ref).sessions).toEqual({});
326 });
327
328 it("accepts a lower revision from a newly restarted server", () => {
329 const snapshot = makeSnapshot();
330 applyPreviewServerEventImpl(ref, {
331 type: "opened",
332 threadId: "thread-1",
333 tabId: snapshot.tabId,
334 createdAt: snapshot.updatedAt,
335 serverEpoch,
336 revision: 12,
337 snapshot,
338 });
339
340 reconcilePreviewServerSessions(ref, {
341 sessions: [],
342 serverEpoch: "server-b",
343 revision: 0,
344 });
345
346 const state = readThreadPreviewState(ref);
347 expect(state.sessions).toEqual({});
348 expect(state.serverEpoch).toBe("server-b");
349 expect(state.serverRevision).toBe(0);
350 });
351
352 it("does not carry raw-tab state across a server restart", () => {
353 const previous = makeSnapshot({
354 navStatus: { _tag: "Success", url: "https://old.example", title: "Old" },
355 updatedAt: "2026-01-01T00:00:02.000Z",
356 });
357 applyPreviewServerEventImpl(ref, {
358 type: "opened",
359 threadId: "thread-1",
360 tabId: previous.tabId,
361 createdAt: previous.updatedAt,
362 serverEpoch,
363 revision: 12,
364 snapshot: previous,
365 });
366 beginPreviewSessionClose(ref, previous.tabId);
367 applyPreviewDesktopState(ref, previous.tabId, {
368 hasWebContents: true,
369 canGoBack: false,
370 canGoForward: false,
371 loading: false,
372 zoomFactor: 1,
373 pictureInPicture: false,
374 colorScheme: "system",
375 audioMuted: false,
376 audible: false,
377 controller: "none",
378 favicon: null,
379 });
380 const restarted = makeSnapshot({
381 navStatus: { _tag: "Success", url: "https://new.example", title: "New" },
382 updatedAt: "2026-01-01T00:00:01.000Z",
383 });
384 reconcilePreviewServerSessions(ref, {
385 sessions: [restarted],
386 serverEpoch: "server-b",
387 revision: 0,
388 });
389
390 const state = readThreadPreviewState(ref);
391 expect(state.sessions[restarted.tabId]).toEqual(restarted);
392 expect(state.suppressedTabIds).toEqual(new Set());
393 expect(state.desktopByTabId).toEqual({});
394 expect(state.desktopOverlay).toBeNull();
395 });
396
397 it("does not replace a streamed snapshot with older SWR data", () => {
398 applyPreviewServerSnapshot(
399 ref,
400 makeSnapshot({
401 navStatus: { _tag: "Success", url: "http://localhost:5173/new", title: "New" },
402 updatedAt: "2026-01-01T00:00:02.000Z",
403 }),
404 );
405 applyPreviewServerSnapshot(
406 ref,
407 makeSnapshot({
408 navStatus: { _tag: "Success", url: "http://localhost:5173/old", title: "Old" },
409 updatedAt: "2026-01-01T00:00:01.000Z",
410 }),
411 );
412
413 const state = readThreadPreviewState(ref);
414 expect(state.snapshot?.navStatus).toEqual({
415 _tag: "Success",
416 url: "http://localhost:5173/new",
417 title: "New",
418 });
419 });
420});