1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Crypto from "effect/Crypto";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as PlatformError from "effect/PlatformError";
7import { beforeEach, vi } from "vite-plus/test";
8
9const { fromPartition, sessions } = vi.hoisted(() => ({
10 fromPartition: vi.fn(),
11 sessions: new Map<
12 string,
13 {
14 readonly clearCache: ReturnType<typeof vi.fn>;
15 readonly clearStorageData: ReturnType<typeof vi.fn>;
16 readonly getUserAgent: ReturnType<typeof vi.fn<() => string>>;
17 readonly setPermissionRequestHandler: ReturnType<typeof vi.fn>;
18 readonly setPermissionCheckHandler: ReturnType<typeof vi.fn>;
19 readonly setUserAgent: ReturnType<typeof vi.fn>;
20 }
21 >(),
22}));
23
24const { fromWebContents } = vi.hoisted(() => ({
25 fromWebContents: vi.fn<(webContents: unknown) => unknown>(() => null),
26}));
27
28vi.mock("electron", () => ({
29 BrowserWindow: { fromWebContents },
30 session: {
31 fromPartition,
32 },
33}));
34
35import * as ElectronDialog from "../electron/ElectronDialog.ts";
36import * as BrowserSession from "./BrowserSession.ts";
37
38const { showMessageBox } = vi.hoisted(() => ({
39 showMessageBox: vi.fn<
40 (options: Electron.MessageBoxOptions, owner?: Electron.BrowserWindow) => number
41 >(() => 1),
42}));
43const layerDialog = Layer.succeed(ElectronDialog.ElectronDialog, {
44 pickFolder: () => Effect.die("unused"),
45 pickFiles: () => Effect.die("unused"),
46 showMessageBox: (options, owner) =>
47 Effect.sync(() => ({ response: showMessageBox(options, owner), checkboxChecked: false })),
48 showErrorBox: () => Effect.void,
49});
50const layer = BrowserSession.layer.pipe(
51 Layer.provide(Layer.merge(NodeServices.layer, layerDialog)),
52);
53
54describe("BrowserSession", () => {
55 beforeEach(() => {
56 sessions.clear();
57 fromPartition.mockReset();
58 fromPartition.mockImplementation((partition: string) => {
59 const browserSession = {
60 clearCache: vi.fn(() => Promise.resolve()),
61 clearStorageData: vi.fn(() => Promise.resolve()),
62 getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"),
63 setPermissionRequestHandler: vi.fn(),
64 setPermissionCheckHandler: vi.fn(),
65 setUserAgent: vi.fn(),
66 };
67 sessions.set(partition, browserSession);
68 return browserSession;
69 });
70 });
71
72 it.effect("derives deterministic partitions and memoizes sessions", () =>
73 Effect.gen(function* () {
74 const browserSessions = yield* BrowserSession.BrowserSession;
75
76 const partition = yield* browserSessions.getPartition("scope-a");
77 const first = yield* browserSessions.getSession("scope-a");
78 const second = yield* browserSessions.getSession("scope-a");
79
80 assert.strictEqual(partition, "persist:t3code-preview-f051bb2c68cb7b2fe969");
81 assert.strictEqual(first, second);
82 assert.strictEqual(fromPartition.mock.calls.length, 1);
83 }).pipe(Effect.provide(layer)),
84 );
85
86 it.effect("keeps scopes that differ only by a lone surrogate in separate partitions", () =>
87 Effect.gen(function* () {
88 const browserSessions = yield* BrowserSession.BrowserSession;
89
90 // TextEncoder folds a lone surrogate to U+FFFD, so without escaping these
91 // two supported ids would hash to one partition and share every cookie.
92 const loneSurrogate = yield* browserSessions.getPartition("p\ud800");
93 const replacementChar = yield* browserSessions.getPartition("p\ufffd");
94 assert.notStrictEqual(loneSurrogate, replacementChar);
95
96 // The escape can't be forged with a literal backslash either.
97 const literal = yield* browserSessions.getPartition("p\\ud800");
98 assert.notStrictEqual(literal, loneSurrogate);
99
100 // And a well-formed scope still lands on its historical partition.
101 assert.strictEqual(
102 yield* browserSessions.getPartition("scope-a"),
103 "persist:t3code-preview-f051bb2c68cb7b2fe969",
104 );
105 }).pipe(Effect.provide(layer)),
106 );
107
108 it.effect("keeps legacy defaults disjoint from nondefault profile partitions", () =>
109 Effect.gen(function* () {
110 const browserSessions = yield* BrowserSession.BrowserSession;
111
112 // These share the same scope string: default environment `a::b`, and
113 // environment `a` with nondefault profile `b`.
114 const legacyDefault = yield* browserSessions.getPartition("a::b");
115 const nondefaultProfile = yield* browserSessions.getPartition("a::b", true, "profile");
116
117 assert.strictEqual(legacyDefault, "persist:t3code-preview-78f0be89237d77f7a70e");
118 assert.strictEqual(nondefaultProfile, "persist:t3code-preview-profile-78f0be89237d77f7a70e");
119 assert.notStrictEqual(nondefaultProfile, legacyDefault);
120 assert.isTrue(browserSessions.isPartition(legacyDefault));
121 assert.isTrue(browserSessions.isPartition(nondefaultProfile));
122 }).pipe(Effect.provide(layer)),
123 );
124
125 it.effect("presents persistent preview sessions as the bundled Chrome", () =>
126 Effect.gen(function* () {
127 // Electron's real UA shape: app token, then Chrome, then Electron, then
128 // Safari — the token order and casing matter to any strip regex.
129 const nativeUserAgent =
130 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Electron/41.5.0 Safari/537.36";
131 fromPartition.mockReset();
132 fromPartition.mockImplementation((partition: string) => {
133 let userAgent = nativeUserAgent;
134 const browserSession = {
135 clearCache: vi.fn(() => Promise.resolve()),
136 clearStorageData: vi.fn(() => Promise.resolve()),
137 getUserAgent: vi.fn(() => userAgent),
138 setPermissionRequestHandler: vi.fn(),
139 setPermissionCheckHandler: vi.fn(),
140 setUserAgent: vi.fn((next: string) => {
141 userAgent = next;
142 }),
143 };
144 sessions.set(partition, browserSession);
145 return browserSession;
146 });
147
148 const browserSessions = yield* BrowserSession.BrowserSession;
149 yield* browserSessions.getSession("scope-a");
150 yield* browserSessions.getSession("private", false);
151 yield* browserSessions.getSession("work", true, "profile");
152
153 assert.strictEqual(sessions.size, 3);
154 for (const browserSession of sessions.values()) {
155 assert.strictEqual(
156 browserSession.getUserAgent(),
157 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
158 );
159 }
160 }).pipe(Effect.provide(layer)),
161 );
162
163 it.effect("grants clipboard-sanitized-write through both the request and check handlers", () =>
164 Effect.gen(function* () {
165 const browserSessions = yield* BrowserSession.BrowserSession;
166 const partition = yield* browserSessions.getPartition("scope-a");
167 yield* browserSessions.getSession("scope-a");
168
169 const browserSession = sessions.get(partition);
170 assert.isDefined(browserSession);
171
172 const requestHandler = browserSession.setPermissionRequestHandler.mock.calls[0]?.[0];
173 const checkHandler = browserSession.setPermissionCheckHandler.mock.calls[0]?.[0];
174 assert.isFunction(requestHandler);
175 assert.isFunction(checkHandler);
176
177 const requestAllows = (permission: string): boolean => {
178 let granted: boolean | undefined;
179 requestHandler(null, permission, (value: boolean) => {
180 granted = value;
181 });
182 assert.isDefined(granted);
183 return granted;
184 };
185
186 for (const permission of [
187 "clipboard-read",
188 "clipboard-sanitized-write",
189 "notifications",
190 "geolocation",
191 "fullscreen",
192 ]) {
193 assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`);
194 assert.isTrue(
195 checkHandler(null, permission) as boolean,
196 `check handler should allow ${permission}`,
197 );
198 }
199
200 // `clipboard-write` is not a real Electron permission — the async write API
201 // uses `clipboard-sanitized-write` — so the stale name must not be granted,
202 // and unrelated permissions stay denied.
203 for (const permission of ["clipboard-write", "midi"]) {
204 assert.isFalse(requestAllows(permission), `request handler should deny ${permission}`);
205 assert.isFalse(
206 checkHandler(null, permission) as boolean,
207 `check handler should deny ${permission}`,
208 );
209 }
210 }).pipe(Effect.provide(layer)),
211 );
212
213 it.effect("opens custom-scheme links externally only after the user confirms", () =>
214 Effect.gen(function* () {
215 const browserSessions = yield* BrowserSession.BrowserSession;
216 const partition = yield* browserSessions.getPartition("scope-a");
217 yield* browserSessions.getSession("scope-a");
218 const requestHandler =
219 sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0];
220 assert.isFunction(requestHandler);
221
222 const request = (externalURL: string) =>
223 Effect.callback<boolean>((resume) => {
224 requestHandler(
225 null,
226 "openExternal",
227 (granted: boolean) => resume(Effect.succeed(granted)),
228 { externalURL },
229 );
230 });
231
232 showMessageBox.mockReset();
233 showMessageBox.mockReturnValueOnce(0).mockReturnValueOnce(1);
234 assert.isTrue(yield* request("slack://open?team=T1"));
235 assert.isFalse(yield* request("zoommtg://zoom.us/join"));
236 assert.strictEqual(showMessageBox.mock.calls.length, 2);
237
238 for (const url of ["file:///etc/passwd", "javascript:alert(1)", "data:text/html,x"]) {
239 assert.isFalse(yield* request(url), `${url} must never open externally`);
240 }
241 assert.strictEqual(showMessageBox.mock.calls.length, 2);
242 }).pipe(Effect.provide(layer)),
243 );
244
245 // A prompt without an owner can open behind the app window, leaving the one
246 // prompt slot taken and every later link silently denied.
247 it.effect("attaches the open-externally prompt to the window hosting the preview", () =>
248 Effect.gen(function* () {
249 const browserSessions = yield* BrowserSession.BrowserSession;
250 const partition = yield* browserSessions.getPartition("scope-a");
251 yield* browserSessions.getSession("scope-a");
252 const requestHandler =
253 sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0];
254 assert.isFunction(requestHandler);
255
256 const hostWindow = { id: 7 } as unknown as Electron.BrowserWindow;
257 const host = { isDestroyed: () => false };
258 const guest = { isDestroyed: () => false, hostWebContents: host };
259 fromWebContents.mockImplementation((webContents) =>
260 webContents === host ? hostWindow : null,
261 );
262 showMessageBox.mockReset();
263 showMessageBox.mockReturnValueOnce(1);
264
265 yield* Effect.callback<boolean>((resume) => {
266 requestHandler(
267 guest,
268 "openExternal",
269 (granted: boolean) => resume(Effect.succeed(granted)),
270 {
271 externalURL: "slack://open?team=T1",
272 },
273 );
274 });
275
276 assert.strictEqual(showMessageBox.mock.calls[0]?.[1], hostWindow);
277 }).pipe(Effect.provide(layer)),
278 );
279
280 it.effect("preserves partition scope and the platform failure chain", () => {
281 const nativeCause = new Error("native digest failed");
282 const platformCause = PlatformError.systemError({
283 _tag: "Unknown",
284 module: "Crypto",
285 method: "digest",
286 cause: nativeCause,
287 });
288 const layerFailingCrypto = Layer.succeed(
289 Crypto.Crypto,
290 Crypto.make({
291 randomBytes: (size) => new Uint8Array(size),
292 digest: () => Effect.fail(platformCause),
293 }),
294 );
295
296 return Effect.gen(function* () {
297 const browserSessions = yield* BrowserSession.BrowserSession;
298 const error = yield* browserSessions.getPartition("environment-a").pipe(Effect.flip);
299
300 assert.instanceOf(error, BrowserSession.BrowserSessionPartitionDerivationError);
301 assert.equal(error.scope, "environment-a");
302 assert.strictEqual(error.cause, platformCause);
303 assert.strictEqual(error.cause.reason.cause, nativeCause);
304 assert.equal(
305 error.message,
306 "Failed to derive a desktop preview browser partition for scope environment-a.",
307 );
308 assert.notInclude(error.message, nativeCause.message);
309 }).pipe(
310 Effect.provide(
311 BrowserSession.layer.pipe(Layer.provide(Layer.merge(layerFailingCrypto, layerDialog))),
312 ),
313 );
314 });
315
316 it.effect("preserves session scope, partition, and the Electron failure", () =>
317 Effect.gen(function* () {
318 const cause = new Error("Electron session failed");
319 fromPartition.mockImplementationOnce(() => {
320 throw cause;
321 });
322 const browserSessions = yield* BrowserSession.BrowserSession;
323 const partition = yield* browserSessions.getPartition("environment-b");
324 const error = yield* browserSessions.getSession("environment-b").pipe(Effect.flip);
325
326 assert.instanceOf(error, BrowserSession.BrowserSessionCreationError);
327 assert.equal(error.scope, "environment-b");
328 assert.equal(error.partition, partition);
329 assert.strictEqual(error.cause, cause);
330 assert.equal(
331 error.message,
332 `Failed to create a desktop preview browser session for scope environment-b (partition ${partition}).`,
333 );
334 assert.notInclude(error.message, cause.message);
335 }).pipe(Effect.provide(layer)),
336 );
337
338 it.effect("clears storage and cache for every created session", () =>
339 Effect.gen(function* () {
340 const browserSessions = yield* BrowserSession.BrowserSession;
341 yield* browserSessions.getSession("scope-a");
342 yield* browserSessions.getSession("scope-b");
343
344 yield* browserSessions.clearCookies();
345 yield* browserSessions.clearCache();
346
347 assert.strictEqual(sessions.size, 2);
348 for (const browserSession of sessions.values()) {
349 assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1);
350 assert.deepEqual(browserSession.clearStorageData.mock.calls[0], [
351 {
352 storages: ["cookies", "localstorage", "indexdb", "serviceworkers"],
353 },
354 ]);
355 assert.strictEqual(browserSession.clearCache.mock.calls.length, 1);
356 }
357 }).pipe(Effect.provide(layer)),
358 );
359
360 it.effect("clears a partition whose session has not been opened yet", () =>
361 Effect.gen(function* () {
362 const browserSessions = yield* BrowserSession.BrowserSession;
363 const partition = yield* browserSessions.getPartition("scope-untouched");
364
365 // Deriving the partition string does not create the session, and the
366 // clear only walks sessions it already holds. Without loading it first
367 // this reports success and deletes nothing — which is what a user
368 // clearing a profile after a restart would get.
369 assert.isUndefined(sessions.get(partition));
370 yield* browserSessions.clearCookies([partition]);
371 assert.isUndefined(sessions.get(partition));
372
373 yield* browserSessions.getSession("scope-untouched");
374 yield* browserSessions.clearCookies([partition]);
375
376 const created = sessions.get(partition);
377 assert.isDefined(created);
378 assert.strictEqual(created.clearStorageData.mock.calls.length, 1);
379 }).pipe(Effect.provide(layer)),
380 );
381
382 it.effect("correlates clear failures while still attempting every session", () =>
383 Effect.gen(function* () {
384 const browserSessions = yield* BrowserSession.BrowserSession;
385 yield* browserSessions.getSession("scope-a");
386 yield* browserSessions.getSession("scope-b");
387 const firstPartition = yield* browserSessions.getPartition("scope-a");
388 const secondPartition = yield* browserSessions.getPartition("scope-b");
389 const firstSession = sessions.get(firstPartition);
390 const secondSession = sessions.get(secondPartition);
391 assert.isDefined(firstSession);
392 assert.isDefined(secondSession);
393
394 const storageCause = new Error("storage clear failed");
395 secondSession.clearStorageData.mockImplementationOnce(() => Promise.reject(storageCause));
396 const storageError = yield* browserSessions.clearCookies().pipe(Effect.flip);
397
398 assert.instanceOf(storageError, BrowserSession.BrowserSessionStorageClearError);
399 assert.equal(storageError.partition, secondPartition);
400 assert.strictEqual(storageError.cause, storageCause);
401 assert.equal(
402 storageError.message,
403 `Failed to clear desktop preview browser storage for partition ${secondPartition}.`,
404 );
405 assert.notInclude(storageError.message, storageCause.message);
406 for (const browserSession of sessions.values()) {
407 assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1);
408 }
409
410 const cacheCause = new Error("cache clear failed");
411 firstSession.clearCache.mockImplementationOnce(() => Promise.reject(cacheCause));
412 const cacheError = yield* browserSessions.clearCache().pipe(Effect.flip);
413
414 assert.instanceOf(cacheError, BrowserSession.BrowserSessionCacheClearError);
415 assert.equal(cacheError.partition, firstPartition);
416 assert.strictEqual(cacheError.cause, cacheCause);
417 assert.equal(
418 cacheError.message,
419 `Failed to clear the desktop preview browser cache for partition ${firstPartition}.`,
420 );
421 assert.notInclude(cacheError.message, cacheCause.message);
422 for (const browserSession of sessions.values()) {
423 assert.strictEqual(browserSession.clearCache.mock.calls.length, 1);
424 }
425 }).pipe(Effect.provide(layer)),
426 );
427});