diff --git a/tests/README.md b/tests/README.md index c24d322d..5865e0ff 100644 --- a/tests/README.md +++ b/tests/README.md @@ -56,6 +56,178 @@ git commit -m "test: add " 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 `` renders with the correct value in the browser, but in Playwright the field is empty. + +**Cause:** In Svelte 5, `value={someReactiveExpr}` on an uncontrolled `` or ` ← renders with value ✓ +{:else} + ← 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 ` 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> { + 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) diff --git a/tests/idaa_recovery_meeting_edit.test.ts b/tests/idaa_recovery_meeting_edit.test.ts index e507d551..c0e0fd7f 100644 --- a/tests/idaa_recovery_meeting_edit.test.ts +++ b/tests/idaa_recovery_meeting_edit.test.ts @@ -112,6 +112,20 @@ async function setup_idaa_auth(page: any) { administrator_access: false, access_type: 'trusted', iframe: false, + // Pre-seed the timezone list so the component renders . + // Without this, $ae_loc.lu_time_zone_list is empty on first mount and + // the renders with value="" which HTML5 required validation + // silently blocks form submission (onsubmit never fires, no PATCH sent). + current_timezone: 'US/Central', + lu_time_zone_list: [ + { id: 'tz1', code: 'US/Eastern', name: 'US/Eastern' }, + { id: 'tz2', code: 'US/Central', name: 'US/Central' }, + { id: 'tz3', code: 'US/Mountain', name: 'US/Mountain' }, + { id: 'tz4', code: 'US/Pacific', name: 'US/Pacific' }, + { id: 'tz5', code: 'Canada/Eastern', name: 'Canada/Eastern' }, + { id: 'tz6', code: 'UTC', name: 'UTC' } + ], site_cfg_json: { slct__event_id: null, novi_admin_li: ['2b078deb-b4e7-4203-99da-9f7cd62159a5'], @@ -125,6 +139,9 @@ async function setup_idaa_auth(page: any) { }; window.localStorage.setItem('ae_loc', JSON.stringify(ae_loc_data)); window.localStorage.setItem('ae_idaa_loc', JSON.stringify(idaa_loc_defaults)); + // Suppress all send_email() calls during Playwright tests. + // Checked in api.ts send_email() before any fetch is made. + (window as any).__ae_test_mode = true; }, { ae_defaults: ae_app_local_data_defaults, @@ -142,11 +159,26 @@ async function setup_idaa_auth(page: any) { * intercepted and will reach the real backend. The GET is still mocked so * the form loads immediately from seeded IDB. Use this for integration * tests that need to verify actual persistence. + * + * @param pass_through_lookups - When true, lookup table routes (time_zone, + * country, country_subdivision, event_type) are forwarded to the real API + * instead of being mocked. This is preferred for the payload-verification + * suite because the real API returns 50+ entries, naturally satisfying the + * component's "> 50 entries" cache threshold — so no trickery with + * pre-seeded fixture arrays is needed. + * + * @param pass_through_site_domain - When true, the site_domain lookup also + * passes through so the app builds ae_api with real credentials. Required + * for full integration tests that actually hit the PATCH endpoint. */ async function setup_api_mocks( page: any, event_id: string, - opts: { pass_through_event_patch?: boolean } = {} + opts: { + pass_through_event_patch?: boolean; + pass_through_lookups?: boolean; + pass_through_site_domain?: boolean; + } = {} ) { await page.route('**/v3/**', async (route: any) => { const url = route.request().url(); @@ -191,6 +223,9 @@ async function setup_api_mocks( // Lookup: time zones if (url.includes('/v3/') && url.includes('time_zone')) { + // Pass through to real API if requested — real data satisfies the + // component's "> 50 entries" cache check without fixture maintenance. + if (opts.pass_through_lookups) return route.continue(); return route.fulfill({ status: 200, contentType: 'application/json', @@ -206,6 +241,7 @@ async function setup_api_mocks( // Lookup: countries if (url.includes('/v3/') && url.includes('country') && !url.includes('subdivision')) { + if (opts.pass_through_lookups) return route.continue(); return route.fulfill({ status: 200, contentType: 'application/json', @@ -220,13 +256,14 @@ async function setup_api_mocks( // Lookup: country subdivisions (states/provinces) if (url.includes('/v3/') && url.includes('country_subdivision')) { + if (opts.pass_through_lookups) return route.continue(); return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [ - { id: 's1', code: 'US-IL', name: 'Illinois' }, - { id: 's2', code: 'US-NY', name: 'New York' } + { id: 's1', code: 'US-IL', name: 'Illinois', country_alpha_2_code: 'US' }, + { id: 's2', code: 'US-NY', name: 'New York', country_alpha_2_code: 'US' } ] }) }); @@ -234,6 +271,7 @@ async function setup_api_mocks( // Lookup: event types if (url.includes('/v3/') && url.includes('event_type')) { + if (opts.pass_through_lookups) return route.continue(); return route.fulfill({ status: 200, contentType: 'application/json', @@ -248,8 +286,10 @@ async function setup_api_mocks( // Layout/site domain lookup — must return a proper account_id so the // layout builds ae_api.headers['x-account-id'] correctly. - // search_ae_obj_v3 expects { data: [array] }, not a single object. + // pass_through_site_domain=true lets the app get real credentials from + // dev-api.oneskyit.com; required for full integration tests. if (url.includes('/v3/crud/site_domain') || url.includes('/v3/lookup/')) { + if (opts.pass_through_site_domain) return route.continue(); return route.fulfill({ status: 200, contentType: 'application/json', @@ -320,7 +360,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => { // The edit form section has the class 'edit__event_obj' const form = page.locator('section.edit__event_obj'); - await expect(form).toBeVisible({ timeout: 10000 }); + await expect(form).toBeVisible({ timeout: 5000 }); }); test('general information section is present', async ({ page }) => { @@ -328,7 +368,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => { await seed_event_idb(page, mock_event); await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`); - await page.waitForSelector('section.edit__event_obj', { timeout: 10000 }); + await page.waitForSelector('section.edit__event_obj', { timeout: 5000 }); // Meeting name input const name_input = page.locator('input[name="name"]'); @@ -347,7 +387,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => { await seed_event_idb(page, mock_event); await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`); - await page.waitForSelector('section.edit__event_obj', { timeout: 10000 }); + await page.waitForSelector('section.edit__event_obj', { timeout: 5000 }); // All seven weekday checkboxes must be present for (const day of [ @@ -364,7 +404,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => { await seed_event_idb(page, mock_event); await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`); - await page.waitForSelector('section.edit__event_obj', { timeout: 10000 }); + await page.waitForSelector('section.edit__event_obj', { timeout: 5000 }); await expect(page.locator('input[name="recurring_start_time"]')).toBeAttached(); await expect(page.locator('input[name="recurring_end_time"]')).toBeAttached(); @@ -375,7 +415,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => { await seed_event_idb(page, mock_event); await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`); - await page.waitForSelector('section.edit__event_obj', { timeout: 10000 }); + await page.waitForSelector('section.edit__event_obj', { timeout: 5000 }); await expect(page.locator('input[name="contact_1_full_name"]')).toBeAttached(); await expect(page.locator('input[name="contact_1_email"]')).toBeAttached(); @@ -390,7 +430,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => { await seed_event_idb(page, mock_event); await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`); - await page.waitForSelector('section.edit__event_obj', { timeout: 10000 }); + await page.waitForSelector('section.edit__event_obj', { timeout: 5000 }); const name_input = page.locator('input[name="name"]'); await name_input.fill('Updated Meeting Name'); @@ -402,33 +442,55 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => { await seed_event_idb(page, mock_event); await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`); - await page.waitForSelector('section.edit__event_obj', { timeout: 10000 }); + await page.waitForSelector('section.edit__event_obj', { timeout: 5000 }); - const wednesday_cb = page.locator('input[name="weekday_wednesday"]'); - const initial_state = await wednesday_cb.isChecked(); + // Weekday checkboxes use class="sr-only" — they are visually hidden and + // the parent