This commit is contained in:
shafi54 2026-09-13 00:29:26 +05:30
parent 5591afb57f
commit c4a33b91da
15 changed files with 1 additions and 667 deletions

10
.gitignore vendored
View file

@ -132,12 +132,4 @@ dist
.pnp.*
type-clusters/*
# ---> Playwright (root E2E suite)
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
tests/.auth/
tests/.env.test
type-clusters/*

View file

@ -6,7 +6,6 @@
"dev": "vite dev",
"build": "vite build",
"preview": "npm run build && wrangler dev",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"deploy": "npm run build && wrangler deploy",
"cf-typegen": "wrangler types"

View file

@ -3,7 +3,6 @@
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json",
"build2": "rimraf ./dist && tsc",
"db:seed": "tsx src/db/seed.ts",

View file

@ -17,14 +17,6 @@ To build this application for production:
bun --bun run build
```
## Testing
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
```bash
bun --bun run test
```
## Styling
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.

View file

@ -6,7 +6,6 @@
"dev": "vite dev",
"build": "vite build",
"preview": "npm run build && wrangler dev",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"deploy": "npm run build && wrangler deploy",
"cf-typegen": "wrangler types"

View file

@ -8,10 +8,6 @@
"dev": "turbo run dev --parallel",
"lint": "turbo run lint",
"typecheck": "bash typecheck",
"test": "turbo run test",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",
"web-ui": "bun run --filter web-ui",
"web-ui:dev": "bun run web-ui dev",
"web-ui:build": "bun run web-ui build",

View file

@ -1,44 +0,0 @@
import { defineConfig, devices } from '@playwright/test'
import { loadTestEnv } from './tests/helpers/env'
loadTestEnv()
const baseURL = process.env.TEST_BASE_URL || 'http://localhost:4175'
const authStatePath = 'tests/.auth/staff.json'
export default defineConfig({
testDir: './tests',
// Long, dependent workflows run sequentially in a single worker.
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
// Generous budget for multi-step admin workflows.
timeout: 120_000,
expect: { timeout: 15_000 },
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL,
// Show the browser by default so runs are watchable; CI stays headless.
// Override with `HEADED=0` to force headless, or `HEADED=1` to force headed.
headless: process.env.HEADED === '1' ? false : process.env.HEADED === '0' ? true : !!process.env.CI,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 20_000,
navigationTimeout: 45_000,
},
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
use: { ...devices['Desktop Chrome'] },
},
{
name: 'chromium',
testMatch: /.*\.spec\.ts/,
dependencies: ['setup'],
use: { ...devices['Desktop Chrome'], storageState: authStatePath },
},
],
})

View file

@ -1,11 +0,0 @@
# Copy this file to `tests/.env.test` and fill in the values.
# `tests/.env.test` is gitignored.
# Base URL of the running admin-web instance.
# Defaults to http://localhost:4175 (admin-web's Vite port) when unset.
TEST_BASE_URL=http://localhost:4175
# Staff credentials used to log into the admin panel.
# The suite skips (with a clear message) when either is empty.
TEST_STAFF_NAME=
TEST_STAFF_PASSWORD=

View file

@ -1,94 +0,0 @@
# admin-web E2E tests (Playwright)
End-to-end tests that drive the admin panel (`apps/admin-web`) through real
browser workflows. Long, multi-step tasks run sequentially in a **single
browser instance** (one worker, one page), mirroring how an admin works.
## Setup
1. Install Playwright + the browser (one time, from the repo root):
```bash
npm i -D @playwright/test
npx playwright install chromium
```
2. Start the admin panel and note its URL (defaults to `http://localhost:4175`):
```bash
npm run admin-web:dev
```
3. Configure credentials — copy the template and fill it in:
```bash
cp tests/.env.test.example tests/.env.test
# then set TEST_STAFF_NAME / TEST_STAFF_PASSWORD (and TEST_BASE_URL if needed)
```
`tests/.env.test` is gitignored. When credentials are missing the whole
suite **skips** with a clear message instead of failing.
## Running
Run from the **repo root** (recommended — picks up `playwright.config.ts`):
```bash
bun run test:e2e # all specs (browser visible locally)
bun run test:e2e:headed # explicit headed run
bun run test:e2e:ui # Playwright UI mode (debug/inspect, time-travel)
bun run test:e2e tests/specs/product-lifecycle.spec.ts # one workflow
```
Run from inside `tests/` — you must point at the root config, otherwise
Playwright finds no config and skips the login setup:
```bash
cd tests
bun run --cwd .. test:e2e # easiest
bunx playwright test -c ../playwright.config.ts
bunx playwright test -c ../playwright.config.ts --headed
bunx playwright show-report ../playwright-report
```
> ⚠️ Don't run a bare `bunx playwright test` from `tests/` — it runs without the
> config (no auth setup, no `baseURL`). Always pass `-c ../playwright.config.ts`.
The browser is **visible by default** locally (headed). It only runs headless in
CI, or when you opt in explicitly:
```bash
HEADED=0 bun run test:e2e # force headless locally
HEADED=1 bun run test:e2e # force headed
bunx playwright test -c ../playwright.config.ts --headed --slow-mo=500 # watch slowly
```
Point the tests at a different UI URL (e.g. a hosted environment):
```bash
TEST_BASE_URL=https://admin.example.com bun run test:e2e
```
## How it works
- `auth.setup.ts` logs in once (`data-testid` login fields) and saves the
session to `tests/.auth/staff.json`. The `chromium` project reuses that
`storageState`, so specs start already authenticated.
- `tests/helpers/admin-app.ts` is a small page object with the reusable
actions (add/update product, create slot, suspend SKU, dialog handling).
- Specs live in `tests/specs/`. `product-lifecycle.spec.ts` is the long
single-instance workflow: **create product → update it → create a slot for it
→ suspend it**, plus a persistence re-check.
## ⚠️ Warning
These tests **create real catalog records** (products, slots) on whatever
backend the running admin-web instance points at. Use a non-production backend
when running them.
## Adding more workflows
Add a sibling spec under `tests/specs/` (matching `*.spec.ts`) and reuse the
helpers in `tests/helpers/admin-app.ts`. Prefer `data-testid` selectors for new
UI; the admin-web components expose test ids where placeholder/role locators
would be brittle.

View file

@ -1,20 +0,0 @@
import { test as setup, expect } from '@playwright/test'
import { CREDENTIALS_HINT, STAFF_NAME, STAFF_PASSWORD, hasStaffCredentials, login } from './helpers/admin-app'
const AUTH_STATE_PATH = 'tests/.auth/staff.json'
/**
* Logs into the admin panel once and persists the session (JWT lives in
* localStorage) so every spec runs already authenticated.
*/
setup('authenticate staff user', async ({ page }) => {
if (!hasStaffCredentials()) {
setup.skip(true, CREDENTIALS_HINT)
}
await login(page, STAFF_NAME, STAFF_PASSWORD)
// Sidebar entry (exact) — the dashboard body also has "Products …" tiles.
await expect(page.getByRole('button', { name: 'Products', exact: true })).toBeVisible()
await page.context().storageState({ path: AUTH_STATE_PATH })
})

View file

@ -1,295 +0,0 @@
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()
}

View file

@ -1,43 +0,0 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
/**
* Minimal dependency-free `.env` loader for the E2E suite.
*
* Reads `tests/.env.test` (or `./.env.test`) if present and populates
* `process.env` WITHOUT overriding values already provided by the real
* environment so `TEST_BASE_URL=... npx playwright test` still wins.
*/
export function loadTestEnv(): void {
const candidates = [
resolve(process.cwd(), 'tests/.env.test'),
resolve(process.cwd(), '.env.test'),
]
const file = candidates.find((candidate) => existsSync(candidate))
if (!file) return
const content = readFileSync(file, 'utf8')
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const separatorIndex = line.indexOf('=')
if (separatorIndex === -1) continue
const key = line.slice(0, separatorIndex).trim()
if (!key) continue
let value = line.slice(separatorIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
if (process.env[key] === undefined) {
process.env[key] = value
}
}
}

View file

@ -1,98 +0,0 @@
import { test, expect } from '@playwright/test'
import {
CREDENTIALS_HINT,
hasStaffCredentials,
trackDialogs,
expectDialog,
openAddProduct,
fillNewProduct,
submitProductForm,
gotoProductsAndSearch,
openProductEdit,
setProductName,
setProductPrice,
toggleSuspend,
isSuspendChecked,
openAddSlot,
fillSlotDatetimes,
addProductToSlot,
submitSlotForm,
futureSlotTimes,
waitForAppHydration,
} from '../helpers/admin-app'
test.skip(!hasStaffCredentials(), CREDENTIALS_HINT)
/**
* Long, single-instance workflow: add a product, update it, create a delivery
* slot for it, then suspend it all in one browser page/context, mirroring how
* an admin actually works through the panel.
*
* Runs as one test with named steps so the whole chain shares page state.
*/
test('product lifecycle: create → update → slot → suspend', async ({ page }) => {
const productName = `E2E Product ${Date.now()}`
const updatedName = `${productName} Updated`
const dialogs = trackDialogs(page)
await test.step('dashboard is available (authenticated session)', async () => {
await page.goto('/dashboard')
await waitForAppHydration(page)
await expect(page.getByRole('button', { name: 'Products', exact: true })).toBeVisible()
})
await test.step('add a new product', async () => {
await openAddProduct(page)
await fillNewProduct(page, {
name: productName,
price: 250,
marketPrice: 300,
quantity: '1 kg',
})
await submitProductForm(page)
await expectDialog(dialogs, 'Product created successfully!')
})
await test.step('the product appears in the products list', async () => {
await gotoProductsAndSearch(page, productName)
await expect(page.getByText(productName).first()).toBeVisible()
})
await test.step('update the product name and price', async () => {
await openProductEdit(page, productName)
await setProductName(page, updatedName)
await setProductPrice(page, 275)
await submitProductForm(page)
await expectDialog(dialogs, 'Product updated successfully!')
})
await test.step('create a delivery slot for the product', async () => {
const { delivery, freeze } = futureSlotTimes()
await openAddSlot(page)
await fillSlotDatetimes(page, delivery, freeze)
await addProductToSlot(page, updatedName)
await submitSlotForm(page)
await expectDialog(dialogs, 'Slot created successfully!')
})
await test.step('the slot card lists the product', async () => {
// submitSlotForm navigates back to /dashboard/slots
await expect(page).toHaveURL(/\/dashboard\/slots/)
await expect(page.getByText(updatedName).first()).toBeVisible()
})
await test.step('suspend the product', async () => {
await openProductEdit(page, updatedName)
expect(await isSuspendChecked(page)).toBe(false)
await toggleSuspend(page)
// The toggle must take effect in the form before we save.
await expect.poll(() => isSuspendChecked(page), { timeout: 5_000 }).toBe(true)
await submitProductForm(page)
await expectDialog(dialogs, 'Product updated successfully!')
})
await test.step('suspension persists across reloads', async () => {
await openProductEdit(page, updatedName)
expect(await isSuspendChecked(page)).toBe(true)
})
})

View file

@ -1,23 +0,0 @@
import { test, expect } from '@playwright/test'
import { CREDENTIALS_HINT, hasStaffCredentials, waitForAppHydration } from '../helpers/admin-app'
// The whole suite requires a staff session; skip cleanly when creds are absent.
test.skip(!hasStaffCredentials(), CREDENTIALS_HINT)
test.describe('admin-web smoke', () => {
test('dashboard shell renders with sidebar navigation', async ({ page }) => {
await page.goto('/dashboard')
await waitForAppHydration(page)
await expect(page.getByRole('button', { name: 'Dashboard', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'Products', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'Slots', exact: true })).toBeVisible()
})
test('products list renders with the add-product action', async ({ page }) => {
await page.goto('/dashboard/products')
await waitForAppHydration(page)
await expect(page.getByRole('button', { name: 'Add Product' })).toBeVisible()
})
})

View file

@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["./**/*.ts", "../playwright.config.ts"]
}