@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.
- package/README.md +34 -71
- package/package.json +23 -29
- package/src/boot_test_app.ts +169 -0
- package/src/brain/index.ts +1 -0
- package/src/brain/stub_brain_provider.ts +72 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/is_memcached_available.ts +75 -0
- package/src/cache/is_redis_available.ts +39 -0
- package/src/compose_test_config.ts +47 -0
- package/src/index.ts +31 -15
- package/src/mem_stream.ts +50 -0
- package/src/postgres/connected_role_bypasses_rls.ts +22 -0
- package/src/postgres/create_test_database.ts +20 -0
- package/src/postgres/index.ts +5 -0
- package/src/postgres/is_postgres_available.ts +33 -0
- package/src/postgres/reset_schema.ts +14 -0
- package/src/postgres/test_database_url.ts +33 -0
- package/src/stub_fetch.ts +56 -0
- package/src/tenant_manager_provider.ts +40 -0
- package/CHANGELOG.md +0 -7
- package/src/browser/db_fresh.ts +0 -38
- package/src/browser/demo_flow.ts +0 -89
- package/src/browser/index.ts +0 -11
- package/src/browser/server_lifecycle.ts +0 -42
- package/src/browser/test_case.ts +0 -572
- package/src/database_manager.ts +0 -131
- package/src/factory.ts +0 -68
- package/src/test_case.ts +0 -312
- package/tsconfig.json +0 -5
package/src/browser/test_case.ts
DELETED
|
@@ -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
|