1import {
2 DEFAULT_PREVIEW_APPEARANCE,
3 DEFAULT_PREVIEW_ZOOM_FACTOR,
4 DEFAULT_CLIENT_SETTINGS,
5 EnvironmentId,
6 ThreadId,
7} from "@t3tools/contracts";
8import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
9
10const { closeTab, createTab, stopBrowserRecording } = vi.hoisted(() => ({
11 closeTab: vi.fn<(tabId: string) => Promise<void>>(async () => undefined),
12 createTab: vi.fn<() => Promise<void>>(),
13 stopBrowserRecording: vi.fn(async () => null),
14}));
15
16vi.mock("~/components/preview/previewBridge", () => ({
17 previewBridge: { closeTab, createTab },
18}));
19
20vi.mock("./browserRecording", () => ({
21 stopBrowserRecording,
22}));
23
24import { acquireDesktopTab } from "./desktopTabLifetime";
25import * as browserDefaults from "./browserDefaults";
26import { __setClientSettingsForTests } from "~/hooks/useSettings";
27
28/** Tests load default settings unless they select other preferences. */
29const DEFAULT_TAB_STATE = {
30 zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
31 colorScheme: DEFAULT_PREVIEW_APPEARANCE,
32};
33import { previewRuntimeTabId } from "./previewRuntimeTabId";
34
35describe("desktopTabLifetime", () => {
36 beforeEach(() => {
37 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
38 closeTab.mockClear();
39 createTab.mockClear();
40 stopBrowserRecording.mockClear();
41 vi.stubGlobal("window", globalThis);
42 });
43
44 afterEach(() => {
45 vi.useRealTimers();
46 vi.unstubAllGlobals();
47 vi.restoreAllMocks();
48 });
49
50 it("does not create a desktop tab after a failed settings read and permits a later retry", async () => {
51 vi.useFakeTimers();
52 const failure = new Error("Settings read failed");
53 vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure);
54 const failed = acquireDesktopTab("tab_settings_retry");
55
56 await expect(failed.ready).rejects.toBe(failure);
57 expect(createTab).not.toHaveBeenCalled();
58 failed.release();
59 await vi.advanceTimersByTimeAsync(0);
60
61 __setClientSettingsForTests({
62 ...DEFAULT_CLIENT_SETTINGS,
63 browserDefaultZoomFactor: 1.25,
64 browserDefaultAppearance: "dark",
65 });
66 createTab.mockResolvedValueOnce(undefined);
67 const retry = acquireDesktopTab("tab_settings_retry");
68 await retry.ready;
69
70 expect(createTab).toHaveBeenCalledExactlyOnceWith("tab_settings_retry", {
71 zoomFactor: 1.25,
72 colorScheme: "dark",
73 });
74 retry.release();
75 await vi.advanceTimersByTimeAsync(0);
76 });
77
78 it("shares tab creation readiness across concurrent leases", async () => {
79 let resolveCreation: (() => void) | undefined;
80 createTab.mockReturnValueOnce(
81 new Promise<void>((resolve) => {
82 resolveCreation = resolve;
83 }),
84 );
85
86 const first = acquireDesktopTab("tab_readiness");
87 const second = acquireDesktopTab("tab_readiness");
88
89 // Both leases share one creation, and it is still in flight: creation now
90 // waits for client settings to hydrate so the guest is born at the user's
91 // zoom and appearance rather than painting at the defaults first.
92 expect(first.ready).toBe(second.ready);
93
94 let ready = false;
95 void first.ready.then(() => {
96 ready = true;
97 });
98 await vi.waitFor(() => expect(createTab).toHaveBeenCalledOnce());
99 expect(ready).toBe(false);
100
101 resolveCreation?.();
102 await first.ready;
103 expect(ready).toBe(true);
104 });
105
106 it("keeps identical server tab ids from two environments in separate desktop slots", async () => {
107 vi.useFakeTimers();
108 createTab.mockResolvedValue(undefined);
109 const tabA = previewRuntimeTabId(
110 {
111 environmentId: EnvironmentId.make("environment-a"),
112 threadId: ThreadId.make("thread-a"),
113 },
114 "epoch-a",
115 "tab_1",
116 );
117 const tabB = previewRuntimeTabId(
118 {
119 environmentId: EnvironmentId.make("environment-b"),
120 threadId: ThreadId.make("thread-b"),
121 },
122 "epoch-b",
123 "tab_1",
124 );
125
126 const first = acquireDesktopTab(tabA);
127 const second = acquireDesktopTab(tabB);
128 await Promise.all([first.ready, second.ready]);
129
130 expect(createTab).toHaveBeenCalledWith(tabA, DEFAULT_TAB_STATE);
131 expect(createTab).toHaveBeenCalledWith(tabB, DEFAULT_TAB_STATE);
132 expect(createTab).toHaveBeenCalledTimes(2);
133
134 first.release();
135 second.release();
136 await vi.advanceTimersByTimeAsync(0);
137 });
138
139 it("stops recording before closing the final desktop tab lease", async () => {
140 vi.useFakeTimers();
141 let resolveStop: (() => void) | undefined;
142 stopBrowserRecording.mockReturnValueOnce(
143 new Promise<null>((resolve) => {
144 resolveStop = () => resolve(null);
145 }),
146 );
147 createTab.mockResolvedValueOnce(undefined);
148
149 const lease = acquireDesktopTab("tab_recording_cleanup");
150 await lease.ready;
151 lease.release();
152 await vi.advanceTimersByTimeAsync(0);
153
154 expect(stopBrowserRecording).toHaveBeenCalledWith("tab_recording_cleanup");
155 expect(closeTab).not.toHaveBeenCalled();
156
157 resolveStop?.();
158 await Promise.resolve();
159 await Promise.resolve();
160 expect(closeTab).toHaveBeenCalledWith("tab_recording_cleanup");
161 });
162
163 it("waits for an in-flight close before recreating a reacquired tab", async () => {
164 vi.useFakeTimers();
165 let resolveClose: (() => void) | undefined;
166 createTab.mockResolvedValue(undefined);
167 closeTab.mockReturnValueOnce(
168 new Promise<void>((resolve) => {
169 resolveClose = resolve;
170 }),
171 );
172
173 const initial = acquireDesktopTab("tab_close_reacquire");
174 await initial.ready;
175 initial.release();
176 await vi.advanceTimersByTimeAsync(0);
177
178 expect(closeTab).toHaveBeenCalledWith("tab_close_reacquire");
179
180 const reacquired = acquireDesktopTab("tab_close_reacquire");
181 expect(createTab).toHaveBeenCalledTimes(1);
182
183 resolveClose?.();
184 await reacquired.ready;
185 expect(createTab).toHaveBeenCalledTimes(2);
186 });
187});