@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.
- package/README.md +2 -0
- package/package.json +8 -7
- package/src/index.js +3 -0
- package/src/proxy-internal.js +7 -2
- package/src/runtime.js +5 -3
- package/src/select-workspace.api.js +5 -1
- package/src/server.js +14 -3
- package/src/tasks/change-stream.js +11 -45
- package/src/test/flow-action.js +62 -0
- package/src/test/flow-action.spec.js +64 -0
- package/src/test/flow-context.js +20 -0
- package/src/test/flow-context.spec.js +14 -0
- package/src/test/flow-download.js +102 -0
- package/src/test/flow-download.spec.js +60 -0
- package/src/test/flow-files.js +83 -0
- package/src/test/flow-files.spec.js +69 -0
- package/src/test/flow-runner.js +428 -68
- package/src/test/test.util.js +37 -0
- package/src/user-app-settings.js +70 -9
- package/src/verify-sign-in-session.js +24 -0
package/src/test/flow-runner.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
-
const
|
|
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
|
|
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 ${
|
|
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
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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
|
|
394
|
-
|
|
395
|
-
|
|
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 }, {
|
package/src/test/test.util.js
CHANGED
|
@@ -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
|
{
|