import { World, setWorldConstructor } from '@cucumber/cucumber' import { chromium, type Browser, type BrowserContext, type Page, } from '@playwright/test' import { config } from './types.js' /** * AppWorld — generic Playwright world for portal-style apps. * * Exposes the primitives every feature needs: * - openBrowser / closeBrowser — lifecycle * - gotoApp(path) — navigate within the frontend * - login(email, password) — fill + submit /login, wait for redirect * - logout() — visit /logout * - expectText(text) — assert visible text * * Project-specific helpers (e.g. createBooking, assignRoom) belong in * step files, not here. Keep this generic. */ export class AppWorld extends World { browser!: Browser context!: BrowserContext page!: Page async openBrowser() { const headed = process.env.HEADED === '1' || process.env.HEADED === 'true' this.browser = await chromium.launch({ headless: !headed, slowMo: headed ? 150 : 0, }) this.context = await this.browser.newContext({ locale: process.env.E2E_LOCALE ?? 'de-DE', }) await this.context.addInitScript((apiUrl) => { ;(window as typeof window & { __E2E_API_URL?: string }).__E2E_API_URL = apiUrl }, config.apiUrl) this.page = await this.context.newPage() this.page.setDefaultTimeout(config.responseTimeout) } async closeBrowser() { await this.context?.close() await this.browser?.close() } async gotoApp(path: string) { const url = path.startsWith('http') ? path : `${config.appUrl}${path.startsWith('/') ? path : `/${path}`}` await this.page.goto(url) await this.page.waitForFunction( () => document.documentElement.dataset.appHydrated === 'true', undefined, { timeout: config.responseTimeout }, ) await this.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {}) await this.page.waitForTimeout(500) } /** * Fills the login form on /login and waits for navigation away from it. * Assumes the login form has inputs named/typed `email` and a password * input. Override per-project if the form differs. */ async login(email: string, password: string) { await this.gotoApp('/login') await this.page.locator('input[type="email"]').fill(email) await this.page.locator('input[type="password"]').fill(password) await this.page.getByRole('button', { name: /(sign in|login|anmelden)/i }).click() await this.page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: config.responseTimeout, }) } async logout() { await this.gotoApp('/logout') } // Calls an RPC through the browser page so session cookies are included. async callRpc(name: string, data: Record = {}) { const apiUrl = config.apiUrl const result = await this.page.evaluate( async ({ apiUrl, name, data }) => { const res = await fetch(`${apiUrl}/rpc/${name}`, { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'include', body: JSON.stringify({ data }), }) if (!res.ok) { const body = await res.text().catch(() => '') throw new Error(`RPC ${name} failed (${res.status}): ${body}`) } return res.json() }, { apiUrl, name, data }, ) return result } async runLifecycle() { return this.callRpc('runBookingLifecycleNow', {}) } async expectText(text: string) { const locator = this.page.getByText(text, { exact: false }) const deadline = Date.now() + config.responseTimeout while (Date.now() < deadline) { const count = await locator.count() for (let i = 0; i < count; i++) { if (await locator.nth(i).isVisible().catch(() => false)) { return } } await this.page.waitForTimeout(100) } throw new Error(`Timed out waiting for visible text: ${text}`) } async getPageText(): Promise { return this.page.innerText('body') } } setWorldConstructor(AppWorld)