295 lines
11 KiB
TypeScript
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()
|
|
}
|