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