# Playwright tests (Aether)

Quick guide for running and editing the Playwright tests in this repo.

Running tests
- Run the full test suite (uses `playwright.config.ts`):

```bash
npx playwright test -c playwright.config.ts
```

- Run a single test file:

```bash
npx playwright test tests/path/to/file.test.ts -c playwright.config.ts
```

- Run a single test by title (grep):

```bash
npx playwright test -g "Badge - interaction" -c playwright.config.ts
```

Notes
- Tests in `tests/disabled/` are ignored by default (see `playwright.config.ts`). Move flaky or environment-dependent tests there.
- The runner starts a local dev server via `npm run dev` by default (see `playwright.config.ts:webServer`). Ensure the app can start on port `5173` or update the config.

Shared test helpers (`tests/_helpers/`)

| File | Purpose |
| --- | --- |
| `env.ts` | Constants: `testing_event_id`, `testing_account_id`, `mock_site_domain` |
| `ae_defaults.ts` | `ae_app_local_data_defaults` — full localStorage seed object with `__version` |
| `idb_helpers.ts` | `inject_badge_and_template()` — write badge + template records into IndexedDB |
| `minimal_ae_api_mocks.ts` | `attach_minimal_routes()`, `seed_trusted_session()`, `setup_badge_test_page()` |
| `leads_helpers.ts` | `setup_leads_test_page()`, `seed_events_loc()`, `seed_ae_loc()`, `attach_leads_routes()`, `minimal_exhibit()`, `minimal_tracking()` — Leads module test helpers |

Note: All five Events sub-stores now use `PersistedState` with their own localStorage keys. `ae_events_loc` no longer exists — do not seed it. Tests that exercise Events module features may need to seed any of the following keys:

| Store | localStorage key | Defaults file |
| --- | --- | --- |
| `badges_loc` | `ae_badges_loc` | `ae_events_stores__badges_defaults.ts` |
| `leads_loc` | `ae_leads_loc` | `ae_events_stores__leads_defaults.ts` |
| `pres_mgmt_loc` | `ae_pres_mgmt_loc` | `ae_events_stores__pres_mgmt_defaults.ts` |
| `launcher_loc` | `ae_launcher_loc` | `ae_events_stores__launcher_defaults.ts` |
| `events_auth_loc` | `ae_events_auth_loc` | `ae_events_stores__auth.svelte.ts` (inline defaults) |

`PersistedState` stores do **not** use a `__version` guard like `ae_loc`. They use spread-merge deserialization (`{ ...defaults, ...JSON.parse(raw) }`), so new fields get their defaults automatically. No version stamp is needed when seeding these stores.

`ae_loc` still uses `svelte-persisted-store` with a `__version` guard — seed it as before (see the `__version` lesson below).

**`setup_badge_test_page(page, event_id)`** is the one-call `beforeEach` for any badge/event print page test. It wires the pageerror listener, all V3 API mocks, and the trusted auth localStorage seed in one call.

**`setup_leads_test_page(page, event_id, exhibit_id, opts)`** is the one-call `beforeEach` for leads exhibit page tests. Accepts `access` (ae_loc flags), `auth_kv` (per-exhibit auth), `staff_passcode`, and `tracking_li` options.

Writing / modifying tests
- Tests are TypeScript files under `tests/` and should export Playwright `test` blocks.
- The badge tests (`event_badge_*.test.ts`) are the **canonical template** — copy the pattern from there when adding tests for any new event module feature.
- Minimal `beforeEach` using shared helpers:

```ts
import { testing_event_id } from './_helpers/env';
import { inject_badge_and_template } from './_helpers/idb_helpers'; // only if IDB needed
import { setup_badge_test_page } from './_helpers/minimal_ae_api_mocks';

const event_id = testing_event_id;

test.beforeEach(async ({ page }) => {
    await setup_badge_test_page(page, event_id);
});
```

- If you need IDB data (badge, template), use the inject-then-reload pattern (see Hard-Won Lessons below).
- Use `page.route('**/v3/**', handler)` to mock backend responses. `attach_minimal_routes` covers the common cases; add inline `page.route()` calls only for test-specific overrides.

Adding new tests
- Create a new file `tests/my_feature.test.ts`.
- Keep tests focused and deterministic: mock network calls and avoid relying on external services.
- Place environment-sensitive tests in `tests/disabled/` so they are not run in CI by default.

Committing
- Stage and commit test changes as usual. Example:

```bash
git add tests/
git commit -m "test: add <description>"
```

Latency probing
- Use the gated probe in `tests/v3_api_latency_probe.test.ts` for quick live rounds against V3 list endpoints.
- Run it only when you have the live API key available:

```bash
RUN_V3_LATENCY_PROBE=1 PUBLIC_AE_API_SECRET_KEY=... npx playwright test tests/v3_api_latency_probe.test.ts -c playwright.config.ts
```
- Tune the rounds with `V3_LATENCY_ROUNDS` and the pause between calls with `V3_LATENCY_PAUSE_MS`.
- Reports are written to `tests/results/` as JSON and Markdown per run.
- Optional bug-finding thresholds:
    - `V3_LATENCY_MAX_ERROR_RATE` (default `0`) — fail if an endpoint exceeds this error rate
    - `V3_LATENCY_MAX_P95_MS` (optional) — fail if endpoint p95 exceeds the threshold
    - `V3_LATENCY_REQUIRE_ROWS=1` (optional) — fail if all rounds return zero rows
    - `V3_LATENCY_OUTPUT_DIR` (optional) — override report directory (default `tests/results`)

Help
- If a test fails due to external network calls or platform-specific behavior, try mocking the relevant endpoints and move the test to `tests/disabled` if it cannot be made deterministic.

---

## Hard-Won Lessons — Badge Print / IDB Tests

These lessons came from debugging the badge attendee workflow tests. Document them here so the next person doesn't spend hours on the same issues.

### The `__version` Guard — Always Include It in `ae_app_local_data_defaults`

**Symptom:** Tests set `trusted_access: true` in `addInitScript`, but after `page.reload()` the print button is gone. Logging `$ae_loc.trusted_access` after reload shows `false`.

**Cause:** `src/lib/stores/store_versions.ts` is a module-level side-effect import that runs *before* `persisted()` hydrates the store. It reads `ae_loc` from localStorage and calls `localStorage.removeItem('ae_loc')` if `parsed.__version !== AE_LOC_VERSION`. After the wipe, `persisted()` falls back to its `initialValue` (app defaults where `trusted_access: false`), erasing the test's auth seed.

**Fix:** `ae_defaults.ts` must include `__version: 1` (matching `AE_LOC_VERSION` in `store_versions.ts`). This is already done — **do not remove it**.

```typescript
export const ae_app_local_data_defaults = {
    __version: 1, // Must match AE_LOC_VERSION in store_versions.ts — store_versions.ts
                  // wipes ae_loc if version doesn't match. Tests will silently lose auth.
    ...
};
```

If `AE_LOC_VERSION` ever increments in `store_versions.ts`, update the value here too or every test that relies on `trusted_access` will silently break.

---

### The IDB Inject-Then-Reload Pattern

**Why reload?** Dexie's `liveQuery` subscribes to IDB change notifications. Writing directly to IDB via the raw `indexedDB` API (as `inject_badge_and_template` does) bypasses Dexie's notification system — `liveQuery` will not fire. Reloading the page forces Dexie to open fresh, query the now-populated IDB, and fire `liveQuery` with the seeded data.

**Correct pattern:**
```typescript
await page.goto(`/events/${event_id}/badges/${badge_id}/print`);
await page.waitForLoadState('domcontentloaded');
// First nav initializes the Dexie schema. Now inject data.
await page.evaluate(inject_badge_and_template, { badge, template });
// Reload so liveQuery starts fresh against populated IDB.
await page.reload();
await page.waitForLoadState('domcontentloaded');
await page.waitForSelector('.event_badge_wrapper', { timeout: 8000 });
```

**Do not** try to `waitForFunction` for IDB changes after `inject_badge_and_template` without reloading — it will time out because liveQuery will not re-fire.

---

## Deep Dive: Testing IDAA Edit Form Components

This section documents hard-won lessons from writing `idaa_recovery_meeting_edit.test.ts`. These issues are not obvious and cost significant debugging time — read this before writing tests for any Svelte 5 form component.

### The #1 Trap: HTML5 Form Validation Silently Blocks Submission

**Symptom:** `page.waitForRequest(...)` times out after clicking the submit button. No network request appears. No JS error is thrown. The button click registers in Playwright, but nothing happens.

**Cause:** A form field with `required` has `value=""` at the moment the button is clicked. The browser's native HTML5 form validation cancels `onsubmit` *before* any JavaScript runs — including Svelte's event handler. Zero network activity. Zero error output.

**How to diagnose:**
```typescript
// Add this before the click to log ALL outgoing requests:
page.on('request', r => console.log(`REQ: ${r.method()} ${r.url()}`));
await page.locator('button[type="submit"]').first().click();
// If nothing is printed after the click, HTML5 validation is the culprit.
```

**How to find the empty required field:**
```typescript
// Run in browser via page.evaluate():
const bad = [...document.querySelectorAll('[required]')].filter(el => !el.value);
console.log(bad.map(el => el.name || el.id));
```

---

### The Svelte 5 `value=` One-Time Bind Trap

**Symptom:** A `<select required>` or `<input required>` renders with the correct value in the browser, but in Playwright the field is empty.

**Cause:** In Svelte 5, `value={someReactiveExpr}` on an uncontrolled `<input>` or `<select>` is a **one-time set at mount**. If the backing reactive store (`$ae_loc`, `liveQuery`, etc.) is empty when the component first renders, the field gets `value=""` and stays that way — even after the store updates.

**Specific instance fixed:** The `[name="timezone"]` field in `ae_idaa_comp__event_obj_id_edit.svelte` has two branches:
```svelte
{#if $ae_loc?.lu_time_zone_list?.length}
    <select name="timezone" required value={...}>  ← renders with value ✓
{:else}
    <input  name="timezone" required value={...}>  ← renders with value="" ✗
{/if}
```
In tests, `$ae_loc.lu_time_zone_list` was populated *after* mount by an API call, so the `{:else}` branch rendered first with `value=""` — then the store updated and switched to `<select>`, but by then the form had an empty `required` input in its history that still failed validation.

**Fix:** Pre-seed `lu_time_zone_list` in `localStorage` inside `addInitScript` *before* any navigation so the `{#if}` branch renders on first mount.

---

### Pre-Seeding Svelte Stores via `addInitScript`

`page.addInitScript()` runs a script in the page context **before** the page's own JavaScript executes. This is the correct way to set up `localStorage`-backed stores so they are populated when Svelte components first mount.

**Pattern used in `setup_idaa_auth()`:**
```typescript
await page.addInitScript(({ ae_defaults, account_id, idaa_loc_defaults }) => {
    const ae_loc_data = {
        ...ae_defaults,
        account_id,
        authenticated_access: true,
        trusted_access: true,
        current_timezone: 'US/Central',
        lu_time_zone_list: [               // ← must be seeded here, not later
            { id: 'tz1', code: 'US/Eastern', name: 'US/Eastern' },
            { id: 'tz2', code: 'US/Central', name: 'US/Central' },
            // ...
        ],
    };
    localStorage.setItem('ae_loc', JSON.stringify(ae_loc_data));
    localStorage.setItem('ae_idaa_loc', JSON.stringify(idaa_loc_defaults));

    // Suppress send_email() calls during tests (see api.ts guard)
    window.__ae_test_mode = true;
}, { ae_defaults, account_id, idaa_loc_defaults });
```

Key rules:
- **Call `addInitScript` before any `page.goto()`** — it only applies to navigations that happen after it is registered.
- Pass data in as a second argument (serialized by Playwright) — do not close over variables from the outer test scope.
- Set `window.__ae_test_mode = true` here to suppress all email sends (see below).

---

### Suppressing Emails During Tests (`__ae_test_mode`)

Components that save data via the API also call `send_staff_notification_email()`, which calls `api.send_email()`. In tests that hit the real API, this sends real emails to the configured admin address.

**Fix:** `api.ts` `send_email()` checks `globalThis.__ae_test_mode` and returns immediately if it is truthy:
```typescript
if (typeof globalThis !== 'undefined' && (globalThis as any).__ae_test_mode) {
    console.log(`[TEST MODE] send_email() suppressed`);
    return null;
}
```

**Activate it** by setting `window.__ae_test_mode = true` inside `addInitScript` (see above). This covers all callers — recovery meetings, BB posts, BB comments — without changing any call sites.

---

### Waiting for Reactive State Before Clicking

After navigating, liveQuery and store effects run asynchronously. Use `waitForFunction` to block until the DOM reflects the expected state before interacting:

```typescript
// Wait for the form name field to be populated by liveQuery
await page.waitForFunction(() => {
    const name = document.querySelector('input[name="name"]') as HTMLInputElement | null;
    return name !== null && name.value.length > 0;
}, { timeout: 5000 });

// Wait for lookup lists to load (ensures required <select> has options)
await page.waitForFunction(() => {
    try {
        const sub = localStorage.getItem('lu_country_subdivision_list');
        const cty = localStorage.getItem('lu_country_list');
        return sub !== null && JSON.parse(sub).length > 50 &&
               cty !== null && JSON.parse(cty).length > 50;
    } catch { return false; }
}, { timeout: 10000 });

// Wait for the timezone required field to have a value (submit guard)
await page.waitForFunction(() => {
    const tz = document.querySelector('[name="timezone"]') as HTMLInputElement | null;
    return tz !== null && tz.value.length > 0;
}, { timeout: 10000 });
```

Use `waitForSelector` only for DOM presence. Use `waitForFunction` when you need a value or condition inside the element.

---

### Capturing PATCH / POST Payloads

Register `page.waitForRequest(...)` **before** the click, then `await` the result after:

```typescript
async function capture_patch_body(page: any): Promise<Record<string, any>> {
    const req_promise = page.waitForRequest(
        (r: any) => r.url().includes(`/v3/crud/event/${TEST_EVENT_ID}`) && r.method() === 'PATCH',
        { timeout: 5000 }
    );
    await page.locator('button[type="submit"]').first().click();
    const req = await req_promise;
    return JSON.parse(req.postData() ?? '{}');
}
```

If `waitForRequest` times out, the cause is almost always HTML5 form validation (see top of this section).

---

### Route Mocking Strategy

`setup_api_mocks()` accepts flags to selectively mock vs. pass through:

- **`pass_through_lookups: true`** — lets country/subdivision/timezone GET calls reach the real API. Preferred for payload-verification tests because the real API returns 50+ entries, naturally satisfying the component's cache threshold without fixture arrays.
- **`pass_through_site_domain: true`** — lets the site domain lookup reach the real API so `$ae_api` is built with real credentials. Required when the test needs to send a real PATCH.
- Default (both false) — all API calls are intercepted and answered with fixtures. Use for pure UI tests that do not need real data.

Mock the event PATCH endpoint to return success without hitting the real database when you only need to verify the payload shape:
```typescript
await page.route(`**/v3/crud/event/${event_id}`, async (route) => {
    if (route.request().method() === 'PATCH') {
        await route.fulfill({ status: 200, contentType: 'application/json',
            body: JSON.stringify({ success: true, id: event_id }) });
    } else {
        await route.fulfill({ status: 200, contentType: 'application/json',
            body: JSON.stringify(mock_event) });
    }
});
```


---

## Hard-Won Lessons — IDAA Auth Tests

These lessons came from writing and debugging `tests/idaa_novi_auth.test.ts`.

### Seed the Full `ae_idaa_loc` Structure — Not Just the Novi Fields

**Symptom:** Test asserts "Access Denied" is not visible (passes) but then fails asserting the UUID span is visible. The page appears to show content (no "Access Denied" heading), but the Novi UUID is nowhere in the DOM.

**Cause:** The `ae_idaa_loc` seed in `addInitScript` was minimal — it included only the Novi-related fields and omitted the nested `bb`, `archives`, and `recovery_meetings` objects. After a successful Novi API call, `verify_novi_uuid()` in `(idaa)/+layout.svelte` sets `$idaa_loc.novi_uuid` and upgrades `$ae_loc` to trusted access, then tries to reset BB query filters:

```typescript
$idaa_loc.bb.qry__hidden = 'not_hidden';
$idaa_loc.bb.qry__enabled = 'enabled';
```

Because `bb` is `undefined` in the seeded store (svelte-persisted-store uses the stored value as-is, not a deep merge with defaults), this throws `TypeError: Cannot set properties of undefined`. The `try/catch` catches it and resets `novi_uuid = null` and `novi_verified = false`. The `$ae_loc.trusted_access` flag was already set before the throw, so the gate passes — but the UUID span uses `{#if $idaa_loc.novi_uuid}` which is now null. Silent failure, no visible error.

**Fix:** Seed `ae_idaa_loc` with the full structure from `idaa_local_data_struct` in `ae_idaa_stores.ts`, including at minimum the `bb` object:

```typescript
const ae_idaa_loc_data = {
    ver: '2024-08-21_1646',
    novi_uuid: null,
    novi_verified: false,
    // ... other Novi fields ...
    // REQUIRED: layout writes to bb.qry__hidden and bb.qry__enabled after verification
    bb: {
        enabled: 'enabled',
        hidden: 'not_hidden',
        limit: 50,
        offset: 0,
        edit_kv: {},
        edit__post_obj: null,
        edit__post_comment_obj: null,
        show_list__post_obj_li: true,
        qry__enabled: 'enabled',
        qry__hidden: 'not_hidden',
        qry__limit: 25,
        qry__offset: 0,
        qry__order_by: 'updated_on',
        qry__order_by_li: { updated_on: 'DESC', created_on: 'DESC' }
    },
    archives: { /* ... */ }
};
```

If `ae_idaa_stores.ts` ever adds new post-verification writes to other nested objects, those must be added to the seed too.

---

### Testing Reactive Persisted-Store Updates — The StorageEvent Approach

**Context:** `$ae_loc.site_cfg_json` is tracked by the IDAA layout Effect 2 outside `untrack()`. When it changes, the effect re-runs and retries Novi verification. Testing this without pre-seeding Dexie (which requires an extra navigate-then-reload cycle) can be done by dispatching a synthetic `StorageEvent`.

**Why:** In the test environment, Dexie is empty on first load, so `lookup_site_domain` takes the slow path — one API call, no background refresh. The two-phase mock approach (stale call 1, fresh call 2) cannot be exercised directly without a Dexie cache hit. The StorageEvent approach directly tests the reactive store update path in isolation.

**Pattern:**

```typescript
// After initial Access Denied (stale cfg, no api_key):
await page.evaluate(
    ({ fresh_cfg }: { fresh_cfg: any }) => {
        const raw = window.localStorage.getItem('ae_loc');
        const current = raw ? JSON.parse(raw) : {};
        const updated = { ...current, site_cfg_json: fresh_cfg };
        const newValue = JSON.stringify(updated);
        window.localStorage.setItem('ae_loc', newValue);
        // svelte-persisted-store listens to 'storage' events.
        // The native browser event only fires in OTHER tabs — but
        // window.dispatchEvent() reaches same-tab listeners too.
        window.dispatchEvent(
            new StorageEvent('storage', {
                key: 'ae_loc',
                newValue,
                storageArea: window.localStorage
            })
        );
    },
    { fresh_cfg: fresh_site_cfg_json() }
);
// Now assert that Effect 2 re-ran and access was granted
await expect(page.getByRole('heading', { name: 'Access Denied' })).not.toBeVisible({ timeout: 8000 });
```

**When to use:** Any time you need to simulate `$ae_loc` (or another persisted store) being updated mid-test by an external source, without navigating away or reloading.

---

### `getByText` Partial Match for UUID in Longer Spans

The layout renders the Novi UUID inside a span that also contains the user's name and email. `page.getByText(uuid)` uses whole-text matching by default and won't find it. Use `{ exact: false }`:

```typescript
await expect(page.getByText(TEST_NOVI_UUID, { exact: false })).toBeVisible();
```

---

## Development / Testing / Demo environment information
* Use snake_case (or Snake_Case or Snake_case or test_NASA_example or test_API_key)
* Aether test/demo base URL: `http://demo.localhost:5173`
* Aether development API: `https://dev-api.oneskyit.com`

These are IDs for records that we can use for testing. Please do not delete them. They are also used for demo purposes with clients.

### Core Modules
* Aether test/demo Account: '_XY7DXtc9MY' (1) "One Sky IT Demo"
* Aether test/demo Site: '92vkYC4fVEl' (12) "One Sky IT Demo"
* Aether test/demo Site Domain: '_6jcTbnJk-o' (12) "demo.localhost:5173"
* Aether test/demo Site Domain: 'heXRgHOs4ns' (30) "sk-demo.oneskyit.com"
* Aether test/demo Site Domain: 'DASm8fP92yw' (69) "dev-demo.oneskyit.com"
* Aether test/demo Site Domain: '2i_0Za6yRPo' (2) "demo.oneskyit.com"
* Aether test/demo Person: 'QWODAPCNLQU' (49) "Osiris Idem"
* Aether test/demo Person: 'HMQRNPIXQMK' (48) "Cleo Idem"

### Events Modules
* Aether test/demo Event: 'pjrcghqwert' (1) "Demo One Sky IT Conference"

* Aether test/demo Event Session: 'DOW3h7v6H42' (703) "How To Do Things"
* Aether test/demo Event Session (Digital Posters): "K8cxUIEWyQk" "The Beginning of Digital Posters!"
* Aether test/demo Event Session (Digital Posters): "1Un1xI1Rgk8" "Poster Session 99: All about posters!"
* Aether test/demo Event Presentation: '7U2eXSjR6H4' (1670) "Build a House"
* Aether test/demo Event Presenter: 'gT-hxnifb-0' (2202) "Bob The Builder"

* Aether test/demo Event File: 'OOsHXtng5mr' (2985) "1 Quick Test for macOS.mp4"

* Aether test/demo Event Badge: 'UIJT-73-63-61' (37163) "Scott Idem"
* Aether test/demo Event Person: 'ffkKxiHpOEC' (16603) "Scott Idem"

* Aether test/demo Event Badge Template: 'jgfixEpYp1B' (18) "Dev Demo 202x"
* Aether test/demo Event Badge Template: 'rzmUgsk7mkq' (19) "Dev Demo 202x Workshops"

* Aether test/demo Event Location: 'VXXY-98-46-14' (26) "Ballroom 1"
* Aether test/demo Event Location: 'FGRN-67-92-45' (298) "Ballroom AB"
* Aether test/demo Event Location: 'PQKB-15-39-81' (78) "Poster Display Station A"

* Aether test/demo Event Exhibit: 'xK_9yEj1bQY' (1) - "One Sky's Awesome Exhibit"
* Aether test/demo Event Exhibit: 'acHCkrCDaYs' (3) - "Exhibit for Precon Events"
* Aether test/demo Event Exhibit: 'MIFC-74-11-33' (177) - "OSIT Test Booth"
* Aether test/demo Event Exhibit: 'yMawNHiNkHo' (4) - "Dev Virtual Exhibit"
* Aether test/demo Event Exhibit: 'XgtAc3xhVsU' (2) - "The Org Group Virtual Exhibit"

### Journals Module
* Aether test/demo Journal: 'BVYE-94-46-29' (42) "Testing Things"
* Aether test/demo Journal Entry: 'xRx-Y4-h3-fU' (233) "Another Journal Entry in the Test Journal"

### Archives Module (IDAA Archives)
* Aether test/demo Archive: 'nAA2bHLv8RK' (1) "One Sky Test Archive"
* Aether test/demo Archive Content: 'UjKzrk-GKu5' (1) "Hosted File Test"

### Posts Module (IDAA Bulletin Board)
* Aether test/demo Post:
* Aether test/demo Post:

### Events Module (IDAA Recovery Meetings)
* Aether test/demo Event: '1Pkd025vvxU' (36) "IDAA Recovery Meeting Test"
* Aether test/demo Event: 'gIZgAjISkf8' (43) "IDAA Recovery Meeting Test"
