Skip to main content

Bridge Internals

The bridge is the communication layer between Praman's Node.js test code and the SAP UI5 framework running in the browser. Understanding its internals is essential for debugging and extending Praman.

Bridge Injection via page.evaluate()​

Praman injects a bridge namespace (window.__praman_bridge) into the browser context using Playwright's page.evaluate(). The bridge provides:

  • UI5 module references (RecordReplay, Element, Log)
  • Version detection (sap.ui.version)
  • Object map for non-control storage (UUID-keyed)
  • getById() with 3-tier API resolution (Decision D19)
  • Helper functions: isPrimitive, saveObject, getObject, deleteObject

Injection Modes​

Praman supports two injection strategies:

Lazy injection (default): Triggered on first UI5 operation via ensureBridgeInjected().

// Every proxy and handler method calls this before browser operations
await ensureBridgeInjected(page);

Eager injection: Registered via addInitScript() before any page loads. Useful for auth flows where the bridge must be ready before the first navigation.

import { injectBridgeEager } from '#bridge/injection.js';

// Inject before any navigation (recommended for auth flows)
await injectBridgeEager(page);
await page.goto('https://sap-system.example.com/app');
// Bridge is already available when UI5 loads

Injection Lifecycle​

  1. Wait for UI5 framework availability (sap.ui.require exists)
  2. Execute bridge injection script (creates window.__praman_bridge)
  3. Wait for bridge readiness (window.__praman_ready === true)

Injection tracking uses WeakSet<Page> to avoid memory leaks. After page navigation invalidates the bridge, call resetPageInjection(page) so the next operation re-injects.

import { resetPageInjection } from '#bridge/injection.js';

// After navigation invalidates the bridge
resetPageInjection(page);
// Next ensureBridgeInjected() call will re-inject

Serialization Constraints​

This is the single most critical concept for anyone working on bridge code.

page.evaluate() serializes ONLY the function body via fn.toString(). This has profound implications:

  • Module-level functions are NOT included in the serialized output.
  • Imports and closures are NOT available inside page.evaluate().
  • TypeScript types (imports, as casts, type aliases) are fine -- they are erased at compile time.

The Inner Function Rule​

ALL helper functions MUST be declared as inner function declarations inside the evaluated function:

// CORRECT: helper is an inner function
await page.evaluate(() => {
function getControlById(id: string) {
// This function IS available in the browser context
return sap.ui.getCore().byId(id);
}
return getControlById('myButton');
});

// WRONG: helper is a module-level function
function getControlById(id: string) {
return sap.ui.getCore().byId(id);
}

await page.evaluate(() => {
// ReferenceError: getControlById is not defined
return getControlById('myButton');
});

Why Unit Tests Give False Positives​

Unit tests run in Node.js where module-level functions ARE accessible via closure. This means a unit test will pass even when the code would fail in the browser:

// This module-level function is accessible in Node.js tests...
function helperFn() {
return 'works';
}

// ...so this test PASSES in Vitest but the code FAILS in the browser
it('should work', () => {
const fn = () => helperFn();
expect(fn()).toBe('works'); // PASSES in Node.js
});

// In the browser, page.evaluate(() => helperFn()) throws ReferenceError

If you see sonarjs/no-identical-functions warnings about duplicate inner functions, suppress them with an ESLint disable comment. The duplication is intentional and required.

String-Based Scripts​

For complex browser operations, Praman uses string-based scripts instead of arrow functions. This avoids serialization issues entirely:

// Bridge injection uses string evaluation
const script = createBridgeInjectionScript(); // returns a string
await page.evaluate(script);

// Find-control and execute-method also use strings
const findScript = createFindControlScript(selector);
const result = await page.evaluate(findScript);

3-Tier API Resolution (D19)​

The bridge provides a centralized getById() function that resolves UI5 control IDs using three fallback tiers:

Tier 1: Element.getElementById()     (UI5 1.108+)
Tier 2: ElementRegistry.get() (UI5 1.84+)
Tier 3: sap.ui.getCore().byId() (All versions)

This is registered once during injection. All browser scripts call bridge.getById() instead of duplicating the lookup logic (which was a problem in dhikraft v2.5.0 where 6 files had independent copies).

Frame Navigation (WorkZone Dual-Frame)​

SAP BTP WorkZone (formerly Fiori Launchpad as a Service) renders applications inside nested iframes. Praman handles this transparently:

+----------------------------------+
| Shell Frame (WorkZone chrome) |
| +----------------------------+ |
| | App Frame (Fiori app) | |
| | UI5 controls live here | |
| +----------------------------+ |
+----------------------------------+

The bridge must be injected into the app frame, not the shell frame. Praman's fixture layer automatically detects the WorkZone dual-frame layout and targets the correct frame for bridge injection and control operations.

After frame navigation (e.g., navigating between Fiori Launchpad tiles), the bridge state in the previous frame is invalidated. The injection tracking resets and re-injects on the next operation.

Object Map Lifecycle​

Non-control UI5 objects (Models, BindingContexts, Routers, etc.) cannot be serialized across the page.evaluate() boundary. Praman stores them in a browser-side Map keyed by UUID:

// Browser side: save an object reference
const uuid = bridge.saveObject(myModel, 'model');
// Returns: 'a1b2c3d4-...'

// Node side: reference the object by UUID in subsequent calls
await page.evaluate((uuid) => bridge.getObject(uuid).getProperty('/Name'), uuid);

TTL-Based Cleanup (D20)​

To prevent memory leaks in long test runs, objects are stored with timestamps:

bridge.objectMap.set(uuid, {
value: obj,
type: type || 'unknown',
storedAt: Date.now(),
});

A cleanup function evicts entries older than the configured TTL. This pairs with the Node-side UI5ObjectCache (TTL + LRU eviction) to keep both sides in sync.

Stability Checks​

Before performing control operations, Praman waits for UI5 to reach a stable (idle) state:

// waitForUI5Stable checks these conditions:
// 1. No pending HTTP requests
// 2. No pending timeouts
// 3. No pending UI5 rendering updates
// 4. RecordReplay is available and idle

await page.waitForFunction(waitForUI5StableScript, { timeout });

The skipStabilityWait option (D23) can be configured globally or per-selector for pages with third-party overlays (like WalkMe) that interfere with stability detection.

Browser Scripts​

The src/bridge/browser-scripts/ directory contains the scripts executed in the browser context:

ScriptPurpose
inject-ui5.tsCore bridge initialization, module references, version detection
find-control.tsUI5 control discovery by selector
execute-method.tsRemote method invocation on UI5 controls
get-selector.tsReverse lookup: DOM element to UI5 selector
get-version.tsUI5 version detection
inspect-control.tsControl metadata introspection for AI
object-map.tsUUID-keyed non-control object storage with TTL
find-control-matchers.tsProperty matcher generation for selectors

Interaction Strategies​

Praman provides three strategies for interacting with UI5 controls:

StrategyApproachBest For
UI5NativeStrategyfire* events -> fireTap -> DOM clickStandard UI5 controls, most reliable
DomFirstStrategyDOM click + auto-detect input typeControls with custom renderers
Opa5StrategyRecordReplay.interactWithControlComplex controls needing SAP's own interaction API

The strategy factory selects the appropriate strategy based on configuration and control type.