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