docs: add Playwright test design doc with scripts for inventory sort and tag fixes
This commit is contained in:
parent
55065eafe3
commit
bf6058fcdd
1 changed files with 341 additions and 0 deletions
341
PLAYRIGHT_TEST_DESIGN.md
Normal file
341
PLAYRIGHT_TEST_DESIGN.md
Normal file
|
|
@ -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/<section>/<feature>.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 |
|
||||||
Loading…
Add table
Reference in a new issue