freshyo/tests/helpers/admin-app.ts
2026-09-12 09:06:33 +05:30

295 lines
11 KiB
TypeScript

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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await fillStable(page.getByPlaceholder('Enter product name'), name)
// Store is a native <select> populated from an async query; option 0 is the
// disabled placeholder. Wait for the real options before choosing.
const storeSelect = page.getByTestId('product-store-select').locator('select')
await expect
.poll(async () => storeSelect.locator('option').count(), { timeout: 15_000 })
.toBeGreaterThan(1)
const firstStoreValue = await storeSelect.locator('option').nth(1).getAttribute('value')
await selectStable(storeSelect, firstStoreValue ?? '1')
// The mandatory `quantity` feature is auto-added as the first variant's first attribute.
await fillStable(page.getByPlaceholder('e.g. 0.5 kg').first(), quantity)
await fillStable(page.getByPlaceholder('Selling price').first(), String(price))
if (marketPrice != null) {
await fillStable(page.getByPlaceholder('MRP').first(), String(marketPrice))
}
}
export async function submitProductForm(page: Page): Promise<void> {
// The submit runs validation then calls the mutation; retry until the page
// actually leaves the form (mutation succeeded) — guards hydration/timing.
await page.getByTestId('product-submit-button').click()
}
export async function gotoProductsAndSearch(page: Page, name: string): Promise<void> {
await page.goto('/dashboard/products')
await waitForAppHydration(page)
await expect(page.getByRole('button', { name: 'Add Product' })).toBeVisible()
// Controlled SearchBar — filtering happens immediately on input.
await fillStable(page.getByPlaceholder('Search products...'), name)
}
export async function openProductEdit(page: Page, name: string): Promise<void> {
await gotoProductsAndSearch(page, name)
await clickUntilUrl(
page,
page.getByRole('button', { name: 'Edit' }).first(),
/\/dashboard\/products\/edit/,
)
await waitForHydration(page, '[data-testid="product-submit-button"]')
await expect(page.getByPlaceholder('Enter product name')).toBeVisible()
}
export async function setProductName(page: Page, name: string): Promise<void> {
await fillStable(page.getByPlaceholder('Enter product name'), name)
}
export async function setProductPrice(page: Page, price: number): Promise<void> {
await fillStable(page.getByPlaceholder('Selling price').first(), String(price))
}
export async function toggleSuspend(page: Page): Promise<void> {
await page.getByTestId('variant-suspend-row-0').locator('button').first().click()
}
export async function isSuspendChecked(page: Page): Promise<boolean> {
return page.getByTestId('variant-suspend-row-0').locator('svg').isVisible()
}
// ---------------------------------------------------------------------------
// Slots
// ---------------------------------------------------------------------------
export interface SlotDateTime {
date: string // YYYY-MM-DD
time: string // HH:MM
}
/** Build future delivery/freeze datetimes (freeze before delivery, as the form requires). */
export function futureSlotTimes(): { delivery: SlotDateTime; freeze: SlotDateTime } {
const formatDate = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const formatTime = (d: Date) =>
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
const now = Date.now()
const delivery = new Date(now + 2 * 24 * 60 * 60 * 1000)
delivery.setHours(18, 0, 0, 0)
const freeze = new Date(now + 1 * 24 * 60 * 60 * 1000)
freeze.setHours(12, 0, 0, 0)
return {
delivery: { date: formatDate(delivery), time: formatTime(delivery) },
freeze: { date: formatDate(freeze), time: formatTime(freeze) },
}
}
export async function openAddSlot(page: Page): Promise<void> {
await page.goto('/dashboard/slots')
await waitForAppHydration(page)
// The FAB is bottom-right, under the always-mounted devtools overlay.
await dispatchClickUntilUrl(
page,
page.getByTestId('add-slot-fab'),
/\/dashboard\/slots\/new/,
)
await waitForHydration(page, '[data-testid="create-slot-button"]')
await expect(page.getByTestId('slot-delivery-datetime')).toBeVisible()
}
export async function fillSlotDatetimes(
page: Page,
delivery: SlotDateTime,
freeze: SlotDateTime,
): Promise<void> {
const deliveryBox = page.getByTestId('slot-delivery-datetime')
await fillStable(deliveryBox.locator('input[type="date"]'), delivery.date)
await fillStable(deliveryBox.locator('input[type="time"]'), delivery.time)
const freezeBox = page.getByTestId('slot-freeze-datetime')
await fillStable(freezeBox.locator('input[type="date"]'), freeze.date)
await fillStable(freezeBox.locator('input[type="time"]'), freeze.time)
}
export async function addProductToSlot(page: Page, productName: string): Promise<void> {
await page.getByTestId('slot-products-selector').locator('button').first().click()
const dialogSearch = page.getByPlaceholder('Search...')
await expect(dialogSearch).toBeVisible()
await fillStable(dialogSearch, productName)
await page
.getByTestId('multiselect-option')
.filter({ hasText: productName })
.first()
.click()
await page.getByRole('button', { name: 'Done' }).click()
}
export async function submitSlotForm(page: Page): Promise<void> {
await page.getByTestId('create-slot-button').click()
}