enh
This commit is contained in:
parent
5591afb57f
commit
c4a33b91da
15 changed files with 1 additions and 667 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -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/*
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -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=
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 })
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue