|
| 1 | +import { mkdtempSync, rmSync } from 'node:fs' |
| 2 | +import { createServer, type Server } from 'node:http' |
| 3 | +import { tmpdir } from 'node:os' |
| 4 | +import { join } from 'node:path' |
| 5 | +import { fileURLToPath } from 'node:url' |
| 6 | +import { type ElectronApplication, _electron as electron, expect, test } from '@playwright/test' |
| 7 | +import type { SimDesktopApi } from '@sim/desktop-bridge' |
| 8 | + |
| 9 | +const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) |
| 10 | +const SCOPE = 'session-cookies-fixture' |
| 11 | + |
| 12 | +/** |
| 13 | + * Electron drops every expiry-less cookie on quit, so a site that keeps its |
| 14 | + * login in a session cookie signed the user out of the built-in browser on |
| 15 | + * every restart. This drives a real quit and relaunch against one profile. |
| 16 | + */ |
| 17 | +test.describe('built-in browser session cookies', () => { |
| 18 | + let server: Server |
| 19 | + let origin: string |
| 20 | + let site: string |
| 21 | + let userData: string |
| 22 | + let app: ElectronApplication | undefined |
| 23 | + let serial = 0 |
| 24 | + let lastCookieHeader: string | undefined |
| 25 | + const calls = new Map< |
| 26 | + string, |
| 27 | + { chatId: string; toolName: string; args: Record<string, unknown> } |
| 28 | + >() |
| 29 | + |
| 30 | + test.beforeAll(async () => { |
| 31 | + server = createServer(async (request, response) => { |
| 32 | + const path = new URL(request.url ?? '/', 'http://localhost').pathname |
| 33 | + if (path === '/api/auth/get-session') { |
| 34 | + response.writeHead(200, { 'Content-Type': 'application/json' }) |
| 35 | + response.end( |
| 36 | + JSON.stringify({ user: { id: 'fixture-user' }, session: { id: 'fixture-session' } }) |
| 37 | + ) |
| 38 | + return |
| 39 | + } |
| 40 | + if (path === '/api/desktop/tool/authorize') { |
| 41 | + let body = '' |
| 42 | + for await (const chunk of request) body += chunk.toString() |
| 43 | + const call = calls.get(JSON.parse(body).toolCallId) |
| 44 | + response.writeHead(call ? 200 : 403, { 'Content-Type': 'application/json' }) |
| 45 | + response.end(JSON.stringify(call ?? {})) |
| 46 | + return |
| 47 | + } |
| 48 | + if (path.startsWith('/api/')) { |
| 49 | + response.writeHead(200, { 'Content-Type': 'application/json' }) |
| 50 | + response.end('{}') |
| 51 | + return |
| 52 | + } |
| 53 | + if (path === '/sign-in') { |
| 54 | + // A login redirect that sets a short-lived cookie the next hop deletes. |
| 55 | + response.writeHead(302, { |
| 56 | + 'Set-Cookie': 'oauth_state=pending; HttpOnly; Path=/', |
| 57 | + Location: '/signed-in', |
| 58 | + }) |
| 59 | + response.end() |
| 60 | + return |
| 61 | + } |
| 62 | + if (path === '/signed-in') { |
| 63 | + response.writeHead(200, { |
| 64 | + 'Content-Type': 'text/html', |
| 65 | + 'Set-Cookie': [ |
| 66 | + 'oauth_state=; Path=/; Max-Age=0', |
| 67 | + 'login=fixture; HttpOnly; SameSite=Lax; Path=/', |
| 68 | + 'remember=1; Path=/; Max-Age=3600', |
| 69 | + ], |
| 70 | + }) |
| 71 | + response.end('<!doctype html><title>Signed in</title>') |
| 72 | + return |
| 73 | + } |
| 74 | + if (path === '/account') { |
| 75 | + lastCookieHeader = request.headers.cookie ?? '' |
| 76 | + response.writeHead(200, { 'Content-Type': 'text/html' }) |
| 77 | + response.end('<!doctype html><title>Account</title>') |
| 78 | + return |
| 79 | + } |
| 80 | + if (request.headers.host?.startsWith('localhost')) { |
| 81 | + // Favicon and other stray site requests must not set the app session cookie on the site. |
| 82 | + response.writeHead(404) |
| 83 | + response.end() |
| 84 | + return |
| 85 | + } |
| 86 | + response.writeHead(200, { |
| 87 | + 'Content-Type': 'text/html', |
| 88 | + 'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/', |
| 89 | + }) |
| 90 | + response.end('<!doctype html><title>Sim fixture</title><h1>Session cookie fixture</h1>') |
| 91 | + }) |
| 92 | + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)) |
| 93 | + const address = server.address() |
| 94 | + if (!address || typeof address === 'string') throw new Error('Missing fixture address') |
| 95 | + origin = `http://127.0.0.1:${address.port}` |
| 96 | + /** Pages outside the app origin browse in the built-in browser's own partition. */ |
| 97 | + site = `http://localhost:${address.port}` |
| 98 | + }) |
| 99 | + |
| 100 | + test.beforeEach(() => { |
| 101 | + userData = mkdtempSync(join(tmpdir(), 'sim-session-cookies-e2e-')) |
| 102 | + }) |
| 103 | + |
| 104 | + test.afterEach(async () => { |
| 105 | + await app?.close() |
| 106 | + app = undefined |
| 107 | + rmSync(userData, { recursive: true, force: true }) |
| 108 | + calls.clear() |
| 109 | + }) |
| 110 | + |
| 111 | + test.afterAll(async () => { |
| 112 | + await new Promise<void>((resolve, reject) => |
| 113 | + server.close((error) => (error ? reject(error) : resolve())) |
| 114 | + ) |
| 115 | + }) |
| 116 | + |
| 117 | + async function launch(): Promise<ElectronApplication> { |
| 118 | + const launched = await electron.launch({ |
| 119 | + args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'], |
| 120 | + cwd: DESKTOP_DIR, |
| 121 | + env: { ...process.env, SIM_DESKTOP_ORIGIN: origin, SIM_DESKTOP_USER_DATA: userData }, |
| 122 | + }) |
| 123 | + const host = await launched.firstWindow() |
| 124 | + await expect(host.getByRole('heading')).toHaveText('Session cookie fixture') |
| 125 | + await host.evaluate(async (scope) => { |
| 126 | + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop |
| 127 | + await api.browserAgent.activateScope(scope) |
| 128 | + const updateBounds = () => |
| 129 | + api.browserAgent.setPanelBounds( |
| 130 | + { x: 0, y: 80, width: innerWidth, height: innerHeight - 80 }, |
| 131 | + null, |
| 132 | + scope |
| 133 | + ) |
| 134 | + updateBounds() |
| 135 | + setInterval(updateBounds, 200) |
| 136 | + }, SCOPE) |
| 137 | + return launched |
| 138 | + } |
| 139 | + |
| 140 | + async function navigate(target: ElectronApplication, url: string) { |
| 141 | + const id = `fixture-${++serial}` |
| 142 | + calls.set(id, { chatId: SCOPE, toolName: 'browser_open_url', args: { url } }) |
| 143 | + const host = await target.firstWindow() |
| 144 | + const result = await host.evaluate( |
| 145 | + async ({ id, url, scope }) => { |
| 146 | + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop |
| 147 | + return api.browserAgent.executeTool(id, 'browser_open_url', { url }, scope) |
| 148 | + }, |
| 149 | + { id, url, scope: SCOPE } |
| 150 | + ) |
| 151 | + expect(result.ok, result.error).toBe(true) |
| 152 | + } |
| 153 | + |
| 154 | + async function cookiesSentToSite(target: ElectronApplication): Promise<string[]> { |
| 155 | + lastCookieHeader = undefined |
| 156 | + await navigate(target, `${site}/account`) |
| 157 | + await expect.poll(() => lastCookieHeader).not.toBeUndefined() |
| 158 | + return (lastCookieHeader ?? '') |
| 159 | + .split(';') |
| 160 | + .map((pair) => pair.trim()) |
| 161 | + .filter(Boolean) |
| 162 | + .sort() |
| 163 | + } |
| 164 | + |
| 165 | + test('keeps a session-cookie login across a restart without reviving deleted cookies', async () => { |
| 166 | + app = await launch() |
| 167 | + await navigate(app, `${site}/sign-in`) |
| 168 | + expect(await cookiesSentToSite(app)).toEqual(['login=fixture', 'remember=1']) |
| 169 | + |
| 170 | + await app.evaluate(({ session }) => |
| 171 | + session.fromPartition('persist:sim-browser-agent').cookies.flushStore() |
| 172 | + ) |
| 173 | + await app.close() |
| 174 | + |
| 175 | + app = await launch() |
| 176 | + expect(await cookiesSentToSite(app)).toEqual(['login=fixture', 'remember=1']) |
| 177 | + }) |
| 178 | +}) |
0 commit comments