148 lines
5.0 KiB
TypeScript
148 lines
5.0 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
const page = this.getPage()
|
|
await page.getByRole('link', { name }).click()
|
|
// Small wait for navigation
|
|
await page.waitForLoadState('networkidle')
|
|
}
|
|
|
|
async clickButton(name: string): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
const page = this.getPage()
|
|
await expect(page.getByText(text, { exact: false }).first()).toBeVisible({ timeout: 10_000 })
|
|
}
|
|
|
|
async expectNoText(text: string): Promise<void> {
|
|
const page = this.getPage()
|
|
await expect(page.getByText(text, { exact: false })).not.toBeVisible({ timeout: 5_000 })
|
|
}
|
|
|
|
async expectHeading(text: string): Promise<void> {
|
|
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)
|