Skip to content

Commit c686ff8

Browse files
test(desktop): cover session-cookie persistence with a relaunch e2e
1 parent ab6a720 commit c686ff8

3 files changed

Lines changed: 179 additions & 75 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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+
})
Lines changed: 1 addition & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import type { OnHeadersReceivedListenerDetails, Session } from 'electron'
5-
import { describe, expect, it, vi } from 'vitest'
4+
import { describe, expect, it } from 'vitest'
65
import {
7-
keepSessionCookiesAcrossRestarts,
86
SESSION_COOKIE_LIFETIME_SECONDS,
97
withSessionCookieLifetime,
108
withSessionCookieMaxAge,
119
} from '@/main/browser-agent/session-cookies'
1210

13-
vi.mock('electron', () => import('@/test/electron-mock'))
14-
1511
const NOW = 1_800_000_000
1612
const MAX_AGE = `Max-Age=${SESSION_COOKIE_LIFETIME_SECONDS}`
1713

@@ -53,53 +49,3 @@ describe('withSessionCookieMaxAge', () => {
5349
expect(withSessionCookieMaxAge('max-age=1')).toBe(`max-age=1; ${MAX_AGE}`)
5450
})
5551
})
56-
57-
describe('keepSessionCookiesAcrossRestarts', () => {
58-
type Listener = (
59-
details: Pick<OnHeadersReceivedListenerDetails, 'responseHeaders'>,
60-
callback: (response: { responseHeaders?: Record<string, string[]> }) => void
61-
) => void
62-
63-
function install(): Listener {
64-
let listener: Listener | undefined
65-
const ses = {
66-
webRequest: {
67-
onHeadersReceived: vi.fn((handler: Listener) => {
68-
listener = handler
69-
}),
70-
},
71-
}
72-
keepSessionCookiesAcrossRestarts(ses as unknown as Session)
73-
if (!listener) throw new Error('listener not installed')
74-
return listener
75-
}
76-
77-
it('rewrites every Set-Cookie value and keeps the other headers', () => {
78-
const callback = vi.fn()
79-
install()(
80-
{
81-
responseHeaders: {
82-
'content-type': ['text/html'],
83-
'Set-Cookie': ['sid=abc; HttpOnly', 'keep=1; Max-Age=60'],
84-
},
85-
},
86-
callback
87-
)
88-
89-
expect(callback).toHaveBeenCalledWith({
90-
responseHeaders: {
91-
'content-type': ['text/html'],
92-
'Set-Cookie': [`sid=abc; HttpOnly; ${MAX_AGE}`, 'keep=1; Max-Age=60'],
93-
},
94-
})
95-
})
96-
97-
it('leaves responses without cookies untouched', () => {
98-
const callback = vi.fn()
99-
install()({ responseHeaders: { 'content-type': ['text/html'] } }, callback)
100-
install()({}, callback)
101-
102-
expect(callback).toHaveBeenNthCalledWith(1, {})
103-
expect(callback).toHaveBeenNthCalledWith(2, {})
104-
})
105-
})

‎apps/desktop/src/main/browser-agent/session.test.ts‎

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2277,26 +2277,6 @@ describe('importAgentCookies', () => {
22772277
sameSite: 'lax' as const,
22782278
})
22792279

2280-
it('gives session cookies a bounded lifetime so they survive a restart', async () => {
2281-
vi.useFakeTimers({ now: new Date('2026-09-26T00:00:00Z') })
2282-
try {
2283-
const set = vi.fn(async () => {})
2284-
const session = withCookieJar(set)
2285-
const expiring = { ...cookie('kept'), expirationDate: 1_900_000_000 }
2286-
2287-
await session.importAgentCookies([cookie('session'), expiring])
2288-
2289-
const nowSeconds = Date.parse('2026-09-26T00:00:00Z') / 1000
2290-
expect(set).toHaveBeenNthCalledWith(1, {
2291-
...cookie('session'),
2292-
expirationDate: nowSeconds + 30 * 24 * 60 * 60,
2293-
})
2294-
expect(set).toHaveBeenNthCalledWith(2, expiring)
2295-
} finally {
2296-
vi.useRealTimers()
2297-
}
2298-
})
2299-
23002280
it('counts a rejected cookie without losing the rest', async () => {
23012281
// Chromium refuses cookies whose attributes are inconsistent. That
23022282
// rejection must cost one cookie, not the whole import.

0 commit comments

Comments
 (0)