Files
rspade_system/app/RSpade/man/custom_playwright_tests_spa.txt

845 lines
31 KiB
Plaintext
Executable File

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)