DEAD_CODE_CLEAN #7
15 changed files with 1 additions and 667 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -132,12 +132,4 @@ dist
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
|
||||||
type-clusters/*
|
type-clusters/*
|
||||||
|
|
||||||
# ---> Playwright (root E2E suite)
|
|
||||||
/test-results/
|
|
||||||
/playwright-report/
|
|
||||||
/blob-report/
|
|
||||||
/playwright/.cache/
|
|
||||||
tests/.auth/
|
|
||||||
tests/.env.test
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "npm run build && wrangler dev",
|
"preview": "npm run build && wrangler dev",
|
||||||
"test": "vitest run",
|
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"deploy": "npm run build && wrangler deploy",
|
"deploy": "npm run build && wrangler deploy",
|
||||||
"cf-typegen": "wrangler types"
|
"cf-typegen": "wrangler types"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
|
||||||
"build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json",
|
"build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json",
|
||||||
"build2": "rimraf ./dist && tsc",
|
"build2": "rimraf ./dist && tsc",
|
||||||
"db:seed": "tsx src/db/seed.ts",
|
"db:seed": "tsx src/db/seed.ts",
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,6 @@ To build this application for production:
|
||||||
bun --bun run build
|
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
|
## Styling
|
||||||
|
|
||||||
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
|
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "npm run build && wrangler dev",
|
"preview": "npm run build && wrangler dev",
|
||||||
"test": "vitest run",
|
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"deploy": "npm run build && wrangler deploy",
|
"deploy": "npm run build && wrangler deploy",
|
||||||
"cf-typegen": "wrangler types"
|
"cf-typegen": "wrangler types"
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,6 @@
|
||||||
"dev": "turbo run dev --parallel",
|
"dev": "turbo run dev --parallel",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"typecheck": "bash typecheck",
|
"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": "bun run --filter web-ui",
|
||||||
"web-ui:dev": "bun run web-ui dev",
|
"web-ui:dev": "bun run web-ui dev",
|
||||||
"web-ui:build": "bun run web-ui build",
|
"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