import { expect, type Locator, type Page } from '@playwright/test' import { loadTestEnv } from './env' // Populate process.env from tests/.env.test as soon as this module loads, so // specs can branch on credentials at module scope. loadTestEnv() export const STAFF_NAME = process.env.TEST_STAFF_NAME ?? '' export const STAFF_PASSWORD = process.env.TEST_STAFF_PASSWORD ?? '' export const hasStaffCredentials = (): boolean => Boolean(process.env.TEST_STAFF_NAME && process.env.TEST_STAFF_PASSWORD) export const CREDENTIALS_HINT = 'Set TEST_STAFF_NAME and TEST_STAFF_PASSWORD in tests/.env.test (see tests/.env.test.example)' /** Collected `window.alert`/`confirm` messages, auto-accepted as they appear. */ export type DialogLog = string[] /** * Auto-accept every dialog (the admin forms confirm success via window.alert) * and record the messages so tests can assert on them. */ export function trackDialogs(page: Page): DialogLog { const messages: DialogLog = [] page.on('dialog', async (dialog) => { messages.push(dialog.message()) await dialog.accept() }) return messages } /** Wait until a recorded dialog message contains `substring`. */ export async function expectDialog(messages: DialogLog, substring: string): Promise { await expect .poll(() => messages.some((message) => message.includes(substring)), { message: `Expected a dialog containing "${substring}". Recorded: ${JSON.stringify(messages)}`, }) .toBe(true) } // --------------------------------------------------------------------------- // Hydration-safe interactions // --------------------------------------------------------------------------- // // These pages are SSR'd and React-controlled. Interacting before hydration // completes silently fails: a `fill()` sets the DOM value and its assertion // passes (the DOM really does hold the value), but React — not yet attached — // never records it, and hydration then resets the controlled input to its // empty state. So we must WAIT FOR HYDRATION, not just for the value. // // React attaches `__reactFiber$…` / `__reactProps$…` markers to elements when it // adopts them during hydration — the reliable, framework-level signal. export async function waitForHydration(page: Page, selector: string): Promise { await page.waitForFunction( (sel) => { const el = document.querySelector(sel) if (!el) return false return Object.keys(el).some( (key) => key.startsWith('__reactFiber$') || key.startsWith('__reactProps$'), ) }, selector, { timeout: 20_000 }, ) } /** Wait for the app container to be hydrated (for pages with no stable test id yet). */ export async function waitForAppHydration(page: Page): Promise { await page.waitForFunction( () => { const el = document.querySelector('#app') || document.body return Object.keys(el).some( (key) => key.startsWith('__reactContainer$') || key.startsWith('__reactFiber$'), ) }, undefined, { timeout: 20_000 }, ) } export async function fillStable(locator: Locator, value: string): Promise { await expect(async () => { await locator.fill(value) await expect(locator).toHaveValue(value) }).toPass({ timeout: 15_000 }) } export async function selectStable(locator: Locator, value: string): Promise { await expect(async () => { await locator.selectOption(value) await expect(locator).toHaveValue(value) }).toPass({ timeout: 15_000 }) } /** * Click a control that should navigate, retrying until the URL matches. * Guards against clicks that land before the element's handler is hydrated * (which are silently swallowed rather than erroring). */ export async function clickUntilUrl(page: Page, locator: Locator, url: RegExp): Promise { await expect(async () => { await locator.click() await page.waitForURL(url, { timeout: 3_000 }) }).toPass({ timeout: 20_000 }) } /** * Like {@link clickUntilUrl} but dispatches the click straight at the element. * Needed for fixed-position controls the TanStack devtools overlay sits on top * of (e.g. the bottom-right slots FAB) — a normal click would be intercepted. */ export async function dispatchClickUntilUrl(page: Page, locator: Locator, url: RegExp): Promise { await expect(async () => { await locator.dispatchEvent('click') await page.waitForURL(url, { timeout: 3_000 }) }).toPass({ timeout: 20_000 }) } // --------------------------------------------------------------------------- // Auth // --------------------------------------------------------------------------- export async function login(page: Page, name: string, password: string): Promise { await page.goto('/login') await waitForHydration(page, '[data-testid="login-button"]') await fillStable(page.getByTestId('login-name-input'), name) await fillStable(page.getByTestId('login-password-input'), password) await page.getByTestId('login-button').click() await expect(page).toHaveURL(/\/dashboard/) } // --------------------------------------------------------------------------- // Products // --------------------------------------------------------------------------- export interface NewProductInput { name: string price: number marketPrice?: number quantity: string } export async function openAddProduct(page: Page): Promise { await page.goto('/dashboard/products') await waitForAppHydration(page) await clickUntilUrl( page, page.getByRole('button', { name: 'Add Product' }), /\/dashboard\/products\/new/, ) await waitForHydration(page, '[data-testid="product-submit-button"]') await expect(page.getByPlaceholder('Enter product name')).toBeVisible() } export async function fillNewProduct( page: Page, { name, price, marketPrice, quantity }: NewProductInput, ): Promise { await fillStable(page.getByPlaceholder('Enter product name'), name) // Store is a native