# 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 |