@strav/testing 0.4.31 → 1.0.0-alpha.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,572 +0,0 @@
1
- import type { SQL, ReservedSQL } from 'bun'
2
- import { app, Configuration, ExceptionHandler } from '@strav/kernel'
3
- import { Database, BaseModel } from '@strav/database'
4
- import { Router } from '@strav/http'
5
- import type { ServerHandle } from './server_lifecycle.ts'
6
- import { startListener, stopListener } from './server_lifecycle.ts'
7
- import { TestDatabaseManager } from '../database_manager.ts'
8
- import { runFresh } from './db_fresh.ts'
9
- import type {
10
- Browser,
11
- BrowserContext,
12
- Cookie,
13
- Page,
14
- Request as PWRequest,
15
- } from 'playwright-core'
16
-
17
- export type BrowserName = 'chromium' | 'firefox' | 'webkit'
18
- export type MailMode = 'capture' | 'real'
19
-
20
- export interface BrowserTestCaseOptions {
21
- /** Optional route loader. Called during setup to register routes against the shared Router. */
22
- routes?: () => Promise<unknown>
23
- /**
24
- * Optional bootstrap callback for apps with non-trivial provider stacks.
25
- * Receives no arguments; runs after Configuration loads but before the
26
- * server starts listening. Use it to register service providers / routes.
27
- */
28
- bootstrap?: () => Promise<void>
29
- /** Boot Auth + SessionManager + PostgresSessionStore (default: true — most browser tests need sessions). */
30
- auth?: boolean
31
- /** Boot ViewEngine (default: false). */
32
- views?: boolean
33
- /**
34
- * Wrap each test in a DB transaction that auto-rollbacks. Default: `false`.
35
- *
36
- * Unlike {@link TestCase}, `BrowserTestCase` defaults `transaction` to off
37
- * because the real HTTP server runs in-process and any multi-request page
38
- * (a navigation that triggers favicon/static fetches plus session writes)
39
- * contends for the single reserved connection — the result is pool
40
- * starvation and timeouts. For per-test DB isolation use `fresh: true`
41
- * (slower but bulletproof) or design tests to clean up their own state.
42
- */
43
- transaction?: boolean
44
- /** User resolver for Auth.useResolver(). */
45
- userResolver?: (id: string | number) => Promise<unknown>
46
- /** Run runFresh() once before setup (drops tables + remigrates). Default: false. */
47
- fresh?: boolean
48
- /** Mail behaviour: 'capture' swaps in MemoryMailTransport (default), 'real' leaves the configured driver. */
49
- mail?: MailMode
50
- /** Playwright browser. Default: 'chromium'. */
51
- browser?: BrowserName
52
- /** Headless mode. Default: true (unless PLAYWRIGHT_HEADLESS=0 is set). */
53
- headless?: boolean
54
- /** Slow-motion ms between actions. Default: 0. */
55
- slowMo?: number
56
- /** Default action timeout in ms. Default: 5000. */
57
- timeout?: number
58
- /** Override the listening port. Default: 0 (ephemeral). */
59
- port?: number
60
- /** Override the listening hostname. Default: '127.0.0.1'. */
61
- hostname?: string
62
- }
63
-
64
- type Matcher = string | RegExp | { gt?: string | number; lt?: string | number; eq?: string | number }
65
-
66
- const DEFAULT_TIMEOUT = 5000
67
-
68
- /**
69
- * Base primitive for browser-driven tests. Boots a real Bun server on an
70
- * ephemeral port, launches Playwright, and exposes navigation/interaction
71
- * helpers plus mail capture and direct session minting.
72
- *
73
- * This is the general-purpose surface. {@link DemoFlow} wraps it with
74
- * AGON-style fixture composition and magic-link sign-in.
75
- */
76
- export class BrowserTestCase {
77
- config!: Configuration
78
- router!: Router
79
- db!: Database
80
- baseUrl!: string
81
- port!: number
82
- hostname!: string
83
-
84
- browser!: Browser
85
- context!: BrowserContext
86
- page!: Page
87
-
88
- private _serverHandle: ServerHandle | null = null
89
- private _memTransport: import('@strav/signal').MemoryMailTransport | null = null
90
- private _originalSql: SQL | null = null
91
- private _reserved: ReservedSQL | null = null
92
- private readonly options: BrowserTestCaseOptions
93
-
94
- constructor(options: BrowserTestCaseOptions = {}) {
95
- this.options = options
96
- }
97
-
98
- // ---------------------------------------------------------------------------
99
- // Lifecycle
100
- // ---------------------------------------------------------------------------
101
-
102
- /** Boot config + DB + (optional) auth/views, register routes, start a real Bun server, launch Playwright. Call in beforeAll. */
103
- async setup(): Promise<void> {
104
- if (this.options.fresh) await runFresh()
105
-
106
- if (!app.has(Configuration)) app.singleton(Configuration)
107
- if (!app.has(Router)) app.singleton(Router)
108
-
109
- this.config = app.resolve(Configuration)
110
- await this.config.load()
111
-
112
- const dbManager = TestDatabaseManager.getInstance()
113
- this.db = await dbManager.getDatabase()
114
-
115
- this.router = app.resolve(Router)
116
- this.router.setDomain(this.options.hostname ?? '127.0.0.1')
117
-
118
- if (this.options.auth !== false) {
119
- // Magic-link tokens, session csrf, and any encrypted payloads require
120
- // EncryptionManager to be keyed. Apps that already configure
121
- // `encryption.key` will overwrite this; tests without a configured key
122
- // get a deterministic fallback so the harness works out of the box.
123
- const { EncryptionManager } = await import('@strav/kernel')
124
- const configuredKey = (this.config.get('encryption.key', '') as string) || process.env.APP_KEY || ''
125
- EncryptionManager.useKey(configuredKey || 'browser-test-case-fixture-key-do-not-use-in-production')
126
-
127
- const { SessionManager, Auth } = await import('@strav/http')
128
- const { PostgresSessionStore } = await import('@strav/database')
129
- if (!app.has(SessionManager)) app.singleton(SessionManager)
130
- if (!app.has(Auth)) app.singleton(Auth)
131
- if (!app.has(PostgresSessionStore)) app.singleton(PostgresSessionStore)
132
- app.resolve(SessionManager)
133
- const sessionStore = app.resolve(PostgresSessionStore)
134
- SessionManager.useStore(sessionStore)
135
- await sessionStore.ensureSchema()
136
- app.resolve(Auth)
137
- await Auth.ensureTables()
138
- if (this.options.userResolver) Auth.useResolver(this.options.userResolver)
139
- }
140
-
141
- if (this.options.views) {
142
- const { ViewEngine } = await import('@strav/view')
143
- const { Context } = await import('@strav/http')
144
- if (!app.has(ViewEngine)) app.singleton(ViewEngine)
145
- const viewEngine = app.resolve(ViewEngine)
146
- Context.setViewEngine(viewEngine)
147
- }
148
-
149
- if (this.options.bootstrap) await this.options.bootstrap()
150
- if (this.options.routes) await this.options.routes()
151
-
152
- this.router.useExceptionHandler(new ExceptionHandler(true))
153
-
154
- if (this.options.mail !== 'real') {
155
- const { MailManager, MemoryMailTransport } = await import('@strav/signal')
156
- // MailManager needs a Configuration-backed instance for its static config.
157
- // If the app hasn't registered it, register + resolve to populate _config.
158
- if (!app.has(MailManager)) app.singleton(MailManager)
159
- app.resolve(MailManager)
160
- this._memTransport = new MemoryMailTransport()
161
- MailManager.useTransport(this._memTransport)
162
- }
163
-
164
- this._serverHandle = startListener(this.router, {
165
- port: this.options.port ?? 0,
166
- hostname: this.options.hostname ?? '127.0.0.1',
167
- })
168
- this.port = this._serverHandle.port
169
- this.hostname = this._serverHandle.hostname
170
- this.baseUrl = this._serverHandle.baseUrl
171
-
172
- const playwright = await loadPlaywright()
173
- const browserName = this.options.browser ?? 'chromium'
174
- const headless = this.options.headless ?? process.env.PLAYWRIGHT_HEADLESS !== '0'
175
- this.browser = await playwright[browserName].launch({
176
- headless,
177
- slowMo: this.options.slowMo ?? 0,
178
- })
179
- }
180
-
181
- /** Close browser, stop server, release DB. Call in afterAll. */
182
- async teardown(): Promise<void> {
183
- if (this.context) {
184
- try { await this.context.close() } catch { /* ignore */ }
185
- }
186
- if (this.browser) {
187
- try { await this.browser.close() } catch { /* ignore */ }
188
- }
189
- if (this._serverHandle) {
190
- stopListener(this._serverHandle)
191
- this._serverHandle = null
192
- }
193
- if (this._reserved) {
194
- try { await this._reserved`ROLLBACK` } catch { /* ignore */ }
195
- this._reserved.release()
196
- this._reserved = null
197
- }
198
- await TestDatabaseManager.getInstance().releaseDatabase()
199
- }
200
-
201
- /** Open a fresh incognito context + page; clear mail buffer; begin transaction (if opted-in). Call in beforeEach. */
202
- async beforeEach(): Promise<void> {
203
- if (this.options.transaction === true) {
204
- this._originalSql = this.db.sql
205
- this._reserved = await this._originalSql.reserve()
206
- await this._reserved`BEGIN`
207
- ;(this.db as any).appConnection = this._reserved
208
- ;(Database as any)._appConnection = this._reserved
209
- }
210
- if (this._memTransport) this._memTransport.clear()
211
-
212
- this.context = await this.browser.newContext()
213
- this.context.setDefaultTimeout(this.options.timeout ?? DEFAULT_TIMEOUT)
214
- this.page = await this.context.newPage()
215
- }
216
-
217
- /** Close the page/context and rollback the transaction. Call in afterEach. */
218
- async afterEach(): Promise<void> {
219
- if (this.context) {
220
- try { await this.context.close() } catch { /* ignore */ }
221
- }
222
- if (this._reserved) {
223
- try { await this._reserved`ROLLBACK` } catch { /* ignore */ }
224
- this._reserved.release()
225
- ;(this.db as any).appConnection = this._originalSql
226
- ;(Database as any)._appConnection = this._originalSql
227
- this._reserved = null
228
- this._originalSql = null
229
- }
230
- }
231
-
232
- /** Boot the test case and auto-register bun:test lifecycle hooks. */
233
- static async boot(options?: BrowserTestCaseOptions): Promise<BrowserTestCase> {
234
- const tc = new BrowserTestCase(options)
235
- await tc.setup()
236
- const { afterAll, beforeEach, afterEach } = await import('bun:test')
237
- afterAll(() => tc.teardown())
238
- beforeEach(() => tc.beforeEach())
239
- afterEach(() => tc.afterEach())
240
- return tc
241
- }
242
-
243
- // ---------------------------------------------------------------------------
244
- // Navigation
245
- // ---------------------------------------------------------------------------
246
-
247
- async goto(path: string): Promise<void> {
248
- const url = path.startsWith('http://') || path.startsWith('https://') ? path : `${this.baseUrl}${path}`
249
- await this.page.goto(url)
250
- }
251
- async reload(): Promise<void> { await this.page.reload() }
252
- async back(): Promise<void> { await this.page.goBack() }
253
-
254
- // ---------------------------------------------------------------------------
255
- // Interaction
256
- // ---------------------------------------------------------------------------
257
-
258
- async click(selector: string, opts?: { timeout?: number }): Promise<void> {
259
- await this.page.locator(selector).click({ timeout: opts?.timeout })
260
- }
261
- async fill(selector: string, value: string): Promise<void> {
262
- await this.page.locator(selector).fill(value)
263
- }
264
- async type(selector: string, value: string, opts?: { delay?: number }): Promise<void> {
265
- await this.page.locator(selector).pressSequentially(value, { delay: opts?.delay })
266
- }
267
- async press(selector: string, key: string): Promise<void> {
268
- await this.page.locator(selector).press(key)
269
- }
270
- async check(selector: string): Promise<void> {
271
- await this.page.locator(selector).check()
272
- }
273
- async selectOption(selector: string, value: string | string[]): Promise<void> {
274
- await this.page.locator(selector).selectOption(value)
275
- }
276
- async hover(selector: string): Promise<void> {
277
- await this.page.locator(selector).hover()
278
- }
279
- async uploadFile(selector: string, paths: string | string[]): Promise<void> {
280
- await this.page.locator(selector).setInputFiles(paths)
281
- }
282
-
283
- // ---------------------------------------------------------------------------
284
- // Wait
285
- // ---------------------------------------------------------------------------
286
-
287
- async waitFor(selector: string, opts?: { state?: 'attached' | 'visible' | 'hidden' | 'detached'; timeout?: number }): Promise<void> {
288
- await this.page.locator(selector).waitFor(opts)
289
- }
290
- async waitForUrl(url: string | RegExp, opts?: { timeout?: number }): Promise<void> {
291
- await this.page.waitForURL(url, opts)
292
- }
293
- async waitForRequest(urlOrPredicate: string | RegExp | ((req: PWRequest) => boolean)): Promise<void> {
294
- await this.page.waitForRequest(urlOrPredicate as any)
295
- }
296
-
297
- // ---------------------------------------------------------------------------
298
- // Assertions
299
- // ---------------------------------------------------------------------------
300
-
301
- async expectUrl(url: string | RegExp): Promise<void> {
302
- const current = this.page.url()
303
- if (typeof url === 'string') {
304
- // Allow callers to pass a path; resolve against baseUrl for comparison.
305
- const expected = url.startsWith('http://') || url.startsWith('https://') ? url : `${this.baseUrl}${url}`
306
- if (current !== expected) {
307
- throw new Error(`expectUrl failed: expected ${expected}, got ${current}`)
308
- }
309
- } else if (!url.test(current)) {
310
- throw new Error(`expectUrl failed: ${url} did not match ${current}`)
311
- }
312
- }
313
-
314
- async expectVisible(selector: string, text?: string | RegExp): Promise<void> {
315
- const locator = this.page.locator(selector).first()
316
- await locator.waitFor({ state: 'visible' })
317
- if (text !== undefined) {
318
- const actual = (await locator.textContent()) ?? ''
319
- if (typeof text === 'string') {
320
- if (!actual.includes(text)) {
321
- throw new Error(`expectVisible(${selector}) text mismatch: expected to include "${text}", got "${actual}"`)
322
- }
323
- } else if (!text.test(actual)) {
324
- throw new Error(`expectVisible(${selector}) text mismatch: ${text} did not match "${actual}"`)
325
- }
326
- }
327
- }
328
-
329
- async expectHidden(selector: string): Promise<void> {
330
- await this.page.locator(selector).first().waitFor({ state: 'hidden' })
331
- }
332
-
333
- async expectText(selector: string, text: string | RegExp): Promise<void> {
334
- const actual = (await this.page.locator(selector).first().textContent()) ?? ''
335
- if (typeof text === 'string') {
336
- if (actual !== text) throw new Error(`expectText(${selector}): expected "${text}", got "${actual}"`)
337
- } else if (!text.test(actual)) {
338
- throw new Error(`expectText(${selector}): ${text} did not match "${actual}"`)
339
- }
340
- }
341
-
342
- async expectAttribute(selector: string, name: string, value: string | RegExp): Promise<void> {
343
- const actual = (await this.page.locator(selector).first().getAttribute(name)) ?? ''
344
- if (typeof value === 'string') {
345
- if (actual !== value) throw new Error(`expectAttribute(${selector}, ${name}): expected "${value}", got "${actual}"`)
346
- } else if (!value.test(actual)) {
347
- throw new Error(`expectAttribute(${selector}, ${name}): ${value} did not match "${actual}"`)
348
- }
349
- }
350
-
351
- async expectCount(selector: string, count: number): Promise<void> {
352
- const actual = await this.page.locator(selector).count()
353
- if (actual !== count) throw new Error(`expectCount(${selector}): expected ${count}, got ${actual}`)
354
- }
355
-
356
- async expectComputedStyle(selector: string, property: string, matcher: Matcher): Promise<void> {
357
- // Split off a ::pseudo-element segment so it's passed to getComputedStyle's second arg.
358
- const { base, pseudo } = splitPseudo(selector)
359
- const actual = await this.page.evaluate<string | null, { base: string; pseudo: string | null; property: string }>(
360
- // The body runs in the browser context — `document` and `window` are
361
- // ambient there. We don't enable the DOM lib at the package level
362
- // because it conflicts with kernel's buffer types, so this callback
363
- // is typed via the explicit generic above.
364
- ((args: { base: string; pseudo: string | null; property: string }) => {
365
- const el = (globalThis as any).document.querySelector(args.base)
366
- if (!el) return null
367
- const style = args.pseudo
368
- ? (globalThis as any).window.getComputedStyle(el, args.pseudo)
369
- : (globalThis as any).window.getComputedStyle(el)
370
- return style.getPropertyValue(args.property) as string
371
- }) as any,
372
- { base, pseudo, property }
373
- )
374
- if (actual === null) {
375
- throw new Error(`expectComputedStyle(${selector}, ${property}): element not found`)
376
- }
377
-
378
- if (typeof matcher === 'string') {
379
- if (!actual.includes(matcher)) {
380
- throw new Error(`expectComputedStyle(${selector}, ${property}): expected to include "${matcher}", got "${actual}"`)
381
- }
382
- return
383
- }
384
- if (matcher instanceof RegExp) {
385
- if (!matcher.test(actual)) {
386
- throw new Error(`expectComputedStyle(${selector}, ${property}): ${matcher} did not match "${actual}"`)
387
- }
388
- return
389
- }
390
-
391
- const actualNum = parsePxNumber(actual)
392
- const expect = (cmp: 'gt' | 'lt' | 'eq', threshold: string | number): boolean => {
393
- const t = typeof threshold === 'string' ? parsePxNumber(threshold) : threshold
394
- if (actualNum === null || t === null) return false
395
- switch (cmp) {
396
- case 'gt': return actualNum > t
397
- case 'lt': return actualNum < t
398
- case 'eq': return actualNum === t
399
- }
400
- }
401
- if (matcher.gt !== undefined && !expect('gt', matcher.gt)) {
402
- throw new Error(`expectComputedStyle(${selector}, ${property}): expected > ${matcher.gt}, got "${actual}"`)
403
- }
404
- if (matcher.lt !== undefined && !expect('lt', matcher.lt)) {
405
- throw new Error(`expectComputedStyle(${selector}, ${property}): expected < ${matcher.lt}, got "${actual}"`)
406
- }
407
- if (matcher.eq !== undefined && !expect('eq', matcher.eq)) {
408
- throw new Error(`expectComputedStyle(${selector}, ${property}): expected == ${matcher.eq}, got "${actual}"`)
409
- }
410
- }
411
-
412
- // ---------------------------------------------------------------------------
413
- // Escape hatches
414
- // ---------------------------------------------------------------------------
415
-
416
- async evaluate<T>(fn: (...args: any[]) => T, ...args: any[]): Promise<T> {
417
- return this.page.evaluate(fn as any, ...args) as Promise<T>
418
- }
419
- async screenshot(_name?: string): Promise<Buffer> {
420
- return await this.page.screenshot()
421
- }
422
- async cookies(): Promise<Cookie[]> {
423
- return await this.context.cookies()
424
- }
425
- async setCookie(cookie: Cookie): Promise<void> {
426
- await this.context.addCookies([cookie])
427
- }
428
-
429
- // ---------------------------------------------------------------------------
430
- // Auth helpers
431
- // ---------------------------------------------------------------------------
432
-
433
- /**
434
- * Mint a session for the user via {@link Session.createForUser} and set the
435
- * session cookie on the Playwright browser context. Default for tests that
436
- * are not exercising the auth flow itself.
437
- */
438
- async signInAs(user: unknown): Promise<void> {
439
- const { Session, SessionManager } = await import('@strav/http')
440
- const session = await Session.createForUser(user, {
441
- ipAddress: '127.0.0.1',
442
- userAgent: 'BrowserTestCase',
443
- })
444
- const cfg = SessionManager.config
445
- await this.context.addCookies([{
446
- name: cfg.cookie,
447
- value: session.id,
448
- domain: this.hostname,
449
- path: '/',
450
- httpOnly: cfg.httpOnly ?? true,
451
- secure: false,
452
- sameSite: normalizeSameSite(cfg.sameSite),
453
- }])
454
- }
455
-
456
- /**
457
- * Full-flow magic-link sign-in: POST to the magic-link endpoint, wait for
458
- * the captured mail to arrive, follow the link in the page. Use this when
459
- * the test is verifying the auth flow itself.
460
- */
461
- async signInWithMagicLink(opts: {
462
- email: string
463
- endpoint?: string
464
- tokenParam?: string
465
- subject?: string | RegExp
466
- }): Promise<void> {
467
- if (!this._memTransport) {
468
- throw new Error('signInWithMagicLink requires mail capture; construct BrowserTestCase with mail: "capture" (the default).')
469
- }
470
- const endpoint = opts.endpoint ?? '/auth/magic'
471
- // Use native fetch (not page.request) — Playwright's request module
472
- // routes through the chromium subprocess pipe, which has surfaced
473
- // flaky "context closed" errors when the server is in-process.
474
- const issueRes = await fetch(`${this.baseUrl}${endpoint}`, {
475
- method: 'POST',
476
- headers: { 'Content-Type': 'application/json' },
477
- body: JSON.stringify({ email: opts.email }),
478
- })
479
- if (!issueRes.ok) {
480
- throw new Error(`signInWithMagicLink: ${endpoint} returned ${issueRes.status}: ${await issueRes.text()}`)
481
- }
482
- const mail = await this._memTransport.waitFor({ to: opts.email, subject: opts.subject })
483
- const param = opts.tokenParam ?? 'token'
484
- const escaped = param.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
485
- const link = this._memTransport.extractLink(mail, new RegExp(`https?:\\/\\/[^\\s"'<>]+[?&]${escaped}=[^\\s"'<>&]+`))
486
- if (!link) throw new Error(`signInWithMagicLink: no magic link with ?${param}=… found in mail to ${opts.email}`)
487
-
488
- // Follow the verify link via fetch (manual redirect handling) so we can
489
- // pluck the Set-Cookie header without a real browser navigation. Then
490
- // inject the cookie into Playwright's context.
491
- const verifyRes = await fetch(link, { redirect: 'manual' })
492
- const setCookieRaw = (verifyRes.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie?.()
493
- ?? splitSetCookie(verifyRes.headers.get('set-cookie'))
494
- const sessionCookie = pickSessionCookie(setCookieRaw)
495
- if (!sessionCookie) {
496
- throw new Error(`signInWithMagicLink: ${link} did not return a Set-Cookie header (status ${verifyRes.status}).`)
497
- }
498
- const { SessionManager } = await import('@strav/http')
499
- await this.context.addCookies([{
500
- name: SessionManager.config.cookie,
501
- value: sessionCookie,
502
- domain: this.hostname,
503
- path: '/',
504
- httpOnly: true,
505
- secure: false,
506
- sameSite: normalizeSameSite(SessionManager.config.sameSite),
507
- }])
508
- }
509
-
510
- /** Returns the in-memory mail transport. Throws if mail !== 'capture'. */
511
- capturedMail(): import('@strav/signal').MemoryMailTransport {
512
- if (!this._memTransport) {
513
- throw new Error('capturedMail() requires mail: "capture" (the default for BrowserTestCase).')
514
- }
515
- return this._memTransport
516
- }
517
- }
518
-
519
- // ---------------------------------------------------------------------------
520
- // Internal helpers
521
- // ---------------------------------------------------------------------------
522
-
523
- async function loadPlaywright(): Promise<typeof import('playwright-core')> {
524
- try {
525
- return await import('playwright-core')
526
- } catch (err) {
527
- throw new Error(
528
- `BrowserTestCase requires 'playwright-core' to be installed. Run: bun add -D playwright-core && bun x playwright install chromium. (${(err as Error).message})`
529
- )
530
- }
531
- }
532
-
533
- function splitPseudo(selector: string): { base: string; pseudo: string | null } {
534
- const match = selector.match(/^(.*?)(::[a-z-]+(?:\([^)]*\))?)\s*$/i)
535
- if (match) return { base: match[1]!.trim(), pseudo: match[2]! }
536
- return { base: selector, pseudo: null }
537
- }
538
-
539
- function parsePxNumber(value: string): number | null {
540
- const m = value.match(/-?\d+(?:\.\d+)?/)
541
- return m ? parseFloat(m[0]) : null
542
- }
543
-
544
- function splitSetCookie(raw: string | null): string[] {
545
- if (!raw) return []
546
- // Naive split: assumes no commas inside attribute values, which holds for
547
- // session cookies (UUID id + standard attrs). For multiple cookies in one
548
- // header, browsers traditionally use comma + space — but inside attrs
549
- // like Expires, comma can appear too. We rely on the modern getSetCookie
550
- // API when available; this is a best-effort fallback.
551
- return [raw]
552
- }
553
-
554
- function pickSessionCookie(setCookies: string[]): string | null {
555
- for (const raw of setCookies) {
556
- const match = raw.match(/^(?:strav_session)=([^;]+)/)
557
- if (match) return decodeURIComponent(match[1]!)
558
- }
559
- return null
560
- }
561
-
562
- function normalizeSameSite(value: unknown): 'Strict' | 'Lax' | 'None' {
563
- if (typeof value !== 'string') return 'Lax'
564
- const lower = value.toLowerCase()
565
- if (lower === 'strict') return 'Strict'
566
- if (lower === 'none') return 'None'
567
- return 'Lax'
568
- }
569
-
570
- // Keep BaseModel as a type-side reference so tree-shakers don't drop the
571
- // Database peer dep; some test apps rely on extractUserId via @strav/database.
572
- void BaseModel