pelagia-portal/Docs/PLAYRIGHT_TEST_DESIGN.md

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:

  1. Name the file after the feature areatests/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