@ossy/platform 3.9.0 → 3.11.1

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.
@@ -15,20 +15,36 @@ import { Router } from '@ossy/router'
15
15
  import { Schema } from '@ossy/schema'
16
16
  import { test, expect } from '@playwright/test'
17
17
  import {
18
+ applyFormFieldOverrides,
18
19
  escapeCssAttrValue,
19
20
  interpolateContextString,
20
21
  resolveContextValue,
21
22
  } from './flow-context.js'
23
+ import { actionSelector, resolveActionId, resolveActionMatchers, resolveActionService } from './flow-action.js'
22
24
  import { resolveClickTarget } from './flow-click.js'
25
+ import { assertDownloadFilenames, resolveDownloadTarget } from './flow-download.js'
26
+ import { resolveFilesTarget } from './flow-files.js'
23
27
  import { resolvePressKey } from './flow-press.js'
24
28
  import { resolveViewportSize } from './flow-viewport.js'
25
29
 
26
30
  export {
31
+ applyFormFieldOverrides,
27
32
  escapeCssAttrValue,
28
33
  interpolateContextString,
29
34
  resolveContextValue,
30
35
  } from './flow-context.js'
36
+ export {
37
+ actionSelector,
38
+ resolveActionId,
39
+ resolveActionMatchers,
40
+ resolveActionService,
41
+ } from './flow-action.js'
31
42
  export { resolveClickTarget } from './flow-click.js'
43
+ export {
44
+ assertDownloadFilenames,
45
+ resolveDownloadTarget,
46
+ } from './flow-download.js'
47
+ export { resolveFilesTarget } from './flow-files.js'
32
48
  export { resolvePressKey } from './flow-press.js'
33
49
  export { resolveViewportSize } from './flow-viewport.js'
34
50
 
@@ -92,15 +108,20 @@ function schemaEngineFromManifest (manifest) {
92
108
  }
93
109
 
94
110
  /**
95
- * Mock form field values via Schema.mock and update the run context.
111
+ * Mock form field values via Schema.mock, apply optional overrides, and update the run context.
96
112
  *
97
113
  * @param {import('@ossy/schema').Schema} engine
98
114
  * @param {{ id: string, fields?: { name: string, type?: string }[] }} template
99
115
  * @param {FlowRunContext} context
116
+ * @param {Record<string, unknown>} [fieldOverrides]
100
117
  */
101
- function mockFormContent (engine, template, context) {
102
- const content = engine.mock(template, { faker })
118
+ function mockFormContent (engine, template, context, fieldOverrides) {
119
+ const mocked = engine.mock(template, { faker })
120
+ const content = applyFormFieldOverrides(mocked, fieldOverrides, context)
103
121
  for (const field of template.fields ?? []) {
122
+ // Skip upload/reference mocks — they are not DOM-fillable and must not be
123
+ // restored onto file inputs during remount-safe action submits.
124
+ if (['file', 'image', 'reference'].includes(field.type)) continue
104
125
  const value = content[field.name]
105
126
  if (value !== undefined) context.fields[field.name] = value
106
127
  if (field.type === 'email' || field.name?.toLowerCase?.().includes('email')) {
@@ -110,23 +131,6 @@ function mockFormContent (engine, template, context) {
110
131
  return content
111
132
  }
112
133
 
113
- function resolveActionId (action) {
114
- if (typeof action === 'string') return action
115
- if (action && typeof action.id === 'string') return action.id
116
- throw new Error('Flow action step requires an action POJO or id string')
117
- }
118
-
119
- function actionSelector (actionId, service) {
120
- return service
121
- ? `[data-action="${actionId}"][data-service="${service}"]`
122
- : `[data-action="${actionId}"]`
123
- }
124
-
125
- function resolveActionService (action, fallback) {
126
- if (typeof action === 'object' && action.service) return action.service
127
- return fallback
128
- }
129
-
130
134
  function resolveContextObject (obj, context) {
131
135
  if (!obj || typeof obj !== 'object') return obj
132
136
  const out = {}
@@ -232,6 +236,67 @@ export async function runFlow (flow, options = {}) {
232
236
  continue
233
237
  }
234
238
 
239
+ if (step.files != null) {
240
+ if (!page) throw new Error('files step requires a Playwright page')
241
+ const { selector, payloads } = resolveFilesTarget(step.files)
242
+ const resolvedSelector = interpolateContextString(selector, context, {
243
+ escape: escapeCssAttrValue,
244
+ })
245
+ const locator = page.locator(resolvedSelector).first()
246
+ const filesTimeout = step.timeout ?? 15000
247
+ // File inputs are often visually hidden behind dropzones — attach is enough.
248
+ await locator.waitFor({ state: 'attached', timeout: filesTimeout })
249
+ // SSR markup is attached before React wires onChange. Settle like action clicks.
250
+ await page.evaluate(async () => {
251
+ await new Promise((resolve) => {
252
+ requestAnimationFrame(() => requestAnimationFrame(resolve))
253
+ })
254
+ await new Promise((resolve) => setTimeout(resolve, 500))
255
+ })
256
+
257
+ // Path payloads use Playwright's setInputFiles. In-memory buffer payloads use
258
+ // DataTransfer — Playwright setInputFiles populates input.files but does not
259
+ // notify React 19's onChange for this input; assigning via DataTransfer + change does.
260
+ const pathPayloads = payloads.filter((p) => typeof p === 'string')
261
+ const bufferPayloads = payloads.filter((p) => typeof p === 'object' && p != null)
262
+ if (pathPayloads.length) {
263
+ await locator.setInputFiles(pathPayloads)
264
+ }
265
+ if (bufferPayloads.length) {
266
+ await locator.evaluate((el, files) => {
267
+ if (!(el instanceof HTMLInputElement) || el.type !== 'file') {
268
+ throw new Error('files step selector must resolve to an HTML file input')
269
+ }
270
+ const dt = new DataTransfer()
271
+ for (const file of files) {
272
+ const bytes = Uint8Array.from(atob(file.base64), (c) => c.charCodeAt(0))
273
+ dt.items.add(new File([bytes], file.name, { type: file.mimeType }))
274
+ }
275
+ el.files = dt.files
276
+ el.dispatchEvent(new Event('input', { bubbles: true }))
277
+ el.dispatchEvent(new Event('change', { bubbles: true }))
278
+ }, bufferPayloads.map((p) => ({
279
+ name: p.name,
280
+ mimeType: p.mimeType,
281
+ base64: Buffer.from(p.buffer).toString('base64'),
282
+ })))
283
+ } else if (pathPayloads.length) {
284
+ // Paths-only: nudge change in case the host needs an explicit event.
285
+ await locator.evaluate((el) => {
286
+ if (!(el instanceof HTMLInputElement) || el.type !== 'file') return
287
+ el.dispatchEvent(new Event('input', { bubbles: true }))
288
+ el.dispatchEvent(new Event('change', { bubbles: true }))
289
+ }).catch(() => {})
290
+ }
291
+
292
+ // Capture the first uploaded filename for later result assertions.
293
+ const firstName = typeof payloads[0] === 'string'
294
+ ? path.basename(payloads[0])
295
+ : payloads[0]?.name
296
+ if (firstName) context.uploadedFileName = firstName
297
+ continue
298
+ }
299
+
235
300
  if (step.form) {
236
301
  if (!page) throw new Error('form step requires a Playwright page')
237
302
  const formMeta = step.form
@@ -245,11 +310,13 @@ export async function runFlow (flow, options = {}) {
245
310
  throw new Error(`Resource schema "${schemaId}" not found or has no fields`)
246
311
  }
247
312
  const engine = schemaEngineFromManifest(manifest)
248
- const content = mockFormContent(engine, template, context)
313
+ // Optional field overrides: `{ form: SignInForm, fields: { email: '$email' } }`
314
+ const content = mockFormContent(engine, template, context, step.fields)
249
315
  const formTimeout = step.timeout ?? 15000
250
316
  const formRoot = formId ? page.locator(`form[id="${formId}"]`) : page
251
317
  await formRoot.waitFor({ state: 'visible', timeout: formTimeout })
252
- for (const field of template.fields) {
318
+
319
+ const fillField = async (field) => {
253
320
  const value = content[field.name]
254
321
  if (value === undefined || value === null) {
255
322
  throw new Error(`mockFormContent produced no value for field "${field.name}"`)
@@ -259,54 +326,261 @@ export async function runFlow (flow, options = {}) {
259
326
  const tag = await locator.evaluate(el => el.tagName.toLowerCase()).catch(() => 'input')
260
327
  if (tag === 'select') {
261
328
  await locator.selectOption(String(value))
262
- } else if (tag === 'input') {
329
+ return
330
+ }
331
+ if (tag === 'input') {
263
332
  const inputType = await locator.getAttribute('type')
264
333
  if (inputType === 'checkbox') {
265
334
  if (value) await locator.check()
266
- } else {
267
- // Retry fills: early hydration can accept DOM input then wipe on re-render.
268
- const asText = String(value)
269
- for (let attempt = 0; attempt < 3; attempt++) {
270
- await locator.fill(asText)
271
- try {
272
- await expectFn(locator).toHaveValue(asText, { timeout: 2000 })
273
- break
274
- } catch (err) {
275
- if (attempt === 2) throw err
276
- await page.waitForTimeout(150)
277
- }
278
- }
335
+ else await locator.uncheck()
336
+ return
337
+ }
338
+ }
339
+ // Retry fills: early hydration / sibling controlled updates can wipe earlier fields.
340
+ const asText = String(value)
341
+ for (let attempt = 0; attempt < 3; attempt++) {
342
+ await locator.fill(asText)
343
+ // Blur so React controlled state commits before the next field fill.
344
+ await locator.blur().catch(() => {})
345
+ try {
346
+ await expectFn(locator).toHaveValue(asText, { timeout: 2000 })
347
+ return
348
+ } catch (err) {
349
+ if (attempt === 2) throw err
350
+ await page.waitForTimeout(150)
351
+ }
352
+ }
353
+ }
354
+
355
+ // File / reference / image fields need real uploads — skip in declarative fills.
356
+ const fillableFields = (template.fields ?? []).filter(
357
+ (field) => !['file', 'image', 'reference'].includes(field.type),
358
+ )
359
+
360
+ for (const field of fillableFields) {
361
+ await fillField(field)
362
+ }
363
+
364
+ // Sibling controlled re-renders can clear earlier inputs after later fills —
365
+ // re-assert text-like fields and repair any that drifted.
366
+ for (let settle = 0; settle < 3; settle++) {
367
+ let drifted = false
368
+ for (const field of fillableFields) {
369
+ const value = content[field.name]
370
+ if (value === undefined || value === null) continue
371
+ const locator = formRoot.locator(`[name="${field.name}"]`).first()
372
+ const tag = await locator.evaluate(el => el.tagName.toLowerCase()).catch(() => 'input')
373
+ if (tag === 'select') continue
374
+ if (tag === 'input') {
375
+ const inputType = await locator.getAttribute('type')
376
+ if (inputType === 'checkbox' || inputType === 'file') continue
377
+ }
378
+ const asText = String(value)
379
+ const current = await locator.inputValue().catch(() => '')
380
+ if (current !== asText) {
381
+ drifted = true
382
+ await fillField(field)
279
383
  }
280
- } else {
281
- await locator.fill(String(value))
282
384
  }
385
+ if (!drifted) break
386
+ if (settle === 2) {
387
+ throw new Error(`form step could not keep field values stable for "${formId}"`)
388
+ }
389
+ await page.waitForTimeout(150)
283
390
  }
284
391
  continue
285
392
  }
286
393
 
287
394
  if (step.action != null) {
288
395
  if (!page) throw new Error('action step requires a Playwright page')
289
- const actionId = resolveActionId(step.action)
290
- const service = resolveActionService(step.action, step.service)
291
- const matches = page.locator(actionSelector(actionId, service))
396
+ const actionStep = typeof step.action === 'object' && step.action != null
397
+ ? resolveContextObject(step.action, context)
398
+ : step.action
399
+ const actionId = resolveActionId(actionStep)
400
+ const matchers = resolveActionMatchers(actionStep, {
401
+ service: resolveContextValue(step.service, context),
402
+ memberEmail: resolveContextValue(step.memberEmail, context),
403
+ language: resolveContextValue(step.language, context),
404
+ })
405
+ const selector = actionSelector(actionId, matchers)
292
406
  const actionTimeout = step.timeout ?? 15000
293
- await matches.first().waitFor({ state: 'attached', timeout: actionTimeout })
407
+ const actionDeadline = Date.now() + actionTimeout
408
+ // SSR markup is visible before React attaches onClick. Wait for visibility,
409
+ // then click: form submits use remount-safe evaluate + requestSubmit; other
410
+ // CTAs use Playwright click (programmatic el.click() often skips React onClick).
411
+ //
412
+ // Re-query the live node after settle: concurrent auth/workspace fetches can
413
+ // remount the tree and detach Playwright locators mid-scroll (and wipe
414
+ // uncontrolled form state). Retry within the action timeout.
294
415
  // Prefer an actionable match when duplicates exist (e.g. hero CTA under a page overlay).
295
- const count = await matches.count()
296
416
  let clicked = false
297
417
  let lastError
298
- for (let i = 0; i < count; i++) {
299
- const candidate = matches.nth(i)
418
+ while (!clicked && Date.now() < actionDeadline) {
419
+ const remaining = Math.max(250, actionDeadline - Date.now())
420
+ const matches = page.locator(selector)
300
421
  try {
301
- await candidate.click({ timeout: Math.min(5000, actionTimeout) })
302
- clicked = true
303
- break
422
+ await matches.first().waitFor({ state: 'attached', timeout: remaining })
304
423
  } catch (err) {
305
424
  lastError = err
425
+ break
426
+ }
427
+ const count = await matches.count()
428
+ for (let i = 0; i < count; i++) {
429
+ const candidate = matches.nth(i)
430
+ try {
431
+ await candidate.waitFor({
432
+ state: 'visible',
433
+ timeout: Math.min(5000, Math.max(250, actionDeadline - Date.now())),
434
+ })
435
+ const fieldRestore = { ...(context.fields ?? {}) }
436
+ if (context.email != null && fieldRestore.email == null) {
437
+ fieldRestore.email = context.email
438
+ }
439
+ // Form submits: restore fills + requestSubmit in one evaluate (remount-safe).
440
+ // Other CTAs (Enable, Publish, OpenExport): Playwright click — programmatic
441
+ // el.click() often never invokes React onClick (no /actions POST; publish stays off).
442
+ const isSubmitButton = await candidate.evaluate((el) => {
443
+ const form = (el instanceof HTMLButtonElement || el instanceof HTMLInputElement)
444
+ ? el.form
445
+ : el.closest?.('form')
446
+ if (!form) return false
447
+ return (el instanceof HTMLButtonElement || el instanceof HTMLInputElement)
448
+ ? el.type === 'submit'
449
+ : el.getAttribute?.('type') === 'submit'
450
+ })
451
+ if (isSubmitButton) {
452
+ await page.evaluate(async ({ selector: sel, fields }) => {
453
+ await new Promise((resolve) => {
454
+ requestAnimationFrame(() => requestAnimationFrame(resolve))
455
+ })
456
+ await new Promise((resolve) => setTimeout(resolve, 500))
457
+ const el = document.querySelector(sel)
458
+ if (!el) throw new Error(`Action control disappeared after settle: ${sel}`)
459
+ if (typeof el.scrollIntoView === 'function') {
460
+ el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
461
+ }
462
+ const form = (el instanceof HTMLButtonElement || el instanceof HTMLInputElement)
463
+ ? el.form
464
+ : el.closest?.('form')
465
+ if (form && fields && typeof fields === 'object') {
466
+ for (const [name, value] of Object.entries(fields)) {
467
+ if (value == null || typeof value === 'object') continue
468
+ const input = form.querySelector(`[name="${name}"]`)
469
+ if (!input || input.disabled) continue
470
+ if (input instanceof HTMLInputElement && input.type === 'file') continue
471
+ const asText = String(value)
472
+ if (input.value === asText) continue
473
+ const proto = Object.getPrototypeOf(input)
474
+ const desc = Object.getOwnPropertyDescriptor(proto, 'value')
475
+ desc?.set?.call(input, asText)
476
+ input.dispatchEvent(new Event('input', { bubbles: true }))
477
+ input.dispatchEvent(new Event('change', { bubbles: true }))
478
+ }
479
+ }
480
+ if (form && typeof form.requestSubmit === 'function') {
481
+ form.requestSubmit(el)
482
+ return
483
+ }
484
+ if (typeof el.click === 'function') el.click()
485
+ }, { selector, fields: fieldRestore })
486
+ } else {
487
+ await candidate.scrollIntoViewIfNeeded().catch(() => {})
488
+ await candidate.click({
489
+ timeout: Math.min(5000, Math.max(250, actionDeadline - Date.now())),
490
+ })
491
+ }
492
+ clicked = true
493
+ break
494
+ } catch (err) {
495
+ lastError = err
496
+ const message = String(err?.message ?? err)
497
+ if (/not attached|disappeared after settle/i.test(message)) {
498
+ await page.waitForTimeout(100)
499
+ break
500
+ }
501
+ }
502
+ }
503
+ if (!clicked && Date.now() < actionDeadline) {
504
+ await page.waitForTimeout(100)
306
505
  }
307
506
  }
308
507
  if (!clicked) {
309
- throw lastError ?? new Error(`No actionable locator for ${actionSelector(actionId, service)}`)
508
+ throw lastError ?? new Error(`No actionable locator for ${selector}`)
509
+ }
510
+ continue
511
+ }
512
+
513
+ if (step.download != null) {
514
+ if (!page) throw new Error('download step requires a Playwright page')
515
+ const target = resolveDownloadTarget(step.download)
516
+ const timeout = step.timeout ?? target.timeout
517
+ const locator = target.actionId
518
+ ? page.locator(actionSelector(target.actionId, target.service))
519
+ : page.locator(target.selector)
520
+ await locator.first().waitFor({ state: 'visible', timeout })
521
+
522
+ // Collect downloads via one listener — parallel waitForEvent('download')
523
+ // waiters all resolve on the *first* event (Playwright EventEmitter fan-out).
524
+ const downloads = []
525
+ const onDownload = (download) => {
526
+ downloads.push(download)
527
+ }
528
+ page.on('download', onDownload)
529
+ try {
530
+ await locator.first().scrollIntoViewIfNeeded()
531
+ await locator.first().click({ timeout })
532
+ const deadline = Date.now() + timeout
533
+ while (downloads.length < target.count) {
534
+ if (Date.now() > deadline) {
535
+ throw new Error(
536
+ `Timed out waiting for ${target.count} download(s); got ${downloads.length}`,
537
+ )
538
+ }
539
+ await page.waitForTimeout(50)
540
+ }
541
+ } finally {
542
+ page.off('download', onDownload)
543
+ }
544
+
545
+ const suggested = downloads.slice(0, target.count).map((download) => download.suggestedFilename())
546
+ assertDownloadFilenames(suggested, target.filenames)
547
+ context.downloads = suggested
548
+ if (suggested[0]) context.downloadedFileName = suggested[0]
549
+ continue
550
+ }
551
+
552
+ if (step.inputs != null) {
553
+ if (!page) throw new Error('inputs step requires a Playwright page')
554
+ const inputsTimeout = step.timeout ?? 15000
555
+ for (const [selector, valueSpec] of Object.entries(step.inputs)) {
556
+ const value = resolveContextValue(valueSpec, context)
557
+ if (value == null) {
558
+ throw new Error(`inputs step: no value resolved for selector "${selector}"`)
559
+ }
560
+ const resolvedSelector = interpolateContextString(selector, context, {
561
+ escape: escapeCssAttrValue,
562
+ })
563
+ const locator = page.locator(resolvedSelector).first()
564
+ await locator.waitFor({ state: 'visible', timeout: inputsTimeout })
565
+ const asText = String(value)
566
+ for (let attempt = 0; attempt < 3; attempt++) {
567
+ await locator.fill(asText)
568
+ await locator.blur().catch(() => {})
569
+ try {
570
+ await expectFn(locator).toHaveValue(asText, { timeout: 2000 })
571
+ break
572
+ } catch (err) {
573
+ if (attempt === 2) throw err
574
+ await page.waitForTimeout(150)
575
+ }
576
+ }
577
+ // Remount-safe action submits restore from context.fields by input name.
578
+ // Keep ad-hoc fills (e.g. sign-in code) in that map so settle remounts
579
+ // do not wipe values before requestSubmit.
580
+ const name = await locator.getAttribute('name')
581
+ if (name) {
582
+ context.fields = { ...(context.fields ?? {}), [name]: asText }
583
+ }
310
584
  }
311
585
  continue
312
586
  }
@@ -340,33 +614,109 @@ export async function runFlow (flow, options = {}) {
340
614
  const to = resolveContextValue(emailStep.to ?? '$email', context)
341
615
  const templateId = emailStep.id ?? emailStep.templateId
342
616
  const clickLabel = emailStep.click ?? emailStep.link
617
+ const extract = emailStep.extract
343
618
  if (!to) throw new Error('email step requires "to" (recipient email)')
344
619
  if (!templateId) throw new Error('email step requires "id" (email template id)')
345
- if (!clickLabel) throw new Error('email step requires "click" (link text in the email)')
620
+ if (!clickLabel && !extract) {
621
+ throw new Error('email step requires "click" or "extract"')
622
+ }
346
623
 
347
624
  const inboxPath = router.getPathname({ id: 'dev-inbox', language: locale }) ?? '/dev/inbox'
348
625
  const params = new URLSearchParams({ to: String(to), template: String(templateId) })
349
626
  const inboxUrl = `${baseURL}${inboxPath}?${params}`
350
- const pattern = linkNamePattern(clickLabel)
351
627
  const deadline = Date.now() + (emailStep.timeout ?? 20000)
352
628
 
353
- let clicked = false
354
- while (Date.now() < deadline && !clicked) {
355
- await page.goto(inboxUrl)
356
- const link = page.getByRole('link', { name: pattern }).first()
629
+ const openInboxBody = async (inboxPage) => {
630
+ await inboxPage.goto(inboxUrl)
631
+ const body = inboxPage.locator('[data-email-body]').first()
632
+ await body.waitFor({ state: 'visible', timeout: 2000 })
633
+ return body
634
+ }
635
+
636
+ // Extract-only must not navigate the flow page — SPA React state (e.g. SignIn
637
+ // success + code form for PWA) is lost on goto/goBack remount.
638
+ if (extract && !clickLabel) {
639
+ const inboxPage = await page.context().newPage()
640
+ let extracted = false
357
641
  try {
358
- await link.waitFor({ state: 'visible', timeout: 2000 })
359
- await Promise.all([
360
- page.waitForLoadState('domcontentloaded'),
361
- link.click(),
362
- ])
363
- clicked = true
364
- } catch {
365
- await page.waitForTimeout(500)
642
+ while (Date.now() < deadline && !extracted) {
643
+ try {
644
+ const body = await openInboxBody(inboxPage)
645
+ for (const [key, spec] of Object.entries(extract)) {
646
+ const selector = typeof spec === 'string' ? spec : spec.selector
647
+ const attr = typeof spec === 'object' ? spec.attr : undefined
648
+ const resolvedSelector = interpolateContextString(selector, context, {
649
+ escape: escapeCssAttrValue,
650
+ })
651
+ const locator = body.locator(resolvedSelector).first()
652
+ await locator.waitFor({ state: 'attached', timeout: 2000 })
653
+ const value = attr
654
+ ? await locator.getAttribute(attr)
655
+ : await locator.textContent()
656
+ context[key] = typeof value === 'string' ? value.trim() : value
657
+ }
658
+ extracted = true
659
+ } catch {
660
+ await page.waitForTimeout(500)
661
+ }
662
+ }
663
+ } finally {
664
+ await inboxPage.close().catch(() => {})
665
+ }
666
+ if (!extracted) {
667
+ throw new Error(`Email extract failed for to=${to}, template=${templateId}`)
366
668
  }
669
+ continue
367
670
  }
368
- if (!clicked) {
369
- throw new Error(`Email link "${clickLabel}" not found for to=${to}, template=${templateId}`)
671
+
672
+ if (extract && clickLabel) {
673
+ let extracted = false
674
+ while (Date.now() < deadline && !extracted) {
675
+ try {
676
+ const body = await openInboxBody(page)
677
+ for (const [key, spec] of Object.entries(extract)) {
678
+ const selector = typeof spec === 'string' ? spec : spec.selector
679
+ const attr = typeof spec === 'object' ? spec.attr : undefined
680
+ const resolvedSelector = interpolateContextString(selector, context, {
681
+ escape: escapeCssAttrValue,
682
+ })
683
+ const locator = body.locator(resolvedSelector).first()
684
+ await locator.waitFor({ state: 'attached', timeout: 2000 })
685
+ const value = attr
686
+ ? await locator.getAttribute(attr)
687
+ : await locator.textContent()
688
+ context[key] = typeof value === 'string' ? value.trim() : value
689
+ }
690
+ extracted = true
691
+ } catch {
692
+ await page.waitForTimeout(500)
693
+ }
694
+ }
695
+ if (!extracted) {
696
+ throw new Error(`Email extract failed for to=${to}, template=${templateId}`)
697
+ }
698
+ }
699
+
700
+ if (clickLabel) {
701
+ const pattern = linkNamePattern(clickLabel)
702
+ let clicked = false
703
+ while (Date.now() < deadline && !clicked) {
704
+ const body = await openInboxBody(page)
705
+ const link = body.getByRole('link', { name: pattern }).first()
706
+ try {
707
+ await link.waitFor({ state: 'visible', timeout: 2000 })
708
+ await Promise.all([
709
+ page.waitForLoadState('domcontentloaded'),
710
+ link.click(),
711
+ ])
712
+ clicked = true
713
+ } catch {
714
+ await page.waitForTimeout(500)
715
+ }
716
+ }
717
+ if (!clicked) {
718
+ throw new Error(`Email link "${clickLabel}" not found for to=${to}, template=${templateId}`)
719
+ }
370
720
  }
371
721
  continue
372
722
  }
@@ -390,9 +740,16 @@ export async function runFlow (flow, options = {}) {
390
740
  await expectFn(locator).toBeVisible({ timeout })
391
741
  }
392
742
  if (action != null) {
393
- const actionId = resolveActionId(action)
394
- const service = resolveActionService(action, step.result.service)
395
- await assertLocator(page.locator(actionSelector(actionId, service)).first())
743
+ const actionResult = typeof action === 'object' && action != null
744
+ ? resolveContextObject(action, context)
745
+ : action
746
+ const actionId = resolveActionId(actionResult)
747
+ const matchers = resolveActionMatchers(actionResult, {
748
+ service: resolveContextValue(step.result.service, context),
749
+ memberEmail: resolveContextValue(step.result.memberEmail, context),
750
+ language: resolveContextValue(step.result.language, context),
751
+ })
752
+ await assertLocator(page.locator(actionSelector(actionId, matchers)).first())
396
753
  }
397
754
  if (selector != null) {
398
755
  const resolvedSelector = interpolateContextString(selector, context, {
@@ -463,6 +820,9 @@ export function registerFlow (mod, options = {}) {
463
820
 
464
821
  test.describe(feature, () => {
465
822
  test(title, async ({ page, baseURL }) => {
823
+ if (typeof meta.timeout === 'number' && meta.timeout > 0) {
824
+ test.setTimeout(meta.timeout)
825
+ }
466
826
  const manifestPath = options.manifestPath
467
827
  const manifest = manifestPath ? loadManifest(manifestPath) : undefined
468
828
  await runFlow({ ...flowBody, steps, metadata: meta }, {
@@ -173,6 +173,43 @@ export class TestUtil {
173
173
  return ev.payload.token
174
174
  }
175
175
 
176
+ /**
177
+ * Resolve a six-digit sign-in code from its stored HMAC digest (integration tests only).
178
+ *
179
+ * @param {string} subjectId
180
+ * @returns {Promise<string>}
181
+ */
182
+ static async getLatestVerificationCodeForSubject(subjectId) {
183
+ const ev = await EventStore.Collection.findOne(
184
+ {
185
+ type: TOKEN_SCHEMA,
186
+ event: 'Created',
187
+ 'payload.type': 'Verification',
188
+ 'payload.subject': subjectId,
189
+ },
190
+ { sort: { created: -1 } },
191
+ )
192
+ const codeHash = ev?.payload?.codeHash
193
+ if (!codeHash) {
194
+ return Promise.reject(new Error('No verification code hash found for subject'))
195
+ }
196
+
197
+ const secret = process.env.TOKEN_SECRET
198
+ if (!secret) {
199
+ return Promise.reject(new Error('TOKEN_SECRET is required to resolve verification codes in tests'))
200
+ }
201
+
202
+ const { createHmac } = await import('node:crypto')
203
+ const hashCode = (code) => createHmac('sha256', secret).update(code).digest('hex')
204
+
205
+ for (let i = 0; i < 1_000_000; i += 1) {
206
+ const code = String(i).padStart(6, '0')
207
+ if (hashCode(code) === codeHash) return code
208
+ }
209
+
210
+ return Promise.reject(new Error('Could not resolve verification code from hash'))
211
+ }
212
+
176
213
  static async getLatestEmailChangeJwtForSubject(subjectId) {
177
214
  const ev = await EventStore.Collection.findOne(
178
215
  {