- QR scanner (single + multi): detect previously-removed leads via IDB enable flag; route to 'reenable' state instead of duplicate error; offer Re-activate button - API fallback: if create fails and no IDB record, search API for disabled tracking record by event_exhibit_id + event_badge_id (adds qry_badge_id param to search__exhibit_tracking) - Lead detail page: Replace raw enable checkbox with Remove Lead (two-click confirm, navigates back after) and Restore Lead card (shown when enable is falsy) - Fix flash of disabled records in leads list: filter !enable in both filtered_lead_li derived and local IDB fast-path in handle_search_refresh - eslint.config.js: disable svelte/no-navigation-without-resolve (no base path configured) - Also includes _random field annotation cleanup (db_events, ae_types), iframe layout fixes, badge view tweaks, test updates, and doc updates from prior session Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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):
npx playwright test -c playwright.config.ts
- Run a single test file:
npx playwright test tests/path/to/file.test.ts -c playwright.config.ts
- Run a single test by title (grep):
npx playwright test -g "Badge - interaction" -c playwright.config.ts
Notes
- Tests in
tests/disabled/are ignored by default (seeplaywright.config.ts). Move flaky or environment-dependent tests there. - The runner starts a local dev server via
npm run devby default (seeplaywright.config.ts:webServer). Ensure the app can start on port5173or 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_v3_mocks.ts |
attach_minimal_v3_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 Playwrighttestblocks. - 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
beforeEachusing shared helpers:
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_v3_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_v3_routescovers the common cases; add inlinepage.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:
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/disabledif 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.
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:
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:
// 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:
// 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:
{#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():
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
addInitScriptbefore anypage.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 = truehere 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:
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:
// 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:
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_apiis 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:
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"