import { World, setWorldConstructor, type IWorldOptions } from '@cucumber/cucumber' import { chromium, type Browser, type BrowserContext, type Page } from 'playwright' import { expect } from '@playwright/test' import { browserConfig } from './browser-config.js' export class BrowserWorld extends World { browser: Browser | null = null context: BrowserContext | null = null page: Page | null = null constructor(options: IWorldOptions) { super(options) } async openBrowser(): Promise { this.browser = await chromium.launch({ headless: !browserConfig.headed, }) this.context = await this.browser.newContext({ viewport: { width: 1280, height: 720 }, }) this.page = await this.context.newPage() this.page.setDefaultTimeout(browserConfig.timeout) } async closeBrowser(): Promise { if (this.page) { await this.page.close().catch(() => {}) this.page = null } if (this.context) { await this.context.close().catch(() => {}) this.context = null } if (this.browser) { await this.browser.close().catch(() => {}) this.browser = null } } async login(email: string): Promise { const page = this.getPage() await page.goto(`${browserConfig.appUrl}/login`, { waitUntil: 'networkidle' }) // The login page pre-fills demo credentials; overwrite with the test user. const emailInput = page.locator('input[type="email"]') await emailInput.fill(email) const passwordInput = page.locator('input[type="password"]') await passwordInput.fill('test') await page.getByRole('button', { name: /sign in/i }).click() // Wait for redirect away from /login, then for the app shell heading. await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 60_000 }) await page.getByRole('heading', { name: 'Dashboard' }).first().waitFor({ state: 'visible', timeout: 30_000 }) } async navigateTo(path: string): Promise { const page = this.getPage() await page.goto(`${browserConfig.appUrl}${path}`, { waitUntil: 'networkidle', timeout: 30_000 }) // Wait for client components to hydrate await page.waitForTimeout(1000) } async clickLink(name: string): Promise { const page = this.getPage() await page.getByRole('link', { name }).click() // Small wait for navigation await page.waitForLoadState('networkidle') } async clickButton(name: string): Promise { const page = this.getPage() const btn = page.getByRole('button', { name, exact: true }) const count = await btn.count() if (count > 1) { // Multiple buttons — close any open dropdowns first, then click the last one (in drawer) await page.keyboard.press('Escape') await page.waitForTimeout(300) // Re-query after Escape (drawer might have closed) const btn2 = page.getByRole('button', { name, exact: true }) const count2 = await btn2.count() const target = count2 > 1 ? btn2.nth(count2 - 1) : btn2 await target.scrollIntoViewIfNeeded() await target.click({ timeout: 10_000 }) } else { await btn.scrollIntoViewIfNeeded() await btn.click({ timeout: 10_000 }) } await page.waitForTimeout(500) } async fillField(label: string, value: string): Promise { const page = this.getPage() const scope = (await page.getByRole('dialog').count()) > 0 ? page.getByRole('dialog') : page const control = page.locator('input:not([type="hidden"]), textarea') let input = scope.getByLabel(label, { exact: true }).and(control) if ((await input.count()) === 0) { input = scope.getByLabel(label).and(control) } const field = input.first() await field.clear() await field.fill(value) } async selectOption(label: string, value: string): Promise { const page = this.getPage() const scope = (await page.getByRole('dialog').count()) > 0 ? page.getByRole('dialog') : page const control = page.locator('input, [role="combobox"], select') let input = scope.getByLabel(label, { exact: true }).and(control) if ((await input.count()) === 0) { input = scope.getByLabel(label).and(control) } await input.first().click() await page.getByRole('option', { name: value, exact: true }).first().click() } async expectText(text: string): Promise { const page = this.getPage() await expect(page.getByText(text, { exact: false }).first()).toBeVisible({ timeout: 10_000 }) } async expectNoText(text: string): Promise { const page = this.getPage() await expect(page.getByText(text, { exact: false })).not.toBeVisible({ timeout: 5_000 }) } async expectHeading(text: string): Promise { const page = this.getPage() await expect(page.getByRole('heading', { name: text }).first()).toBeVisible({ timeout: 10_000 }) } getPage(): Page { if (!this.page) { throw new Error('Browser not open — did the Before hook run?') } return this.page } } setWorldConstructor(BrowserWorld)