chore: seminarhof customer project
This commit is contained in:
270
e2e/tests/steps/common.steps.ts
Normal file
270
e2e/tests/steps/common.steps.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { AppWorld } from '../support/world.js'
|
||||
|
||||
/**
|
||||
* Generic step library — shared across every project that copies this harness.
|
||||
*
|
||||
* Project-specific business steps (e.g. "I create a booking for <date>")
|
||||
* belong in their own *.steps.ts files. Keep this file framework-agnostic.
|
||||
*/
|
||||
|
||||
Given('I visit {string}', async function (this: AppWorld, path: string) {
|
||||
await this.gotoApp(path)
|
||||
})
|
||||
|
||||
Given(
|
||||
'I am logged in as {string} with password {string}',
|
||||
async function (this: AppWorld, email: string, password: string) {
|
||||
await this.login(email, password)
|
||||
},
|
||||
)
|
||||
|
||||
When('I log in as {string} with password {string}', async function (
|
||||
this: AppWorld,
|
||||
email: string,
|
||||
password: string,
|
||||
) {
|
||||
await this.login(email, password)
|
||||
})
|
||||
|
||||
When('I log out', async function (this: AppWorld) {
|
||||
await this.logout()
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve a "field" by trying multiple strategies. CSS selectors win when the
|
||||
* name looks like one. Otherwise: data-testid, then accessible label, then
|
||||
* placeholder. Mantine sometimes parks data-testid on the wrapper div, so we
|
||||
* descend to the inner control when present.
|
||||
*/
|
||||
const looksLikeCss = (s: string) => /[\[#.>:\s]/.test(s) || s.startsWith('input')
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
async function field(world: AppWorld, name: string) {
|
||||
if (looksLikeCss(name)) return world.page.locator(name).first()
|
||||
// Wait for the testid to attach — page often paints before React Query
|
||||
// resolves, so a synchronous count() check fires too early.
|
||||
const byId = world.page.locator(`[data-testid="${name}"]`).first()
|
||||
try {
|
||||
await byId.waitFor({ state: 'attached', timeout: 5000 })
|
||||
const inner = byId.locator('input, textarea, select').first()
|
||||
if (await inner.count()) return inner
|
||||
return byId
|
||||
} catch {
|
||||
// Fall through to label / placeholder.
|
||||
}
|
||||
const byLabel = world.page.getByLabel(name, { exact: false })
|
||||
if (await byLabel.count()) return byLabel.first()
|
||||
return world.page.getByPlaceholder(name, { exact: false }).first()
|
||||
}
|
||||
|
||||
async function isVisibleWithin(
|
||||
target: ReturnType<AppWorld['page']['locator']>,
|
||||
timeout = 5000,
|
||||
) {
|
||||
try {
|
||||
await target.waitFor({ state: 'visible', timeout })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(target: ReturnType<AppWorld['page']['locator']>, timeout = 5000) {
|
||||
try {
|
||||
await target.click({ timeout })
|
||||
return
|
||||
} catch {
|
||||
await target.evaluate((node) => {
|
||||
;(node as HTMLElement).click()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function settleAfterClick(world: AppWorld, previousUrl?: string) {
|
||||
if (previousUrl) {
|
||||
await world.page
|
||||
.waitForURL((url) => url.toString() !== previousUrl, { timeout: 3000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
|
||||
await world.page.waitForTimeout(750)
|
||||
}
|
||||
|
||||
async function clickAndConfirmIfNeeded(world: AppWorld, target: ReturnType<AppWorld['page']['locator']>) {
|
||||
await activate(target)
|
||||
await settleAfterClick(world)
|
||||
|
||||
if (
|
||||
await isVisibleWithin(
|
||||
world.page.getByRole('button', { name: /bestätigen\?|confirm\?/i }).first(),
|
||||
1000,
|
||||
)
|
||||
) {
|
||||
await activate(
|
||||
world.page.getByRole('button', { name: /bestätigen\?|confirm\?/i }).first(),
|
||||
1000,
|
||||
)
|
||||
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
|
||||
await world.page.waitForTimeout(250)
|
||||
return
|
||||
}
|
||||
if (
|
||||
await isVisibleWithin(
|
||||
world.page.getByText(/bestätigen\?|confirm\?/i, { exact: false }).first(),
|
||||
1000,
|
||||
)
|
||||
) {
|
||||
await activate(
|
||||
world.page.getByText(/bestätigen\?|confirm\?/i, { exact: false }).first(),
|
||||
1000,
|
||||
)
|
||||
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
|
||||
await world.page.waitForTimeout(250)
|
||||
}
|
||||
}
|
||||
|
||||
When(
|
||||
'I fill {string} with {string}',
|
||||
async function (this: AppWorld, name: string, value: string) {
|
||||
const target = await field(this, name)
|
||||
await target.fill('')
|
||||
await target.fill(value)
|
||||
},
|
||||
)
|
||||
|
||||
When('I check {string}', async function (this: AppWorld, name: string) {
|
||||
const target = await field(this, name)
|
||||
await target.check()
|
||||
})
|
||||
|
||||
When('I uncheck {string}', async function (this: AppWorld, name: string) {
|
||||
const target = await field(this, name)
|
||||
await target.uncheck()
|
||||
})
|
||||
|
||||
When(
|
||||
'I select {string} from {string}',
|
||||
async function (this: AppWorld, value: string, name: string) {
|
||||
console.log(`[select] resolving "${name}"`)
|
||||
const target = await field(this, name)
|
||||
const tag = await target
|
||||
.evaluate((el) => (el as HTMLElement).tagName.toLowerCase())
|
||||
.catch(() => '')
|
||||
console.log(`[select] resolved → ${tag}`)
|
||||
if (tag === 'select') {
|
||||
await target.selectOption({ label: value })
|
||||
return
|
||||
}
|
||||
console.log(`[select] clicking to open`)
|
||||
await target.click()
|
||||
console.log(`[select] clicking option "${value}"`)
|
||||
await this.page
|
||||
.getByRole('option', { name: value, exact: false })
|
||||
.first()
|
||||
.click()
|
||||
console.log(`[select] done`)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'in row {string} I do not see {string}',
|
||||
async function (this: AppWorld, rowText: string, text: string) {
|
||||
const row = this.page.getByRole('row', { name: new RegExp(rowText) })
|
||||
await expect(row.getByText(text, { exact: false }).first()).toBeHidden()
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'in row {string} I click {string}',
|
||||
async function (this: AppWorld, rowText: string, label: string) {
|
||||
const row = this.page.getByRole('row', { name: new RegExp(rowText) })
|
||||
const roles = ['button', 'link', 'menuitem'] as const
|
||||
const previousUrl = this.page.url()
|
||||
for (const role of roles) {
|
||||
const candidate = row.getByRole(role, { name: new RegExp(label, 'i') }).first()
|
||||
if (await isVisibleWithin(candidate)) {
|
||||
await activate(candidate)
|
||||
await settleAfterClick(this, previousUrl)
|
||||
return
|
||||
}
|
||||
}
|
||||
const byText = row.getByText(label, { exact: false }).first()
|
||||
if (await isVisibleWithin(byText)) {
|
||||
await activate(byText)
|
||||
await settleAfterClick(this, previousUrl)
|
||||
return
|
||||
}
|
||||
throw new Error(`Could not click "${label}" inside row "${rowText}"`)
|
||||
},
|
||||
)
|
||||
|
||||
When('I click {string}', async function (this: AppWorld, label: string) {
|
||||
// 1) data-testid first — feature files use this for testid'd row actions.
|
||||
const byId = this.page.getByTestId(label).first()
|
||||
if (await isVisibleWithin(byId)) {
|
||||
await clickAndConfirmIfNeeded(this, byId)
|
||||
return
|
||||
}
|
||||
// 2) Common interactive roles. Mantine renders tabs with role=tab,
|
||||
// anchors and buttons-as-links with role=link, etc.
|
||||
const fuzzyName = new RegExp(escapeRegex(label), 'i')
|
||||
const roles = ['button', 'tab', 'link', 'menuitem'] as const
|
||||
for (const role of roles) {
|
||||
const candidate = this.page.getByRole(role, { name: fuzzyName }).first()
|
||||
if (await isVisibleWithin(candidate)) {
|
||||
await clickAndConfirmIfNeeded(this, candidate)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 3) Plain text fallback for click-handler-on-div widgets.
|
||||
const byText = this.page.getByText(label, { exact: false }).first()
|
||||
if (await isVisibleWithin(byText)) {
|
||||
await clickAndConfirmIfNeeded(this, byText)
|
||||
return
|
||||
}
|
||||
throw new Error(`Could not click "${label}"`)
|
||||
})
|
||||
|
||||
When('I accept the next confirmation', async function (this: AppWorld) {
|
||||
this.page.once('dialog', (d) => void d.accept())
|
||||
})
|
||||
|
||||
When(
|
||||
'I answer the next prompt with {string}',
|
||||
async function (this: AppWorld, value: string) {
|
||||
await this.page.evaluate((v) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).prompt = () => v
|
||||
}, value)
|
||||
},
|
||||
)
|
||||
|
||||
Then('I see {string}', async function (this: AppWorld, text: string) {
|
||||
await this.expectText(text)
|
||||
})
|
||||
|
||||
Then(
|
||||
'the field {string} has value {string}',
|
||||
async function (this: AppWorld, name: string, value: string) {
|
||||
const target = await field(this, name)
|
||||
await expect(target).toHaveValue(value)
|
||||
},
|
||||
)
|
||||
|
||||
Then('I do not see {string}', async function (this: AppWorld, text: string) {
|
||||
await expect(
|
||||
this.page.getByText(text, { exact: false }).first(),
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
Then('the URL contains {string}', async function (this: AppWorld, fragment: string) {
|
||||
await this.page.waitForURL((url) => url.toString().includes(fragment))
|
||||
})
|
||||
|
||||
Then(
|
||||
'the URL does not contain {string}',
|
||||
async function (this: AppWorld, fragment: string) {
|
||||
await this.page.waitForURL((url) => !url.toString().includes(fragment))
|
||||
},
|
||||
)
|
||||
61
e2e/tests/steps/invoice.steps.ts
Normal file
61
e2e/tests/steps/invoice.steps.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { When } from '@cucumber/cucumber'
|
||||
import path from 'node:path'
|
||||
import type { AppWorld } from '../support/world.js'
|
||||
|
||||
/**
|
||||
* Project-specific invoice steps. Talk to the Invoices tab on the admin
|
||||
* booking-detail page (Mantine form). Locale-aware: the form uses German
|
||||
* labels by default in this app.
|
||||
*/
|
||||
|
||||
When(
|
||||
'I create an invoice numbered {string} for {float} €',
|
||||
async function (this: AppWorld, invoiceNumber: string, amountEuro: number) {
|
||||
// The form sits in the "Neue Rechnung" panel — use data-testid hooks so we
|
||||
// are not coupled to localized labels.
|
||||
const numberField = this.page
|
||||
.getByTestId('invoice-create-form')
|
||||
.locator('input[data-testid="invoice-number"], [data-testid="invoice-number"] input')
|
||||
.first()
|
||||
await numberField.fill(invoiceNumber)
|
||||
const amount = this.page
|
||||
.getByTestId('invoice-create-form')
|
||||
.locator('input[data-testid="invoice-amount"], [data-testid="invoice-amount"] input')
|
||||
.first()
|
||||
await amount.fill('')
|
||||
await amount.fill(String(amountEuro.toFixed(2)))
|
||||
await this.page
|
||||
.getByRole('button', { name: /rechnung erstellen|create invoice/i })
|
||||
.click()
|
||||
// Wait for the row to appear in the invoices table above the form.
|
||||
await this.page.getByText(invoiceNumber, { exact: false }).first().waitFor({
|
||||
state: 'visible',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'I attach invoice PDF {string}',
|
||||
async function (this: AppWorld, filename: string) {
|
||||
const filePath = path.resolve(process.cwd(), 'tests', 'fixtures', filename)
|
||||
await this.page.getByTestId('invoice-pdf').setInputFiles(filePath)
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'I mark invoice {string} as paid on {string}',
|
||||
async function (this: AppWorld, invoiceNumber: string, paidOn: string) {
|
||||
// Stub the prompt before clicking — the UI uses window.prompt to collect
|
||||
// the payment date.
|
||||
await this.page.evaluate((value) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).prompt = () => value
|
||||
}, paidOn)
|
||||
|
||||
const row = this.page.getByRole('row', { name: new RegExp(invoiceNumber) })
|
||||
await row
|
||||
.getByTestId(`invoice-mark-paid-${invoiceNumber}`)
|
||||
.click()
|
||||
await row.getByText(/bezahlt|paid/i).waitFor({ state: 'visible' })
|
||||
},
|
||||
)
|
||||
104
e2e/tests/steps/lifecycle.steps.ts
Normal file
104
e2e/tests/steps/lifecycle.steps.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { When, Then } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { AppWorld } from '../support/world.js'
|
||||
import { config } from '../support/types.js'
|
||||
|
||||
When('the lifecycle cron runs', async function (this: AppWorld) {
|
||||
await this.runLifecycle()
|
||||
})
|
||||
|
||||
Then(
|
||||
'the lifecycle cron sent {int} contracts',
|
||||
async function (this: AppWorld, count: number) {
|
||||
const result = await this.runLifecycle()
|
||||
expect(result.contractsSent).toBe(count)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the lifecycle cron sent {int} reminders',
|
||||
async function (this: AppWorld, count: number) {
|
||||
const result = await this.runLifecycle()
|
||||
expect(result.reminders).toBe(count)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the lifecycle cron completed {int} bookings',
|
||||
async function (this: AppWorld, count: number) {
|
||||
const result = await this.runLifecycle()
|
||||
expect(result.ended).toBe(count)
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'I search the bookings list for {string}',
|
||||
async function (this: AppWorld, text: string) {
|
||||
await this.page
|
||||
.getByPlaceholder(/suchen/i)
|
||||
.first()
|
||||
.fill(text)
|
||||
await this.page.waitForTimeout(400) // debounce
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'I trigger booking action {string}',
|
||||
async function (this: AppWorld, actionId: string) {
|
||||
const action = this.page.getByTestId(actionId).first()
|
||||
await action.waitFor({ state: 'visible', timeout: config.responseTimeout })
|
||||
await action.click({ timeout: config.responseTimeout })
|
||||
const confirm = this.page.getByTestId(`${actionId}-confirm`).first()
|
||||
await confirm.waitFor({ state: 'visible', timeout: config.responseTimeout })
|
||||
await confirm.click({ timeout: config.responseTimeout })
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
|
||||
await this.page.waitForTimeout(750)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the audit log tab includes {string}',
|
||||
async function (this: AppWorld, entry: string) {
|
||||
await this.page.getByRole('tab', { name: /protokoll|audit/i }).click()
|
||||
await expect(
|
||||
this.page.getByText(entry, { exact: false }).first(),
|
||||
).toBeVisible({ timeout: config.responseTimeout })
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'the admin approves the booking {string}',
|
||||
async function (this: AppWorld, bookingId: string) {
|
||||
await this.gotoApp(`/admin/bookings/${bookingId}`)
|
||||
await this.page.getByRole('button', { name: /reserv/i }).first().click()
|
||||
await this.page.waitForTimeout(500)
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'the admin records the deposit received for {string}',
|
||||
async function (this: AppWorld, bookingId: string) {
|
||||
await this.gotoApp(`/admin/bookings/${bookingId}`)
|
||||
await this.page.getByRole('button', { name: /anzahlung/i }).first().click()
|
||||
await this.page.waitForTimeout(500)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the booking {string} has status {string}',
|
||||
async function (this: AppWorld, bookingId: string, status: string) {
|
||||
await this.gotoApp(`/admin/bookings/${bookingId}`)
|
||||
await this.expectText(status)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the audit log for {string} includes {string}',
|
||||
async function (this: AppWorld, bookingId: string, entry: string) {
|
||||
await this.gotoApp(`/admin/bookings/${bookingId}`)
|
||||
await this.page.getByRole('tab', { name: /protokoll|audit/i }).click()
|
||||
await expect(
|
||||
this.page.getByText(entry, { exact: false }).first(),
|
||||
).toBeVisible({ timeout: config.responseTimeout })
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user