340 lines
16 KiB
Markdown
340 lines
16 KiB
Markdown
# 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()` |
|
|
|
|
**`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.
|
|
|
|
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>"
|
|
```
|
|
|
|
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) });
|
|
}
|
|
});
|
|
```
|
|
|
|
|
|
## 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"
|
|
|
|
### 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"
|