mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-25 22:34:56 +00:00
680 lines
25 KiB
TypeScript
680 lines
25 KiB
TypeScript
/**
|
|
* JSON-driven Playwright test runner for the pastebin frontend.
|
|
*
|
|
* Discovers every *.json file in this directory (except md3-schema.json and
|
|
* node_modules / tsconfig artefacts) and runs the tests defined in each file
|
|
* as a separate describe block. No TypeScript test logic lives in the JSON
|
|
* files — complex operations are referenced by hook name and executed here.
|
|
*
|
|
* New action types (in addition to the original set):
|
|
* hook — call a named SetupHook (side effects, no return value)
|
|
* evalExpect — call a named EvalHook and assert the result
|
|
* store — call a named EvalHook and save the result to a test-scoped variable
|
|
* tap — touch-tap an element (mobile tests)
|
|
* setViewport — change the browser viewport size
|
|
*
|
|
* Hook registry: tests/e2e/hooks.ts
|
|
* Schema: schemas/package-schemas/playwright.schema.json
|
|
*/
|
|
|
|
import { test, expect, Page, Locator, APIRequestContext } from '@playwright/test'
|
|
import { readFileSync, existsSync, readdirSync } from 'fs'
|
|
import { join, dirname, basename } from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
import { setupHooks, evalHooks, type Vars } from './hooks'
|
|
|
|
const __filename = fileURLToPath(import.meta.url)
|
|
const __dirname = dirname(__filename)
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface SkipIf {
|
|
selector: string
|
|
state: 'hidden' | 'visible' | 'enabled' | 'disabled'
|
|
}
|
|
|
|
interface Assertion {
|
|
matcher: string
|
|
not?: boolean
|
|
expected?: unknown
|
|
text?: string
|
|
value?: unknown
|
|
name?: string
|
|
property?: string
|
|
className?: unknown
|
|
count?: number
|
|
url?: string
|
|
title?: string
|
|
pattern?: string
|
|
script?: string
|
|
timeout?: number
|
|
}
|
|
|
|
interface TestStep {
|
|
action: string
|
|
description?: string
|
|
skipIf?: SkipIf
|
|
// Hook / eval fields
|
|
name?: string
|
|
args?: Record<string, unknown>
|
|
as?: string
|
|
// Locator fields
|
|
testId?: string
|
|
role?: string
|
|
text?: string
|
|
label?: string
|
|
placeholder?: string
|
|
alt?: string
|
|
title?: string
|
|
selector?: string
|
|
nth?: number
|
|
// Action-specific fields
|
|
url?: string
|
|
urlPattern?: string
|
|
waitUntil?: string
|
|
state?: string
|
|
value?: string
|
|
key?: string
|
|
keys?: string | string[]
|
|
timeout?: number
|
|
filename?: string
|
|
fullPage?: boolean
|
|
clipFrom?: string
|
|
width?: number
|
|
height?: number
|
|
method?: string
|
|
body?: unknown
|
|
x?: number
|
|
y?: number
|
|
delay?: number
|
|
script?: string
|
|
assertion?: Assertion
|
|
}
|
|
|
|
interface ParameterizeConfig {
|
|
data: Record<string, string>[]
|
|
}
|
|
|
|
interface TestCase {
|
|
name: string
|
|
description?: string
|
|
skip?: boolean
|
|
only?: boolean
|
|
timeout?: number
|
|
tags?: string[]
|
|
parameterize?: ParameterizeConfig
|
|
beforeEach?: TestStep[]
|
|
afterEach?: TestStep[]
|
|
steps: TestStep[]
|
|
}
|
|
|
|
interface TestFile {
|
|
suite?: string
|
|
description?: string
|
|
package?: string
|
|
tests: TestCase[]
|
|
}
|
|
|
|
// ─── Variable interpolation ───────────────────────────────────────────────────
|
|
|
|
function interpolateStr(value: string, data: Record<string, string>): string {
|
|
return value.replace(/\$\{(\w+)\}/g, (_, key) => (key in data ? data[key] : `\${${key}}`))
|
|
}
|
|
|
|
function interpolateStep(step: TestStep, data: Record<string, string>): TestStep {
|
|
const result: TestStep = { action: step.action }
|
|
const out = result as unknown as Record<string, unknown>
|
|
for (const [key, val] of Object.entries(step)) {
|
|
if (typeof val === 'string') {
|
|
out[key] = interpolateStr(val, data)
|
|
} else if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
|
|
const nested: Record<string, unknown> = {}
|
|
for (const [nk, nv] of Object.entries(val as Record<string, unknown>)) {
|
|
nested[nk] = typeof nv === 'string' ? interpolateStr(nv, data) : nv
|
|
}
|
|
out[key] = nested
|
|
} else {
|
|
out[key] = val
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ─── Interpreter ──────────────────────────────────────────────────────────────
|
|
|
|
class PlaywrightTestInterpreter {
|
|
private page!: Page
|
|
private request!: APIRequestContext
|
|
/** Per-test variable store — reset on setPage(). */
|
|
private vars: Vars = new Map()
|
|
|
|
setPage(page: Page): void {
|
|
this.page = page
|
|
this.request = page.request
|
|
this.vars = new Map()
|
|
}
|
|
|
|
async executeSteps(steps: TestStep[], data: Record<string, string> = {}): Promise<void> {
|
|
for (const rawStep of steps) {
|
|
const step = Object.keys(data).length > 0 ? interpolateStep(rawStep, data) : rawStep
|
|
try {
|
|
if (await this.shouldSkip(step)) {
|
|
console.log(` ↷ skipped "${step.action}" (skipIf matched)`)
|
|
continue
|
|
}
|
|
await this.executeStep(step)
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error)
|
|
console.error(` ✗ Failed step "${step.action}": ${msg}`)
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
private async shouldSkip(step: TestStep): Promise<boolean> {
|
|
if (!step.skipIf) return false
|
|
const { selector, state } = step.skipIf
|
|
const locator = this.page.locator(selector)
|
|
try {
|
|
switch (state) {
|
|
case 'hidden':
|
|
await locator.waitFor({ state: 'hidden', timeout: 2000 })
|
|
return true
|
|
case 'visible':
|
|
await locator.waitFor({ state: 'visible', timeout: 2000 })
|
|
return true
|
|
case 'enabled':
|
|
return await locator.isEnabled()
|
|
case 'disabled':
|
|
return await locator.isDisabled()
|
|
default:
|
|
return false
|
|
}
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async executeStep(step: TestStep): Promise<void> {
|
|
switch (step.action) {
|
|
// ── Navigation ──────────────────────────────────────────────────────────
|
|
case 'navigate':
|
|
await this.page.goto(step.url as string, {
|
|
waitUntil: (step.waitUntil as any) || 'domcontentloaded',
|
|
})
|
|
break
|
|
|
|
case 'waitForLoadState':
|
|
await this.page.waitForLoadState((step.state as any) || 'domcontentloaded')
|
|
break
|
|
|
|
case 'waitForURL':
|
|
await this.page.waitForURL(step.urlPattern as string, {
|
|
timeout: step.timeout ?? 5000,
|
|
})
|
|
break
|
|
|
|
case 'waitForNavigation':
|
|
await this.page.waitForNavigation()
|
|
break
|
|
|
|
case 'reload':
|
|
await this.page.reload()
|
|
break
|
|
|
|
case 'goBack':
|
|
await this.page.goBack()
|
|
break
|
|
|
|
case 'goForward':
|
|
await this.page.goForward()
|
|
break
|
|
|
|
// ── Viewport ────────────────────────────────────────────────────────────
|
|
case 'setViewport':
|
|
await this.page.setViewportSize({
|
|
width: step.width as number,
|
|
height: step.height as number,
|
|
})
|
|
break
|
|
|
|
// ── DOM interaction ──────────────────────────────────────────────────────
|
|
case 'click':
|
|
await this.getLocator(step).click()
|
|
break
|
|
|
|
case 'dblclick':
|
|
await this.getLocator(step).dblclick()
|
|
break
|
|
|
|
case 'tap':
|
|
await this.getLocator(step).tap()
|
|
break
|
|
|
|
case 'hover':
|
|
await this.getLocator(step).hover()
|
|
break
|
|
|
|
case 'focus':
|
|
await this.getLocator(step).focus()
|
|
break
|
|
|
|
case 'blur':
|
|
await this.getLocator(step).blur()
|
|
break
|
|
|
|
case 'fill':
|
|
await this.getLocator(step).fill(step.value as string)
|
|
break
|
|
|
|
case 'type':
|
|
await this.getLocator(step).pressSequentially(step.text as string, {
|
|
delay: step.delay ?? 50,
|
|
})
|
|
break
|
|
|
|
case 'select':
|
|
await this.page.locator(step.selector as string).selectOption(step.value as string)
|
|
break
|
|
|
|
case 'check':
|
|
await this.getLocator(step).check()
|
|
break
|
|
|
|
case 'uncheck':
|
|
await this.getLocator(step).uncheck()
|
|
break
|
|
|
|
case 'press':
|
|
await this.getLocator(step).press(step.key as string)
|
|
break
|
|
|
|
case 'keyboard':
|
|
for (const rawKey of [step.keys].flat()) {
|
|
// Normalize shorthand modifier names to Playwright key names
|
|
const key = (rawKey as string)
|
|
.replace(/\bctrl\b/gi, 'Control')
|
|
.replace(/\balt\b/gi, 'Alt')
|
|
.replace(/\bmeta\b/gi, 'Meta')
|
|
.replace(/\bshift\b/gi, 'Shift')
|
|
await this.page.keyboard.press(key)
|
|
}
|
|
break
|
|
|
|
case 'pageType':
|
|
await this.page.keyboard.type(step.text as string)
|
|
break
|
|
|
|
case 'scroll':
|
|
if (step.selector) {
|
|
await this.page.locator(step.selector).scrollIntoViewIfNeeded()
|
|
} else {
|
|
await this.page.evaluate(
|
|
({ x, y }) => window.scrollTo(x, y),
|
|
{ x: step.x ?? 0, y: step.y ?? 0 },
|
|
)
|
|
}
|
|
break
|
|
|
|
// ── Waits ────────────────────────────────────────────────────────────────
|
|
case 'wait':
|
|
await this.page.waitForTimeout(step.timeout as number)
|
|
break
|
|
|
|
case 'waitForSelector':
|
|
await this.page.waitForSelector(step.selector as string, {
|
|
timeout: step.timeout ?? 5000,
|
|
...(step.state
|
|
? { state: step.state as 'attached' | 'detached' | 'visible' | 'hidden' }
|
|
: {}),
|
|
})
|
|
break
|
|
|
|
case 'waitForHidden':
|
|
await this.page.waitForSelector(step.selector as string, {
|
|
state: 'hidden',
|
|
timeout: step.timeout ?? 5000,
|
|
})
|
|
break
|
|
|
|
// ── Hooks — named TypeScript functions, no inline code ──────────────────
|
|
case 'hook': {
|
|
const hookName = step.name as string
|
|
const fn = setupHooks[hookName]
|
|
if (!fn) {
|
|
throw new Error(
|
|
`Unknown setup hook: "${hookName}". Available: ${Object.keys(setupHooks).join(', ')}`,
|
|
)
|
|
}
|
|
await fn(this.page, step.args ?? {}, this.vars)
|
|
break
|
|
}
|
|
|
|
case 'evalExpect': {
|
|
const hookName = step.name as string
|
|
const fn = evalHooks[hookName]
|
|
if (!fn) {
|
|
throw new Error(
|
|
`Unknown eval hook: "${hookName}". Available: ${Object.keys(evalHooks).join(', ')}`,
|
|
)
|
|
}
|
|
const result = await fn(this.page, step.args ?? {}, this.vars)
|
|
await this.assertValue(result, step.assertion as Assertion)
|
|
break
|
|
}
|
|
|
|
case 'store': {
|
|
const hookName = step.name as string
|
|
const varName = step.as as string
|
|
const fn = evalHooks[hookName]
|
|
if (!fn) {
|
|
throw new Error(
|
|
`Unknown eval hook: "${hookName}". Available: ${Object.keys(evalHooks).join(', ')}`,
|
|
)
|
|
}
|
|
const result = await fn(this.page, step.args ?? {}, this.vars)
|
|
this.vars.set(varName, result)
|
|
break
|
|
}
|
|
|
|
// ── Screenshot ───────────────────────────────────────────────────────────
|
|
case 'screenshot': {
|
|
const clipVar = step.clipFrom ? this.vars.get(step.clipFrom) : undefined
|
|
const clip = clipVar as { x: number; y: number; width: number; height: number } | undefined
|
|
await this.page.screenshot({
|
|
path: step.filename as string,
|
|
fullPage: !!step.fullPage,
|
|
...(clip ? { clip } : {}),
|
|
})
|
|
break
|
|
}
|
|
|
|
// ── Assertions ───────────────────────────────────────────────────────────
|
|
case 'expect':
|
|
await this.handleExpect(step)
|
|
break
|
|
|
|
// ── API requests ─────────────────────────────────────────────────────────
|
|
case 'apiRequest':
|
|
await this.handleApiRequest(step)
|
|
break
|
|
|
|
// ── Legacy — avoid in new tests ──────────────────────────────────────────
|
|
case 'evaluate':
|
|
// Kept for backwards compatibility with tests.json. New tests should
|
|
// use action:"hook" or action:"evalExpect" instead.
|
|
await this.page.evaluate(step.script as string)
|
|
break
|
|
|
|
default:
|
|
console.warn(` ⚠ Unknown action: "${step.action}"`)
|
|
}
|
|
}
|
|
|
|
// ── Locator resolution ────────────────────────────────────────────────────
|
|
|
|
private getLocator(step: TestStep): Locator {
|
|
const applyNth = (loc: Locator) =>
|
|
typeof step.nth === 'number' ? loc.nth(step.nth) : loc
|
|
|
|
if (step.testId) return applyNth(this.page.getByTestId(step.testId))
|
|
if (step.role) {
|
|
const loc = step.text
|
|
? this.page.getByRole(step.role as any, { name: step.text })
|
|
: this.page.getByRole(step.role as any)
|
|
return applyNth(loc)
|
|
}
|
|
if (step.label) return applyNth(this.page.getByLabel(step.label))
|
|
if (step.placeholder) return applyNth(this.page.getByPlaceholder(step.placeholder))
|
|
if (step.alt) return applyNth(this.page.getByAltText(step.alt))
|
|
if (step.title) return applyNth(this.page.getByTitle(step.title))
|
|
if (step.text) return applyNth(this.page.getByText(step.text))
|
|
if (step.selector) return applyNth(this.page.locator(step.selector))
|
|
throw new Error(`No locator strategy provided in step: ${JSON.stringify(step)}`)
|
|
}
|
|
|
|
// ── expect action ─────────────────────────────────────────────────────────
|
|
|
|
private async handleExpect(step: TestStep): Promise<void> {
|
|
const assertion = step.assertion as Assertion
|
|
if (!assertion?.matcher) throw new Error('expect action requires an assertion.matcher')
|
|
|
|
// Page-level matchers
|
|
if (assertion.matcher === 'toHaveURL') {
|
|
let expectedUrl = (assertion.url ?? assertion.expected) as string
|
|
// Rewrite hardcoded http://localhost/pastebin URLs to match the actual test origin
|
|
// so tests work with both port 80 (Docker stack) and dynamic ports (Testcontainers).
|
|
if (expectedUrl && typeof expectedUrl === 'string') {
|
|
const pageOrigin = new URL(this.page.url()).origin
|
|
expectedUrl = expectedUrl.replace(/^http:\/\/localhost(?=\/pastebin)/, pageOrigin)
|
|
}
|
|
const base = assertion.not ? expect(this.page).not : expect(this.page)
|
|
await (base as any).toHaveURL(expectedUrl, {
|
|
timeout: assertion.timeout,
|
|
})
|
|
return
|
|
}
|
|
if (assertion.matcher === 'toHaveTitle') {
|
|
const base = assertion.not ? expect(this.page).not : expect(this.page)
|
|
await (base as any).toHaveTitle(assertion.title ?? assertion.expected, {
|
|
timeout: assertion.timeout,
|
|
})
|
|
return
|
|
}
|
|
|
|
const locator = this.getLocator(step)
|
|
const base = assertion.not ? expect(locator).not : expect(locator)
|
|
const opts = assertion.timeout ? { timeout: assertion.timeout } : undefined
|
|
|
|
switch (assertion.matcher) {
|
|
case 'toBeVisible': await (base as any).toBeVisible(opts); break
|
|
case 'toBeHidden': await (base as any).toBeHidden(opts); break
|
|
case 'toBeEnabled': await (base as any).toBeEnabled(opts); break
|
|
case 'toBeDisabled': await (base as any).toBeDisabled(opts); break
|
|
case 'toBeChecked': await (base as any).toBeChecked(opts); break
|
|
case 'toBeEditable': await (base as any).toBeEditable(opts); break
|
|
case 'toBeEmpty': await (base as any).toBeEmpty(opts); break
|
|
case 'toContainText': await (base as any).toContainText(assertion.expected ?? assertion.text, opts); break
|
|
case 'toHaveText': await (base as any).toHaveText(assertion.expected ?? assertion.text, opts); break
|
|
case 'toHaveValue': await (base as any).toHaveValue(assertion.value ?? assertion.expected, opts); break
|
|
case 'toHaveCount': await (base as any).toHaveCount(assertion.count, opts); break
|
|
case 'toHaveAttribute': await (base as any).toHaveAttribute(assertion.name, assertion.value, opts); break
|
|
case 'toHaveClass': await (base as any).toHaveClass(assertion.className, opts); break
|
|
case 'toHaveCSS': await (base as any).toHaveCSS(assertion.property, assertion.value, opts); break
|
|
case 'toHaveScreenshot':await (base as any).toHaveScreenshot(assertion.name, opts); break
|
|
case 'toBeInViewport': {
|
|
const box = await locator.boundingBox()
|
|
assertion.not ? expect(box).toBeNull() : expect(box).not.toBeNull()
|
|
break
|
|
}
|
|
case 'custom':
|
|
// Legacy: kept for backwards compatibility. Use evalExpect instead.
|
|
await (assertion.not
|
|
? expect(await this.page.evaluate(assertion.script as string)).toBeFalsy()
|
|
: expect(await this.page.evaluate(assertion.script as string)).toBeTruthy())
|
|
break
|
|
default:
|
|
throw new Error(`Unknown expect matcher: "${assertion.matcher}"`)
|
|
}
|
|
}
|
|
|
|
// ── evalExpect value assertions ──────────────────────────────────────────
|
|
|
|
private async assertValue(value: unknown, assertion: Assertion): Promise<void> {
|
|
if (!assertion?.matcher) throw new Error('evalExpect requires an assertion.matcher')
|
|
const not = assertion.not === true
|
|
|
|
switch (assertion.matcher) {
|
|
case 'toBeTruthy':
|
|
not ? expect(value).toBeFalsy() : expect(value).toBeTruthy()
|
|
break
|
|
case 'toBeFalsy':
|
|
not ? expect(value).toBeTruthy() : expect(value).toBeFalsy()
|
|
break
|
|
case 'toBeNull':
|
|
not ? expect(value).not.toBeNull() : expect(value).toBeNull()
|
|
break
|
|
case 'toEqual':
|
|
not
|
|
? expect(value).not.toEqual(assertion.expected)
|
|
: expect(value).toEqual(assertion.expected)
|
|
break
|
|
case 'toContain':
|
|
not
|
|
? expect(value).not.toContain(assertion.expected)
|
|
: expect(value).toContain(assertion.expected)
|
|
break
|
|
case 'toHaveLength':
|
|
not
|
|
? expect(value as unknown[]).not.toHaveLength(assertion.count!)
|
|
: expect(value as unknown[]).toHaveLength(assertion.count!)
|
|
break
|
|
case 'toBeEmpty':
|
|
not
|
|
? expect(value as unknown[]).not.toHaveLength(0)
|
|
: expect(value as unknown[]).toHaveLength(0)
|
|
break
|
|
case 'toMatch':
|
|
not
|
|
? expect(String(value)).not.toMatch(new RegExp(assertion.pattern!))
|
|
: expect(String(value)).toMatch(new RegExp(assertion.pattern!))
|
|
break
|
|
default:
|
|
throw new Error(`Unknown evalExpect matcher: "${assertion.matcher}"`)
|
|
}
|
|
}
|
|
|
|
// ── apiRequest action ────────────────────────────────────────────────────
|
|
|
|
private async handleApiRequest(step: TestStep): Promise<void> {
|
|
const method = ((step.method as string) || 'GET').toUpperCase()
|
|
const url = step.url as string
|
|
const assertion = step.assertion as { status?: number; bodyContains?: string } | undefined
|
|
|
|
let response: Awaited<ReturnType<APIRequestContext['fetch']>>
|
|
switch (method) {
|
|
case 'GET': response = await this.request.get(url); break
|
|
case 'POST': response = await this.request.post(url, { data: step.body }); break
|
|
case 'PUT': response = await this.request.put(url, { data: step.body }); break
|
|
case 'PATCH': response = await this.request.patch(url, { data: step.body }); break
|
|
case 'DELETE': response = await this.request.delete(url); break
|
|
default: response = await this.request.fetch(url, { method }); break
|
|
}
|
|
|
|
if (assertion?.status !== undefined) expect(response.status()).toBe(assertion.status)
|
|
if (assertion?.bodyContains !== undefined) expect(await response.text()).toContain(assertion.bodyContains)
|
|
}
|
|
}
|
|
|
|
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Authenticate before each test by calling the Flask API to get a JWT token
|
|
* and writing it directly to IndexedDB (redux-persist format). This avoids
|
|
* the login form entirely — no JS bundle download, no React hydration wait.
|
|
*
|
|
* The page must navigate AFTER this call so redux-persist reads the token
|
|
* on rehydration.
|
|
*/
|
|
async function loginViaApi(page: Page): Promise<void> {
|
|
await setupHooks.loginViaApi(page, {}, new Map())
|
|
}
|
|
|
|
// ─── JSON discovery & test registration ──────────────────────────────────────
|
|
|
|
/** Files to skip when discovering JSON test suites. */
|
|
const JSON_SKIP = new Set(['md3-schema.json', 'tsconfig.json', 'package.json'])
|
|
|
|
const jsonFiles = readdirSync(__dirname)
|
|
.filter((f) => f.endsWith('.json') && !JSON_SKIP.has(f))
|
|
.sort()
|
|
|
|
for (const jsonFile of jsonFiles) {
|
|
const filePath = join(__dirname, jsonFile)
|
|
if (!existsSync(filePath)) continue
|
|
|
|
let testDef: TestFile
|
|
try {
|
|
testDef = JSON.parse(readFileSync(filePath, 'utf-8')) as TestFile
|
|
} catch (err) {
|
|
console.error(`Failed to parse ${jsonFile}:`, err)
|
|
continue
|
|
}
|
|
|
|
if (!Array.isArray(testDef.tests) || testDef.tests.length === 0) continue
|
|
|
|
const suiteName = testDef.suite ?? testDef.description ?? testDef.package ?? basename(jsonFile, '.json')
|
|
|
|
test.describe(suiteName, () => {
|
|
// Login before every test so AuthGuard doesn't redirect
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginViaApi(page)
|
|
})
|
|
|
|
for (const testCase of testDef.tests) {
|
|
registerTest(testCase)
|
|
}
|
|
})
|
|
}
|
|
|
|
function registerTest(testCase: TestCase): void {
|
|
const testFn = testCase.skip ? test.skip : testCase.only ? test.only : test
|
|
|
|
// ── Describe group: testCase has beforeEach / afterEach ──────────────────
|
|
if (testCase.beforeEach || testCase.afterEach) {
|
|
test.describe(testCase.name, () => {
|
|
const interpreter = new PlaywrightTestInterpreter()
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
interpreter.setPage(page)
|
|
if (testCase.beforeEach) await interpreter.executeSteps(testCase.beforeEach)
|
|
})
|
|
|
|
test.afterEach(async ({ page }) => {
|
|
interpreter.setPage(page)
|
|
if (testCase.afterEach) await interpreter.executeSteps(testCase.afterEach)
|
|
})
|
|
|
|
if (testCase.parameterize?.data) {
|
|
for (const dataRow of testCase.parameterize.data) {
|
|
const label = dataRow.label ?? JSON.stringify(dataRow)
|
|
testFn(`${testCase.name} [${label}]`, async ({ page }) => {
|
|
if (testCase.timeout) test.setTimeout(testCase.timeout)
|
|
interpreter.setPage(page)
|
|
await interpreter.executeSteps(testCase.steps, dataRow)
|
|
})
|
|
}
|
|
} else {
|
|
testFn(testCase.name, async ({ page }) => {
|
|
if (testCase.timeout) test.setTimeout(testCase.timeout)
|
|
interpreter.setPage(page)
|
|
await interpreter.executeSteps(testCase.steps)
|
|
})
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
// ── Parameterised (no hooks) ─────────────────────────────────────────────
|
|
if (testCase.parameterize?.data) {
|
|
for (const dataRow of testCase.parameterize.data) {
|
|
const label = dataRow.label ?? JSON.stringify(dataRow)
|
|
testFn(`${testCase.name} [${label}]`, async ({ page }) => {
|
|
if (testCase.timeout) test.setTimeout(testCase.timeout)
|
|
const interpreter = new PlaywrightTestInterpreter()
|
|
interpreter.setPage(page)
|
|
await interpreter.executeSteps(testCase.steps, dataRow)
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
// ── Plain test ───────────────────────────────────────────────────────────
|
|
testFn(testCase.name, async ({ page }) => {
|
|
if (testCase.timeout) test.setTimeout(testCase.timeout)
|
|
const interpreter = new PlaywrightTestInterpreter()
|
|
interpreter.setPage(page)
|
|
await interpreter.executeSteps(testCase.steps)
|
|
})
|
|
}
|