PLAYWRIGHT_SPA(7) RSX Framework PLAYWRIGHT_SPA(7) NAME custom_playwright_tests_spa - writing custom Playwright tests for SPA applications with jqhtml component lifecycle awareness SYNOPSIS node bin/my-test.js [auth-token] Where auth-token is generated by the PHP helper: php -r " foreach (file('.env') as \$line) { if (strpos(\$line, 'APP_KEY=') === 0) { \$app_key = trim(substr(\$line, 8)); break; } } \$payload = json_encode([ 'url' => '/initial-route', 'user_id' => 1, 'portal' => false ]); echo hash_hmac('sha256', \$payload, \$app_key); " DESCRIPTION rsx:debug is designed for single-page-load diagnostics. For tests that require multi-step SPA navigation (e.g., verifying cache behavior across page transitions, testing component state persistence), you need a custom Playwright script. This document covers the patterns required to write reliable Playwright tests against an RSX SPA application, including authentication, SPA navigation, and observing jqhtml component lifecycle events like loading spinners and data rendering. AUTHENTICATION RSX uses backdoor header-based auth in development. The same mechanism that powers rsx:debug works for custom scripts. Required Headers X-Dev-Auth-User-Id User ID to authenticate as X-Dev-Auth-Token HMAC-SHA256 signature (see SYNOPSIS) X-Playwright-Test Set to '1' for plain-text error responses CRITICAL: Auth Headers Only on First Document Request Auth headers must ONLY be sent on the initial document request, NOT on every request. Sending auth headers on Ajax/XHR calls causes RsxAuth::login() to run repeatedly, which regenerates the session on each request. This makes Rsx.validate_session() detect a session_hash mismatch and trigger a full page reload via location.replace(), breaking SPA navigation entirely. After the initial document request sets a session cookie, all subsequent requests authenticate via that cookie automatically. let initial_doc_sent = false; await page.route('**/*', async (route, request) => { const url = request.url(); if (url.startsWith(BASE_URL)) { const headers = { ...request.headers() }; if (!initial_doc_sent && request.resourceType() === 'document') { Object.assign(headers, authHeaders); initial_doc_sent = true; } else { headers['X-Playwright-Test'] = '1'; } await route.continue({ headers }); } else { await route.continue(); } }); The X-Playwright-Test header is safe to send on every request (it only affects error response formatting). The auth headers (X-Dev-Auth-User-Id, X-Dev-Auth-Token) must be restricted. Token Scope The HMAC token is signed against a specific URL. For SPA tests, only the initial page.goto() URL matters -- all subsequent SPA navigations happen client-side and do not trigger new page loads. Generate the token for the first URL you navigate to. SESSION VALIDATION After initial page load, RSX runs Rsx.validate_session() on every SPA dispatch. This Ajax call compares the server's current session_hash with the hash stored in window.rsxapp.session_hash. If they differ, RSX does location.replace() to force a full page reload. In test environments using dev auth headers, the session established by the header-based login may have a different hash than what validate_session returns. This is a test environment artifact -- in production, sessions are stable. Patching validate_session After the initial page loads and the SPA is ready, patch validate_session to prevent false-positive reloads: // After initial page.goto() and wait_for_rsx_ready() await page.evaluate(() => { Rsx.validate_session = async () => true; }); This must happen AFTER the initial page is fully loaded (after _debug_ready fires) but BEFORE any SPA dispatch calls. Detecting Full Page Reloads Track document requests after the initial load to catch unexpected reloads caused by validate_session or other issues: let page_reload_count = 0; page.on('request', req => { if (req.resourceType() === 'document' && initial_doc_sent) { page_reload_count++; console.log(`!! UNEXPECTED RELOAD: ${req.url()}`); } }); SPA NAVIGATION RSX SPA applications use client-side routing via Spa.dispatch(). Understanding the difference between browser navigation and SPA navigation is critical for writing correct tests. Browser Navigation (page.goto) Use ONLY for the initial page load: await page.goto(`${BASE_URL}/contacts`, { waitUntil: 'commit' }); Use 'commit' rather than 'networkidle' -- the SPA will fire many async requests during component lifecycle. Use RSX lifecycle events (see LIFECYCLE EVENTS below) for precise ready detection. SPA Navigation (Spa.dispatch) For all subsequent navigation, use Spa.dispatch() directly: await page.evaluate((path) => { Spa.dispatch(path); }, '/engagements'); Do NOT click sidebar links with page.evaluate + element.click(). The jQuery .click() override that prevents default and triggers SPA dispatch may not fire correctly from Playwright's evaluation context. Calling Spa.dispatch() directly is deterministic. Context Destruction SPA navigation replaces the DOM content area. Playwright's execution context can be destroyed during this process. Any page.evaluate() call during a SPA transition may throw: Error: Execution context was destroyed This is expected. Wrap evaluate calls in try/catch if they run during or immediately after SPA navigation. The addInitScript approach (see below) survives these transitions. LIFECYCLE EVENTS RSX provides two key events for determining when the application is ready. These replace arbitrary timeouts with precise signals. _debug_ready (Initial Page Load) Fires once after ALL framework init phases complete, including SPA action dispatch. This is the definitive "page is fully loaded" signal for the initial page.goto(): async function wait_for_rsx_ready() { await page.evaluate(() => { return new Promise((resolve) => { if (window.Rsx && window.Rsx.on) { Rsx.on('_debug_ready', () => resolve()); } else { // Fallback for non-RSX pages document.addEventListener( 'DOMContentLoaded', () => setTimeout(resolve, 200) ); } }); }); } The _debug_ready event fires after these phases complete: 1. framework_init (framework bootstrap) 2. modules_init (manifest, SPA router setup) 3. app_init (application module init) 4. _spa_init (Spa.dispatch() -- awaits full action lifecycle) 5. app_ready (final ready hooks) Because _spa_init awaits the SPA action's full lifecycle (including on_load data fetching and on_ready), _debug_ready guarantees the first action is fully rendered with data. spa_dispatch_ready (SPA Navigation) Fires after each SPA dispatch completes (action's on_ready has fired and all children are ready): async function spa_navigate(path) { // Set up ready listener BEFORE dispatching const ready = page.evaluate(() => { return new Promise((resolve) => { Rsx.on('spa_dispatch_ready', () => resolve()); }); }); // Dispatch navigation try { await page.evaluate((p) => { Spa.dispatch(p); }, path); } catch(e) {} // Wait for action to fully load await ready; // Optional: wait for dynamic height recalculation await page.waitForTimeout(500); } IMPORTANT: Register the spa_dispatch_ready listener BEFORE calling Spa.dispatch(). The promise must be waiting before the event fires, or it will be missed. Dynamic Height Settle Time DataGrids with $rows="dynamic" recalculate per_page from viewport height with a 200ms debounce after render. Add a 500ms wait after the ready event to allow this recalculation to complete: const DYNAMIC_HEIGHT_SETTLE = 500; await ready_promise; await page.waitForTimeout(DYNAMIC_HEIGHT_SETTLE); OBSERVING STATE jqhtml components have async lifecycles: on_create (sync) -> render (sync) -> on_load (async) -> on_ready (async). A DataGrid may show a loading spinner for 50-500ms before data appears. Point-in-time checks miss these transitions. The Wrong Way: Point-in-Time Checks This misses fast transitions: // BAD: spinner may appear and disappear between these checks await page.waitForTimeout(500); const visible = await page.evaluate(() => { return !!document.querySelector('.spinner.is-visible'); }); The Right Way: addInitScript with Polling Observer Inject a persistent polling loop via addInitScript. This runs on every page load (including the initial one), survives SPA navigations that destroy the execution context, and captures every state transition: await page.addInitScript(() => { let _last_state = null; let _step = 'init'; let _step_start = Date.now(); // Allow the test to label steps window.__test_set_step = function(step) { _step = step; _step_start = Date.now(); _last_state = null; console.log(`[TEST] === ${step} === (t=0)`); }; // Poll every 10ms, log transitions via console setInterval(() => { const spinner = !!document.querySelector( '.datagrid-loading-overlay.is-visible' ); const rows = document.querySelectorAll( '.DataGrid_Table tr:not(.placeholder-row)' + ':not(.empty-row):not(.loading-row)' ).length; const key = `s=${spinner},r=${rows}`; if (key !== _last_state) { const elapsed = Date.now() - _step_start; const label = spinner ? 'SPINNER' : (rows > 0 ? `DATA(${rows})` : 'EMPTY'); console.log(`[TEST] ${elapsed}ms: ${label}`); _last_state = key; } }, 10); }); Capture these logs from the Playwright side: const state_log = []; page.on('console', msg => { const text = msg.text(); if (text.startsWith('[TEST]')) { state_log.push(text); } }); Setting Steps Label each phase of your test so the log is readable: async function set_step(label) { try { await page.evaluate((l) => { if (window.__test_set_step) { window.__test_set_step(l); } }, label); } catch(e) { // Context may be destroyed during SPA nav } } Interpreting Output A healthy cached SPA navigation looks like: [TEST] === STEP 3 === (t=0) [TEST] 30ms: DATA(17) <-- instant from cache A broken/uncached navigation looks like: [TEST] === STEP 3 === (t=0) [TEST] 30ms: EMPTY [TEST] 150ms: SPINNER [TEST] 800ms: DATA(10) <-- had to fetch from server [TEST] 1200ms: DATA(17) <-- dynamic resize reload TRACKING XHR CALLS Monitor Ajax requests to understand how many server round-trips a navigation triggers: let xhr_count = 0; page.on('request', req => { if (req.url().includes('datagrid_fetch')) xhr_count++; }); // Reset before each step xhr_count = 0; await spa_navigate('/contacts'); console.log(`XHR calls: ${xhr_count}`); This is essential for diagnosing redundant loads. A well-cached SPA return navigation should trigger 1 XHR (background revalidation) not 2-4. VIEWPORT AND DYNAMIC SIZING DataGrids in dynamic row mode calculate per_page from viewport height. Inconsistent viewport sizes between test runs produce different per_page values, which produce different cache keys, which break cache hit expectations. Always set an explicit viewport: const context = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1920, height: 1080 } }); FULL EXAMPLE A complete, runnable SPA navigation test. Copy and customize the route paths, observer selectors, and step sequence for your specific test scenario. See also: bin/test-datagrid-cache.js (working reference) #!/usr/bin/env node /** * SPA Navigation Test Template * * Tests multi-step SPA navigation with component state * observation. Customize ROUTE_A, ROUTE_B, and the * addInitScript observer for your specific scenario. * * AUTH: Headers only on first document request. * WAIT: RSX lifecycle events (_debug_ready, * spa_dispatch_ready) instead of arbitrary timeouts. */ const { chromium } = require( '/var/www/html/system/node_modules/playwright' ); const BASE_URL = 'https://myapp.dev.example.com'; const USER_ID = '1'; const TOKEN = process.argv[2] || null; const ROUTE_A = '/contacts'; // Primary route to test const ROUTE_B = '/engagements'; // Route to navigate away to const DYNAMIC_HEIGHT_SETTLE = 500; async function run() { const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const context = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1920, height: 1080 } }); const page = await context.newPage(); // ------------------------------------------------- // AUTH: headers only on first document request // ------------------------------------------------- // Sending auth headers on every request causes // RsxAuth::login() to regenerate the session, // breaking validate_session(). After the first // document response sets a cookie, all subsequent // requests authenticate via that cookie. const authHeaders = { 'X-Dev-Auth-User-Id': USER_ID, 'X-Playwright-Test': '1' }; if (TOKEN) { authHeaders['X-Dev-Auth-Token'] = TOKEN; } let initial_doc_sent = false; await page.route('**/*', async (route, request) => { const url = request.url(); if (url.startsWith(BASE_URL)) { const headers = { ...request.headers() }; if (!initial_doc_sent && request.resourceType() === 'document' ) { Object.assign(headers, authHeaders); initial_doc_sent = true; } else { headers['X-Playwright-Test'] = '1'; } await route.continue({ headers }); } else { await route.continue(); } }); // ------------------------------------------------- // Console log capture // ------------------------------------------------- const state_log = []; page.on('console', msg => { const text = msg.text(); if (text.startsWith('[TEST]')) { state_log.push(text); } }); // ------------------------------------------------- // Persistent observer via addInitScript // ------------------------------------------------- // Survives SPA navigations. Polls every 10ms and // logs state transitions via console. Customize the // selectors for your component under test. await page.addInitScript(() => { let _last_state = null; let _step_start = Date.now(); window.__test_set_step = function(step) { _step_start = Date.now(); _last_state = null; console.log( `[TEST] === ${step} === (t=0)` ); }; setInterval(() => { // -- Customize these selectors -- const spinner = !!document.querySelector( '.datagrid-loading-overlay.is-visible' ); const tbody = document.querySelector( '.DataGrid_Table' ); let data_rows = 0; if (tbody) { data_rows = tbody.querySelectorAll( 'tr:not(.placeholder-row)' + ':not(.empty-row)' + ':not(.loading-row)' ).length; } // -- End customizable selectors -- const key = `s=${spinner},r=${data_rows}`; if (key !== _last_state) { const ms = Date.now() - _step_start; const label = spinner ? 'SPINNER' : (data_rows > 0 ? `DATA(${data_rows})` : 'EMPTY'); console.log( `[TEST] ${String(ms).padStart(5)}` + `ms: ${label}` ); _last_state = key; } }, 10); }); // ------------------------------------------------- // XHR tracking // ------------------------------------------------- let xhr_count = 0; page.on('request', req => { if (req.url().includes('datagrid_fetch')) { xhr_count++; } }); // ------------------------------------------------- // Full page reload detection // ------------------------------------------------- // Any document request after initial load means // something broke SPA navigation (likely // validate_session or auth header issue). let reload_count = 0; page.on('request', req => { if (req.resourceType() === 'document' && initial_doc_sent ) { reload_count++; console.log( ` !! UNEXPECTED RELOAD: ${req.url()}` ); } }); // ------------------------------------------------- // Helpers // ------------------------------------------------- // Label test steps for the observer log async function set_step(label) { try { await page.evaluate((l) => { if (window.__test_set_step) { window.__test_set_step(l); } }, label); } catch(e) {} } // Print and reset the observer log function flush_log() { console.log(state_log.join('\n')); state_log.length = 0; } // Wait for RSX _debug_ready (initial page load) async function wait_for_rsx_ready() { await page.evaluate(() => { return new Promise((resolve) => { if (window.Rsx && window.Rsx.on) { Rsx.on('_debug_ready', () => resolve()); } else { if (document.readyState === 'complete' || document.readyState === 'interactive' ) { setTimeout(resolve, 200); } else { document.addEventListener( 'DOMContentLoaded', () => setTimeout( resolve, 200 ) ); } } }); }); } // SPA navigate and wait for action ready async function spa_navigate(path) { xhr_count = 0; // Listen BEFORE dispatching const ready = page.evaluate(() => { return new Promise((resolve) => { Rsx.on('spa_dispatch_ready', () => resolve()); }); }); try { await page.evaluate((p) => { Spa.dispatch(p); }, path); } catch(e) {} await ready; await page.waitForTimeout( DYNAMIC_HEIGHT_SETTLE ); } // ------------------------------------------------- // Test steps // ------------------------------------------------- console.log(''); console.log('=== SPA Navigation Test ==='); console.log(`Viewport: 1920x1080`); console.log(''); // STEP 1: Cold start — first visit to ROUTE_A console.log( `--- STEP 1: First visit to ${ROUTE_A}` + ` (cold start) ---` ); state_log.length = 0; xhr_count = 0; await set_step('STEP 1'); await page.goto(`${BASE_URL}${ROUTE_A}`, { waitUntil: 'commit' }); await wait_for_rsx_ready(); await page.waitForTimeout(DYNAMIC_HEIGHT_SETTLE); await page.waitForLoadState('networkidle') .catch(() => {}); // Patch validate_session after initial load. // Dev auth sessions have a different hash than // what validate_session returns — test artifact. await page.evaluate(() => { Rsx.validate_session = async () => true; }); const s1_xhrs = xhr_count; flush_log(); console.log(` XHR count: ${s1_xhrs}`); console.log(''); // STEP 2: Navigate away to ROUTE_B console.log( `--- STEP 2: SPA navigate to` + ` ${ROUTE_B} ---` ); await set_step('STEP 2'); await spa_navigate(ROUTE_B); flush_log(); console.log(''); // STEP 3: Return to ROUTE_A (2nd visit) console.log( `--- STEP 3: SPA back to ${ROUTE_A}` + ` (2nd visit) ---` ); xhr_count = 0; await set_step('STEP 3'); await spa_navigate(ROUTE_A); const s3_xhrs = xhr_count; flush_log(); console.log(` XHR count: ${s3_xhrs}`); console.log(''); // STEP 4: Navigate away again console.log( `--- STEP 4: SPA navigate to` + ` ${ROUTE_B} ---` ); await set_step('STEP 4'); await spa_navigate(ROUTE_B); flush_log(); console.log(''); // STEP 5: Return to ROUTE_A (3rd visit) console.log( `--- STEP 5: SPA back to ${ROUTE_A}` + ` (3rd visit) ---` ); xhr_count = 0; await set_step('STEP 5'); await spa_navigate(ROUTE_A); const s5_xhrs = xhr_count; flush_log(); console.log(` XHR count: ${s5_xhrs}`); console.log(''); // ------------------------------------------------- // Summary // ------------------------------------------------- console.log('=== SUMMARY ==='); console.log( `Step 1 (cold): ${s1_xhrs} XHRs` ); console.log( `Step 3 (2nd visit): ${s3_xhrs} XHRs` ); console.log( `Step 5 (3rd visit): ${s5_xhrs} XHRs` ); console.log( `Page reloads: ${reload_count}` + ` (should be 0)` ); console.log(''); if (reload_count > 0) { console.log( 'FAIL: Full page reloads detected' + ' during SPA navigation.' ); } else { console.log( 'OK: No full page reloads' + ' — true SPA navigation achieved.' ); } console.log(''); await browser.close(); } run().catch(err => { console.error('Test failed:', err); process.exit(1); }); COMMON PITFALLS Auth Headers on Every Request (Session Regeneration) The most critical pitfall. Sending X-Dev-Auth-User-Id on every request (not just the first document request) causes RsxAuth::login() to regenerate the session on each hit. This changes the session_hash, which Rsx.validate_session() detects as a mismatch, triggering location.replace() -- a full page reload that destroys SPA state. Symptom: "Execution context was destroyed" errors, window markers disappearing, document request count incrementing after SPA dispatch calls. Fix: Auth headers on first document request only. All subsequent requests use the session cookie set by that first response. See AUTHENTICATION section. validate_session Causing Full Page Reloads Even with correct auth header scoping, dev-auth-established sessions may have a hash mismatch with what validate_session returns. This is a test environment artifact. Symptom: SPA dispatch triggers a full page reload despite auth headers being correctly scoped. Fix: Patch Rsx.validate_session after initial load. See SESSION VALIDATION section. Playwright Require Path Playwright is installed in system/node_modules/, not in the project root. If your script is outside system/, use an absolute require path: require('/var/www/html/system/node_modules/playwright') SPA Navigation via Link Clicks Do not use element.click() on sidebar links from page.evaluate(). jQuery's .click() override may not fire correctly through Playwright's evaluation bridge. Use Spa.dispatch() directly. Checking Visibility by DOM Presence Many RSX components (loading overlays, spinners) are always in the DOM. They toggle visibility via CSS classes like 'is-visible' or opacity changes. Check for the class, not the element: // WRONG: element always exists !!document.querySelector('.datagrid-loading-overlay') // RIGHT: check for the visibility class !!document.querySelector('.datagrid-loading-overlay.is-visible') Execution Context Destroyed Any page.evaluate() during SPA navigation can throw "Execution context was destroyed". This is normal -- the SPA replaced the DOM. Use try/catch or the addInitScript approach which is immune to context destruction. addInitScript Reruns addInitScript runs on every NEW page load, not on SPA navigation. If you see your observer's timer reset to 0 mid-step, it means a full page reload occurred (not an SPA transition). This is a useful diagnostic signal -- SPA transitions should NOT cause addInitScript to re-run. Dynamic Row Recalculation DataGrids with $rows="dynamic" measure the viewport after first render and may change per_page, triggering a second load. This is normal on first visit but should be cached on return visits. Expect to see two data states (e.g., DATA(10) then DATA(17)) as the grid adjusts. Arbitrary Timeouts Instead of Lifecycle Events Do NOT use page.waitForTimeout() or page.waitForLoadState() as primary wait mechanisms. Use RSX lifecycle events: - _debug_ready for initial page load - spa_dispatch_ready for SPA navigations Only use waitForTimeout for the dynamic height settle period (500ms after ready event), where no lifecycle event exists. FILES system/bin/route-debug.js Reference implementation for Playwright auth and single-page testing. Source for the _debug_ready wait pattern. system/app/RSpade/Core/Js/Rsx.js Framework core. Contains validate_session() logic, _debug_ready trigger, and init phase orchestration. system/app/RSpade/Core/SPA/Spa.js SPA router. Contains _on_spa_init() (awaited during init), Spa.dispatch(), and spa_dispatch_ready event trigger. system/node_modules/@jqhtml/core/dist/jqhtml-core.esm.js jqhtml component lifecycle, caching (Jqhtml_Local_Storage), and Load_Coordinator. Understanding _load(), _reload(), and the cache key generation is essential for diagnosing cache miss issues. rsx/theme/components/datagrid/datagrid_abstract.js DataGrid state management, dynamic row calculation, and load_page() orchestration. The parent component that drives DataGrid_Table reloads. rsx/theme/components/datagrid/datagrid_table.js The child component whose on_load() actually fetches data. Its args (per_page, sort, filter, etc.) form the cache key. bin/test-datagrid-cache.js Working reference test that validates DataGrid caching across SPA navigations. Demonstrates all patterns from this document. SEE ALSO rsx_debug, spa, jqhtml, caching RSX Framework March 2026 PLAYWRIGHT_SPA(7)