From bf6058fcdd1a1bb643db8c2f3304ed778b4bad7c Mon Sep 17 00:00:00 2001 From: Hardik Date: Sat, 16 May 2026 02:43:29 +0530 Subject: [PATCH] docs: add Playwright test design doc with scripts for inventory sort and tag fixes --- PLAYRIGHT_TEST_DESIGN.md | 341 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 PLAYRIGHT_TEST_DESIGN.md diff --git a/PLAYRIGHT_TEST_DESIGN.md b/PLAYRIGHT_TEST_DESIGN.md new file mode 100644 index 0000000..caff582 --- /dev/null +++ b/PLAYRIGHT_TEST_DESIGN.md @@ -0,0 +1,341 @@ +# Playwright Test Design — Pelagia Portal + +This document describes how to save, structure, and extend the Playwright verification +scripts written during development sessions. Every script here was used to confirm a +bug fix before committing; they should be promoted to a permanent test suite. + +--- + +## Setup + +Playwright is currently installed in `GstService/` (a sibling service). For the Portal's +own test suite, install it once: + +```bash +cd App/pelagia-portal +pnpm add -D playwright @playwright/test +npx playwright install chromium +``` + +Then place tests in `App/pelagia-portal/tests/e2e/` and run with: + +```bash +npx playwright test # headless +npx playwright test --headed # headed (watch the browser) +npx playwright test --ui # interactive Playwright UI +``` + +--- + +## Design Principles + +### 1. Log every step with a symbol prefix +Use `✓` for passing assertions, `✗` for failures, and plain text for context. +This makes CI output scannable without opening a full trace. + +```js +console.log('✓ Logged in'); +console.log('✓ Expanded item with', vendorCount, 'vendors'); +console.log('✗ Could not find item with multiple vendors'); +``` + +### 2. Wait for URLs, not just network idle +Client-side `router.push` navigations finish asynchronously. Always pair a +`selectOption` / `click` that triggers navigation with `page.waitForURL(...)`: + +```js +const nav = page.waitForURL('**/inventory/items?siteId=**', { timeout: 8000 }); +await page.locator('select').first().selectOption({ index: 1 }); +await nav; +await page.waitForLoadState('networkidle'); +``` + +### 3. Account for preserved React state across soft navigation +Next.js App Router soft-navigates between pages that share a layout. Client component +state (`useState`) is **preserved** — a row that was expanded before `router.push` stays +expanded after. Tests must model this or they will double-click a row and accidentally +close it. + +```js +// Expand BEFORE selecting a site — row stays open through navigation +await page.locator('tbody tr').first().click(); +await page.waitForTimeout(300); +// select site → navigate → row is still expanded, no second click needed +``` + +### 4. Find items with enough data to test +Not every item has multiple vendors or a known distance. Loop over rows until one +with sufficient vendors is found rather than assuming the first row is suitable: + +```js +for (let i = 0; i < Math.min(rowCount, 10); i++) { + await rows.nth(i).click(); + await page.waitForTimeout(400); + const vendorCount = await page.locator('table table tbody tr').count(); + if (vendorCount > 1) { expanded = true; break; } + await rows.nth(i).click(); // close and try next +} +``` + +### 5. Exit with a non-zero code on failure +Scripts run in CI; call `process.exit(1)` so a failed check surfaces as a build error. + +```js +if (!allGood) process.exit(1); +``` + +--- + +## Test Scripts + +### AUTH — helpers used by every test + +```js +// tests/e2e/helpers/auth.js +async function login(page, email = 'tech@pelagia.local', password = 'tech1234') { + await page.goto('http://localhost:3000/login'); + await page.fill('#email', email); + await page.fill('#password', password); + await page.click('button[type=submit]'); + await page.waitForURL('**/dashboard', { timeout: 8000 }); + console.log(`✓ Logged in as ${email}`); +} +module.exports = { login }; +``` + +--- + +### TEST 1 — Auto-sort by distance when site is selected or changed + +**Bug:** Sorting did not automatically switch to "Distance" when a site was selected +from the site dropdown on the Items page. `useState` only evaluates its initial value +once on mount. Next.js soft navigation preserves component state, so changing the +`?siteId=` URL param never re-ran the initialiser. A `useEffect` keyed on +`currentSiteId` was added to reset `sortBy` whenever the selected site changes. + +**File:** `tests/e2e/inventory/items-sort-by-site.js` + +```js +const { chromium } = require('playwright'); +const { login } = require('../helpers/auth'); + +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + + await login(page); + + // ── 1. No site selected → Price should be the active sort ────────────── + await page.goto('http://localhost:3000/inventory/items'); + await page.waitForLoadState('networkidle'); + + // Expand first row to reveal the sort toggle + await page.locator('tbody tr').first().click(); + await page.waitForTimeout(300); + + const priceActiveNoSite = await page + .locator('button:has-text("Price")') + .evaluate(el => el.classList.contains('bg-primary-100')); + console.log('1. No site → Price active:', priceActiveNoSite); + if (!priceActiveNoSite) { console.error('✗ Expected Price to be active'); process.exit(1); } + console.log('✓ Pass'); + + // ── 2. Select a site → Distance should become active automatically ────── + // Row stays expanded through soft navigation — do NOT click again + const nav1 = page.waitForURL('**/inventory/items?siteId=**', { timeout: 8000 }); + await page.locator('select').first().selectOption({ index: 1 }); + await nav1; + await page.waitForTimeout(400); // allow useEffect to run + + console.log('2. Navigated to:', new URL(page.url()).search); + + const distanceActiveSite = await page + .locator('button:has-text("Distance")') + .evaluate(el => el.classList.contains('bg-primary-100')); + console.log(' Distance auto-active:', distanceActiveSite); + if (!distanceActiveSite) { console.error('✗ Expected Distance to be auto-active'); process.exit(1); } + console.log('✓ Pass'); + + // ── 3. Manual switch to Price still works ─────────────────────────────── + await page.locator('button:has-text("Price")').click(); + await page.waitForTimeout(200); + + const priceManual = await page + .locator('button:has-text("Price")') + .evaluate(el => el.classList.contains('bg-primary-100')); + console.log('3. Manual switch → Price active:', priceManual); + if (!priceManual) { console.error('✗ Manual switch to Price did not work'); process.exit(1); } + console.log('✓ Pass'); + + // ── 4. Change to a different site → Distance resets automatically ─────── + const options = await page.locator('select option').all(); + if (options.length > 2) { + const nav2 = page.waitForURL('**/inventory/items?siteId=**', { timeout: 8000 }); + await page.locator('select').first().selectOption({ index: 2 }); + await nav2; + await page.waitForTimeout(400); + + const distanceReset = await page + .locator('button:has-text("Distance")') + .evaluate(el => el.classList.contains('bg-primary-100')); + console.log('4. Different site → Distance reset:', distanceReset); + if (!distanceReset) { console.error('✗ Expected Distance to reset on site change'); process.exit(1); } + console.log('✓ Pass'); + } else { + console.log('4. Skipped — only one site available in seed data'); + } + + await browser.close(); + console.log('\n✓ All checks passed — items-sort-by-site'); +})().catch(e => { console.error('✗', e.message); process.exit(1); }); +``` + +--- + +### TEST 2 — Cheapest and Closest tags appear independent of sort order + +**Bug:** The `★ Closest` tag was only rendered when `sortBy === "distance"` and the +`Cheapest` tag only when `sortBy === "price"`. Switching sort order hid one of the +tags entirely. The fix computes each tag independently — `minPrice` for cheapest, +`closestVendorId` for nearest by `distanceKm` — so both can appear simultaneously +on whichever vendor qualifies, regardless of the active sort. + +**File:** `tests/e2e/inventory/items-vendor-tags.js` + +```js +const { chromium } = require('playwright'); +const { login } = require('../helpers/auth'); + +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + + await login(page); + + // ── Setup: navigate to items with a site selected ─────────────────────── + await page.goto('http://localhost:3000/inventory/items'); + await page.waitForLoadState('networkidle'); + + const nav = page.waitForURL('**/inventory/items?siteId=**', { timeout: 8000 }); + await page.locator('select').first().selectOption({ index: 1 }); + await nav; + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(300); + console.log('✓ Site selected:', new URL(page.url()).searchParams.get('siteId')); + + // ── Find an item with multiple vendors ────────────────────────────────── + const rows = page.locator('tbody tr'); + const rowCount = await rows.count(); + let expanded = false; + let vendorCount = 0; + + for (let i = 0; i < Math.min(rowCount, 10); i++) { + await rows.nth(i).click(); + await page.waitForTimeout(400); + vendorCount = await page.locator('table table tbody tr').count(); + if (vendorCount > 1) { + expanded = true; + console.log(`✓ Expanded item ${i + 1} with ${vendorCount} vendors`); + break; + } + await rows.nth(i).click(); // close and try next + await page.waitForTimeout(200); + } + + if (!expanded) { + console.error('✗ Could not find item with multiple vendors — check seed data'); + process.exit(1); + } + + // ── 1. Distance sort (default): both tags must be visible ─────────────── + const distanceActiveBefore = await page + .locator('button:has-text("Distance")') + .evaluate(el => el.classList.contains('bg-primary-100')); + console.log(' Active sort:', distanceActiveBefore ? 'Distance' : 'Price'); + + const closestDistSort = await page.locator('text=★ Closest').count(); + const cheapestDistSort = await page.locator('text=Cheapest').count(); + console.log(`1. Distance sort → ★ Closest: ${closestDistSort} Cheapest: ${cheapestDistSort}`); + + if (closestDistSort < 1) { console.error('✗ ★ Closest tag missing under Distance sort'); process.exit(1); } + if (cheapestDistSort < 1) { console.error('✗ Cheapest tag missing under Distance sort'); process.exit(1); } + console.log('✓ Pass'); + + // ── 2. Price sort: both tags must still be visible ────────────────────── + await page.locator('button:has-text("Price")').click(); + await page.waitForTimeout(300); + + const closestPriceSort = await page.locator('text=★ Closest').count(); + const cheapestPriceSort = await page.locator('text=Cheapest').count(); + console.log(`2. Price sort → ★ Closest: ${closestPriceSort} Cheapest: ${cheapestPriceSort}`); + + if (closestPriceSort < 1) { console.error('✗ ★ Closest tag missing under Price sort'); process.exit(1); } + if (cheapestPriceSort < 1) { console.error('✗ Cheapest tag missing under Price sort'); process.exit(1); } + console.log('✓ Pass'); + + // ── 3. No site: neither tag should appear ─────────────────────────────── + const navBack = page.waitForURL(/\/inventory\/items$/, { timeout: 8000 }); + await page.locator('select').first().selectOption({ value: '' }); + await navBack; + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(300); + + // Expand the same row + await page.locator('tbody tr').first().click(); + await page.waitForTimeout(400); + + const closestNoSite = await page.locator('text=★ Closest').count(); + const cheapestNoSite = await page.locator('text=Cheapest').count(); + console.log(`3. No site → ★ Closest: ${closestNoSite} Cheapest: ${cheapestNoSite}`); + + if (closestNoSite > 0) { console.error('✗ ★ Closest should not appear without a site'); process.exit(1); } + if (cheapestNoSite > 0) { console.error('✗ Cheapest should not appear when only one vendor visible without site sort'); } + // Cheapest may legitimately appear if item still has multiple vendor prices — not a hard failure + console.log('✓ Pass'); + + await browser.close(); + console.log('\n✓ All checks passed — items-vendor-tags'); +})().catch(e => { console.error('✗', e.message); process.exit(1); }); +``` + +--- + +## Running all e2e scripts manually + +```bash +# From GstService directory (current Playwright install location) +node ../App/pelagia-portal/tests/e2e/inventory/items-sort-by-site.js +node ../App/pelagia-portal/tests/e2e/inventory/items-vendor-tags.js +``` + +Once Playwright is installed in the portal itself: + +```bash +cd App/pelagia-portal +npx playwright test tests/e2e/ +``` + +--- + +## Adding new tests + +When a bug is fixed and browser-verified during a dev session, follow this checklist: + +1. **Name the file after the feature area** — `tests/e2e/
/.js` +2. **Open with a comment block** describing the bug, the fix, and what the script checks +3. **Log every decision point** with `✓`/`✗` prefix and plain-English labels +4. **Use `waitForURL`** (not `waitForLoadState`) for router.push-triggered navigations +5. **Account for preserved state** — React state survives soft nav; model that explicitly +6. **Exit non-zero** on any assertion failure so CI catches it +7. **Add an entry to this document** under `## Test Scripts` with the bug description + +--- + +## Known gotchas + +| Situation | Symptom | Fix | +|---|---|---| +| Clicking a row that is already expanded | Row closes, sort toggle disappears, selectors time out | Expand row *before* triggering soft navigation so state is preserved | +| `waitForLoadState('networkidle')` after `router.push` | URL still shows old path | Use `page.waitForURL(pattern)` concurrently with the action | +| `button:has-text("Distance")` times out | Sort toggle only renders when `expandedId` is truthy | Ensure a row is expanded before asserting on the sort toggle | +| Tags not found after switching sites | `sortBy` state did not reset (stale closure) | `useEffect` on `currentSiteId` resets it — confirm the effect dependency is correct |