import { expect, Locator, Page } from '@playwright/test'; /** One flight result card. */ export class FlightCard { constructor(readonly root: Locator) {} get price(): Locator { return this.root.locator('.price'); } get seatsBadge(): Locator { return this.root.locator('.fare-tags .badge').last(); } get selectButton(): Locator { return this.root.getByRole('button', { name: 'Select' }); } async fare(): Promise { const text = (await this.price.innerText()).replace(/[^0-9.]/g, ''); return Number(text); } async seatsLeft(): Promise { const text = await this.seatsBadge.innerText(); return Number(text.replace(/[^0-9]/g, '')); } async flightNumber(): Promise { return (await this.root.locator('.carrier-meta').innerText()).split('·')[0].trim(); } async durationMinutes(): Promise { const text = await this.root.locator('.leg-duration').innerText(); const match = /(?:(\d+)h\s*)?(\d+)m/.exec(text); if (!match) { throw new Error(`unparseable duration: ${text}`); } return Number(match[1] ?? 0) * 60 + Number(match[2]); } } /** The flight search screen. */ export class SearchPage { readonly origin: Locator; readonly destination: Locator; readonly date: Locator; readonly cabin: Locator; readonly travellers: Locator; readonly searchButton: Locator; readonly resetButton: Locator; readonly sort: Locator; readonly resultSummary: Locator; readonly results: Locator; readonly emptyState: Locator; readonly nextPage: Locator; readonly prevPage: Locator; constructor(private readonly page: Page) { this.origin = page.locator('#origin'); this.destination = page.locator('#destination'); this.date = page.locator('#date'); this.cabin = page.locator('#cabin'); this.travellers = page.locator('#passengers'); this.searchButton = page.getByRole('button', { name: 'Search', exact: true }); this.resetButton = page.getByRole('button', { name: 'Reset' }); this.sort = page.locator('.sort select'); this.resultSummary = page.locator('.results-bar span').first(); this.results = page.locator('.flight'); this.emptyState = page.locator('.empty'); this.nextPage = page.getByRole('button', { name: 'Next →' }); this.prevPage = page.getByRole('button', { name: '← Previous' }); } async goto(): Promise { await this.page.goto('/search'); await this.settled(); } card(index = 0): FlightCard { return new FlightCard(this.results.nth(index)); } /** Waits for the result list to stop showing skeletons. */ async settled(): Promise { await expect(this.page.locator('.skeleton-card')).toHaveCount(0); await expect(this.resultSummary).not.toHaveText(/Searching/); } async searchRoute(from: string, to: string): Promise { await this.origin.selectOption(from); await this.destination.selectOption(to); await this.searchButton.click(); await this.settled(); } async setTravellers(n: number): Promise { await this.travellers.selectOption(String(n)); await this.searchButton.click(); await this.settled(); } async sortBy(value: 'price' | 'duration' | 'departure'): Promise { await this.sort.selectOption(value); await this.settled(); } /** Reads the "Showing 1-10 of 42 flights" line. */ async totalResults(): Promise { const text = await this.resultSummary.innerText(); const match = /of (\d+) flights/.exec(text); return match ? Number(match[1]) : 0; } /** Finds the card for a given flight number on the current page. */ cardForFlight(flightNumber: string): FlightCard { return new FlightCard(this.results.filter({ hasText: flightNumber }).first()); } }