test(idaa): IDAA recovery meeting edit form Playwright test suite + README docs
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)
This commit is contained in:
172
tests/README.md
172
tests/README.md
@@ -56,6 +56,178 @@ 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)
|
||||
|
||||
Reference in New Issue
Block a user