import { World, setWorldConstructor } from '@cucumber/cucumber' import { chromium, type Browser, type BrowserContext, type Page, } from '@playwright/test' import { config } from './types.js' type ActorSession = { context: BrowserContext page: Page } export class AppWorld extends World { browser!: Browser private actorSessions = new Map() private currentActor = 'default' candidateName?: string candidateEmail?: string candidateId?: string async openBrowser() { const headed = process.env.HEADED === '1' || process.env.HEADED === 'true' this.browser = await chromium.launch({ headless: !headed, slowMo: headed ? 150 : 0, }) await this.useActor('default') } async closeBrowser() { for (const { context } of this.actorSessions.values()) { await context.close().catch(() => {}) } this.actorSessions.clear() await this.browser?.close() } get context() { return this.getActorSession(this.currentActor).context } get page() { return this.getActorSession(this.currentActor).page } get activeActor() { return this.currentActor } async useActor(name: string) { if (!this.actorSessions.has(name)) { const context = await this.browser.newContext({ locale: process.env.E2E_LOCALE ?? 'en-US', }) const page = await context.newPage() page.setDefaultTimeout(config.responseTimeout) this.actorSessions.set(name, { context, page }) } this.currentActor = name } setActorPage(name: string, page: Page) { const session = this.getActorSession(name) page.setDefaultTimeout(config.responseTimeout) this.actorSessions.set(name, { ...session, page }) } private getActorSession(name: string) { const session = this.actorSessions.get(name) if (!session) { throw new Error(`Actor session "${name}" has not been initialized`) } return session } async gotoApp(path: string) { const url = path.startsWith('http') ? path : `${config.appUrl}${path.startsWith('/') ? path : `/${path}`}` await this.page.goto(url, { waitUntil: 'domcontentloaded' }) await this.page.waitForLoadState('networkidle', { timeout: 2_500 }).catch(() => {}) await this.page.waitForTimeout(400) } async login(email: string, password: string) { const startingUrl = this.page.url() await this.page.locator('input[name="email"]').fill(email) await this.page.locator('input[name="password"]').fill(password) await this.page.getByRole('button', { name: /^login$/i }).click({ noWaitAfter: true, }) await Promise.race([ this.page.waitForURL( (url) => url.toString() !== startingUrl && !url.toString().includes('/auth/login'), { timeout: 5_000 }, ), this.page .getByText(/invalid email or password/i, { exact: false }) .first() .waitFor({ state: 'visible', timeout: 5_000 }), ]).catch(() => {}) await this.page.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => {}) await this.page.waitForTimeout(400) const invalidCredentials = await this.page .getByText(/invalid email or password/i, { exact: false }) .first() .isVisible() .catch(() => false) if (invalidCredentials) { throw new Error(`Login failed for ${email}: invalid email or password`) } } async expectText(text: string) { const locator = this.page.getByText(text, { exact: false }).first() try { await locator.waitFor({ state: 'visible', timeout: config.responseTimeout }) } catch (error) { const bodyText = (await this.page.locator('body').innerText().catch(() => '')) .replace(/\s+/g, ' ') .slice(0, 1000) throw new Error( `Expected to see "${text}" but it was not visible. Body excerpt: ${bodyText}`, { cause: error }, ) } } } setWorldConstructor(AppWorld)