🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
845 lines
31 KiB
Plaintext
Executable File
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)
|