Full payload-verification test suite for ae_idaa_comp__event_obj_id_edit_v2. Root cause fixed: $ae_loc.lu_time_zone_list empty at mount caused Svelte 5 to render <input type=text name=timezone required value=''> instead of the <select> branch. HTML5 required validation silently cancelled onsubmit with no JS error and zero network activity — waitForRequest timed out with no obvious cause. Fix: pre-seed lu_time_zone_list in addInitScript so the <select> branch renders on first mount with a valid value already set. Key patterns established: - setup_idaa_auth(): pre-seeds ae_loc + ae_idaa_loc in localStorage via addInitScript; includes lu_time_zone_list and window.__ae_test_mode = true - setup_api_mocks(): selective pass-through flags for lookups and site_domain - open_edit_form(): waitForFunction guards for name field, country lists, and the timezone required field before any interaction - capture_patch_body(): registers waitForRequest before click, awaits after README.md updated with deep-dive section covering: - HTML5 form validation silent block and how to diagnose it - Svelte 5 one-time value= bind trap - addInitScript store pre-seeding pattern - __ae_test_mode email suppression - waitForFunction patterns for reactive state - Route mock strategy (pass-through vs fixture)
277 lines
12 KiB
Markdown
277 lines
12 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.
|
|
|
|
Writing / modifying tests
|
|
- Tests are TypeScript files under `tests/` and should export Playwright `test` blocks. Example header:
|
|
|
|
```ts
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test('example', async ({ page }) => {
|
|
await page.goto('/');
|
|
await expect(page).toHaveTitle(/OSIT/);
|
|
});
|
|
```
|
|
|
|
- Use `page.route('**/v3/**', handler)` to mock backend responses for deterministic tests.
|
|
- Use `page.addInitScript` to inject `ae_loc` localStorage defaults when tests need authenticated/admin state.
|
|
|
|
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.
|
|
|
|
---
|
|
|
|
## 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_v2.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 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" |