| 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Crypto from "effect/Crypto"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as PlatformError from "effect/PlatformError"; |
| 7 | import { beforeEach, vi } from "vite-plus/test"; |
| 8 | |
| 9 | const { 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 | |
| 24 | const { fromWebContents } = vi.hoisted(() => ({ |
| 25 | fromWebContents: vi.fn<(webContents: unknown) => unknown>(() => null), |
| 26 | })); |
| 27 | |
| 28 | vi.mock("electron", () => ({ |
| 29 | BrowserWindow: { fromWebContents }, |
| 30 | session: { |
| 31 | fromPartition, |
| 32 | }, |
| 33 | })); |
| 34 | |
| 35 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 36 | import * as BrowserSession from "./BrowserSession.ts"; |
| 37 | |
| 38 | const { showMessageBox } = vi.hoisted(() => ({ |
| 39 | showMessageBox: vi.fn< |
| 40 | (options: Electron.MessageBoxOptions, owner?: Electron.BrowserWindow) => number |
| 41 | >(() => 1), |
| 42 | })); |
| 43 | const 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 | }); |
| 50 | const layer = BrowserSession.layer.pipe( |
| 51 | Layer.provide(Layer.merge(NodeServices.layer, layerDialog)), |
| 52 | ); |
| 53 | |
| 54 | describe("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 | }); |