14 KiB
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:
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:
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.
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(...):
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.
// 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:
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.
if (!allGood) process.exit(1);
Test Scripts
AUTH — helpers used by every test
// 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
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
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
# 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:
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:
- Name the file after the feature area —
tests/e2e/<section>/<feature>.js - Open with a comment block describing the bug, the fix, and what the script checks
- Log every decision point with
✓/✗prefix and plain-English labels - Use
waitForURL(notwaitForLoadState) for router.push-triggered navigations - Account for preserved state — React state survives soft nav; model that explicitly
- Exit non-zero on any assertion failure so CI catches it
- Add an entry to this document under
## Test Scriptswith 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 |