# Praman SAP UI5 Testing Guide > Authentication, navigation, FLP, OData, Fiori Elements, control cookbook, gold standard tests, and SAP-specific examples. This file contains all documentation content in a single document following the llmstxt.org standard. ## Auth Setup # Authentication Setup Example Complete SAP authentication setup supporting OnPrem, BTP Cloud SAML, and Office 365 strategies. Copy this file into your project as `tests/auth-setup.ts` and configure it in `playwright.config.ts` as a setup project. ## Environment Variables ```bash # Required (set in .env or CI secrets) SAP_CLOUD_BASE_URL=https://your-sap-system.example.com SAP_CLOUD_USERNAME=TEST_USER SAP_CLOUD_PASSWORD= # Optional SAP_AUTH_STRATEGY=btp-saml # 'basic' | 'btp-saml' | 'office365' SAP_CLIENT=100 # OnPrem only SAP_LANGUAGE=EN # Default: EN ``` ## Playwright Config ```typescript export default defineConfig({ projects: [ // Auth setup -- runs first, saves session to .auth/sap-session.json { name: 'auth', testMatch: '**/auth-setup.ts', teardown: 'auth-teardown', // optional }, // Auth teardown -- runs after all tests (optional) { name: 'auth-teardown', testMatch: '**/auth-teardown.ts', }, // Main test project -- reuses saved session { name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: '.auth/sap-session.json', }, dependencies: ['auth'], }, ], }); ``` ## Full Source ```typescript const AUTH_STATE_PATH = join(process.cwd(), '.auth', 'sap-session.json'); function requireEnv(name: string): string { const value = process.env[name]; if (value === undefined || value === '') { throw new Error( `Missing required environment variable: ${name}. ` + 'Set it in your .env.test file or CI secrets.', ); } return value; } setup('SAP authentication', async ({ page, context }) => { const baseUrl = requireEnv('SAP_CLOUD_BASE_URL'); const username = requireEnv('SAP_CLOUD_USERNAME'); const password = requireEnv('SAP_CLOUD_PASSWORD'); const strategy = process.env['SAP_AUTH_STRATEGY'] ?? 'btp-saml'; const client = process.env['SAP_CLIENT'] ?? '100'; const language = process.env['SAP_LANGUAGE'] ?? 'EN'; if (strategy === 'basic') { // OnPrem SAP NetWeaver login const loginUrl = `${baseUrl}/sap/bc/ui5_ui5/ui2/ushell/shells/abap/FioriLaunchpad.html` + `?sap-client=${client}&sap-language=${language}`; await page.goto(loginUrl); await page.locator('#sap-user, input[name="sap-user"]').fill(username); await page.locator('#sap-password, input[name="sap-password"]').fill(password); await page.locator('#LOGON_BUTTON, button[type="submit"], input[type="submit"]').click(); await page.waitForSelector('#shell-header, .sapUshellShellHead', { timeout: 30_000 }); } else if (strategy === 'btp-saml') { // BTP Cloud Foundry / SAP IAS SAML2 redirect await page.goto(baseUrl); await page.waitForSelector('input[name="j_username"], input[name="email"], #logOnFormEmail', { timeout: 30_000, }); await page .locator('input[name="j_username"], input[name="email"], #logOnFormEmail') .fill(username); await page .locator('input[name="j_password"], input[name="password"], #logOnFormPassword') .fill(password); await page.locator('button[type="submit"], #logOnFormSubmit, input[type="submit"]').click(); await page.waitForURL(`${baseUrl}/**`, { timeout: 60_000 }); await page.waitForSelector('#shell-header, .sapUshellShellHead', { timeout: 30_000 }); } else if (strategy === 'office365') { // Microsoft Entra ID (Azure AD) / Office 365 SSO await page.goto(baseUrl); await page.waitForURL('**/login.microsoftonline.com/**', { timeout: 30_000 }); await page.locator('input[type="email"]').fill(username); await page.locator('input[type="submit"], #idSIButton9').click(); await page.waitForSelector('input[type="password"]', { timeout: 15_000 }); await page.locator('input[type="password"]').fill(password); await page.locator('input[type="submit"], #idSIButton9').click(); const staySignedIn = page.locator('#idSIButton9, input[type="submit"]'); if (await staySignedIn.isVisible().catch(() => false)) { await staySignedIn.click(); } await page.waitForSelector('#shell-header, .sapUshellShellHead', { timeout: 60_000 }); } else { throw new Error( `Unknown auth strategy: "${strategy}". Supported: "basic", "btp-saml", "office365".`, ); } // Save authenticated state for reuse by all test projects await context.storageState({ path: AUTH_STATE_PATH }); }); ``` :::tip Why use a setup project? Playwright's [project dependencies](https://playwright.dev/docs/auth) run the auth setup once and share the session across all test files. This avoids logging in before every test, saving time and reducing flaky failures from login page changes. ::: --- ## BTP Multi-Tenant Auth Demonstrates how to handle multi-tenant authentication on SAP BTP Work Zone, including tenant switching, session isolation, and tenant-scoped data verification. ## When to Use - ISV applications deployed to multiple BTP tenants - Multi-tenant SaaS testing where each tenant has its own data context - Verifying session cookies are properly scoped per tenant - Testing tenant-specific FLP tile catalogs and content ## Source ```typescript test.describe('BTP Work Zone Multi-Tenant Auth', () => { test('authenticate and verify tenant context', async ({ page, ui5, btpWorkZone }) => { await test.step('Detect BTP Work Zone and navigate to app', async () => { // Authentication is handled by the seed spec (storageState). // The btpWorkZone fixture detects the frame structure automatically. await btpWorkZone.detect(); await btpWorkZone.navigateToApp('PurchaseOrder-manage'); await ui5.waitForUI5(); await expect(page).toHaveURL(/site#/); }); await test.step('Verify tenant-specific FLP content', async () => { await expect(async () => { const tile = await ui5.control({ controlType: 'sap.ushell.ui.launchpad.Tile', }); expect(tile).toBeTruthy(); }).toPass({ timeout: 30_000, intervals: [2000, 5000] }); }); await test.step('Navigate to tenant-specific app', async () => { await expect(async () => { await ui5.press({ controlType: 'sap.m.GenericTile', properties: { header: 'Manage Purchase Orders' }, }); }).toPass({ timeout: 60_000, intervals: [5000, 10_000] }); await ui5.waitForUI5(); }); await test.step('Verify tenant data context via OData', async () => { await ui5.odata.waitForLoad(); const orders = await ui5.odata.getModelData('/PurchaseOrders'); expect(orders).toBeTruthy(); }); }); test('switch tenant and verify session isolation', async ({ page, ui5, btpWorkZone }) => { await test.step('Detect Work Zone for first tenant', async () => { // Authentication is handled by the seed spec (storageState). await btpWorkZone.detect(); await ui5.waitForUI5(); }); await test.step('Switch to second tenant', async () => { // Tenant switching requires re-authentication via seed spec. // Use a separate Playwright project/storageState per tenant. await btpWorkZone.switchToShell(); await btpWorkZone.navigateToApp('PurchaseOrder-manage'); await ui5.waitForUI5(); await expect(page).toHaveURL(/site#/); }); await test.step('Verify second tenant has different data', async () => { await expect(async () => { await ui5.press({ controlType: 'sap.m.GenericTile', properties: { header: 'Manage Purchase Orders' }, }); }).toPass({ timeout: 60_000, intervals: [5000, 10_000] }); await ui5.waitForUI5(); await ui5.odata.waitForLoad(); }); }); test('verify session cookies are tenant-scoped', async ({ page, btpWorkZone }) => { await test.step('Detect Work Zone for tenant', async () => { // Authentication is handled by the seed spec (storageState). await btpWorkZone.detect(); }); await test.step('Verify session cookies contain tenant subdomain', async () => { const cookies = await page.context().cookies(); const sessionCookies = cookies.filter( (c) => c.name.includes('JSESSIONID') || c.name.includes('SAP_SESSIONID'), ); expect(sessionCookies.length).toBeGreaterThan(0); for (const cookie of sessionCookies) { expect(cookie.domain).toContain(process.env['BTP_TENANT_1']!); } }); await test.step('Verify session cleanup on context disposal', async () => { // Logout is handled by Playwright context disposal. // Verify cookies exist while context is active. const cookies = await page.context().cookies(); const sessionCookies = cookies.filter( (c) => c.name.includes('JSESSIONID') || c.name.includes('SAP_SESSIONID'), ); expect(sessionCookies.length).toBeGreaterThan(0); }); }); }); ``` ## Key Concepts - **`btpWorkZone` fixture** -- handles the BTP Work Zone frame structure and app navigation - **`btpWorkZone.detect()`** -- detects the Work Zone frame structure (shell + app frames) - **`btpWorkZone.navigateToApp()`** -- navigates to a specific app by semantic object and action - **`btpWorkZone.switchToShell()`** -- switches context back to the outer shell frame - **`btpWorkZone.getAppFrameForEval()`** -- returns the app iframe for direct evaluation - **Tenant isolation** -- each tenant has its own FLP catalog, data context, and session cookies - **Cookie scoping** -- session cookies (`JSESSIONID`, `SAP_SESSIONID`) are scoped to the tenant subdomain ## Environment Variables | Variable | Description | Example | | ------------------ | -------------------------- | ------------------------------------------------------- | | `BTP_WORKZONE_URL` | BTP Work Zone base URL | `https://myapp.launchpad.cfapps.eu10.hana.ondemand.com` | | `BTP_TENANT_1` | First tenant subdomain | `acme-dev` | | `BTP_TENANT_2` | Second tenant subdomain | `globex-dev` | | `SAP_USERNAME` | IDP credentials (username) | `testuser@acme.com` | | `SAP_PASSWORD` | IDP credentials (password) | (stored in CI vault) | --- ## Dialog Handling Demonstrates how to open, interact with, and close SAP UI5 dialogs using Praman. ## Critical Pattern: `searchOpenDialogs` ```typescript // Without searchOpenDialogs -- ONLY searches main view const btn = await ui5.control({ id: 'myBtn' }); // Won't find dialog controls! // With searchOpenDialogs -- searches dialogs too const btn = await ui5.control({ id: 'myBtn', searchOpenDialogs: true }); // Correct ``` :::warning Always use `searchOpenDialogs: true` when finding controls inside an `sap.m.Dialog`. Without it, `ui5.control()` only searches the main view and will throw `ERR_CONTROL_NOT_FOUND`. ::: ## Source ```typescript test.describe('Dialog Handling', () => { test('open dialog, fill fields, and dismiss', async ({ page, ui5 }) => { await test.step('Navigate to app', async () => { await page.goto(process.env['SAP_CLOUD_BASE_URL']!); await page.waitForLoadState('domcontentloaded'); await expect(page).toHaveTitle(/Home/, { timeout: 60_000 }); await expect(async () => { await ui5.press({ controlType: 'sap.m.GenericTile', properties: { header: 'My App' }, }); }).toPass({ timeout: 60_000, intervals: [5000, 10_000] }); await ui5.waitForUI5(); }); await test.step('Open Create dialog', async () => { await ui5.press({ controlType: 'sap.m.Button', properties: { text: 'Create' }, }); await ui5.waitForUI5(); }); await test.step('Verify dialog structure', async () => { // searchOpenDialogs: true is REQUIRED for dialog controls const nameField = await ui5.control({ id: 'createDialog--nameField', searchOpenDialogs: true, }); const controlType = await nameField.getControlType(); expect(controlType).toBe('sap.ui.comp.smartfield.SmartField'); const isRequired = await nameField.getRequired(); expect(isRequired).toBe(true); }); await test.step('Fill dialog fields', async () => { // ui5.fill() = atomic setValue + fireChange + waitForUI5 await ui5.fill({ id: 'createDialog--nameInput', searchOpenDialogs: true }, 'Test Entry'); await ui5.waitForUI5(); const value = await ui5.getValue({ id: 'createDialog--nameInput', searchOpenDialogs: true, }); expect(value).toBe('Test Entry'); }); await test.step('Dismiss dialog with Cancel', async () => { await ui5.press({ id: 'createDialog--cancelBtn', searchOpenDialogs: true, }); await ui5.waitForUI5(); // Verify dialog closed let dialogClosed = false; try { await ui5.control({ id: 'createDialog--cancelBtn', searchOpenDialogs: true, }); } catch { dialogClosed = true; } expect(dialogClosed).toBe(true); }); }); test('confirm dialog with value help', async ({ page, ui5 }) => { await test.step('Open value help dialog', async () => { await ui5.press({ id: 'myField-input-vhi', searchOpenDialogs: true, }); const vhDialog = await ui5.control({ id: 'myField-input-valueHelpDialog', searchOpenDialogs: true, }); const isOpen = await vhDialog.isOpen(); expect(isOpen).toBe(true); }); await test.step('Close value help dialog', async () => { const vhDialog = await ui5.control({ id: 'myField-input-valueHelpDialog', searchOpenDialogs: true, }); // close() calls the UI5 Dialog.close() method directly await vhDialog.close(); await ui5.waitForUI5(); }); }); }); ``` ## Key Concepts - **`searchOpenDialogs: true`** -- mandatory for any control inside a dialog - **`ui5.fill()`** -- atomic `setValue()` + `fireChange()` + `waitForUI5()` - **`isOpen()` / `close()`** -- UI5 Dialog lifecycle methods via proxy - **Value help dialogs** are secondary dialogs that open from SmartField icons --- ## Fiori Elements Demonstrates testing SAP Fiori Elements applications using Praman's `playwright-praman/fe` sub-path helpers: List Report filter bar operations, variant management, Object Page section navigation, and the edit/save cycle. ## Supported Floorplans - **List Report** -- filter bar, table, variant management, row navigation - **Object Page** -- sections, header, edit mode, form fields - **Both V2 and V4** -- works with Smart Templates (V2) and Fiori Elements for OData V4 ## Source ```typescript getListReportTable, getFilterBar, setFilterBarField, executeSearch, clearFilterBar, getAvailableVariants, selectVariant, navigateToItem, getHeaderTitle, getObjectPageSections, getSectionData, navigateToSection, clickEditButton, clickSaveButton, isInEditMode, feGetTableRowCount, feGetColumnNames, feGetCellValue, feFindRowByValues, } from 'playwright-praman/fe'; test.describe('Fiori Elements -- Material Master', () => { test('List Report: filter, search, and browse materials', async ({ page, ui5 }) => { await test.step('Navigate to Material List Report', async () => { await page.goto('/sap/bc/ui5_ui5/ui2/ushell/shells/abap/FioriLaunchpad.html#Material-manage'); await ui5.waitForUI5(); }); await test.step('Apply filters and execute search', async () => { const filterBarId = await getFilterBar(page); await setFilterBarField(page, filterBarId, 'MaterialType', 'ROH'); await setFilterBarField(page, filterBarId, 'Plant', '1000'); await executeSearch(page, filterBarId); await ui5.waitForUI5(); const tableId = await getListReportTable(page); const rowCount = await feGetTableRowCount(page, tableId); expect(rowCount).toBeGreaterThan(0); }); await test.step('Read table column headers', async () => { const tableId = await getListReportTable(page); const columns = await feGetColumnNames(page, tableId); expect(columns).toContain('Material'); expect(columns).toContain('Description'); }); await test.step('Find row by column values', async () => { const tableId = await getListReportTable(page); const firstMaterial = await feGetCellValue(page, tableId, 0, 'Material'); expect(firstMaterial).toBeTruthy(); const rowIndex = await feFindRowByValues(page, tableId, { Material: 'RAW-0001', }); }); await test.step('Clear filters', async () => { const filterBarId = await getFilterBar(page); await clearFilterBar(page, filterBarId); await executeSearch(page, filterBarId); await ui5.waitForUI5(); }); }); test('Variant Management', async ({ page, ui5 }) => { await test.step('Navigate to List Report', async () => { await page.goto('/sap/bc/ui5_ui5/ui2/ushell/shells/abap/FioriLaunchpad.html#Material-manage'); await ui5.waitForUI5(); }); await test.step('List and select variants', async () => { const variants = await getAvailableVariants(page); expect(variants.length).toBeGreaterThan(0); expect(variants).toContain('Standard'); const customVariant = variants.find((v) => v !== 'Standard'); if (customVariant) { await selectVariant(page, customVariant); await ui5.waitForUI5(); } }); }); test('Object Page: view sections, edit, and save', async ({ page, ui5 }) => { await test.step('Navigate to first item', async () => { await page.goto('/sap/bc/ui5_ui5/ui2/ushell/shells/abap/FioriLaunchpad.html#Material-manage'); await ui5.waitForUI5(); const filterBarId = await getFilterBar(page); await executeSearch(page, filterBarId); await ui5.waitForUI5(); const tableId = await getListReportTable(page); await navigateToItem(page, tableId, 0); await ui5.waitForUI5(); }); await test.step('Verify Object Page header and sections', async () => { const title = await getHeaderTitle(page); expect(title).toBeTruthy(); const sections = await getObjectPageSections(page); expect(sections.length).toBeGreaterThan(0); }); await test.step('Navigate to section and read data', async () => { await navigateToSection(page, 'General Information'); const data = await getSectionData(page, 'General Information'); expect(data).toHaveProperty('Material Type'); }); await test.step('Edit and save', async () => { expect(await isInEditMode(page)).toBe(false); await clickEditButton(page); await ui5.waitForUI5(); expect(await isInEditMode(page)).toBe(true); await ui5.fill( { controlType: 'sap.m.Input', properties: { name: 'Description' } }, 'Updated by Praman test', ); await ui5.waitForUI5(); await clickSaveButton(page); await ui5.waitForUI5(); expect(await isInEditMode(page)).toBe(false); }); }); }); ``` ## Key Concepts ### List Report - **`getFilterBar(page)`** -- discovers the filter bar control ID - **`setFilterBarField(page, id, field, value)`** -- sets a filter field value - **`executeSearch(page, id)`** -- clicks the "Go" button to trigger search - **`clearFilterBar(page, id)`** -- clears all filter fields - **`getListReportTable(page)`** -- discovers the main table control ID - **`navigateToItem(page, tableId, rowIndex)`** -- clicks a row to open Object Page ### Variant Management - **`getAvailableVariants(page)`** -- lists all saved variants - **`selectVariant(page, name)`** -- switches to a variant by name ### Object Page - **`getHeaderTitle(page)`** -- reads the Object Page header title - **`getObjectPageSections(page)`** -- returns all sections with sub-sections - **`navigateToSection(page, name)`** -- scrolls/navigates to a named section - **`getSectionData(page, name)`** -- reads form field key-value pairs from a section - **`isInEditMode(page)`** -- checks if Object Page is in edit mode - **`clickEditButton(page)`** -- toggles edit mode on - **`clickSaveButton(page)`** -- saves changes ### FE Table Helpers - **`feGetTableRowCount(page, tableId)`** -- counts visible rows - **`feGetColumnNames(page, tableId)`** -- reads column headers - **`feGetCellValue(page, tableId, row, column)`** -- reads a specific cell - **`feFindRowByValues(page, tableId, { col: val })`** -- finds a row by column values --- ## Gold Standard BOM E2E # Gold Standard: BOM End-to-End Flow A complete end-to-end test for SAP Bill of Material (BOM) maintenance using V1 SmartField controls (`sap.ui.comp`). This is the Praman **gold standard** -- demonstrating every major pattern in a single, realistic test. ## What This Example Covers 1. **FLP navigation** -- space tabs, tile press with auto-retry 2. **Create dialog** -- open, verify structure, check required fields 3. **Value help dialogs** -- open, read OData data, close 4. **ComboBox dropdown** -- get items, open/close, set selected key 5. **Form filling** -- `ui5.fill()` for inputs, `setSelectedKey()` for dropdowns 6. **Verification** -- read back all values before submission 7. **Error handling** -- graceful recovery from SAP validation errors 8. **Navigation confirmation** -- verify return to list report ## Discovery Results - **UI5 Version**: 1.142.x - **App**: Maintain Bill of Material (Fiori Elements V2 List Report) - **System**: SAP S/4HANA Cloud - **OData**: V2 with SmartField controls (`sap.ui.comp.smartfield.SmartField`) ## Source (Abbreviated) The full source is available at `examples/bom-e2e-praman-gold-standard.spec.ts` (720+ lines). Key excerpts below. ### Control ID Constants ```typescript const IDS = { materialField: 'createBOMFragment--material', materialInput: 'createBOMFragment--material-input', materialVHIcon: 'createBOMFragment--material-input-vhi', materialVHDialog: 'createBOMFragment--material-input-valueHelpDialog', materialVHTable: 'createBOMFragment--material-input-valueHelpDialog-table', plantField: 'createBOMFragment--plant', plantInput: 'createBOMFragment--plant-input', plantVHIcon: 'createBOMFragment--plant-input-vhi', plantVHDialog: 'createBOMFragment--plant-input-valueHelpDialog', plantVHTable: 'createBOMFragment--plant-input-valueHelpDialog-table', bomUsageField: 'createBOMFragment--variantUsage', bomUsageCombo: 'createBOMFragment--variantUsage-comboBoxEdit', okBtn: 'createBOMFragment--OkBtn', cancelBtn: 'createBOMFragment--CancelBtn', } as const; ``` ### Step 1: Navigate to App ```typescript await test.step('Step 1: Navigate to BOM Maintenance App', async () => { await page.goto(process.env.SAP_CLOUD_BASE_URL!); await page.waitForLoadState('domcontentloaded'); await expect(page).toHaveTitle(/Home/, { timeout: 60_000 }); // FLP space tabs -- DOM click is the only reliable method await page.getByText('Bills Of Material', { exact: true }).click(); // Tile press with auto-retry (waitForUI5 can time out on slow systems) await expect(async () => { await ui5.press({ controlType: 'sap.m.GenericTile', properties: { header: 'Maintain Bill Of Material' }, }); }).toPass({ timeout: 60_000, intervals: [5000, 10_000] }); // Wait for Create BOM button to prove app loaded await expect(async () => { const createBtn = await ui5.control({ controlType: 'sap.m.Button', properties: { text: 'Create BOM' }, }); expect(await createBtn.getProperty('text')).toBe('Create BOM'); }).toPass({ timeout: 120_000, intervals: [5000, 10_000] }); }); ``` ### Step 3: Value Help Pattern ```typescript await test.step('Step 3: Test Material Value Help', async () => { // Open value help via icon await ui5.press({ id: IDS.materialVHIcon, searchOpenDialogs: true }); // Verify dialog opened const dialog = await ui5.control({ id: IDS.materialVHDialog, searchOpenDialogs: true }); expect(await dialog.isOpen()).toBe(true); // Get inner table via SmartTable.getTable() const smartTable = await ui5.control({ id: IDS.materialVHTable, searchOpenDialogs: true }); const innerTable = await smartTable.getTable(); const rows = await innerTable.getRows(); expect(rows.length).toBeGreaterThan(0); // Wait for OData data with Playwright auto-retry await expect(async () => { let count = 0; for (const row of rows) { if (await row.getBindingContext()) count++; } expect(count).toBeGreaterThan(0); }).toPass({ timeout: 60_000, intervals: [1000, 2000, 5000] }); await dialog.close(); await ui5.waitForUI5(); }); ``` ### Step 6: Form Fill with Verification ```typescript await test.step('Step 6: Fill Form with Valid Data', async () => { // Get material value from value help OData const smartTable = await ui5.control({ id: IDS.materialVHTable, searchOpenDialogs: true }); const innerTable = await smartTable.getTable(); const ctx = await innerTable.getContextByIndex(0); const entity = await ctx.getObject(); const materialValue = entity.Material; // Fill using ui5.fill() -- atomic setValue + fireChange + waitForUI5 await ui5.fill({ id: IDS.materialInput, searchOpenDialogs: true }, materialValue); // Set ComboBox by key (localization-safe) const bomUsageCombo = await ui5.control({ id: IDS.bomUsageCombo, searchOpenDialogs: true }); await bomUsageCombo.setSelectedKey('1'); await bomUsageCombo.fireChange({ value: '1' }); // Verify all values before submission expect(await ui5.getValue({ id: IDS.materialInput, searchOpenDialogs: true })).toBe( materialValue, ); }); ``` ## Compliance Report | Metric | Value | | --------------------------- | ----------------------------------------------------- | | Controls discovered | 15+ | | UI5 elements interacted | 15+ | | Praman fixture usage | 100% | | Playwright native usage | 0% (except `page.goto`, `page.getByText` for FLP tab) | | Forbidden patterns detected | 0 | ## Key Patterns Demonstrated - **`expect().toPass()`** -- auto-retry for slow SAP systems - **`getTable()` on SmartTable** -- access the inner table for row operations - **`getContextByIndex().getObject()`** -- data-driven testing via OData binding - **`setSelectedKey()` for ComboBox** -- localization-safe (no text matching) - **`ui5.fill()`** -- atomic `setValue()` + `fireChange()` + `waitForUI5()` - **Graceful error recovery** -- check for MessagePopover and MessageBox after actions --- ## Hybrid Login # Hybrid Login Flow Demonstrates the **auto-fallback** pattern: use Playwright native locators for non-UI5 pages (login forms, IDP redirects) and seamlessly switch to Praman UI5 fixtures once inside the SAP application. ## When to Use Each Approach | Page Type | Approach | Why | | --------------------------------------- | ----------------------------------------------- | ----------------------------- | | Login page / IDP redirect | `page.locator()`, `page.fill()`, `page.click()` | These are plain HTML, not UI5 | | SAP Fiori Launchpad tiles | `ui5.control()`, `ui5.press()` | UI5 controls with dynamic IDs | | SAP app controls (Input, Table, Dialog) | `ui5.control()`, `ui5.fill()` | UI5 controls with event model | ## Source ```typescript test.describe('Hybrid Login Flow', () => { test('login with Playwright native, then test with Praman', async ({ page, ui5 }) => { // ================================================================ // PHASE 1: Playwright Native -- Login Form (NOT UI5) // ================================================================ await test.step('Login via SAP IAS (Playwright native)', async () => { await page.goto(process.env['SAP_CLOUD_BASE_URL']!); // IAS login form is plain HTML -- use Playwright locators await page.waitForSelector('input[name="j_username"], input[name="email"]', { timeout: 30_000, }); await page .locator('input[name="j_username"], input[name="email"]') .fill(process.env['SAP_USERNAME']!); await page .locator('input[name="j_password"], input[name="password"]') .fill(process.env['SAP_PASSWORD']!); await page.locator('button[type="submit"], #logOnFormSubmit').click(); await expect(page).toHaveTitle(/Home/, { timeout: 60_000 }); }); // ================================================================ // PHASE 2: Praman UI5 Fixtures -- SAP Application (UI5) // ================================================================ await test.step('Navigate FLP tile (Praman UI5)', async () => { // Now on the Fiori Launchpad -- switch to Praman fixtures. // The bridge is injected automatically on the first ui5.* call. const tile = await ui5.control({ controlType: 'sap.m.GenericTile', properties: { header: 'Purchase Orders' }, }); const header = await tile.getProperty('header'); expect(header).toBe('Purchase Orders'); await ui5.press({ controlType: 'sap.m.GenericTile', properties: { header: 'Purchase Orders' }, }); await ui5.waitForUI5(); }); await test.step('Interact with app controls (Praman UI5)', async () => { const searchField = await ui5.control({ controlType: 'sap.m.SearchField', }); const controlType = await searchField.getControlType(); expect(controlType).toBe('sap.m.SearchField'); await ui5.fill({ controlType: 'sap.m.SearchField' }, 'PO-12345'); await ui5.waitForUI5(); }); }); }); ``` ## Key Concepts - **No special configuration** -- Praman auto-detects UI5 pages and injects the bridge on first `ui5.*` call - **Playwright native for non-UI5** -- login pages, IDP redirects, and static HTML forms - **Praman fixtures for UI5** -- all SAP Fiori controls inside the application - **Seamless transition** -- both approaches work in the same test, same `page` object --- ## Intent API Demonstrates Praman's intent-based testing layer from `playwright-praman/intents`: core wrappers (`fillField`, `clickButton`, `assertField`, `selectOption`), domain functions (`procurement.createPurchaseOrder`), vocabulary integration, and the `IntentResult` envelope. ## Core Wrappers vs Domain Functions | Layer | Granularity | Example | | -------------------- | ------------- | --------------------------------------------------- | | **Core wrappers** | Single action | `fillField(ui5, vocab, 'Vendor', '100001')` | | **Domain functions** | Business flow | `procurement.createPurchaseOrder(ui5, vocab, data)` | Core wrappers are low-level building blocks. Domain functions compose them into multi-step SAP business flows. ## Source ```typescript fillField, clickButton, selectOption, assertField, navigateAndSearch, confirmAndWait, waitForSave, } from 'playwright-praman/intents'; test.describe('Intent API -- Procurement Flows', () => { test('core wrappers: fill, assert, select, click', async ({ ui5, ui5Navigation }) => { await test.step('Navigate to Purchase Order creation', async () => { await ui5Navigation.navigateToApp('PurchaseOrder-create'); await ui5.waitForUI5(); }); await test.step('Fill fields using direct selectors', async () => { // Without vocabulary -- pass UI5 selectors directly const vendorResult = await fillField(ui5, undefined, { id: 'vendorInput' }, '100001'); expect(vendorResult.status).toBe('success'); const orgResult = await fillField(ui5, undefined, { id: 'purchOrgInput' }, '1000'); expect(orgResult.status).toBe('success'); }); await test.step('Select option and assert values', async () => { const result = await selectOption(ui5, undefined, { id: 'currencySelect' }, 'EUR'); expect(result.status).toBe('success'); const check = await assertField(ui5, undefined, { id: 'vendorInput' }, '100001'); expect(check.status).toBe('success'); }); await test.step('Click save button', async () => { const result = await clickButton(ui5, undefined, 'Save'); expect(result.status).toBe('success'); const saveResult = await waitForSave(ui5); expect(saveResult.status).toBe('success'); }); }); test('core wrappers with vocabulary: resolve business terms', async ({ ui5, ui5Navigation }) => { const vocab = createVocabularyService(); await vocab.loadDomain('procurement'); await test.step('Navigate to Purchase Order creation', async () => { await ui5Navigation.navigateToApp('PurchaseOrder-create'); await ui5.waitForUI5(); }); await test.step('Fill fields using vocabulary terms', async () => { // With vocabulary: "Vendor" resolves to the appropriate UI5 selector const vendorResult = await fillField(ui5, vocab, 'Vendor', '100001', { domain: 'procurement', }); expect(vendorResult.status).toBe('success'); const orgResult = await fillField(ui5, vocab, 'Purchasing Organization', '1000', { domain: 'procurement', }); expect(orgResult.status).toBe('success'); const matResult = await fillField(ui5, vocab, 'Material', 'RAW-0001', { domain: 'procurement', }); expect(matResult.status).toBe('success'); }); }); test('domain function: create purchase order end-to-end', async ({ ui5, ui5Navigation }) => { const vocab = createVocabularyService(); await vocab.loadDomain('procurement'); await test.step('Navigate to PO creation', async () => { await ui5Navigation.navigateToApp('PurchaseOrder-create'); await ui5.waitForUI5(); }); await test.step('Create PO using domain function', async () => { // Composes: navigate -> fillVendor -> fillMaterial -> fillQuantity -> save const result = await procurement.createPurchaseOrder( ui5, vocab, { vendor: '100001', material: 'RAW-0001', quantity: 50, plant: '1000', purchasingOrganization: '1000', purchasingGroup: '001', }, { skipNavigation: true, timeout: 60_000, }, ); expect(result.status).toBe('success'); expect(result.data).toBeTruthy(); expect(result.metadata.sapModule).toBe('MM'); expect(result.metadata.intentName).toBe('createPurchaseOrder'); expect(result.metadata.stepsExecuted.length).toBeGreaterThan(0); }); }); test('handle intent errors gracefully', async ({ ui5, ui5Navigation }) => { const vocab = createVocabularyService(); await vocab.loadDomain('procurement'); await test.step('Navigate to PO creation', async () => { await ui5Navigation.navigateToApp('PurchaseOrder-create'); await ui5.waitForUI5(); }); await test.step('Unknown vocabulary term returns error result', async () => { const result = await fillField(ui5, vocab, 'NonExistentField', 'value', { domain: 'procurement', }); // Error result -- not an exception expect(result.status).toBe('error'); expect(result.error).toBeTruthy(); expect(result.metadata.suggestions.length).toBeGreaterThan(0); }); }); }); ``` ## Key Concepts ### IntentResult Envelope All intent functions return a typed `IntentResult`: ```typescript interface IntentResult { readonly status: 'success' | 'error' | 'partial'; readonly data?: T; readonly error?: { readonly code: string; readonly message: string }; readonly metadata: { readonly duration: number; readonly retryable: boolean; readonly suggestions: string[]; readonly intentName: string; readonly sapModule: string; readonly stepsExecuted: string[]; }; } ``` ### Core Wrappers | Wrapper | Purpose | | ------------------- | ------------------------------------------ | | `fillField` | Fill a UI5 input, with optional vocab term | | `clickButton` | Click a button by text label or selector | | `selectOption` | Select a dropdown/ComboBox option | | `assertField` | Assert field value matches expected text | | `navigateAndSearch` | Navigate to an app and execute a search | | `confirmAndWait` | Click confirmation and wait for stability | | `waitForSave` | Wait for save operation (message toast) | ### Domain Functions - **`procurement.createPurchaseOrder()`** -- fill vendor, material, quantity, plant, and save - **`procurement.searchPurchaseOrders()`** -- navigate and filter by vendor, date, etc. - **`procurement.displayPurchaseOrder()`** -- navigate to a specific PO detail page ### Vocabulary Integration Core wrappers accept an optional `VocabLookup` to resolve business terms: ```typescript // With vocabulary: "Vendor" resolves to { id: 'vendorInput' } await fillField(ui5, vocab, 'Vendor', '100001', { domain: 'procurement' }); // Without vocabulary: pass a selector directly await fillField(ui5, undefined, { id: 'vendorInput' }, '100001'); ``` ### IntentOptions ```typescript interface IntentOptions { skipNavigation?: boolean; // Skip FLP navigation (app already open) timeout?: number; // Override default timeout (ms) validateViaOData?: boolean; // Validate result via OData GET after save } ``` --- ## OData CRUD Operations Demonstrates the full OData lifecycle using Praman's `ui5.odata` fixture: model operations (browser-side reads), HTTP operations (direct CRUD), CSRF token handling, and test data lifecycle management. ## Two Approaches | Approach | Access Path | Use Case | | -------------------- | ---------------------------- | ----------------------------------------- | | **Model operations** | Browser-side UI5 model | Reading data the UI already loaded | | **HTTP operations** | Direct HTTP to OData service | CRUD operations, test data setup/teardown | ## Source ```typescript const SERVICE_URL = '/sap/opu/odata4/sap/API_PURCHASEORDER_2/srvd_a2x/sap/purchaseorder/0002'; test.describe('OData CRUD Operations', () => { let createdPONumber: string; test('read entities via browser-side model operations', async ({ page, ui5, ui5Navigation }) => { await test.step('Navigate to Purchase Order app', async () => { await ui5Navigation.navigateToApp('PurchaseOrder-manage'); await ui5.waitForUI5(); }); await test.step('Wait for OData model to load', async () => { await ui5.odata.waitForLoad(); }); await test.step('Read entity collection from model', async () => { const orders = await ui5.odata.getModelData('/PurchaseOrders'); expect(orders.length).toBeGreaterThan(0); }); await test.step('Read single property from model', async () => { const vendor = await ui5.odata.getModelProperty("/PurchaseOrders('4500000001')/Vendor"); expect(vendor).toBeTruthy(); }); await test.step('Count entities in model', async () => { const count = await ui5.odata.getEntityCount('/PurchaseOrders'); expect(count).toBeGreaterThan(0); }); }); test('query entities via direct HTTP operations', async ({ ui5 }) => { await test.step('Query with filters and sorting', async () => { const orders = await ui5.odata.queryEntities(SERVICE_URL, 'PurchaseOrders', { filter: "CompanyCode eq '1000'", select: 'PurchaseOrder,Vendor,CompanyCode,PurchaseOrderDate', orderby: 'PurchaseOrder desc', top: 5, }); expect(orders.length).toBeGreaterThan(0); expect(orders.length).toBeLessThanOrEqual(5); }); await test.step('Query with expand for navigation properties', async () => { const orders = await ui5.odata.queryEntities(SERVICE_URL, 'PurchaseOrders', { filter: "PurchaseOrder eq '4500000001'", select: 'PurchaseOrder,Vendor', expand: 'Items', top: 1, }); expect(orders.length).toBe(1); }); }); test('create, update, and delete entity via HTTP', async ({ ui5 }) => { await test.step('Create a new Purchase Order', async () => { const newPO = await ui5.odata.createEntity(SERVICE_URL, 'PurchaseOrders', { Vendor: '100001', PurchasingOrganization: '1000', PurchasingGroup: '001', CompanyCode: '1000', DocumentCurrency: 'EUR', Items: [ { Material: 'MAT-TEST-001', OrderQuantity: 10, PurchaseOrderQuantityUnit: 'EA', NetPriceAmount: 25.0, Plant: '1000', }, ], }); createdPONumber = String(newPO.PurchaseOrder); expect(createdPONumber).toBeTruthy(); }); await test.step('Update the created Purchase Order', async () => { await ui5.odata.updateEntity(SERVICE_URL, 'PurchaseOrders', `'${createdPONumber}'`, { PurchaseOrderNote: 'Updated by Praman E2E test', }); // Verify by reading back const updated = await ui5.odata.queryEntities(SERVICE_URL, 'PurchaseOrders', { filter: `PurchaseOrder eq '${createdPONumber}'`, select: 'PurchaseOrder,PurchaseOrderNote', top: 1, }); expect(updated[0].PurchaseOrderNote).toBe('Updated by Praman E2E test'); }); await test.step('Delete the test Purchase Order', async () => { await ui5.odata.deleteEntity(SERVICE_URL, 'PurchaseOrders', `'${createdPONumber}'`); const remaining = await ui5.odata.queryEntities(SERVICE_URL, 'PurchaseOrders', { filter: `PurchaseOrder eq '${createdPONumber}'`, top: 1, }); expect(remaining.length).toBe(0); }); }); test('verify model dirty state during form editing', async ({ ui5, ui5Navigation }) => { await test.step('Navigate to Purchase Order detail', async () => { await ui5Navigation.navigateToApp('PurchaseOrder-manage'); await ui5.waitForUI5(); await ui5.odata.waitForLoad(); await ui5.press({ controlType: 'sap.m.ColumnListItem', ancestor: { controlType: 'sap.m.Table' }, }); await ui5.waitForUI5(); }); await test.step('Enter edit mode and verify pending changes', async () => { await ui5.press({ id: 'editBtn' }); await ui5.waitForUI5(); const cleanBefore = await ui5.odata.hasPendingChanges(); expect(cleanBefore).toBe(false); await ui5.fill({ id: 'noteField' }, 'Test pending changes'); await ui5.waitForUI5(); const dirty = await ui5.odata.hasPendingChanges(); expect(dirty).toBe(true); }); await test.step('Cancel edit and verify model is clean', async () => { await ui5.press({ controlType: 'sap.m.Button', properties: { text: 'Cancel' }, }); await expect(async () => { await ui5.press({ controlType: 'sap.m.Button', properties: { text: 'Discard' }, searchOpenDialogs: true, }); }).toPass({ timeout: 5000, intervals: [1000] }); await ui5.waitForUI5(); const cleanAfter = await ui5.odata.hasPendingChanges(); expect(cleanAfter).toBe(false); }); }); }); ``` ## Key Concepts - **`ui5.odata.getModelData(path)`** -- reads from the browser-side UI5 OData model (no extra HTTP requests) - **`ui5.odata.getModelProperty(path)`** -- reads a single property value from the model - **`ui5.odata.getEntityCount(path)`** -- counts entities loaded in the model - **`ui5.odata.waitForLoad()`** -- polls until OData data is available (15s default timeout) - **`ui5.odata.queryEntities(url, set, opts)`** -- HTTP GET with `$filter`, `$select`, `$expand`, `$orderby`, `$top` - **`ui5.odata.createEntity(url, set, data)`** -- HTTP POST with automatic CSRF token - **`ui5.odata.updateEntity(url, set, key, data)`** -- HTTP PATCH with CSRF token - **`ui5.odata.deleteEntity(url, set, key)`** -- HTTP DELETE with CSRF token - **`ui5.odata.hasPendingChanges()`** -- checks if the model has unsaved edits (dirty state) - **`ui5.odata.fetchCSRFToken(url)`** -- manually fetches a CSRF token for custom requests ## Test Data Lifecycle A common pattern for CRUD tests: 1. **Create** test data via `createEntity()` in the test setup 2. **Verify** via UI interactions or model reads 3. **Cleanup** via `deleteEntity()` in the test teardown This keeps tests hermetic and avoids polluting the system with stale test data. --- ## Table Operations Demonstrates how to interact with SAP UI5 tables: discovering SmartTables, reading rows, and accessing OData binding data. ## Supported Table Types This pattern works for: - **`sap.ui.table.Table`** (grid table) -- use directly - **`sap.m.Table`** (responsive table) -- use directly - **`sap.ui.comp.smarttable.SmartTable`** -- use `getTable()` to get the inner table first ## Source ```typescript test.describe('Table Operations', () => { test('read table rows and OData binding data', async ({ page, ui5 }) => { await test.step('Navigate to app with table', async () => { await page.goto(process.env['SAP_CLOUD_BASE_URL']!); await page.waitForLoadState('domcontentloaded'); await expect(page).toHaveTitle(/Home/, { timeout: 60_000 }); await expect(async () => { await ui5.press({ controlType: 'sap.m.GenericTile', properties: { header: 'My List Report' }, }); }).toPass({ timeout: 60_000, intervals: [5000, 10_000] }); await ui5.waitForUI5(); }); await test.step('Discover table and read rows', async () => { // SmartTable wraps an inner table const smartTable = await ui5.control({ controlType: 'sap.ui.comp.smarttable.SmartTable', }); // getTable() returns the inner table control as a proxy const innerTable = await smartTable.getTable(); // getRows() returns array of row proxies const rows = await innerTable.getRows(); expect(rows.length).toBeGreaterThan(0); }); await test.step('Read OData binding from rows', async () => { const smartTable = await ui5.control({ controlType: 'sap.ui.comp.smarttable.SmartTable', }); const innerTable = await smartTable.getTable(); // Wait for OData data to load using Playwright auto-retry await expect(async () => { const rows = await innerTable.getRows(); let dataRowCount = 0; for (const row of rows) { const ctx = await row.getBindingContext(); if (ctx) dataRowCount++; } expect(dataRowCount).toBeGreaterThan(0); }).toPass({ timeout: 60_000, intervals: [1000, 2000, 5000] }); }); await test.step('Read entity data via getContextByIndex', async () => { const smartTable = await ui5.control({ controlType: 'sap.ui.comp.smarttable.SmartTable', }); const innerTable = await smartTable.getTable(); // getContextByIndex returns OData binding context for a specific row const ctx = await innerTable.getContextByIndex(0); expect(ctx).toBeTruthy(); // getObject() returns the full OData entity as a plain object const entity = await ctx.getObject(); expect(entity).toBeTruthy(); }); }); }); ``` ## Key Concepts - **SmartTable pattern**: `ui5.control({ controlType: 'sap.ui.comp.smarttable.SmartTable' })` then `getTable()` for the inner table - **`getRows()`** -- returns proxy instances for each visible row - **`getBindingContext()`** -- accesses the OData model binding for a row - **`getContextByIndex(n)`** -- shortcut for getting the nth row's binding context - **`getObject()`** -- returns the full OData entity as a plain JavaScript object - **`expect().toPass()`** -- Playwright auto-retry for waiting on async OData data loading --- ## Vocabulary Discovery Demonstrates Praman's vocabulary system from `playwright-praman/vocabulary`: domain loading, fuzzy term search, confidence scoring, field selector resolution, autocomplete suggestions, and integration with the intent API. ## SAP Domain Coverage The vocabulary system ships with 6 SAP domain JSON files: | Domain | SAP Module | Coverage | | --------------- | ---------- | -------------------------------------- | | `procurement` | MM | Vendors, POs, goods receipts | | `sales` | SD | Customers, sales orders, deliveries | | `finance` | FI | GL accounts, AP, AR | | `manufacturing` | PP | Production orders, BOMs, routings | | `warehouse` | WM/EWM | Storage locations, picking | | `quality` | QM | Inspection lots, quality notifications | ## Source ```typescript test.describe('Vocabulary Discovery', () => { let vocab: VocabularyService; test.beforeEach(() => { vocab = createVocabularyService(); }); test('load domains and search terms', async () => { await test.step('Load SAP procurement domain', async () => { await vocab.loadDomain('procurement'); const stats = vocab.getStats(); expect(stats.loadedDomains).toContain('procurement'); expect(stats.totalTerms).toBeGreaterThan(0); }); await test.step('Load multiple domains', async () => { await vocab.loadDomain('sales'); await vocab.loadDomain('finance'); const stats = vocab.getStats(); expect(stats.loadedDomains).toContain('procurement'); expect(stats.loadedDomains).toContain('sales'); expect(stats.loadedDomains).toContain('finance'); }); await test.step('Search across all domains', async () => { const results = await vocab.search('vendor'); expect(results.length).toBeGreaterThan(0); expect(results[0]!.confidence).toBeGreaterThanOrEqual(0.85); }); await test.step('Search within a specific domain', async () => { const results = await vocab.search('vendor', 'procurement'); for (const result of results) { expect(result.domain).toBe('procurement'); } }); }); test('fuzzy matching handles typos and partial terms', async () => { await test.step('Load domain', async () => { await vocab.loadDomain('procurement'); }); await test.step('Exact match (confidence = 1.0)', async () => { const results = await vocab.search('supplier'); const exact = results.find((r) => r.confidence === 1.0); expect(exact).toBeTruthy(); }); await test.step('Synonym match (confidence = 1.0)', async () => { const results = await vocab.search('vendor'); const match = results.find((r) => r.confidence === 1.0); expect(match).toBeTruthy(); }); await test.step('Prefix match (confidence = 0.9)', async () => { const results = await vocab.search('sup'); const prefix = results.find((r) => r.confidence === 0.9); expect(prefix).toBeTruthy(); }); await test.step('Fuzzy match with typo (confidence = 0.5)', async () => { // "vendro" is within Levenshtein distance 2 of "vendor" const results = await vocab.search('vendro'); const fuzzy = results.find((r) => r.confidence >= 0.5); expect(fuzzy).toBeTruthy(); }); await test.step('No match for unrelated terms', async () => { const results = await vocab.search('xyznonexistent'); expect(results.length).toBe(0); }); }); test('resolve field selectors from business terms', async () => { await test.step('Load domain', async () => { await vocab.loadDomain('procurement'); }); await test.step('High-confidence term resolves to UI5 selector', async () => { const selector = await vocab.getFieldSelector('vendor', 'procurement'); expect(selector).toBeTruthy(); }); await test.step('Ambiguous terms return undefined', async () => { // "number" could match PO number, material number, vendor number const selector = await vocab.getFieldSelector('number', 'procurement'); // Returns undefined rather than guessing }); await test.step('SAP ABAP field names resolve via synonyms', async () => { const results = await vocab.search('LIFNR'); const match = results.find((r) => r.sapField === 'LIFNR'); expect(match).toBeTruthy(); }); }); test('suggestions for autocomplete', async () => { await test.step('Load domains', async () => { await vocab.loadDomain('procurement'); await vocab.loadDomain('sales'); }); await test.step('Get suggestions for partial input', async () => { const suggestions = await vocab.getSuggestions('pur'); expect(suggestions.length).toBeGreaterThan(0); }); await test.step('Limit suggestion count', async () => { const top3 = await vocab.getSuggestions('mat', 3); expect(top3.length).toBeLessThanOrEqual(3); }); }); test('vocabulary-driven field filling in a live SAP app', async ({ ui5, ui5Navigation }) => { await test.step('Load vocabulary and navigate', async () => { await vocab.loadDomain('procurement'); await ui5Navigation.navigateToApp('PurchaseOrder-create'); await ui5.waitForUI5(); }); await test.step('Fill fields using vocabulary terms', async () => { const vendorResult = await fillField(ui5, vocab, 'Vendor', '100001', { domain: 'procurement', }); expect(vendorResult.status).toBe('success'); const materialResult = await fillField(ui5, vocab, 'Material', 'RAW-0001', { domain: 'procurement', }); expect(materialResult.status).toBe('success'); const quantityResult = await fillField(ui5, vocab, 'Order Quantity', '100', { domain: 'procurement', }); expect(quantityResult.status).toBe('success'); }); await test.step('Verify cache statistics', async () => { const stats = vocab.getStats(); // After lookups, cache should have hits }); }); }); ``` ## Key Concepts ### Match Confidence Tiers | Tier | Confidence | Criteria | | ------- | ---------- | ----------------------------------------------------- | | Exact | 1.0 | Query matches term name or synonym (case-insensitive) | | Prefix | 0.9 | Term name starts with the query string | | Partial | 0.7 | Term name contains the query string | | Fuzzy | 0.5 | Levenshtein distance <= 3 characters | ### VocabularyService API - **`createVocabularyService()`** -- factory function, returns a new service instance - **`svc.loadDomain(domain)`** -- lazily loads a SAP domain JSON file - **`svc.search(query, domain?)`** -- searches across loaded domains, returns `VocabularySearchResult[]` sorted by confidence - **`svc.getFieldSelector(term, domain?)`** -- resolves a high-confidence term to a UI5 selector (returns `undefined` if ambiguous) - **`svc.getSuggestions(prefix, limit?)`** -- autocomplete suggestions for partial input - **`svc.getStats()`** -- returns loaded domains, total terms, cache hits/misses ### Disambiguation When `getFieldSelector()` encounters multiple matches scoring between 0.7 and 0.85 without a clear winner, it returns `undefined` rather than guessing. This allows the caller (or an AI agent) to present options to the user. ### ABAP Field Name Support SAP technical field names (e.g., `LIFNR`, `MATNR`, `BUKRS`) are included as synonyms in the domain JSON files, allowing resolution from either business terms or ABAP names. --- ## Authentication Guide :::info[In this guide] - Choose the right authentication strategy for your SAP system (on-premise, BTP, Office 365) - Configure the setup project pattern for authenticate-once, reuse-everywhere - Set up environment variables for each auth strategy - Use the `sapAuth` fixture for explicit auth control in tests - Handle session expiry and BTP WorkZone frame contexts ::: Praman supports 6 pluggable authentication strategies for SAP systems. ## Auth Strategy Decision Flowchart ```mermaid flowchart TD A[Start: What SAP system?] --> B{Cloud or On-Premise?} B -->|On-Premise| C{Standard login page?} C -->|Yes| D["Use basicSAP NetWeaver / ABAP"] C -->|No| E["Use customRegister custom strategy"] B -->|Cloud / BTP| F{Which IDP?} F -->|SAP IAS / SAML| G["Use btp-samlAuto-detected from URL"] F -->|Azure AD / Microsoft 365| H["Use office365Set SAP_AUTH_STRATEGY=office365"] F -->|Client Certificate| I["Use certificateSet auth.certificatePath"] F -->|Multi-Tenant| J["Use multi-tenantSet auth.subdomain"] F -->|Other IDP| E ``` ## Auth Strategy Mapping The `auth.strategy` config field accepts 4 values. Each maps to an internal strategy implementation: | Config Value | Strategy File | Use Case | | ------------ | ---------------------- | -------------------------------- | | `basic` | onprem-strategy.ts | SAP NetWeaver / ABAP on-premise | | `btp-saml` | cloud-saml-strategy.ts | SAP BTP Cloud via IAS / SAML | | `office365` | office365-strategy.ts | Azure AD / Microsoft 365 SSO | | `custom` | (user-provided) | Custom IDP or non-standard login | ### Specialized Strategies (Not Config-Selectable) These strategies are used programmatically, not via the `auth.strategy` config: - **api-strategy.ts** — Headless API-based auth (for CI/CD token-based auth). Use via `sapAuth.loginWithApi()`. - **certificate-strategy.ts** — Client certificate auth (configured at browser context level in `playwright.config.ts`, not via Praman config). - **multi-tenant-strategy.ts** — BTP multi-tenant routing (delegates to cloud-saml or onprem internally, selected by `SAP_ACTIVE_SYSTEM` env var). ## Which Strategy Do I Need? If you don't set `auth.strategy` explicitly, Praman auto-detects based on your config fields and URL pattern. The priority chain is: | Priority | Condition | Strategy Selected | Example | | -------- | --------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------- | | 1 | `auth.strategy` is set explicitly | The named strategy (custom registry first, then built-in) | `strategy: 'office365'` | | 2 | `auth.certificatePath` is set | Client Certificate | `certificatePath: '/certs/client.p12'` | | 3 | `auth.subdomain` is set | Multi-Tenant (delegates to cloud-saml or onprem) | `subdomain: 'customer-a'` | | 4 | URL matches a cloud pattern | Cloud SAML (IAS) | `*.cloud.sap`, `*.s4hana.cloud.sap`, `*.hana.ondemand.com` | | 5 | None of the above | On-Premise (Basic Auth) | Any other URL | ### Decision by SAP System Type | Your SAP System | Set These Env Vars | Strategy Used | | --------------------------- | --------------------------------------------------------------------------- | ------------------------------- | | S/4HANA Cloud | `SAP_CLOUD_BASE_URL=https://tenant.s4hana.cloud.sap` | Auto-detected: **Cloud SAML** | | BTP Launchpad | `SAP_CLOUD_BASE_URL=https://tenant.launchpad.cfapps.eu10.hana.ondemand.com` | Auto-detected: **Cloud SAML** | | BTP + Azure AD SSO | Same as above + `SAP_AUTH_STRATEGY=office365` | Explicit: **Office 365** | | SAP ECC / NetWeaver on-prem | `SAP_ONPREM_BASE_URL=https://sap.corp:8443/...` | Auto-detected: **On-Premise** | | BTP Multi-Tenant | `SAP_CLOUD_BASE_URL` + `SAP_SUBDOMAIN=customer-a` | Auto-detected: **Multi-Tenant** | | Client Certificate (PKI) | `SAP_CERTIFICATE_PATH=/certs/client.p12` | Auto-detected: **Certificate** | | Non-standard IDP | `SAP_AUTH_STRATEGY=custom` + custom strategy registration | Explicit: **Custom** | ### Cloud URL Patterns (Auto-Detection) These URL patterns trigger Cloud SAML auto-detection: - `*.cloud.sap` - `*.s4hana.cloud.sap` - `*.hana.ondemand.com` - `*.cfapps.*.hana.ondemand.com` If your Cloud URL doesn't match these patterns, set `auth.strategy: 'btp-saml'` explicitly. ## Setup Project Pattern The recommended pattern uses Playwright's **setup projects** to authenticate once and share the session across all tests. ### 1. Auth Setup File Create `tests/auth-setup.ts` using the `sapAuth` fixture (provided by `playwright-praman`): ```typescript // tests/auth-setup.ts const authFile = join(process.cwd(), '.auth', 'sap-session.json'); setup('SAP authentication', async ({ sapAuth, page, context }) => { // sapAuth is auto-configured from praman config and environment variables. // The fixture resolves the correct strategy based on SAP_ACTIVE_SYSTEM // and SAP_AUTH_STRATEGY env vars. await sapAuth.login(page); await context.storageState({ path: authFile }); }); ``` :::info[No direct auth imports] `SAPAuthHandler` and `createAuthStrategy` are **not** public exports. The `playwright-praman/auth` sub-path does **not** exist. All authentication is done through the `sapAuth` fixture, which reads credentials and strategy from the Praman config / environment variables. ::: ### 2. Playwright Config ```typescript // playwright.config.ts export default defineConfig({ projects: [ { name: 'setup', testMatch: /auth-setup\.ts/, teardown: 'teardown', }, { name: 'teardown', testMatch: /auth-teardown\.ts/, }, { name: 'chromium', dependencies: ['setup'], use: { ...devices['Desktop Chrome'], storageState: '.auth/sap-session.json', }, }, ], }); ``` ### 3. Tests Use Saved Session ```typescript // tests/purchase-order.spec.ts // No login code needed — storageState handles it test('navigate after auth', async ({ ui5Navigation }) => { await ui5Navigation.navigateToApp('PurchaseOrder-manage'); }); ``` ## Strategy Examples ### On-Premise (`onprem`) ```bash # .env SAP_ACTIVE_SYSTEM=onprem SAP_ONPREM_BASE_URL=https://sapserver.example.com:8443/sap/bc/ui5_ui5/ SAP_ONPREM_USERNAME=TESTUSER SAP_ONPREM_PASSWORD= SAP_CLIENT=100 SAP_LANGUAGE=EN ``` ### Cloud SAML (`cloud-saml`) ```bash # .env SAP_ACTIVE_SYSTEM=cloud SAP_CLOUD_BASE_URL=https://tenant.launchpad.cfapps.eu10.hana.ondemand.com SAP_CLOUD_USERNAME=user@company.com SAP_CLOUD_PASSWORD= ``` ### Office 365 SSO ```bash # .env SAP_ACTIVE_SYSTEM=cloud SAP_AUTH_STRATEGY=office365 SAP_CLOUD_BASE_URL=https://tenant.launchpad.cfapps.eu10.hana.ondemand.com SAP_CLOUD_USERNAME=user@company.onmicrosoft.com SAP_CLOUD_PASSWORD= ``` ## Using the `sapAuth` Fixture For tests that need explicit auth control: ```typescript // tests/explicit-auth.spec.ts test('explicit auth', async ({ sapAuth, page }) => { // sapAuth.login() uses credentials from praman config / env vars. // Pass overrides only when needed: await sapAuth.login(page, { url: 'https://sapserver.example.com', username: 'TESTUSER', password: 'secret', strategy: 'onprem', }); // isAuthenticated() requires the page parameter expect(await sapAuth.isAuthenticated(page)).toBe(true); const session = sapAuth.getSessionInfo(); // SessionInfo has: authenticatedAt, strategyName, isValid expect(session.isValid).toBe(true); expect(session.strategyName).toBe('onprem'); // Auto-logout on fixture teardown }); ``` :::tip[Strategy name mapping] The config schema names map to runtime strategy names as follows: | Config Value | Runtime `strategyName` | | ------------- | ---------------------- | | `'basic'` | `'onprem'` | | `'btp-saml'` | `'cloud-saml'` | | `'office365'` | `'office365'` | | `'custom'` | User-provided name | When checking `session.strategyName`, use the **runtime** name (right column). ::: ## Session Expiry The default session timeout is 1800 seconds (30 minutes), matching SAP ICM defaults. The 30-minute default matches SAP ICM session timeout defaults. ## BTP WorkZone Authentication BTP WorkZone renders apps inside nested iframes. The `btpWorkZone` fixture handles frame context automatically: ```typescript // tests/workzone.spec.ts test('WorkZone app', async ({ btpWorkZone, ui5 }) => { const appFrame = btpWorkZone.getAppFrameForEval(); // All operations route through the correct frame }); ``` :::warning[Common mistake] Do not import `SAPAuthHandler` or `createAuthStrategy` directly. These are internal APIs. All authentication must go through the `sapAuth` fixture, which reads credentials and strategy from the Praman config and environment variables automatically. ::: ## FAQ
Which authentication strategy should I use for my SAP system? Use the decision flowchart at the top of this page. In short: - **SAP BTP / S/4HANA Cloud** with SAP IAS: Use `btp-saml` (auto-detected from URL) - **SAP BTP with Azure AD / Microsoft SSO**: Set `SAP_AUTH_STRATEGY=office365` - **SAP NetWeaver / ECC on-premise**: Use `basic` (auto-detected for non-cloud URLs) - **Client certificate (PKI)**: Set `auth.certificatePath` in config - **Multi-tenant BTP**: Set `auth.subdomain` in config - **Non-standard IDP**: Use `custom` with a registered strategy If unsure, let Praman auto-detect — it uses URL patterns to choose the right strategy.
How do I debug authentication failures? Run the auth setup in headed mode to see what happens in the browser: ```bash npx playwright test tests/auth-setup.ts --headed --debug ``` Common causes: - **Wrong URL**: `SAP_CLOUD_BASE_URL` must be the Fiori Launchpad URL, not an API endpoint - **Wrong strategy**: A cloud URL with `SAP_AUTH_STRATEGY=basic` will fail — use `btp-saml` - **MFA / TOTP**: Praman does not handle multi-factor auth automatically. Use `custom` strategy with manual MFA handling, or use API tokens - **Session expired**: The default session timeout is 30 minutes. Increase `auth.sessionTimeout` for long-running test suites
Can I use saved cookies or tokens instead of username/password? Yes. The setup project pattern saves the browser session to `.auth/sap-session.json` after the first login. All subsequent tests reuse this session without re-authenticating. This is the recommended approach — it runs login once and shares the session across all test files. For headless CI/CD environments, you can also use `sapAuth.loginWithApi()` via the `api-strategy.ts` for token-based authentication without a browser login flow.
Why does auth work locally but fail in CI? Check these common CI issues: - Environment variables (`SAP_CLOUD_BASE_URL`, `SAP_CLOUD_USERNAME`, `SAP_CLOUD_PASSWORD`) must be set as CI secrets - The CI runner must have network access to the SAP system (VPN, allowlisted IPs) - Use `storageState` in `playwright.config.ts` to reuse auth sessions across tests - Increase timeouts for slow CI networks: set `auth.loginTimeout` to `60000` or higher
:::tip[Next steps] - **[Getting Started →](./getting-started)** — Install Praman and run your first test - **[Agent & IDE Setup →](./agent-setup)** — Configure AI agents that handle auth automatically - **[Selectors Guide →](./selectors)** — Learn how to find UI5 controls after authentication ::: --- ## Business Process Examples :::info[In this guide] - Test Purchase Order creation via classic GUI (ME21N) and Fiori Elements - Test Sales Order creation via VA01 and Fiori Elements - Test Journal Entry posting via FB50 and Fiori (F0718) - Run a full Purchase-to-Pay (P2P) cross-process end-to-end flow - Apply data cleanup strategies for repeatable test runs ::: Complete end-to-end test examples for core SAP business processes. Each example is runnable with Praman fixtures and demonstrates real-world patterns for Purchase Orders, Sales Orders, Journal Entries, and cross-process P2P flows. ## Purchase Order — ME21N / Fiori ### Classic GUI (ME21N via Fiori Launchpad) ```typescript test.describe('Purchase Order Creation (ME21N)', () => { test('create standard purchase order', async ({ ui5, ui5Navigation }) => { await test.step('navigate to Create Purchase Order app', async () => { await ui5Navigation.navigateToApp('PurchaseOrder-create'); }); await test.step('fill header data', async () => { await ui5.fill({ controlType: 'sap.m.Input', id: /supplierInput/ }, '100001'); await ui5.fill({ controlType: 'sap.m.Input', id: /purchOrgInput/ }, '1000'); await ui5.fill({ controlType: 'sap.m.Input', id: /companyCodeInput/ }, '1000'); }); await test.step('add line item', async () => { await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Add Item' } }); await ui5.fill({ controlType: 'sap.m.Input', id: /materialInput/ }, 'MAT-001'); await ui5.fill({ controlType: 'sap.m.Input', id: /quantityInput/ }, '100'); await ui5.fill({ controlType: 'sap.m.Input', id: /plantInput/ }, '1000'); }); await test.step('save and verify', async () => { await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Save' } }); const messageStrip = await ui5.control({ controlType: 'sap.m.MessageStrip', properties: { type: 'Success' }, }); await expect(messageStrip).toHaveUI5Text(/Purchase Order \d+ created/); }); }); }); ``` ### Fiori Elements — Manage Purchase Orders ```typescript test.describe('Manage Purchase Orders (Fiori Elements)', () => { test('create PO via Fiori Elements object page', async ({ ui5, ui5Navigation, fe }) => { await test.step('navigate to list report', async () => { await ui5Navigation.navigateToApp('PurchaseOrder-manage'); }); await test.step('create new entry', async () => { await fe.listReport.clickButton('Create'); }); await test.step('fill header fields', async () => { await ui5.fill({ id: /Supplier/ }, '100001'); await ui5.fill({ id: /PurchasingOrganization/ }, '1000'); await ui5.fill({ id: /CompanyCode/ }, '1000'); await ui5.fill({ id: /PurchasingGroup/ }, '001'); }); await test.step('add line item via table', async () => { await fe.objectPage.clickButton('Create'); await ui5.fill({ id: /Material/ }, 'MAT-001'); await ui5.fill({ id: /OrderQuantity/ }, '100'); await ui5.fill({ id: /Plant/ }, '1000'); }); await test.step('save and verify', async () => { await fe.objectPage.clickSave(); const messageStrip = await ui5.control({ controlType: 'sap.m.MessageStrip', properties: { type: 'Success' }, }); await expect(messageStrip).toHaveUI5Text(/Purchase Order \d+ created/); }); }); }); ``` ## Sales Order — VA01 / Fiori ### Classic GUI (VA01 via Fiori Launchpad) ```typescript test.describe('Sales Order Creation (VA01)', () => { test('create standard sales order', async ({ ui5, ui5Navigation }) => { await test.step('navigate to Create Sales Order app', async () => { await ui5Navigation.navigateToApp('SalesOrder-create'); }); await test.step('fill order type and org data', async () => { await ui5.fill( { controlType: 'sap.m.Input', id: /orderTypeInput/ }, 'OR', // Standard Order ); await ui5.fill({ controlType: 'sap.m.Input', id: /salesOrgInput/ }, '1000'); await ui5.fill({ controlType: 'sap.m.Input', id: /distChannelInput/ }, '10'); await ui5.fill({ controlType: 'sap.m.Input', id: /divisionInput/ }, '00'); }); await test.step('fill customer and item data', async () => { await ui5.fill({ controlType: 'sap.m.Input', id: /soldToPartyInput/ }, 'CUST-001'); await ui5.fill({ controlType: 'sap.m.Input', id: /materialInput/ }, 'MAT-100'); await ui5.fill({ controlType: 'sap.m.Input', id: /quantityInput/ }, '50'); }); await test.step('save and verify', async () => { await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Save' } }); const message = await ui5.control({ controlType: 'sap.m.MessageStrip', properties: { type: 'Success' }, }); await expect(message).toHaveUI5Text(/Sales Order \d+ created/); }); }); }); ``` ### Fiori Elements — Manage Sales Orders ```typescript test.describe('Manage Sales Orders (Fiori Elements)', () => { test('create sales order via object page', async ({ ui5, ui5Navigation, fe }) => { await test.step('open app and create', async () => { await ui5Navigation.navigateToApp('SalesOrder-manage'); await fe.listReport.clickButton('Create'); }); await test.step('fill header', async () => { await ui5.fill({ id: /SalesOrderType/ }, 'OR'); await ui5.fill({ id: /SoldToParty/ }, 'CUST-001'); await ui5.fill({ id: /SalesOrganization/ }, '1000'); await ui5.fill({ id: /DistributionChannel/ }, '10'); }); await test.step('add line items', async () => { await fe.objectPage.clickButton('Create'); await ui5.fill({ id: /Material/ }, 'MAT-100'); await ui5.fill({ id: /RequestedQuantity/ }, '50'); }); await test.step('save and capture order number', async () => { await fe.objectPage.clickSave(); const messageStrip = await ui5.control({ controlType: 'sap.m.MessageStrip', properties: { type: 'Success' }, }); await expect(messageStrip).toHaveUI5Text(/Sales Order \d+ created/); }); }); }); ``` ## Journal Entry — FB50 / Fiori ### Classic GUI (FB50 via Fiori Launchpad) ```typescript test.describe('Journal Entry (FB50)', () => { test('post G/L account journal entry', async ({ ui5, ui5Navigation }) => { await test.step('navigate to Post Journal Entry', async () => { await ui5Navigation.navigateToApp('JournalEntry-create'); }); await test.step('fill document header', async () => { await ui5.fill({ controlType: 'sap.m.DatePicker', id: /documentDatePicker/ }, '2025-01-15'); await ui5.fill({ controlType: 'sap.m.Input', id: /companyCodeInput/ }, '1000'); await ui5.fill({ controlType: 'sap.m.Input', id: /currencyInput/ }, 'USD'); }); await test.step('add debit line', async () => { await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Add Row' } }); await ui5.fill( { controlType: 'sap.m.Input', id: /glAccountInput--0/ }, '400000', // Revenue account ); await ui5.fill({ controlType: 'sap.m.Input', id: /debitAmountInput--0/ }, '1000.00'); }); await test.step('add credit line', async () => { await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Add Row' } }); await ui5.fill( { controlType: 'sap.m.Input', id: /glAccountInput--1/ }, '113100', // Bank account ); await ui5.fill({ controlType: 'sap.m.Input', id: /creditAmountInput--1/ }, '1000.00'); }); await test.step('post and verify', async () => { await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Post' } }); const message = await ui5.control({ controlType: 'sap.m.MessageStrip', properties: { type: 'Success' }, }); await expect(message).toHaveUI5Text(/Document \d+ posted/); }); }); }); ``` ### Fiori — Post Journal Entries (F0718) ```typescript test.describe('Post Journal Entries Fiori (F0718)', () => { test('create and post journal entry', async ({ ui5, ui5Navigation }) => { await test.step('navigate to Post Journal Entries', async () => { await ui5Navigation.navigateToIntent({ semanticObject: 'JournalEntry', action: 'create' }); }); await test.step('fill header and line items', async () => { await ui5.fill( { controlType: 'sap.ui.comp.smartfield.SmartField', id: /CompanyCode/ }, '1000', ); await ui5.fill( { controlType: 'sap.ui.comp.smartfield.SmartField', id: /DocumentDate/ }, '2025-01-15', ); // Debit line await ui5.fill( { controlType: 'sap.ui.comp.smartfield.SmartField', id: /GLAccount.*0/ }, '400000', ); await ui5.fill( { controlType: 'sap.ui.comp.smartfield.SmartField', id: /AmountInTransCrcy.*0/ }, '1000.00', ); // Credit line await ui5.fill( { controlType: 'sap.ui.comp.smartfield.SmartField', id: /GLAccount.*1/ }, '113100', ); await ui5.fill( { controlType: 'sap.ui.comp.smartfield.SmartField', id: /AmountInTransCrcy.*1/ }, '-1000.00', ); }); await test.step('post document', async () => { await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Post' } }); const message = await ui5.control({ controlType: 'sap.m.MessageStrip', properties: { type: 'Success' }, }); await expect(message).toHaveUI5Text(/Document \d+ posted/); }); }); }); ``` ## Purchase-to-Pay (P2P) Cross-Process E2E The P2P flow spans multiple SAP modules: procurement, goods receipt, invoice verification, and payment. This tutorial demonstrates a complete end-to-end test that exercises the full chain. ```typescript test.describe('Purchase-to-Pay E2E Flow', () => { let poNumber: string; let grDocNumber: string; let invoiceNumber: string; test('complete P2P cycle', async ({ ui5, ui5Navigation, fe }) => { await test.step('Step 1: Create Purchase Order', async () => { await ui5Navigation.navigateToIntent({ semanticObject: 'PurchaseOrder', action: 'create' }); await ui5.fill({ id: /Supplier/ }, '100001'); await ui5.fill({ id: /PurchasingOrganization/ }, '1000'); await ui5.fill({ id: /CompanyCode/ }, '1000'); await fe.objectPage.clickButton('Create'); await ui5.fill({ id: /Material/ }, 'MAT-001'); await ui5.fill({ id: /OrderQuantity/ }, '10'); await ui5.fill({ id: /Plant/ }, '1000'); await fe.objectPage.clickSave(); const successMsg = await ui5.control({ controlType: 'sap.m.MessageStrip', properties: { type: 'Success' }, }); const text = await successMsg.getText(); const match = text.match(/Purchase Order (\d+)/); poNumber = match?.[1] ?? ''; expect(poNumber).toBeTruthy(); }); await test.step('Step 2: Post Goods Receipt (MIGO)', async () => { await ui5Navigation.navigateToIntent({ semanticObject: 'GoodsReceipt', action: 'create' }); await ui5.fill({ controlType: 'sap.m.Input', id: /poNumberInput/ }, poNumber); await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Load' } }); // Verify items auto-populated const table = await ui5.control({ controlType: 'sap.m.Table', id: /itemsTable/ }); await expect(table).toHaveUI5RowCount(1); await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Post' } }); const grMsg = await ui5.control({ controlType: 'sap.m.MessageStrip', properties: { type: 'Success' }, }); const grText = await grMsg.getText(); const grMatch = grText.match(/Document (\d+)/); grDocNumber = grMatch?.[1] ?? ''; expect(grDocNumber).toBeTruthy(); }); await test.step('Step 3: Create Invoice (MIRO)', async () => { await ui5Navigation.navigateToIntent({ semanticObject: 'SupplierInvoice', action: 'create' }); await ui5.fill({ controlType: 'sap.m.Input', id: /poReferenceInput/ }, poNumber); await ui5.fill({ controlType: 'sap.m.Input', id: /invoiceAmountInput/ }, '5000.00'); await ui5.fill({ controlType: 'sap.m.DatePicker', id: /invoiceDatePicker/ }, '2025-02-01'); await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Post' } }); const invMsg = await ui5.control({ controlType: 'sap.m.MessageStrip', properties: { type: 'Success' }, }); const invText = await invMsg.getText(); const invMatch = invText.match(/Invoice (\d+)/); invoiceNumber = invMatch?.[1] ?? ''; expect(invoiceNumber).toBeTruthy(); }); await test.step('Step 4: Verify Payment Run (F110)', async () => { await ui5Navigation.navigateToApp('PaymentRun-display'); await fe.listReport.setFilter('Supplier', '100001'); await fe.listReport.setFilter('Status', 'Paid'); await fe.listReport.search(); }); await test.step('Step 5: Verify complete document flow', async () => { await ui5Navigation.navigateToIntent( { semanticObject: 'PurchaseOrder', action: 'display' }, { PurchaseOrder: poNumber }, ); await ui5.click({ controlType: 'sap.m.IconTabFilter', properties: { key: 'documentFlow' }, }); const flowTable = await ui5.control({ controlType: 'sap.m.Table', id: /docFlowTable/ }); await expect(flowTable).toHaveUI5RowCount(3); // PO + GR + Invoice }); }); }); ``` ### Key Patterns in the P2P Example | Pattern | Why | | --------------------------------------------- | --------------------------------------------- | | `test.step()` for each business step | Clear trace output, easy to pinpoint failures | | Capture document numbers between steps | Enables cross-referencing across documents | | `expect(poNumber).toBeTruthy()` after capture | Fail fast if a prior step silently failed | | Navigate by semantic object + action | Resilient to FLP configuration changes | | Verify document flow at the end | Confirms SAP linked all documents correctly | ## Data Cleanup Strategies For repeatable tests, clean up created data after the test run: ```typescript test.afterAll(async ({ ui5 }) => { // Option 1: Use OData to delete test data await ui5.odata.deleteEntity('/PurchaseOrders', poNumber); // Option 2: Use an SAP cleanup BAPI via RFC // (Requires backend RFC connection setup) // Option 3: Flag for batch cleanup // Store document numbers for nightly cleanup jobs }); ``` See the [Gold Standard Test Pattern](./gold-standard-test.md) for a complete template that includes data cleanup, error handling, and all best practices. ## FAQ
Can I run these examples against a real SAP system? Yes, but you need to update the control IDs and semantic objects to match your specific SAP configuration. The selector patterns (controlType + id RegExp) shown here are based on standard SAP Fiori apps. Your system may use different IDs depending on custom views, extensions, and UI5 versions. Use the Praman CLI or browser DevTools to discover the actual control IDs on your system.
How do I capture document numbers created during a test? After a save action, find the success message strip and extract the number with a regex: ```typescript const msg = await ui5.control({ controlType: 'sap.m.MessageStrip', properties: { type: 'Success' }, }); const text = await msg.getText(); const match = text.match(/Document (\d+)/); const docNumber = match?.[1] ?? ''; expect(docNumber).toBeTruthy(); ``` Store the number in a variable declared at the `describe` scope so subsequent steps and cleanup can reference it.
Should I run P2P tests sequentially or in parallel? Run P2P and other cross-process tests sequentially (`workers: 1` in Playwright config). These tests create and reference data across multiple SAP transactions, so parallel execution would cause document number conflicts and race conditions. Single-process tests (like creating a standalone PO) can run in parallel if they use unique test data.
:::tip[Next steps] - **[Gold Standard Test Pattern →](./gold-standard-test.md)** — Reference template with error handling, cleanup, and checklist - **[Navigation →](./navigation.md)** — All 11 FLP navigation methods used in these examples - **[Custom Matchers →](./custom-matchers.md)** — Assert UI5 control state after each business step - **[Selector Reference →](./selectors.md)** — Build resilient selectors for your SAP controls ::: --- ## Capabilities & Recipes Guide Praman ships with a capability registry and a recipe registry that describe what the framework can do and how to do it. These registries serve two audiences: human testers browsing the API surface, and AI agents that need structured context for test generation. :::tip CLI Capability Manifest Praman also provides an offline CLI command for agents that need to discover capabilities without importing the package: ```bash npx playwright-praman capabilities --agent ``` This returns a compact manifest of 13 CLI-specific capabilities (discovery, interaction, navigation, stability, generation) that work via `playwright-cli run-code` and `eval`. The CLI manifest is separate from the fixture registry documented below — see [CLI Quick Reference](./playwright-cli-reference.md#capabilities--capability-manifest) for details. ::: ## Capabilities A **capability** is a single API method or fixture function that Praman exposes. Each capability has a name, description, category, usage example, and priority tier. ### Listing All Capabilities ```typescript // List every registered capability const all = capabilities.list(); console.log(`Total capabilities: ${all.length}`); for (const cap of all) { console.log(`${cap.name} (${cap.category}): ${cap.description}`); } ``` ### Filtering by Priority Capabilities are organized into three priority tiers: | Priority | Meaning | Example | | ---------------- | ---------------------------------------------------------- | ------------------------------------------------ | | `fixture` | Primary Playwright fixture API (recommended for consumers) | `ui5.control()`, `ui5Navigation.navigateToApp()` | | `namespace` | Secondary utility or handler export | `SAPAuthHandler`, `buildPageContext()` | | `implementation` | Internal implementation detail | Bridge adapters, serializers | ```typescript // Only the fixture-level APIs (what most testers need) const fixtures = capabilities.listFixtures(); console.log(`Fixture-level capabilities: ${fixtures.length}`); // Or by any priority const namespaceLevel = capabilities.listByPriority('namespace'); ``` ### Searching Capabilities ```typescript // Search by substring in name or description const tableOps = capabilities.find('table'); for (const cap of tableOps) { console.log(`${cap.name}: ${cap.usageExample}`); } // Look up a specific capability by name const clickCap = capabilities.findByName('clickButton'); if (clickCap) { console.log(clickCap.usageExample); // => await ui5.click({ id: 'submitBtn' }) } ``` ### Browsing by Category ```typescript // List all categories const categories = capabilities.getCategories(); console.log(categories); // => ['ui5', 'auth', 'navigate', 'table', 'dialog', 'date', ...] // Get capabilities in a specific category (CapabilityCategory enum value) const navCaps = capabilities.byCategory('navigate'); for (const cap of navCaps) { console.log(`${cap.name}: ${cap.description}`); } ``` ### Looking Up by ID Each capability has a unique kebab-case ID. ```typescript const cap = capabilities.get('click-button'); if (cap) { console.log(cap.name); // 'clickButton' console.log(cap.description); // 'Clicks a UI5 button by selector' console.log(cap.usageExample); // "await ui5.click({ id: 'submitBtn' })" console.log(cap.category); // 'ui5' (CapabilityCategory enum value) console.log(cap.priority); // 'fixture' console.log(cap.qualifiedName); // 'ui5.clickButton' } ``` ### Statistics ```typescript const stats = capabilities.getStatistics(); console.log(`Total methods: ${stats.totalMethods}`); console.log(`Categories: ${stats.categories.join(', ')}`); console.log(`Fixtures: ${stats.byPriority.fixture}`); console.log(`Namespace: ${stats.byPriority.namespace}`); console.log(`Implementation: ${stats.byPriority.implementation}`); ``` ## Recipes A **recipe** is a reusable test pattern -- a curated code snippet that demonstrates how to accomplish a specific testing task. Recipes have a name, description, domain, priority, capabilities, and a ready-to-use pattern. ### Recipe Metadata | Field | Type | Description | | -------------- | ---------------- | -------------------------------------------------------------------------- | | `id` | `string` | Unique kebab-case identifier (e.g. `'recipe-ui5-button-click'`) | | `name` | `string` | Short descriptive title | | `description` | `string` | What this recipe demonstrates | | `domain` | `string` | Domain grouping (`'ui5'`, `'auth'`, `'navigation'`, etc.) | | `priority` | `RecipePriority` | `'essential'`, `'recommended'`, `'optional'`, `'advanced'`, `'deprecated'` | | `capabilities` | `string[]` | Capability IDs this recipe uses (e.g. `['UI5-UI5-003']`) | | `pattern` | `string` | Ready-to-use TypeScript code pattern | ### Selecting by Priority ```typescript // Must-know patterns for any SAP Fiori test suite const essential = recipes.selectByPriority('essential'); // Best-practice patterns for common scenarios const recommended = recipes.selectByPriority('recommended'); // Specialized patterns for complex edge cases const advanced = recipes.selectByPriority('advanced'); ``` ### Selecting by Domain ```typescript // All authentication recipes const authRecipes = recipes.selectByDomain('auth'); // All navigation recipes const navRecipes = recipes.selectByDomain('navigation'); // List all available domains const domains = recipes.getDomains(); console.log(domains); ``` ### Combined Filtering Use `recipes.select()` with multiple criteria. ```typescript // Essential recipes in the auth domain const filtered = recipes.select({ domain: 'auth', priority: 'essential', }); ``` ### Searching Recipes ```typescript // Free-text search across name, description, and capabilities const poRecipes = recipes.search('purchase order'); for (const r of poRecipes) { console.log(`${r.name}: ${r.description}`); console.log(r.pattern); } // Check if a recipe exists if (recipes.has('Login to SAP BTP via SAML')) { const steps = recipes.getSteps('Login to SAP BTP via SAML'); console.log(steps); } ``` ### Finding Recipes for a Capability ```typescript // Find recipes that use a specific capability const clickRecipes = recipes.forCapability('click'); for (const r of clickRecipes) { console.log(`${r.name} [${r.priority}]`); } ``` ### Finding Recipes for a Business Process ```typescript // Recipes for procurement workflows const procRecipes = recipes.forProcess('procurement'); // Recipes for a specific SAP domain const finRecipes = recipes.forDomain('finance'); ``` ### Top Recipes ```typescript // Get the top 5 recipes (by insertion order) const topFive = recipes.getTopRecipes(5); for (const r of topFive) { console.log(`[${r.priority}] ${r.name}`); } ``` ## AI Agent Usage Both registries are designed to be consumed by AI agents (Claude, GPT, Gemini) as structured context for test generation. ### capabilities.forAI() Returns the full registry as structured JSON optimized for AI consumption. ```typescript const aiContext = capabilities.forAI(); // Returns CapabilitiesJSON: // { // name: 'playwright-praman', // version: '1.0.0', // totalMethods: 120, // byPriority: { fixture: 40, namespace: 50, implementation: 30 }, // fixtures: [...], // Fixture-level entries listed first // methods: [...], // All entries // } ``` ### Provider-Specific Formatting Each AI provider has an output format optimized for its consumption model. ```typescript // XML-structured for Claude const claudeContext = capabilities.forProvider('claude'); // JSON registry snapshot for OpenAI const openaiContext = capabilities.forProvider('openai'); // JSON registry snapshot for Gemini const geminiContext = capabilities.forProvider('gemini'); ``` ### recipes.forAI() Returns all recipes in a flat array suitable for injection into an AI prompt. ```typescript const allRecipes = recipes.forAI(); // Returns RecipeEntry[] with full pattern, capabilities, and metadata ``` ### Building an AI Prompt with Capabilities and Recipes ```typescript // Build a context string for an AI agent const capabilityContext = JSON.stringify(capabilities.forAI(), null, 2); const relevantRecipes = recipes.select({ domain: 'navigation' }); const prompt = ` You are a test automation agent for SAP Fiori applications. Available capabilities: ${capabilityContext} Relevant recipes: ${JSON.stringify(relevantRecipes, null, 2)} Generate a Playwright test that navigates to the Purchase Order app and verifies the table has at least one row. `; ``` ### Using with the Agentic Handler The `pramanAI` fixture integrates capabilities and recipes automatically. ```typescript test('AI-generated test', async ({ page, pramanAI }) => { // Discover the current page (builds PageContext) const context = await pramanAI.discoverPage({ interactiveOnly: true }); // Generate a test from a natural language description // The agentic handler automatically uses capabilities + recipes as context const result = await pramanAI.agentic.generateTest( 'Create a purchase order for vendor 100001 with material MAT-001', page, ); console.log(result.steps); // ['Navigate to app', 'Fill vendor', ...] console.log(result.code); // Generated TypeScript test code console.log(result.metadata); // Model, tokens, duration, capabilities used }); ``` ## Advanced: Registering Custom Capabilities If your project extends Praman with custom helpers, register them so AI agents can discover them. ```typescript capabilities.registry.register({ id: 'UI5-CUSTOM-001', qualifiedName: 'custom.approveWorkflow', name: 'approveWorkflow', description: 'Approves a workflow item in the SAP Fiori inbox', category: 'ui5', intent: 'approve', sapModule: 'cross.fnd.fiori.inbox', usageExample: "await customHelpers.approveWorkflow({ workitemId: '000123' })", registryVersion: 1, priority: 'fixture', }); // Now AI agents can discover it const workflowCaps = capabilities.find('workflow'); ``` ## Validating Recipes Check that a recipe exists and inspect its metadata. ```typescript const result = recipes.validate('Login to SAP BTP via SAML'); if (result.valid) { console.log('Recipe found'); } else { console.log('Recipe not found'); } ``` ## JSON Export Export the full registries for external tools, dashboards, or documentation generators. ```typescript // Capabilities as JSON const capJson = capabilities.toJSON(); // Recipes as JSON const recipeJson = recipes.toJSON(); // Write to file for external consumption await writeFile('capabilities.json', JSON.stringify(capJson, null, 2)); await writeFile('recipes.json', JSON.stringify(recipeJson, null, 2)); ``` --- ## Feature Inventory Complete feature inventory for Praman v1.0. Each entry documents what the feature does, why it matters, its API, implicit behaviors, and source files. :::tip For the auto-generated API reference from TSDoc comments, see the **API Reference** in the navbar. This page provides a higher-level feature overview with usage examples and context. ::: ## Overview | Metric | Count | | ------------------------ | ----- | | **Public Functions** | 120+ | | **Public Types** | 100+ | | **Error Codes** | 58 | | **Fixture Modules** | 12 | | **Custom Matchers** | 10 | | **UI5 Control Types** | 199 | | **Auth Strategies** | 6 | | **SAP Business Domains** | 5 | | **Sub-Path Exports** | 6 | For the full detailed inventory with API examples, source files, and implicit behaviors, see the [capabilities document](https://github.com/mrkanitkar/playwright-praman/blob/main/plans/capabilities.md). ## Core Playwright Extensions ### UI5 Control Discovery Discovers SAP UI5 controls using a multi-strategy chain (cache, direct-ID, RecordReplay, registry scan) and returns a typed proxy for interaction. Eliminates brittle CSS/XPath selectors. ```typescript const btn = await ui5.control({ id: 'saveBtn' }); const input = await ui5.control({ controlType: 'sap.m.Input', properties: { placeholder: 'Enter vendor' }, }); ``` ### Control Proxy (Typed Method Forwarding) Wraps discovered controls in a JavaScript Proxy that routes method calls through `page.evaluate()` with automatic type detection and method blacklisting. ```typescript const control = await ui5.control({ id: 'myInput' }); await control.press(); await control.enterText('Hello'); const value = await control.getValue(); ``` ### Three Interaction Strategies - **UI5-native** (default): `firePress()` / `fireTap()` / DOM click fallback - **DOM-first**: DOM click first, UI5 event fallback - **OPA5**: SAP RecordReplay API ### 10 Custom UI5 Matchers Extends `expect()` with UI5-specific assertions: `toHaveUI5Text`, `toBeUI5Visible`, `toBeUI5Enabled`, `toHaveUI5Property`, `toHaveUI5ValueState`, `toHaveUI5Binding`, `toBeUI5ControlType`, `toHaveUI5RowCount`, `toHaveUI5CellText`, `toHaveUI5SelectedRows`. ## SAP UI5 Control Support - **199 typed control definitions** covering all major categories - **Table operations** for 6 table variants (sap.m.Table, sap.ui.table.Table, TreeTable, AnalyticalTable, SmartTable, mdc.Table) - **Dialog management** for 10+ dialog types - **Date/time picker** operations with locale awareness ## UI5 Lifecycle & Synchronization - **Automatic stability wait** — three-tier: bootstrap (60s) / stable (15s) / DOM settle (500ms) - **Request interception** — blocks WalkMe, Google Analytics, Qualtrics - **Retry with exponential backoff** — infrastructure-level retry with jitter ## Fixtures & Test Abstractions - **13 fixture modules** merged via `mergeTests()` - **6 authentication strategies**: basic, BTP SAML, Office 365, API, certificate, multi-tenant - **11 FLP navigation functions**: app, tile, intent, hash, home, back, forward, search, getCurrentHash, space, sectionLink - **FLP shell & footer** interaction - **SM12 lock management** with auto-cleanup - **User settings** reader (language, date format, timezone) - **Test data generation** with UUID/timestamp substitution ## OData / API Automation - **Model-level access** — read data from UI5 OData models in the browser - **HTTP-level CRUD** — direct OData operations with CSRF token management ## AI & Agent Enablement - **Page discovery** — structured `PageContext` for AI agents - **Capability registry** — machine-readable API catalog for prompt engineering - **Recipe registry** — reusable test pattern library - **Agentic handler** — autonomous test generation from natural language - **LLM abstraction** — provider-agnostic (Claude, OpenAI, Azure OpenAI) - **Vocabulary service** — business term to UI5 selector resolution ## Configuration & Extensibility - **Zod-validated config** with environment variable overrides - **14 error classes, 66 error codes** with `retryable` flag and `suggestions[]` - **Structured logging** with secret redaction - **OpenTelemetry integration** for distributed tracing - **Playwright step decoration** for rich HTML reports ## Domain-Specific Features - **Fiori Elements helpers** — List Report + Object Page APIs - **5 SAP domain intents** — Procurement, Sales, Finance, Manufacturing, Master Data - **Compliance reporter** — tracks Praman vs raw Playwright usage - **OData trace reporter** — per-entity-set performance statistics - **CLI tools** — `init` (scaffold + IDE agent installation), `doctor`, `uninstall` - **AI agents** — 3 Claude Code SAP agents (planner, generator, healer) installed by `init` - **Seed file** — authenticated SAP browser session for AI discovery (`tests/seeds/sap-seed.spec.ts`) --- ## Discovery and Interaction Strategies :::info[In this guide] - Understand the 3 discovery strategies (direct-id, recordreplay, registry) and their tradeoffs - Choose between 3 interaction strategies (ui5-native, dom-first, opa5) for your app type - Configure strategies via environment variables or Praman config - Use the decision matrix to pick the right combination for your SAP application - Debug strategy selection with pino log output ::: Praman uses two configurable strategy systems to find and interact with UI5 controls. **Discovery strategies** determine _how controls are located_ in the browser. **Interaction strategies** determine _how actions are performed_ on those controls. Both are independently configurable via environment variables. ## Part 1: Control Discovery Strategies Discovery is the process of finding a UI5 control on the page given a `UI5Selector`. Praman runs a **priority chain** — it tries each configured strategy in order and stops at the first match. ### The Discovery Flow ```text ┌──────────────────────┐ │ ui5.control(selector)│ └──────────┬───────────┘ │ v ┌──────────────────────┐ │ Tier 0: CACHE │ In-memory proxy cache │ (always first) │ Zero cost on repeat lookups └──────────┬───────────┘ │ miss v ┌──────────────────────┐ │ ensureBridgeInjected │ Inject Praman bridge into │ (idempotent) │ the browser if not present └──────────┬───────────┘ │ v ┌──────────────────────────────────────┐ │ getDiscoveryPriorities(selector, │ │ configuredStrategies) │ │ │ │ If ID-only selector AND direct-id │ │ is configured: promote direct-id │ │ to first position after cache │ └──────────────────┬───────────────────┘ │ v ┌────────────────────────────────────────────┐ │ For each strategy in priority order: │ │ │ │ ┌─ direct-id ──────────────────────────┐ │ │ │ Strips selector to { id } only │ │ │ │ Calls bridge.getById(id) │ │ │ │ Fastest: single lookup, no scanning │ │ │ └──────────────────────────────────────┘ │ │ │ null │ │ v │ │ ┌─ recordreplay ───────────────────────┐ │ │ │ Passes full selector │ │ │ │ Tries Tier 1 (getById) first │ │ │ │ Then Tier 2 (registry scan) │ │ │ │ Then Tier 3 (RecordReplay API) │ │ │ └──────────────────────────────────────┘ │ │ │ null │ │ v │ │ ┌─ registry ───────────────────────────┐ │ │ │ Forces full registry scan (Tier 2) │ │ │ │ Skips Tier 1 entirely │ │ │ │ Matches by type, properties, bindings│ │ │ └──────────────────────────────────────┘ │ │ │ null │ │ v │ │ throw ControlError │ │ ERR_CONTROL_NOT_FOUND │ └────────────────────────────────────────────┘ ``` _Source: [discovery.ts](https://github.com/nicheui/praman/blob/main/src/proxy/discovery.ts) lines 145–201, [discovery-factory.ts](https://github.com/nicheui/praman/blob/main/src/proxy/discovery-factory.ts) lines 74–101_ ### Strategy Details #### `direct-id` — Direct ID Lookup (Tier 1) The fastest discovery path. Strips the selector to `{ id }` only and calls the bridge's `getById()` accessor, which resolves the control in a single call. **SAP UI5 APIs used:** | API | What It Does | Reference | | --------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `sap.ui.core.Element.registry` | Access the global element registry (UI5 >= 1.84) | [API Reference](https://ui5.sap.com/#/api/sap.ui.core.Element%23methods/sap.ui.core.Element.registry) | | `sap.ui.core.ElementRegistry` | Legacy registry accessor (UI5 < 1.84) | [API Reference](https://ui5.sap.com/#/api/sap.ui.core.ElementRegistry) | | `control.getId()` | Returns the control's stable ID | [API Reference](https://ui5.sap.com/#/api/sap.ui.core.Element%23methods/getId) | | `control.getMetadata().getName()` | Returns the fully qualified type (e.g., `sap.m.Button`) | [API Reference](https://ui5.sap.com/#/api/sap.ui.base.ManagedObject%23methods/getMetadata) | **When it works:** The selector has an `id` field and that ID exists in the UI5 registry. **When it skips:** The selector has no `id` field, or the ID is not found. **Behavior in code** — `tryStrategy('direct-id', ...)` at [discovery.ts:79–89](https://github.com/nicheui/praman/blob/main/src/proxy/discovery.ts#L79-L89): ```typescript // Passes id-only selector for fastest getById() path const result = await page.evaluate(browserFindControl, { selector: { id: selector.id }, bridgeNs: BRIDGE_GLOBALS.NAMESPACE, preferVisibleControls, }); ``` #### `recordreplay` — SAP RecordReplay API (Tier 3) Uses SAP's `RecordReplay.findDOMElementByControlSelector()` to resolve the full selector (including `controlType`, `properties`, `bindingPath`, `ancestor`, etc.) to a DOM element, then bridges the DOM element back to a UI5 control via `Element.closestTo()`. **SAP UI5 APIs used:** | API | What It Does | Reference | | ------------------------------------------------ | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `RecordReplay.findDOMElementByControlSelector()` | Resolves a UI5 selector to a DOM element | [API Reference](https://ui5.sap.com/#/api/sap.ui.test.RecordReplay%23methods/sap.ui.test.RecordReplay.findDOMElementByControlSelector) | | `sap.ui.core.Element.closestTo(domElement)` | Finds the UI5 control that owns a DOM element (UI5 >= 1.106) | [API Reference](https://ui5.sap.com/#/api/sap.ui.core.Element%23methods/sap.ui.core.Element.closestTo) | | `jQuery(domElement).control(0)` | Legacy fallback: jQuery UI5 plugin for DOM-to-control resolution | — | **When it works:** UI5 >= 1.94 with the `sap.ui.test.RecordReplay` module loaded. **When it skips:** RecordReplay is not available (older UI5 versions or module not loaded). **Internal sub-chain:** When `recordreplay` is configured, the browser script actually tries Tier 1 (getById) first, then Tier 2 (registry scan), then Tier 3 (RecordReplay API) — this is an internal optimization, not visible to the user. **Behavior in code** — `tryStrategy('recordreplay', ...)` at [discovery.ts:92–101](https://github.com/nicheui/praman/blob/main/src/proxy/discovery.ts#L92-L101): ```typescript // Passes full selector for RecordReplay.findDOMElementByControlSelector() const result = await page.evaluate(browserFindControl, { selector: { ...selector }, bridgeNs: BRIDGE_GLOBALS.NAMESPACE, preferVisibleControls, }); ``` #### `registry` — Full Registry Scan (Tier 2) Scans all instantiated UI5 controls via `registry.all()` and matches by `controlType`, `properties`, `bindingPath`, `viewName`, and visibility. The most thorough but slowest strategy. **SAP UI5 APIs used:** | API | What It Does | Reference | | ------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | `registry.all()` | Returns all instantiated UI5 controls as a map | [API Reference](https://ui5.sap.com/#/api/sap.ui.core.ElementRegistry%23methods/all) | | `control.getVisible()` | Check visibility (used when `preferVisibleControls` is on) | [API Reference](https://ui5.sap.com/#/api/sap.ui.core.Control%23methods/getVisible) | | `control.getProperty(name)` | Read any property by name for matching | [API Reference](https://ui5.sap.com/#/api/sap.ui.base.ManagedObject%23methods/getProperty) | | `control.getBinding(name)` | Get data binding for a property | [API Reference](https://ui5.sap.com/#/api/sap.ui.base.ManagedObject%23methods/getBinding) | | `binding.getPath()` | Read the OData binding path (e.g., `/Products/0/Name`) | [API Reference](https://ui5.sap.com/#/api/sap.ui.model.Binding%23methods/getPath) | | `control.getParent()` | Walk the control tree for ancestor/view matching | [API Reference](https://ui5.sap.com/#/api/sap.ui.base.ManagedObject%23methods/getParent) | | `metadata.getAllProperties()` | Get all property definitions for a control type | [API Reference](https://ui5.sap.com/#/api/sap.ui.base.ManagedObjectMetadata%23methods/getAllProperties) | | `metadata.getAllAggregations()` | Get all aggregation definitions | [API Reference](https://ui5.sap.com/#/api/sap.ui.base.ManagedObjectMetadata%23methods/getAllAggregations) | **When it works:** Always — the element registry is available in all UI5 versions. **When it's slow:** Pages with thousands of controls (large List Reports, complex Object Pages). **Behavior in code** — `tryStrategy('registry', ...)` at [discovery.ts:103–114](https://github.com/nicheui/praman/blob/main/src/proxy/discovery.ts#L103-L114): ```typescript // Forces Tier 2 registry scan, skipping Tier 1 direct-id const result = await page.evaluate(browserFindControl, { selector: { ...selector }, bridgeNs: BRIDGE_GLOBALS.NAMESPACE, preferVisibleControls, forceRegistryScan: true, }); ``` ### Smart Priority Promotion The discovery factory at [discovery-factory.ts:84–93](https://github.com/nicheui/praman/blob/main/src/proxy/discovery-factory.ts#L84-L93) automatically promotes `direct-id` to first position for ID-only selectors, regardless of the configured order: ```text Config: discoveryStrategies: ['recordreplay', 'direct-id'] Selector: { id: 'btn1' } ← ID-only Actual: ['cache', 'direct-id', 'recordreplay'] ← direct-id promoted Selector: { controlType: 'sap.m.Button', properties: { text: 'Save' } } ← complex Actual: ['cache', 'recordreplay', 'direct-id'] ← config order preserved ``` ### Comparison Table | | `direct-id` | `recordreplay` | `registry` | | -------------------- | -------------------------------- | ---------------------------------------------------- | -------------------------- | | **Speed** | Fastest (single lookup) | Medium (API call + DOM bridge) | Slowest (full scan) | | **Selector support** | `id` only | Full selector (type, properties, bindings, ancestor) | Full selector | | **UI5 version** | All versions | >= 1.94 | All versions | | **Best for** | Known stable IDs | Complex selectors, SAP standard apps | Dynamic controls, fallback | | **Risk** | Fails if ID changes across views | Requires RecordReplay module loaded | Slow on large pages | ### UI5 Version Compatibility for Discovery ```text ┌─────────────────────────────────────────────────────────────────────┐ │ UI5 Version │ direct-id │ registry │ recordreplay │ │────────────────┼────────────┼────────────┼──────────────────────────│ │ < 1.84 │ ✓ (legacy │ ✓ (Element │ ✗ │ │ │ registry) │ Registry) │ │ │ 1.84 – 1.93 │ ✓ │ ✓ (Element.│ ✗ │ │ │ │ registry) │ │ │ >= 1.94 │ ✓ │ ✓ │ ✓ │ │ >= 1.106 │ ✓ │ ✓ │ ✓ (uses closestTo) │ └─────────────────────────────────────────────────────────────────────┘ ``` _Source: Registry fallback at [find-control-fn.ts:189–198](https://github.com/nicheui/praman/blob/main/src/bridge/browser-scripts/find-control-fn.ts#L189-L198), closestTo vs jQuery at [find-control-fn.ts:315–326](https://github.com/nicheui/praman/blob/main/src/bridge/browser-scripts/find-control-fn.ts#L315-L326)_ --- ## Part 2: Interaction Strategies Interaction strategies define how Praman performs `press()`, `enterText()`, and `select()` operations on discovered controls. Each strategy uses different SAP UI5 and DOM APIs with its own internal fallback chain. ### The Interaction Flow ```text ┌─────────────────────────────┐ │ ui5.click({ id: 'btn1' }) │ └──────────────┬──────────────┘ │ v ┌─────────────────────────────┐ │ Discovery (Part 1 above) │ │ Returns: control proxy │ │ with controlId │ └──────────────┬──────────────┘ │ v ┌─────────────────────────────┐ │ strategy.press(page, id) │ │ │ │ Configured at worker start │ │ via pramanConfig fixture │ └──────────────┬──────────────┘ │ ┌────────────────┼────────────────┐ │ │ │ v v v ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ ui5-native │ │ dom-first │ │ opa5 │ │ (default) │ │ │ │ │ │ firePress() │ │ dom.click() │ │ RecordReplay │ │ fireSelect() │ │ firePress() │ │ .interact..()│ │ fireTap() │ │ fireSelect() │ │ firePress() │ │ dom.click() │ │ fireTap() │ │ fireSelect() │ └──────────────┘ └──────────────┘ └──────────────┘ ``` _Source: [strategy-factory.ts](https://github.com/nicheui/praman/blob/main/src/bridge/interaction-strategies/strategy-factory.ts) lines 38–51, [module-fixtures.ts](https://github.com/nicheui/praman/blob/main/src/fixtures/module-fixtures.ts) line 324_ ### Strategy Interface All three strategies implement the same contract defined in [strategy.ts](https://github.com/nicheui/praman/blob/main/src/bridge/interaction-strategies/strategy.ts): ```typescript interface InteractionStrategy { readonly name: string; press(page: Page, controlId: string): Promise; enterText(page: Page, controlId: string, text: string): Promise; select(page: Page, controlId: string, itemId: string): Promise; } ``` ### `ui5-native` — Default Strategy Uses direct UI5 control event firing. The most reliable strategy for standard SAP Fiori applications because it triggers the same code paths that real user interactions would. **SAP UI5 APIs used:** | API | Operation | What It Does | Reference | | ----------------------------------- | --------- | --------------------------------------------- | ---------------------------------------------------------------------------------- | | `control.firePress()` | press | Fires the press event on buttons, links | [API Reference](https://ui5.sap.com/#/api/sap.m.Button%23events/press) | | `control.fireSelect()` | press | Fires the select event on selectable controls | [API Reference](https://ui5.sap.com/#/api/sap.m.Select%23events/change) | | `control.fireTap()` | press | Fires tap event (legacy, pre-1.50 controls) | — | | `control.getDomRef()` | press | Gets the DOM element for DOM click fallback | [API Reference](https://ui5.sap.com/#/api/sap.ui.core.Element%23methods/getDomRef) | | `control.setValue(text)` | enterText | Sets text value on input controls | [API Reference](https://ui5.sap.com/#/api/sap.m.InputBase%23methods/setValue) | | `control.fireLiveChange({ value })` | enterText | Fires live change as user types | [API Reference](https://ui5.sap.com/#/api/sap.m.Input%23events/liveChange) | | `control.fireChange({ value })` | enterText | Fires change when input loses focus | [API Reference](https://ui5.sap.com/#/api/sap.m.InputBase%23events/change) | | `control.setSelectedKey(key)` | select | Sets the selected key on dropdowns | [API Reference](https://ui5.sap.com/#/api/sap.m.Select%23methods/setSelectedKey) | | `control.fireSelectionChange()` | select | Fires selection change (List, Table, ComboBox — not Select) | [API Reference](https://ui5.sap.com/#/api/sap.m.ComboBox%23events/selectionChange) | **Fallback chains in code:** ```text press() — ui5-native-strategy.ts lines 65–97 ├── firePress() ─┐ ├── fireSelect() ─┤ both fire if available │ └── success ──────→ done ├── fireTap() → done └── getDomRef().click() → done └── fail → throw ControlError enterText() — ui5-native-strategy.ts lines 100–128 Sequential (all called, not fallback): ├── setValue(text) ├── fireLiveChange({ value: text }) └── fireChange({ value: text }) select() — ui5-native-strategy.ts lines 131–161 ├── setSelectedKey(itemId) ├── fireSelectionChange({ selectedItem }) → done (List, Table, ComboBox) └── fireChange({ selectedItem }) → done (Select, or fallback) ``` :::info Why firePress and fireSelect both fire The ui5-native strategy fires both `firePress()` and `fireSelect()` on the same control (lines 74–76). This is intentional — some controls like `SegmentedButton` items respond to `fireSelect()` but not `firePress()`, while standard buttons respond to `firePress()` only. Firing both covers both cases. ::: ### `dom-first` — DOM Events First Performs DOM-level interactions first, then falls back to UI5 events. This strategy uses Playwright's `page.evaluate(fn, args)` with typed argument objects — no string interpolation — which eliminates script injection risks. Best for form controls and custom composite controls that respond to DOM events but don't expose `firePress()`. **SAP UI5 and DOM APIs used:** | API | Operation | Type | Reference | | ---------------------------------------- | --------- | ---- | ------------------------------------------------------------------------------------------- | | `control.getDomRef()` | press | UI5 | [API Reference](https://ui5.sap.com/#/api/sap.ui.core.Element%23methods/getDomRef) | | `dom.click()` | press | DOM | [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/click) | | `control.getFocusDomRef()` | enterText | UI5 | [API Reference](https://ui5.sap.com/#/api/sap.ui.core.Element%23methods/getFocusDomRef) | | `HTMLInputElement.value = text` | enterText | DOM | [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/value) | | `dom.dispatchEvent(new Event('input'))` | enterText | DOM | [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent) | | `dom.dispatchEvent(new Event('change'))` | enterText | DOM | [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent) | | `control.setValue(text)` | enterText | UI5 | [API Reference](https://ui5.sap.com/#/api/sap.m.InputBase%23methods/setValue) | | `control.fireChange({ value })` | enterText | UI5 | [API Reference](https://ui5.sap.com/#/api/sap.m.InputBase%23events/change) | | `control.firePress()` | press | UI5 | [API Reference](https://ui5.sap.com/#/api/sap.m.Button%23events/press) | **Fallback chains in code:** ```text press() — dom-first-strategy.ts lines 79–135 ├── getDomRef() → dom.click() → done (DOM first) ├── firePress() ─┐ ├── fireSelect() ─┤ both fire if available │ └── success ──────────────────→ done ├── fireTap() → done └── fail → throw ControlError enterText() — dom-first-strategy.ts lines 138–193 ├── getFocusDomRef() or getDomRef() │ └── if tagName === 'INPUT': │ ├── input.value = text (DOM property set) │ ├── dispatchEvent('input') (synthetic DOM event) │ └── dispatchEvent('change') → done (early return) └── UI5 fallback: ├── setValue(text) └── fireChange({ value: text }) → done select() — dom-first-strategy.ts lines 196–243 Same as ui5-native (no DOM shortcut for select): ├── setSelectedKey(itemId) ├── fireSelectionChange({ selectedItem }) → done (List, Table, ComboBox) └── fireChange({ selectedItem }) → done (Select, or fallback) ``` ### `opa5` — SAP RecordReplay API Uses SAP's official `RecordReplay.interactWithControl()` API from the OPA5 testing framework. Most compatible with SAP testing standards. Includes an optional **auto-wait** that calls `waitForUI5()` for UI5 stability before each interaction. Falls back to native UI5 fire\* methods if RecordReplay is not available. :::warning UI5 Version Requirement The opa5 strategy requires UI5 >= 1.94 for the `sap.ui.test.RecordReplay` API. On older versions, it falls back to `firePress()` / `setValue()` — effectively behaving like ui5-native with reduced coverage. ::: **SAP UI5 APIs used:** | API | Operation | What It Does | Reference | | ------------------------------------ | --------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `RecordReplay.interactWithControl()` | all | SAP's official control interaction API | [API Reference](https://ui5.sap.com/#/api/sap.ui.test.RecordReplay%23methods/sap.ui.test.RecordReplay.interactWithControl) | | `RecordReplay.waitForUI5()` | all | Waits for UI5 to finish pending async operations | [API Reference](https://ui5.sap.com/#/api/sap.ui.test.RecordReplay%23methods/sap.ui.test.RecordReplay.waitForUI5) | | `control.firePress()` | press | Fallback if RecordReplay unavailable | [API Reference](https://ui5.sap.com/#/api/sap.m.Button%23events/press) | | `control.fireSelect()` | press | Fallback if RecordReplay unavailable | — | | `control.setValue(text)` | enterText | Fallback if RecordReplay unavailable | [API Reference](https://ui5.sap.com/#/api/sap.m.InputBase%23methods/setValue) | | `control.setSelectedKey(key)` | select | Fallback if RecordReplay unavailable | [API Reference](https://ui5.sap.com/#/api/sap.m.Select%23methods/setSelectedKey) | **OPA5-specific configuration** — defined in [schema.ts:119–123](https://github.com/nicheui/praman/blob/main/src/core/config/schema.ts#L119-L123): | Field | Default | Description | | -------------------- | ------- | ------------------------------------------------------ | | `interactionTimeout` | `5000` | Internal OPA5 interaction timeout in ms (not passed to RecordReplay API) | | `autoWait` | `true` | Call `waitForUI5()` before each interaction | | `debug` | `false` | Log `[praman:opa5]` messages to browser console | **Fallback chains in code:** ```text press() — opa5-strategy.ts lines 82–140 if RecordReplay available: │ ├── RecordReplay.waitForUI5() │ └── RecordReplay.interactWithControl({ │ selector: { id }, │ interactionType: 'PRESS' │ }) → done │ if RecordReplay NOT available (fallback): ├── firePress() ─┐ └── fireSelect() ─┤ → done └── fail → throw ControlError enterText() — opa5-strategy.ts lines 143–194 if RecordReplay available: │ ├── waitForUI5() │ └── RecordReplay.interactWithControl({ │ interactionType: 'ENTER_TEXT', │ enterText: text │ }) → done │ if RecordReplay NOT available: └── setValue(text) → done select() — opa5-strategy.ts lines 197–246 if RecordReplay available: │ ├── waitForUI5() │ └── RecordReplay.interactWithControl({ │ interactionType: 'PRESS' ← uses PRESS, not SELECT │ }) → done │ if RecordReplay NOT available: └── setSelectedKey(itemId) → done ``` ### Understanding SAP Pages: DOM, UI5, and Web Components Together Real SAP Fiori applications often render a mix of control types on the same page: ```text ┌─ SAP Fiori Launchpad (FLP) ────────────────────────────────┐ │ │ │ ┌─ Shell Bar ──────────────────────────────────────────┐ │ │ │ Pure DOM elements (custom CSS, no UI5 control) │ │ │ │ Example: logo image, custom toolbar buttons │ │ │ │ → Only dom-first can interact with these │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │ ┌─ App Container ──────────────────────────────────────┐ │ │ │ │ │ │ │ ┌─ Standard UI5 Controls ──────────────────────┐ │ │ │ │ │ sap.m.Button, sap.m.Input, sap.m.Table │ │ │ │ │ │ → All strategies work (ui5-native best) │ │ │ │ │ └──────────────────────────────────────────────┘ │ │ │ │ │ │ │ │ ┌─ Smart Controls (sap.ui.comp) ───────────────┐ │ │ │ │ │ SmartField wraps inner Input/ComboBox/etc. │ │ │ │ │ │ → ui5-native works on outer SmartField │ │ │ │ │ │ → dom-first can reach inner element │ │ │ │ │ └──────────────────────────────────────────────┘ │ │ │ │ │ │ │ │ ┌─ UI5 Web Components ─────────────────────────┐ │ │ │ │ │ ui5-button, ui5-input (Shadow DOM) │ │ │ │ │ │ → dom-first required (shadow DOM piercing) │ │ │ │ │ │ → ui5-native cannot fire events on WC │ │ │ │ │ └──────────────────────────────────────────────┘ │ │ │ │ │ │ │ │ ┌─ Third-Party / Custom Controls ──────────────┐ │ │ │ │ │ Custom composite controls, chart libraries │ │ │ │ │ │ → dom-first often the only option │ │ │ │ │ │ → ui5-native works if they extend ManagedObj │ │ │ │ │ └──────────────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` This is why choosing the right strategy matters. No single strategy handles every control type on every page. The built-in fallback chains in each strategy are designed to cover the most common combinations. ### Comparison Table | | `ui5-native` | `dom-first` | `opa5` | | ------------------------- | ------------------------- | ---------------------------- | ---------------------------- | | **Speed** | Fast | Fast | Medium (waitForUI5 call) | | **Standard UI5 controls** | Best | Good (via fallback) | Best (SAP official) | | **Custom composites** | May fail (no `firePress`) | Best | May fail | | **UI5 Web Components** | Does not work | Works (DOM events) | Does not work | | **Plain DOM elements** | Falls back to DOM click | Works natively | Does not work | | **OData model updates** | `setValue` + events | DOM events may miss bindings | RecordReplay handles | | **UI5 version** | All | All | >= 1.94 | | **SAP compliance** | No official API | No official API | Yes (`RecordReplay`) | | **Fallback chain depth** | 4 levels | 4 levels | 2 levels + RecordReplay | --- ## Part 3: Configuring Strategies — A Practical Guide ### How Configuration Flows Through the Code ```text Environment Variable Zod Schema Defaults PRAMAN_INTERACTION_STRATEGY=dom-first interactionStrategy: 'ui5-native' PRAMAN_DISCOVERY_STRATEGIES=... discoveryStrategies: ['direct-id','recordreplay'] │ │ v v ┌──────────────────────────────────────────────────────────┐ │ loadConfig() — src/core/config/loader.ts:142 │ │ Merges: schema defaults ← env vars (env wins) │ │ Validates with Zod, returns Readonly │ └────────────────────────┬─────────────────────────────────┘ │ v ┌──────────────────────────────────────────────────────────┐ │ pramanConfig fixture — src/fixtures/core-fixtures.ts:164│ │ Worker-scoped: loaded ONCE per Playwright worker │ │ Frozen: Object.freeze(config) │ └────────────────────────┬─────────────────────────────────┘ │ ┌────────────┴────────────┐ v v ┌───────────────────────┐ ┌────────────────────────────────┐ │ createInteraction │ │ new UI5Handler({ │ │ Strategy( │ │ discoveryStrategies: │ │ config.interaction │ │ config.discoveryStrategies │ │ Strategy, │ │ }) │ │ config.opa5 │ │ │ │ ) │ │ src/fixtures/module-fixtures.ts│ │ │ │ line 337 │ │ src/fixtures/ │ └────────────────────────────────┘ │ module-fixtures.ts │ │ line 324 │ └───────────────────────┘ ``` _Source: [loader.ts:62–78](https://github.com/nicheui/praman/blob/main/src/core/config/loader.ts#L62-L78) defines the env var mappings, [module-fixtures.ts:322–347](https://github.com/nicheui/praman/blob/main/src/fixtures/module-fixtures.ts#L322-L347) wires config to strategy and handler_ ### Setting Strategies via Environment Variables The most direct way to configure strategies without changing code. #### Interaction Strategy ```bash # Use DOM-first for a test run PRAMAN_INTERACTION_STRATEGY=dom-first npx playwright test # Use OPA5 RecordReplay PRAMAN_INTERACTION_STRATEGY=opa5 npx playwright test # Use the default (ui5-native) — no env var needed npx playwright test ``` Valid values: `ui5-native`, `dom-first`, `opa5` _Source: env var mapping at [loader.ts:70](https://github.com/nicheui/praman/blob/main/src/core/config/loader.ts#L70)_ #### Discovery Strategies ```bash # ID lookup first, then RecordReplay, then full registry scan PRAMAN_DISCOVERY_STRATEGIES=direct-id,recordreplay,registry npx playwright test # Only use RecordReplay (skip ID lookup) PRAMAN_DISCOVERY_STRATEGIES=recordreplay npx playwright test # Only registry scan (thorough but slow) PRAMAN_DISCOVERY_STRATEGIES=registry npx playwright test ``` Valid values (comma-separated): `direct-id`, `recordreplay`, `registry` _Source: env var mapping at [loader.ts:72–75](https://github.com/nicheui/praman/blob/main/src/core/config/loader.ts#L72-L75), parsed as comma-separated array at [loader.ts:106–113](https://github.com/nicheui/praman/blob/main/src/core/config/loader.ts#L106-L113)_ #### OPA5 Tuning (when using `opa5` interaction strategy) ```bash PRAMAN_INTERACTION_STRATEGY=opa5 npx playwright test ``` The OPA5 sub-config (`interactionTimeout`, `autoWait`, `debug`) does not have env var mappings. These are set via inline overrides or config file only. #### Combining Both ```bash # DOM-first interaction + registry-based discovery with all 3 tiers PRAMAN_INTERACTION_STRATEGY=dom-first \ PRAMAN_DISCOVERY_STRATEGIES=direct-id,recordreplay,registry \ npx playwright test ``` #### In CI/CD Pipelines ```yaml # GitHub Actions - name: Run SAP E2E Tests run: npx playwright test env: PRAMAN_INTERACTION_STRATEGY: opa5 PRAMAN_DISCOVERY_STRATEGIES: direct-id,recordreplay SAP_CLOUD_BASE_URL: ${{ secrets.SAP_URL }} ``` ```yaml # Azure Pipelines - script: npx playwright test env: PRAMAN_INTERACTION_STRATEGY: dom-first PRAMAN_DISCOVERY_STRATEGIES: direct-id,recordreplay,registry ``` :::danger Typo in env var values discards ALL env vars If any single env var fails Zod validation, the loader discards **all** env var overrides and falls back to defaults. A typo like `PRAMAN_DISCOVERY_STRATEGIES=direct-id,recordreply` (missing `a` in replay) causes both the discovery and interaction env vars to be silently ignored. Only a `warn`-level pino log is emitted. This is implemented at [loader.ts:159–171](https://github.com/nicheui/praman/blob/main/src/core/config/loader.ts#L159-L171). Verify your values match exactly: `direct-id`, `recordreplay`, `registry`, `ui5-native`, `dom-first`, `opa5`. ::: ### Decision Matrix: Choosing Your Strategy #### Step 1: Choose Your Interaction Strategy ```text Is your app standard SAP Fiori (SAPUI5 controls only)? ├── YES → Does your team require SAP compliance audits? │ ├── YES → Use opa5 (SAP's official API) │ └── NO → Use ui5-native (default, broadest fallback) │ └── NO → Does your app have any of these? ├── UI5 Web Components (Shadow DOM) → Use dom-first ├── Custom composite controls → Use dom-first ├── Plain DOM elements mixed with UI5 → Use dom-first └── Standard UI5 only in older version → Use ui5-native ``` #### Step 2: Choose Your Discovery Priorities ```text Do your selectors use stable IDs (e.g., { id: 'saveBtn' })? ├── YES → Include direct-id first: ['direct-id', 'recordreplay'] │ (direct-id is auto-promoted for ID-only selectors anyway) │ └── NO → Do you use complex selectors (controlType + properties)? ├── YES → Is UI5 >= 1.94? │ ├── YES → ['recordreplay', 'direct-id'] │ └── NO → ['registry', 'direct-id'] │ └── Mostly binding paths or ancestor matching? └── ['registry', 'recordreplay'] ``` #### Step 3: Apply the Configuration ```bash # Scenario A: Standard Fiori, stable IDs (most common) # Just use defaults — no env vars needed npx playwright test # Scenario B: Mixed controls, need DOM access PRAMAN_INTERACTION_STRATEGY=dom-first npx playwright test # Scenario C: SAP compliance, complex selectors PRAMAN_INTERACTION_STRATEGY=opa5 \ PRAMAN_DISCOVERY_STRATEGIES=recordreplay,direct-id,registry \ npx playwright test # Scenario D: Legacy app (UI5 < 1.94), dynamic controls PRAMAN_DISCOVERY_STRATEGIES=direct-id,registry npx playwright test ``` ### Recommended Configurations by App Type | Application Type | Interaction | Discovery | Why | | ----------------------------- | ------------ | ------------------------------------------- | -------------------------------- | | Standard Fiori (S/4HANA) | `ui5-native` | `['direct-id', 'recordreplay']` | Stable IDs, standard controls | | Fiori Elements List Report | `ui5-native` | `['recordreplay', 'direct-id']` | Generated IDs, complex selectors | | Custom Fiori App | `dom-first` | `['direct-id', 'recordreplay', 'registry']` | Custom controls may lack fire\* | | Hybrid (UI5 + Web Components) | `dom-first` | `['direct-id', 'registry']` | Shadow DOM needs DOM events | | Migration from OPA5 | `opa5` | `['recordreplay', 'direct-id']` | Behavioral parity with OPA5 | | Legacy (UI5 < 1.94) | `ui5-native` | `['direct-id', 'registry']` | No RecordReplay available | ### Verifying Your Configuration To confirm which strategies are active, enable debug logging: ```bash PRAMAN_LOG_LEVEL=debug \ PRAMAN_INTERACTION_STRATEGY=dom-first \ PRAMAN_DISCOVERY_STRATEGIES=direct-id,recordreplay \ npx playwright test --headed ``` The pino logger will output which strategy was created and which discovery chain is active. ### Pros and Cons of Environment Variables | Pros | Cons | | --------------------------------------------- | ---------------------------------------------- | | Works today, no code changes | Not version-controlled (ephemeral) | | Easy CI/CD override per pipeline | No IDE autocomplete or type checking | | Switch strategies per test run | One typo silently discards ALL env vars | | No file to maintain | Team must document in CI config or README | | Highest priority (overrides all other config) | Cannot set OPA5 sub-config (timeout, autoWait) | ## FAQ
Which strategy combination should I start with? Start with the defaults: `ui5-native` interaction and `['direct-id', 'recordreplay']` discovery. This works for the majority of standard SAP Fiori applications. Only change strategies if you encounter specific issues, such as custom controls that lack `firePress()` (switch to `dom-first`) or older UI5 versions without RecordReplay (drop `recordreplay`).
What happens if a typo in an environment variable name goes undetected? Praman only reads specific env var names (`PRAMAN_INTERACTION_STRATEGY`, `PRAMAN_DISCOVERY_STRATEGIES`). If you misspell the variable name, it is silently ignored and defaults are used. If you misspell a **value** (e.g., `recordreply` instead of `recordreplay`), Zod validation fails and **all** env var overrides are discarded — both discovery and interaction fall back to defaults.
Can I use different strategies for different tests in the same run? Strategies are configured at the worker level (via `pramanConfig` fixture). To use different strategies for different tests, create separate Playwright projects in your config, each with different environment variables. Tests in each project will use that project's strategy configuration.
## See Also - [Selector Reference](./selectors.md) — How to write `UI5Selector` objects - [Configuration Reference](./configuration.md) — Full config schema - [Control Interactions](./control-interactions.md) — `ui5.click()`, `ui5.fill()`, etc. - [Bridge Internals](./bridge-internals.md) — How the bridge injects into the browser - [Control Proxy](./control-proxy.md) — How discovered controls become proxy objects :::tip[Next steps] - **[Selector Reference →](./selectors.md)** — Learn all UI5Selector fields for finding controls - **[Control Interactions →](./control-interactions.md)** — How `ui5.click()`, `ui5.fill()`, and other methods work - **[Gold Standard Test Pattern →](./gold-standard-test.md)** — Complete reference test using best practices ::: --- ## Fiori Elements Testing The `playwright-praman/fe` sub-path provides specialized helpers for testing SAP Fiori Elements applications, including List Report, Object Page, table operations, and the FE Test Library integration. ## Getting Started ```typescript getListReportTable, getFilterBar, setFilterBarField, executeSearch, navigateToItem, getObjectPageLayout, getHeaderTitle, navigateToSection, } from 'playwright-praman/fe'; ``` ## List Report Operations ### Finding the Table and Filter Bar ```typescript // Discover the main List Report table const tableId = await getListReportTable(page); // Discover the filter bar const filterBarId = await getFilterBar(page); ``` Both functions wait for UI5 stability before querying and throw a `ControlError` if the control is not found. ### Filter Bar Operations ```typescript // Set a filter field value await setFilterBarField(page, filterBarId, 'CompanyCode', '1000'); // Read a filter field value const value = await getFilterBarFieldValue(page, filterBarId, 'CompanyCode'); // Clear all filter bar fields await clearFilterBar(page, filterBarId); // Execute search (triggers the Go button) await executeSearch(page, filterBarId); ``` ### Variant Management ```typescript // List available variants const variants = await getAvailableVariants(page); // ['Standard', 'My Open Orders', 'Last 30 Days'] // Select a variant by name await selectVariant(page, 'My Open Orders'); ``` ### Row Navigation ```typescript // Navigate to a specific row (opens Object Page) await navigateToItem(page, tableId, 0); // Click first row ``` ### Options All List Report functions accept an optional `ListReportOptions` parameter: ```typescript interface ListReportOptions { readonly timeout?: number; // Default: CONTROL_DISCOVERY timeout readonly skipStabilityWait?: boolean; // Default: false } // Example with options await executeSearch(page, filterBarId, { timeout: 15_000 }); ``` ## Object Page Operations ### Layout and Header ```typescript // Discover the Object Page layout const layoutId = await getObjectPageLayout(page); // Read the header title const title = await getHeaderTitle(page); // 'Purchase Order 4500012345' ``` ### Sections ```typescript // Get all sections with their sub-sections const sections = await getObjectPageSections(page); // [ // { id: 'sec1', title: 'General Information', visible: true, index: 0, subSections: [...] }, // { id: 'sec2', title: 'Items', visible: true, index: 1, subSections: [...] }, // ] // Navigate to a specific section await navigateToSection(page, 'Items'); // Read form data from a section const data = await getSectionData(page, 'General Information'); // { 'Vendor': 'Acme GmbH', 'Company Code': '1000', ... } ``` ### Edit Mode ```typescript // Check if Object Page is in edit mode const editing = await isInEditMode(page); // Toggle edit mode await clickEditButton(page); // Click custom buttons await clickObjectPageButton(page, 'Delete'); // Save changes await clickSaveButton(page); ``` ## FE Table Helpers For direct table manipulation (works with both List Report and Object Page tables): ```typescript feGetTableRowCount, feGetColumnNames, feGetCellValue, feClickRow, feFindRowByValues, } from 'playwright-praman/fe'; // Get row count const count = await feGetTableRowCount(page, tableId); // Get column headers const columns = await feGetColumnNames(page, tableId); // ['Material', 'Description', 'Quantity', 'Unit Price'] // Read a cell value by row index and column name const material = await feGetCellValue(page, tableId, 0, 'Material'); // Click a row await feClickRow(page, tableId, 2); // Find a row by column values const rowIndex = await feFindRowByValues(page, tableId, { Material: 'RAW-0001', Quantity: '100', }); ``` ## FE List Helpers For list-based Fiori Elements views (e.g., Master-Detail): ```typescript feGetListItemCount, feGetListItemTitle, feGetListItemDescription, feClickListItem, feSelectListItem, feFindListItemByTitle, } from 'playwright-praman/fe'; // Get number of list items const count = await feGetListItemCount(page, listId); // Read item titles and descriptions const title = await feGetListItemTitle(page, listId, 0); const desc = await feGetListItemDescription(page, listId, 0); // Interact with list items await feClickListItem(page, listId, 0); await feSelectListItem(page, listId, 2); // Find item by title text const index = await feFindListItemByTitle(page, listId, 'Purchase Order 4500012345'); ``` ## FE Test Library Integration Praman integrates with SAP's own Fiori Elements Test Library (`sap.fe.test`): ```typescript // Initialize the FE Test Library on the page await initializeFETestLibrary(page, { timeout: 30_000, }); // The test library provides SAP's official FE testing API // through the bridge, enabling assertions that match SAP's own test patterns ``` ## Complete Example A typical Fiori Elements List Report to Object Page test flow: ```typescript getListReportTable, getFilterBar, setFilterBarField, executeSearch, navigateToItem, getHeaderTitle, getObjectPageSections, getSectionData, } from 'playwright-praman/fe'; test('browse purchase orders and view details', async ({ page, ui5 }) => { await test.step('Navigate to List Report', async () => { await page.goto( '/sap/bc/ui5_ui5/ui2/ushell/shells/abap/FioriLaunchpad.html#PurchaseOrder-manage', ); await ui5.waitForUI5(); }); await test.step('Filter by company code', async () => { const filterBar = await getFilterBar(page); await setFilterBarField(page, filterBar, 'CompanyCode', '1000'); await executeSearch(page, filterBar); }); await test.step('Navigate to first purchase order', async () => { const table = await getListReportTable(page); await navigateToItem(page, table, 0); await ui5.waitForUI5(); }); await test.step('Verify Object Page header', async () => { const title = await getHeaderTitle(page); expect(title).toContain('Purchase Order'); }); await test.step('Read General Information section', async () => { const data = await getSectionData(page, 'General Information'); expect(data).toHaveProperty('Vendor'); expect(data).toHaveProperty('Company Code'); }); }); ``` ## Error Handling All FE helpers throw structured `ControlError` or `NavigationError` instances with suggestions: ```typescript try { await getListReportTable(page); } catch (error) { // ControlError { // code: 'ERR_CONTROL_NOT_FOUND', // message: 'List Report table not found on current page', // suggestions: [ // 'Verify the current page is a Fiori Elements List Report', // 'Wait for the page to fully load before accessing the table', // ] // } } ``` --- ## Gold Standard Test Pattern :::info[In this guide] - Use a complete reference test as a template for business process tests - Apply every Praman best practice: `test.step()`, fixtures, semantic selectors, custom matchers - Handle error dialogs and attach failure diagnostics to test reports - Clean up test data in `afterAll` for repeatable test runs - Run data-driven variants with parameterized test data ::: A complete reference test that demonstrates every Praman best practice in a single runnable example. Use this as a template for your most critical business process tests. ## What Makes a "Gold Standard" Test | Practice | Why It Matters | | ------------------------------------ | -------------------------------------- | | `test.step()` for every logical step | Clear trace output, pinpoints failures | | Fixtures for all SAP interactions | Type-safe, auto-waiting, reusable | | Semantic selectors (not DOM) | Resilient to UI5 version upgrades | | Custom matchers for assertions | Readable, consistent error messages | | Structured error handling | Actionable failure diagnostics | | Auth via setup project | One login, shared across tests | | Data cleanup in `afterAll` | Repeatable, no test pollution | | No `page.waitForTimeout()` | Deterministic, fast execution | ## Complete Example ```typescript /** * Gold Standard: Purchase Order Approval Flow * * Tests the complete PO creation and approval workflow: * 1. Create PO with required approvals * 2. Verify PO status and workflow * 3. Approve the PO * 4. Verify final status * 5. Clean up test data */ test.describe('Purchase Order Approval Flow', () => { // Track created entities for cleanup let createdPONumber: string; // ── Auth: handled by setup project dependency ── // See playwright.config.ts: { dependencies: ['setup'] } test.beforeEach(async ({ ui5Navigation }) => { await test.step('navigate to Fiori Launchpad home', async () => { await ui5Navigation.navigateToIntent({ semanticObject: 'Shell', action: 'home' }); }); }); test('create and approve purchase order', async ({ ui5, ui5Navigation, fe, page }) => { // ── Step 1: Create Purchase Order ── await test.step('create purchase order', async () => { await test.step('navigate to PO creation', async () => { await ui5Navigation.navigateToIntent({ semanticObject: 'PurchaseOrder', action: 'create' }); }); await test.step('fill header data', async () => { await ui5.fill({ id: /Supplier/ }, '100001'); await ui5.fill({ id: /PurchasingOrganization/ }, '1000'); await ui5.fill({ id: /CompanyCode/ }, '1000'); await ui5.fill({ id: /PurchasingGroup/ }, '001'); }); await test.step('add line item', async () => { await fe.objectPage.clickButton('Create'); await ui5.fill({ id: /Material/ }, 'MAT-APPROVE-001'); await ui5.fill({ id: /OrderQuantity/ }, '500'); await ui5.fill({ id: /Plant/ }, '1000'); await ui5.fill({ id: /NetPrice/ }, '10000.00'); }); await test.step('save and capture PO number', async () => { await fe.objectPage.clickSave(); // Assert success message const messageStrip = await ui5.control({ controlType: 'sap.m.MessageStrip', properties: { type: 'Success' }, }); await expect(messageStrip).toHaveUI5Text(/Purchase Order (\d+) created/); // Capture PO number for subsequent steps and cleanup const text = await messageStrip.getText(); const match = text.match(/Purchase Order (\d+)/); createdPONumber = match?.[1] ?? ''; expect(createdPONumber).toBeTruthy(); }); }); // ── Step 2: Verify PO Requires Approval ── await test.step('verify PO status requires approval', async () => { await test.step('navigate to PO display', async () => { await ui5Navigation.navigateToIntent( { semanticObject: 'PurchaseOrder', action: 'display' }, { PurchaseOrder: createdPONumber }, ); }); await test.step('check status field', async () => { const statusField = await ui5.control({ controlType: 'sap.m.ObjectStatus', id: /overallStatusText/, }); await expect(statusField).toHaveUI5Text('Awaiting Approval'); }); await test.step('verify workflow section exists', async () => { await ui5.click({ controlType: 'sap.m.IconTabFilter', properties: { key: 'workflow' }, }); const workflowTable = await ui5.control({ controlType: 'sap.m.Table', id: /workflowItemsTable/, }); await expect(workflowTable).toHaveUI5RowCount(1); }); }); // ── Step 3: Approve the Purchase Order ── await test.step('approve the purchase order', async () => { await test.step('navigate to approval inbox', async () => { await ui5Navigation.navigateToIntent({ semanticObject: 'WorkflowTask', action: 'displayInbox', }); }); await test.step('find and open PO approval task', async () => { // Filter for our specific PO await fe.listReport.setFilter('TaskTitle', `Purchase Order ${createdPONumber}`); await fe.listReport.search(); // Click the task row await ui5.click({ controlType: 'sap.m.ColumnListItem', ancestor: { controlType: 'sap.m.Table', id: /taskListTable/ }, }); }); await test.step('click approve button', async () => { await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Approve' }, }); // Confirm approval dialog if present const confirmButton = await ui5.control({ controlType: 'sap.m.Button', properties: { text: 'Confirm' }, searchOpenDialogs: true, }); await confirmButton.click(); }); await test.step('verify approval success', async () => { const successMsg = await ui5.control({ controlType: 'sap.m.MessageStrip', properties: { type: 'Success' }, }); await expect(successMsg).toHaveUI5Text(/approved/i); }); }); // ── Step 4: Verify Final Status ── await test.step('verify PO is approved', async () => { await ui5Navigation.navigateToIntent( { semanticObject: 'PurchaseOrder', action: 'display' }, { PurchaseOrder: createdPONumber }, ); const statusField = await ui5.control({ controlType: 'sap.m.ObjectStatus', id: /overallStatusText/, }); await expect(statusField).toHaveUI5Text('Approved'); }); }); // ── Step 5: Cleanup ── test.afterAll(async ({ ui5 }) => { if (createdPONumber) { await test.step('delete test purchase order', async () => { try { await ui5.odata.deleteEntity('/PurchaseOrders', createdPONumber); } catch { // Log but do not fail the test suite on cleanup errors console.warn(`Cleanup: failed to delete PO ${createdPONumber}`); } }); } }); }); ``` ## Anatomy of the Gold Standard ### 1. Test Organization ```text describe('Business Process Name') beforeEach → navigate to known starting point test → the actual business flow step 1 → first business action (may have sub-steps) step 2 → verification step 3 → next action step N → final verification afterAll → cleanup created data ``` ### 2. Selector Strategy (Priority Order) Use the most resilient selector available. In priority order: ```typescript // 1. Binding path (most resilient — survives ID changes) await ui5.control({ controlType: 'sap.ui.comp.smartfield.SmartField', bindingPath: { path: '/CompanyCode' }, }); // 2. Properties (semantic — survives refactoring) await ui5.control({ controlType: 'sap.m.Button', properties: { text: 'Save' }, }); // 3. ID with RegExp (flexible — survives view prefix changes) await ui5.control({ controlType: 'sap.m.Input', id: /materialInput/, }); // 4. Exact ID (brittle — use only when nothing else works) await ui5.control({ controlType: 'sap.m.Input', id: 'myApp--detailView--materialInput', }); ``` ### 3. Assertion Patterns ```typescript // Use custom matchers for SAP-specific assertions (registered via expect.extend()) await expect(control).toHaveUI5Text('Expected Text'); await expect(control).toHaveUI5Text(/partial match/); await expect(control).toHaveUI5Property('enabled', true); await expect(table).toHaveUI5RowCount(5); await expect(control).toBeUI5Visible(); await expect(control).toBeUI5Enabled(); // For standard Playwright assertions on the page await expect(page).toHaveURL(/PurchaseOrder/); await expect(page).toHaveTitle(/SAP/); ``` ### 4. Error Handling Pattern ```typescript await test.step('handle potential error dialogs', async () => { // Check for and dismiss error popups before proceeding let errorDialog; try { errorDialog = await ui5.control({ controlType: 'sap.m.Dialog', properties: { type: 'Message' }, searchOpenDialogs: true, }); } catch { errorDialog = null; } if (errorDialog) { const errorText = await errorDialog.getProperty('content'); // Attach error details to the test report await test.info().attach('sap-error-dialog', { body: JSON.stringify(errorText, null, 2), contentType: 'application/json', }); // Dismiss the dialog await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Close' }, searchOpenDialogs: true, }); // Fail with actionable message throw new Error(`SAP error dialog appeared: ${JSON.stringify(errorText)}`); } }); ``` ### 5. Data-Driven Variant For running the same test with multiple data sets: ```typescript const testData = [ { supplier: '100001', material: 'MAT-001', quantity: '10', plant: '1000' }, { supplier: '100002', material: 'MAT-002', quantity: '50', plant: '2000' }, { supplier: '100003', material: 'MAT-003', quantity: '100', plant: '3000' }, ]; for (const data of testData) { test(`create PO for supplier ${data.supplier}`, async ({ ui5, ui5Navigation, fe }) => { await ui5Navigation.navigateToIntent({ semanticObject: 'PurchaseOrder', action: 'create' }); await ui5.fill({ id: /Supplier/ }, data.supplier); await ui5.fill({ id: /Material/ }, data.material); await ui5.fill({ id: /OrderQuantity/ }, data.quantity); await ui5.fill({ id: /Plant/ }, data.plant); await fe.objectPage.clickSave(); const messageStrip = await ui5.control({ controlType: 'sap.m.MessageStrip', properties: { type: 'Success' }, }); await expect(messageStrip).toHaveUI5Text(/created/); }); } ``` ## Playwright Config for Gold Standard Tests ```typescript // playwright.config.ts export default defineConfig({ testDir: './tests', timeout: 120_000, // 2 minutes for SAP transactions retries: 1, workers: 1, // Sequential for SAP state-dependent tests use: { baseURL: process.env.SAP_CLOUD_BASE_URL, trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'on-first-retry', }, projects: [ { name: 'setup', testMatch: /auth-setup\.ts/, use: { ...devices['Desktop Chrome'] }, }, { name: 'gold-standard', dependencies: ['setup'], testMatch: /gold-standard\.spec\.ts/, use: { ...devices['Desktop Chrome'], storageState: '.auth/sap-session.json', }, }, ], reporter: [ ['html', { open: 'never' }], ['playwright-praman/reporters', { outputDir: 'reports' }], ], }); ``` ## Checklist Use this checklist when writing new tests to verify they meet gold standard quality: - [ ] Every logical step wrapped in `test.step()` - [ ] Selectors use UI5 control types (not CSS selectors) - [ ] Assertions use Praman custom matchers where applicable - [ ] No `page.waitForTimeout()` calls - [ ] No hardcoded DOM selectors - [ ] Document numbers captured and used for cross-referencing - [ ] Data cleanup in `afterAll` or `afterEach` - [ ] Error dialog handling for SAP message popups - [ ] Auth handled by setup project (not inline login) - [ ] Test can run independently (no dependency on prior test state) - [ ] Trace and screenshot configured for failure analysis ## FAQ
Do I need to follow every practice in the gold standard for every test? No. The gold standard is a reference for your most critical business process tests. For simpler tests (e.g., verifying a single field value), you can skip `test.step()` nesting, data cleanup, and error dialog handling. The key non-negotiable practices are: use Praman fixtures (never `page.click('#id')`), use semantic selectors, and avoid `page.waitForTimeout()`.
Why use test.step() instead of separate test() calls? `test.step()` groups related actions within a single test while preserving individual step visibility in the HTML trace report. Separate `test()` calls would require re-authentication and re-navigation for each test, adding 30-60 seconds of overhead per test on SAP systems. Steps also share state (like captured document numbers) without needing external storage.
How do I handle cleanup when a test fails mid-way? Use `test.afterAll()` or `test.afterEach()` with a try/catch. Track created entity IDs in variables declared at the `describe` scope. The cleanup code runs even when the test fails, and the try/catch prevents cleanup errors from masking the original failure: ```typescript test.afterAll(async ({ ui5 }) => { if (createdPONumber) { try { await ui5.odata.deleteEntity('/PurchaseOrders', createdPONumber); } catch { console.warn(`Cleanup failed for PO ${createdPONumber}`); } } }); ```
:::tip[Next steps] - **[Business Process Examples →](./business-process-examples.md)** — PO, SO, Journal Entry, and P2P end-to-end tests - **[Control Interactions →](./control-interactions.md)** — Detailed reference for each `ui5.*` method - **[Custom Matchers →](./custom-matchers.md)** — All 10 UI5-specific assertion matchers - **[Navigation →](./navigation.md)** — All navigation methods for FLP and BTP WorkZone ::: --- ## Intent API(Guides) The `playwright-praman/intents` sub-path provides high-level, business-oriented test operations for SAP S/4HANA modules. Instead of interacting with individual UI5 controls, intent APIs express test steps in business terms. ## Overview ```typescript fillField, clickButton, selectOption, assertField, navigateAndSearch, } from 'playwright-praman/intents'; ``` The intent layer is built on two foundations: 1. **Core wrappers** -- low-level building blocks that accept a UI5Handler slice and optional vocabulary lookup. 2. **Domain namespaces** -- 5 SAP module-specific APIs that compose the core wrappers into business flows. ## Core Wrappers Core wrappers are the shared building blocks used by all domain intent functions. They accept a minimal `UI5HandlerSlice` interface, making them easy to unit test with mocks: ```typescript interface UI5HandlerSlice { control(selector: UI5Selector, options?: { timeout?: number }): Promise; click(selector: UI5Selector): Promise; fill(selector: UI5Selector, value: string): Promise; select(selector: UI5Selector, key: string): Promise; getText(selector: UI5Selector): Promise; waitForUI5(timeout?: number): Promise; } ``` ### Available Core Wrappers | Wrapper | Purpose | | ------------------- | ------------------------------------------------------------------ | | `fillField` | Fill a UI5 input control, with optional vocabulary term resolution | | `clickButton` | Click a button by text label or selector | | `selectOption` | Select an option in a dropdown/ComboBox | | `assertField` | Assert the value of a field matches expected text | | `navigateAndSearch` | Navigate to an app via FLP and execute a search | | `confirmAndWait` | Click a confirmation button and wait for UI5 stability | | `waitForSave` | Wait for a save operation to complete (message toast) | ### Vocabulary Integration Core wrappers accept an optional `VocabLookup` to resolve business terms to UI5 selectors: ```typescript interface VocabLookup { getFieldSelector(term: string, domain?: string): Promise; } // With vocabulary: "Vendor" resolves to { id: 'vendorInput' } await fillField(ui5, vocab, 'Vendor', '100001', { domain: 'procurement' }); // Without vocabulary: pass a selector directly await fillField(ui5, undefined, { id: 'vendorInput' }, '100001'); ``` When a vocabulary term cannot be resolved, the wrapper returns an `IntentResult` with `status: 'error'` and helpful suggestions. ## IntentResult Envelope All intent functions return a typed `IntentResult`: ```typescript interface IntentResult { readonly status: 'success' | 'error' | 'partial'; readonly data?: T; readonly error?: { readonly code: string; readonly message: string }; readonly metadata: { readonly duration: number; readonly retryable: boolean; readonly suggestions: string[]; readonly intentName: string; readonly sapModule: string; readonly stepsExecuted: string[]; }; } ``` Example usage: ```typescript const result = await procurement.createPurchaseOrder(ui5, vocab, { vendor: '100001', material: 'RAW-0001', quantity: 100, plant: '1000', }); if (result.status === 'success') { console.log(`PO created: ${result.data}`); console.log(`Steps: ${result.metadata.stepsExecuted.join(' -> ')}`); // Steps: navigate -> fillVendor -> fillMaterial -> fillQuantity -> save } ``` ## IntentOptions All domain functions accept optional `IntentOptions`: ```typescript interface IntentOptions { skipNavigation?: boolean; // Skip FLP navigation (app already open) timeout?: number; // Override default timeout (ms) validateViaOData?: boolean; // Validate result via OData GET after save } // Skip navigation when already on the correct page const result = await procurement.createPurchaseOrder(ui5, vocab, poData, { skipNavigation: true, timeout: 60_000, }); ``` ## Domain Namespaces ### Procurement (MM -- Materials Management) ```typescript // Create a purchase order await procurement.createPurchaseOrder(ui5, vocab, { vendor: '100001', material: 'RAW-0001', quantity: 100, plant: '1000', }); // Search purchase orders await procurement.searchPurchaseOrders(ui5, vocab, { vendor: '100001' }); // Display purchase order details await procurement.displayPurchaseOrder(ui5, vocab, '4500012345'); ``` ### Sales (SD -- Sales and Distribution) ```typescript // Create a sales order await sales.createSalesOrder(ui5, vocab, { customer: '200001', material: 'FG-1000', quantity: 50, salesOrganization: '1000', }); // Search sales orders await sales.searchSalesOrders(ui5, vocab, { customer: '200001' }); ``` ### Finance (FI -- Financial Accounting) ```typescript // Post a journal entry await finance.postJournalEntry(ui5, vocab, { documentDate: '2026-02-20', postingDate: '2026-02-20', lineItems: [ { glAccount: '400000', debitCredit: 'S', amount: 1000 }, { glAccount: '110000', debitCredit: 'H', amount: 1000 }, ], }); // Post a vendor invoice await finance.postVendorInvoice(ui5, vocab, { vendor: '100001', invoiceDate: '2026-02-20', amount: 5000, currency: 'EUR', }); // Process a vendor payment await finance.processPayment(ui5, vocab, { vendor: '100001', amount: 5000, paymentDate: '2026-02-28', }); ``` ### Manufacturing (PP -- Production Planning) ```typescript // Create a production order await manufacturing.createProductionOrder(ui5, vocab, { material: 'FG-1000', plant: '1000', quantity: 50, }); // Confirm a production order await manufacturing.confirmProductionOrder(ui5, vocab, { orderNumber: '1000012', quantity: 50, }); ``` ### Master Data (MD -- Cross-Module) ```typescript // Create a vendor master await masterData.createVendor(ui5, vocab, { name: 'Acme GmbH', country: 'DE', taxId: 'DE123456789', }); // Create a customer master await masterData.createCustomer(ui5, vocab, { name: 'Globex Corp', country: 'US', salesOrganization: '1000', }); // Create a material master await masterData.createMaterial(ui5, vocab, { materialNumber: 'RAW-0001', description: 'Raw material A', materialType: 'ROH', baseUnit: 'KG', }); ``` ## Data Shapes Each domain defines typed data interfaces: | Type | Domain | Purpose | | ---------------------------- | ------ | ------------------------------------ | | `JournalEntryData` | FI | General-ledger journal entry posting | | `VendorInvoiceData` | FI | Accounts-payable vendor invoice | | `PaymentData` | FI | Outgoing payment processing | | `ProductionOrderData` | PP | Shop-floor order creation | | `ProductionConfirmationData` | PP | Production order confirmation | | `VendorMasterData` | MD | Vendor master data creation | | `CustomerMasterData` | MD | Customer master data creation | | `MaterialMasterData` | MD | Material master data creation | ## Architecture The intent layer follows strict dependency rules: ```text Domain functions (procurement.ts, sales.ts, etc.) | v Core wrappers (core-wrappers.ts) | v UI5HandlerSlice (structural interface) + VocabLookup (optional) ``` - Core wrappers do NOT import from any domain file (no circular dependencies). - Domain functions compose core wrappers into SAP business flows. - The `UI5HandlerSlice` is a structural sub-type -- it does not import the full `UI5Handler` class. - The `VocabLookup` interface is declared inline to avoid hard dependencies on the vocabulary module. --- ## Navigation :::info[In this guide] - Navigate between SAP Fiori apps without full page reloads - Use 11 navigation methods via the `ui5Navigation` and `btpWorkZone` fixtures - Manage BTP WorkZone iframe switching with the `btpWorkZone` fixture - Understand why `page.goto()` is the wrong approach for FLP navigation - Combine shell and workspace interactions in cross-frame tests ::: Praman provides 11 navigation functions in total: 9 via the `ui5Navigation` fixture (`navigateToApp`, `navigateToTile`, `navigateToIntent`, `navigateToHash`, `navigateToHome`, `navigateBack`, `navigateForward`, `searchAndOpenApp`, `getCurrentHash`), plus BTP WorkZone iframe management via `btpWorkZone` (`getAppFrameForEval`, `getShellFrame`). All navigation uses the FLP's own `hasher` API for reliable hash-based routing without full page reloads. ## Why Not page.goto()? SAP Fiori Launchpad uses hash-based routing. Calling `page.goto()` with a new URL triggers a full page reload, which means: - UI5 core must re-bootstrap (30-60 seconds on BTP) - All OData models are re-initialized - Authentication may need to re-negotiate Praman's navigation functions use `window.hasher.setHash()` via `page.evaluate()`, which changes the hash fragment without a page reload. The FLP router handles the transition in-place. ## Navigation Methods ### navigateToApp Navigates to an app by its semantic object-action string. This is the most common navigation method. ```typescript await ui5Navigation.navigateToApp('PurchaseOrder-manage'); await ui5Navigation.navigateToApp('SalesOrder-create'); await ui5Navigation.navigateToApp('Material-display'); ``` ### navigateToTile Clicks an FLP tile by its visible title text. Useful for homepage-based navigation. ```typescript await ui5Navigation.navigateToTile('Create Purchase Order'); await ui5Navigation.navigateToTile('Manage Sales Orders'); ``` ### navigateToIntent Navigates using a semantic object + action + optional parameters. This is the most flexible method for parameterized navigation. ```typescript // Basic intent await ui5Navigation.navigateToIntent({ semanticObject: 'PurchaseOrder', action: 'create' }); // With parameters await ui5Navigation.navigateToIntent( { semanticObject: 'PurchaseOrder', action: 'create' }, { plant: '1000', purchOrg: '1000' }, ); // Produces hash: #PurchaseOrder-create?plant=1000&purchOrg=1000 ``` ### navigateToHash Sets the URL hash fragment directly. Useful for deep-linking to specific views or states. ```typescript await ui5Navigation.navigateToHash("PurchaseOrder-manage&/PurchaseOrders('4500000001')"); await ui5Navigation.navigateToHash('Shell-home'); ``` ### navigateToHome Returns to the FLP homepage. ```typescript await ui5Navigation.navigateToHome(); ``` ### navigateBack / navigateForward Browser history navigation. ```typescript await ui5Navigation.navigateBack(); await ui5Navigation.navigateForward(); ``` ### searchAndOpenApp Types a query into the FLP shell search bar and opens the first result. ```typescript await ui5Navigation.searchAndOpenApp('Purchase Order'); await ui5Navigation.searchAndOpenApp('Vendor Master'); ``` ### getCurrentHash Reads the current URL hash fragment. Useful for assertions. ```typescript const hash = await ui5Navigation.getCurrentHash(); expect(hash).toContain('PurchaseOrder'); expect(hash).toContain('manage'); ``` ## Auto-Stability After Navigation Every navigation function calls `waitForUI5Stable()` after changing the hash. This ensures the target app has fully loaded before the next test step executes. To skip this wait (e.g., if you have a custom wait): ```typescript // The waitForStable option is available on navigation methods await ui5Navigation.navigateToApp('PurchaseOrder-manage'); // Stability wait fires automatically after this call returns ``` ## Intent Navigation Patterns ### Simple Navigation ```typescript test('open purchase order', async ({ ui5Navigation }) => { await ui5Navigation.navigateToApp('PurchaseOrder-manage'); }); ``` ### Parameterized Navigation ```typescript test('open specific purchase order', async ({ ui5Navigation }) => { await ui5Navigation.navigateToIntent( { semanticObject: 'PurchaseOrder', action: 'display' }, { PurchaseOrder: '4500000001' }, ); }); ``` ### Multi-Step Navigation ```typescript test('list report to object page', async ({ ui5Navigation, ui5 }) => { // Step 1: Navigate to list report await ui5Navigation.navigateToApp('PurchaseOrder-manage'); // Step 2: Click a row to navigate to object page await ui5.click({ controlType: 'sap.m.ColumnListItem', ancestor: { id: 'poTable' }, }); // Step 3: Verify we're on the object page const hash = await ui5Navigation.getCurrentHash(); expect(hash).toContain('PurchaseOrders'); // Step 4: Go back await ui5Navigation.navigateBack(); }); ``` ## BTP WorkZone Iframe Management SAP BTP WorkZone renders applications inside nested iframes. The `btpWorkZone` fixture handles frame switching transparently. ### The Frame Structure ```text Outer Frame (WorkZone shell) └── Inner Frame (workspace / application) └── UI5 Application ``` Without frame management, Playwright targets the outer frame and cannot find UI5 controls inside the inner workspace frame. ### Using btpWorkZone ```typescript test('WorkZone navigation', async ({ btpWorkZone, ui5, page }) => { // The manager detects the frame structure automatically const appFrame = btpWorkZone.getAppFrameForEval(); // UI5 operations automatically target the correct frame await ui5.click({ id: 'saveBtn' }); }); ``` ### Cross-Frame Operations When you need to interact with controls in both the outer shell and inner workspace: ```typescript test('shell + workspace', async ({ btpWorkZone, ui5Shell, ui5 }) => { // Shell header is in the outer frame await ui5Shell.expectShellHeader(); // Application controls are in the inner frame await ui5.click({ id: 'appButton' }); }); ``` ## Step Decoration Every navigation call is wrapped in a Playwright `test.step()` for HTML report visibility: ```text test 'create purchase order' ├── ui5Navigation.navigateToApp: PurchaseOrder-create ├── ui5.fill: vendorInput ├── ui5.click: saveBtn └── ui5Navigation.navigateToHome ``` ## Complete Example ```typescript test('end-to-end purchase order workflow', async ({ ui5Navigation, ui5, ui5Shell, ui5Footer }) => { // Navigate to create app await ui5Navigation.navigateToApp('PurchaseOrder-create'); // Fill form await ui5.fill({ id: 'vendorInput' }, '100001'); await ui5.select({ id: 'purchOrgSelect' }, '1000'); // Save via footer await ui5Footer.clickSave(); // Verify navigation to display view const hash = await ui5Navigation.getCurrentHash(); expect(hash).toContain('PurchaseOrder'); // Navigate home await ui5Shell.clickHome(); // Verify we're on the homepage const homeHash = await ui5Navigation.getCurrentHash(); expect(homeHash).toContain('Shell-home'); }); ``` ## FAQ
Why not use page.goto() for SAP Fiori navigation? `page.goto()` triggers a full page reload. In SAP Fiori Launchpad, this means the UI5 core must re-bootstrap (30-60 seconds on BTP), all OData models are re-initialized, and authentication may need to re-negotiate. Praman's navigation functions use `window.hasher.setHash()` via `page.evaluate()`, which changes the hash fragment in-place without a reload.
How do I navigate to a specific entity (e.g., a purchase order by number)? Use `navigateToIntent` with parameters or `navigateToHash` with a deep link: ```typescript // Intent with parameters await ui5Navigation.navigateToIntent( { semanticObject: 'PurchaseOrder', action: 'display' }, { PurchaseOrder: '4500000001' }, ); // Direct hash await ui5Navigation.navigateToHash("PurchaseOrder-display&/PurchaseOrders('4500000001')"); ```
Do I need special setup for BTP WorkZone apps? Use the `btpWorkZone` fixture. It detects the nested iframe structure automatically and ensures `ui5.*` operations target the correct frame. Without it, Playwright targets the outer WorkZone shell frame and cannot find UI5 controls rendered inside the inner workspace frame.
:::tip[Next steps] - **[Control Interactions →](./control-interactions.md)** — Click, fill, and read controls after navigating - **[Business Process Examples →](./business-process-examples.md)** — End-to-end tests with multi-step navigation - **[Gold Standard Test Pattern →](./gold-standard-test.md)** — Complete reference test with navigation, interaction, and cleanup ::: --- ## OData Mocking Strategies for mocking OData services in Praman tests. Covers Playwright `page.route()` interception, SAP CAP mock server integration, and offline test patterns. ## When to Mock OData | Scenario | Approach | When to Use | | -------------------- | --------------------------- | --------------------------------------------- | | Unit/component tests | `page.route()` interception | No backend needed, fast, deterministic | | Integration tests | CAP mock server | Realistic service behavior, schema validation | | E2E tests | Real SAP backend | Full system validation, production-like | | CI/CD pipeline | `page.route()` or CAP mock | Reliable, no SAP system dependency | ## Playwright page.route() Interception ### Basic OData Response Mock ```typescript test.describe('Purchase Order List (Mocked)', () => { test.beforeEach(async ({ page }) => { // Intercept OData requests and return mock data await page.route('**/sap/opu/odata/sap/API_PURCHASEORDER_PROCESS_SRV/**', async (route) => { const url = route.request().url(); if (url.includes('PurchaseOrderSet')) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ d: { results: [ { PurchaseOrder: '4500000001', Supplier: '100001', CompanyCode: '1000', PurchasingOrganization: '1000', NetAmount: '5000.00', Currency: 'USD', Status: 'Approved', }, { PurchaseOrder: '4500000002', Supplier: '100002', CompanyCode: '2000', PurchasingOrganization: '2000', NetAmount: '12000.00', Currency: 'EUR', Status: 'Pending', }, ], }, }), }); } else { await route.continue(); } }); }); test('display purchase orders from mock', async ({ ui5, ui5Navigation }) => { await ui5Navigation.navigateToIntent({ semanticObject: 'PurchaseOrder', action: 'manage' }); const table = await ui5.control({ controlType: 'sap.ui.comp.smarttable.SmartTable', }); await expect(table).toHaveUI5RowCount(2); }); }); ``` ### OData V4 Mock ```typescript test.beforeEach(async ({ page }) => { await page.route('**/odata/v4/PurchaseOrderService/**', async (route) => { const url = route.request().url(); if (url.includes('PurchaseOrders') && route.request().method() === 'GET') { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ '@odata.context': '$metadata#PurchaseOrders', value: [ { PurchaseOrder: '4500000001', Supplier: '100001', NetAmount: 5000, Currency: 'USD', }, ], }), }); } else { await route.continue(); } }); }); ``` ### Mock OData $metadata Some UI5 apps require `$metadata` to render controls correctly: ```typescript const metadata = ` `; await page.route('**/$metadata', async (route) => { await route.fulfill({ status: 200, contentType: 'application/xml', body: metadata, }); }); ``` ### Mock Error Responses Test how the app handles OData errors: ```typescript test('handles OData error gracefully', async ({ ui5, ui5Navigation, page }) => { await page.route('**/PurchaseOrderSet**', async (route) => { await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: { code: 'SY/530', message: { lang: 'en', value: 'Internal server error during data retrieval', }, innererror: { errordetails: [ { code: 'SY/530', message: 'Database connection timeout', severity: 'error', }, ], }, }, }), }); }); await ui5Navigation.navigateToIntent({ semanticObject: 'PurchaseOrder', action: 'manage' }); // Verify the app shows an error message const errorDialog = await ui5.control({ controlType: 'sap.m.Dialog', properties: { type: 'Message' }, searchOpenDialogs: true, }); expect(errorDialog).toBeTruthy(); }); ``` ### Conditional Mocking (First Call vs Subsequent) ```typescript let callCount = 0; await page.route('**/PurchaseOrderSet**', async (route) => { callCount++; if (callCount === 1) { // First call: return empty list await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ d: { results: [] } }), }); } else { // Subsequent calls: return data (simulates data creation) await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ d: { results: [{ PurchaseOrder: '4500000001', Supplier: '100001' }], }, }), }); } }); ``` ## Mock Factory Pattern Create reusable mock factories for consistent test data: ```typescript // tests/mocks/odata-mocks.ts interface PurchaseOrderMock { PurchaseOrder: string; Supplier: string; CompanyCode: string; NetAmount: string; Currency: string; Status: string; } export function createPurchaseOrderMock( overrides: Partial = {}, ): PurchaseOrderMock { return { PurchaseOrder: '4500000001', Supplier: '100001', CompanyCode: '1000', NetAmount: '5000.00', Currency: 'USD', Status: 'Approved', ...overrides, }; } export function createODataV2Response(results: T[]) { return { d: { results } }; } export function createODataV4Response(value: T[], context: string) { return { '@odata.context': context, value }; } ``` Usage: ```typescript await page.route('**/PurchaseOrderSet**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify( createODataV2Response([ createPurchaseOrderMock(), createPurchaseOrderMock({ PurchaseOrder: '4500000002', Status: 'Pending' }), ]), ), }); }); ``` ## SAP CAP Mock Server For more realistic OData mocking, use SAP CAP (Cloud Application Programming Model) to run a local OData service with schema validation. ### Setup ```bash # Install CAP CLI npm install --save-dev @sap/cds-dk # Initialize mock project mkdir tests/mock-server && cd tests/mock-server cds init --add hana ``` ### Define Service Schema ```cds // tests/mock-server/srv/purchase-order-service.cds using { cuid, managed } from '@sap/cds/common'; entity PurchaseOrders : cuid, managed { PurchaseOrder : String(10); Supplier : String(10); CompanyCode : String(4); PurchasingOrganization : String(4); NetAmount : Decimal(16,3); Currency : String(5); Status : String(20); Items : Composition of many PurchaseOrderItems on Items.parent = $self; } entity PurchaseOrderItems : cuid { parent : Association to PurchaseOrders; ItemNo : String(5); Material : String(40); Quantity : Decimal(13,3); Plant : String(4); NetPrice : Decimal(16,3); } service PurchaseOrderService { entity PurchaseOrderSet as projection on PurchaseOrders; } ``` ### Seed Test Data ```json // tests/mock-server/db/data/PurchaseOrders.csv PurchaseOrder;Supplier;CompanyCode;PurchasingOrganization;NetAmount;Currency;Status 4500000001;100001;1000;1000;5000.000;USD;Approved 4500000002;100002;2000;2000;12000.000;EUR;Pending 4500000003;100003;1000;1000;800.000;USD;Draft ``` ### Playwright Config with CAP Server ```typescript // playwright.config.ts export default defineConfig({ webServer: [ { command: 'npx cds serve --project tests/mock-server --port 4004', port: 4004, reuseExistingServer: !process.env.CI, timeout: 30_000, }, ], use: { baseURL: 'http://localhost:8080', // UI5 app server }, projects: [ { name: 'with-mock-server', testDir: './tests/integration', use: { ...devices['Desktop Chrome'] }, }, ], }); ``` ### Proxy OData Requests to CAP ```typescript // In your UI5 app's xs-app.json or proxy config, route OData to CAP: // /sap/opu/odata/sap/API_PURCHASEORDER_PROCESS_SRV → http://localhost:4004/odata/v4/PurchaseOrderService // Or use page.route() to redirect: await page.route('**/sap/opu/odata/**', async (route) => { const url = new URL(route.request().url()); url.hostname = 'localhost'; url.port = '4004'; url.pathname = url.pathname.replace( '/sap/opu/odata/sap/API_PURCHASEORDER_PROCESS_SRV', '/odata/v4/PurchaseOrderService', ); await route.continue({ url: url.toString() }); }); ``` ## HAR File Replay Record and replay real OData traffic for deterministic tests: ```typescript // Record: run test once with HAR recording test.use({ // @ts-expect-error -- Playwright context option recordHar: { path: 'tests/fixtures/odata-traffic.har', urlFilter: '**/odata/**', }, }); // Replay: use recorded HAR in subsequent test runs test('replay recorded OData traffic', async ({ page }) => { await page.routeFromHAR('tests/fixtures/odata-traffic.har', { url: '**/odata/**', update: false, }); // Test runs against recorded responses — fast, deterministic }); ``` ## Best Practices | Practice | Why | | -------------------------------- | ----------------------------------------------------------------------- | | Mock at the OData level, not DOM | UI5 models parse OData responses — mock the data, not the rendered HTML | | Include `$metadata` mocks | SmartFields/SmartTables need metadata to render correctly | | Use mock factories | Consistent, type-safe test data across tests | | Test error responses too | Validate your app handles 400, 403, 500 gracefully | | Reset mocks between tests | Use `beforeEach` to set up fresh routes | | Match `$filter` and `$expand` | Mock responses should respect OData query options when possible | --- ## OData Operations Praman provides two approaches to OData data access: **model operations** (browser-side, via `ui5.odata`) and **HTTP operations** (server-side, via `ui5.odata`). This page covers both, including V2 vs V4 differences and the ODataTraceReporter. ## Two Approaches | Approach | Access Path | Use Case | | -------------------- | ---------------------------- | ----------------------------------------- | | **Model operations** | Browser-side UI5 model | Reading data the UI already loaded | | **HTTP operations** | Direct HTTP to OData service | CRUD operations, test data setup/teardown | ## Model Operations (Browser-Side) Model operations query the UI5 OData model that is already loaded in the browser. No additional HTTP requests are made — you are reading the same data the UI5 application is displaying. ### getModelData Reads data at any model path. Returns the full object tree. ```typescript const orders = await ui5.odata.getModelData('/PurchaseOrders'); console.log(orders.length); // Number of loaded POs const singlePO = await ui5.odata.getModelData("/PurchaseOrders('4500000001')"); console.log(singlePO.Vendor); // '100001' ``` ### getModelProperty Reads a single property value from the model. ```typescript const vendor = await ui5.odata.getModelProperty("/PurchaseOrders('4500000001')/Vendor"); const amount = await ui5.odata.getModelProperty("/PurchaseOrders('4500000001')/NetAmount"); ``` ### getEntityCount Counts entities in a set. Reads from the model, not from $count. ```typescript const count = await ui5.odata.getEntityCount('/PurchaseOrders'); expect(count).toBeGreaterThan(0); ``` ### waitForLoad Polls the model until data is available. Useful after navigation when OData requests are still in flight. ```typescript await ui5.odata.waitForLoad(); // Data is now available const orders = await ui5.odata.getModelData('/PurchaseOrders'); ``` Default timeout: 15 seconds. Polling interval: 100ms. ### hasPendingChanges Checks if the model has unsaved changes (dirty state). ```typescript await ui5.fill({ id: 'vendorInput' }, '100002'); const dirty = await ui5.odata.hasPendingChanges(); expect(dirty).toBe(true); await ui5.click({ id: 'saveBtn' }); const clean = await ui5.odata.hasPendingChanges(); expect(clean).toBe(false); ``` ### fetchCSRFToken Fetches a CSRF token from an OData service URL. Needed for write operations via HTTP. ```typescript const token = await ui5.odata.fetchCSRFToken('/sap/opu/odata/sap/API_PO_SRV'); // Use token in custom HTTP requests ``` ### Named Model Support By default, model operations query the component's default model. To query a named model: ```typescript const data = await ui5.odata.getModelData('/Products', { modelName: 'productModel' }); ``` ## HTTP Operations (Server-Side) HTTP operations perform direct OData CRUD operations against the service endpoint using Playwright's request context. These are independent of the browser — useful for test data setup, teardown, and backend verification. ### queryEntities Performs an HTTP GET with OData query parameters. ```typescript const orders = await ui5.odata.queryEntities('/sap/opu/odata/sap/API_PO_SRV', 'PurchaseOrders', { filter: "Status eq 'A'", select: 'PONumber,Vendor,Amount', expand: 'Items', orderby: 'PONumber desc', top: 10, skip: 0, }); ``` ### createEntity Performs an HTTP POST with automatic CSRF token management. ```typescript const newPO = await ui5.odata.createEntity('/sap/opu/odata/sap/API_PO_SRV', 'PurchaseOrders', { Vendor: '100001', PurchOrg: '1000', CompanyCode: '1000', Items: [{ Material: 'MAT-001', Quantity: 10, Unit: 'EA' }], }); console.log(newPO.PONumber); // Server-generated PO number ``` ### updateEntity Performs an HTTP PATCH with CSRF token and optional ETag for optimistic concurrency. ```typescript await ui5.odata.updateEntity('/sap/opu/odata/sap/API_PO_SRV', 'PurchaseOrders', "'4500000001'", { Status: 'B', Note: 'Updated by test', }); ``` ### deleteEntity Performs an HTTP DELETE with CSRF token. ```typescript await ui5.odata.deleteEntity('/sap/opu/odata/sap/API_PO_SRV', 'PurchaseOrders', "'4500000001'"); ``` ### callFunctionImport Calls an OData function import (action). ```typescript const result = await ui5.odata.callFunctionImport('/sap/opu/odata/sap/API_PO_SRV', 'ApprovePO', { PONumber: '4500000001', }); ``` ## OData V2 vs V4 Differences | Aspect | OData V2 | OData V4 | | ------------------ | --------------------------------------------------- | ------------------------------------------ | | **Model class** | `sap.ui.model.odata.v2.ODataModel` | `sap.ui.model.odata.v4.ODataModel` | | **URL convention** | `/sap/opu/odata/sap/SERVICE_SRV` | `/sap/opu/odata4/sap/SERVICE/srvd_a2x/...` | | **Batch** | `$batch` with changesets | `$batch` with JSON:API format | | **CSRF token** | Fetched via HEAD request with `X-CSRF-Token: Fetch` | Same mechanism | | **Deep insert** | Supported via navigation properties | Supported natively | | **Filter syntax** | `$filter=Status eq 'A'` | `$filter=Status eq 'A'` (same) | Praman's model operations work with both V2 and V4 models transparently — the model class handles the protocol differences. HTTP operations use the URL conventions of your service. ## OData Trace Reporter The `ODataTraceReporter` captures all OData HTTP requests during test runs and generates a performance trace. See the [Reporters](./reporters.md) guide for configuration. The trace output includes per-entity-set statistics: ```json { "entitySets": { "PurchaseOrders": { "GET": { "count": 12, "avgDuration": 340, "maxDuration": 1200, "errors": 0 }, "POST": { "count": 2, "avgDuration": 890, "maxDuration": 1100, "errors": 0 }, "PATCH": { "count": 1, "avgDuration": 450, "maxDuration": 450, "errors": 0 } }, "Vendors": { "GET": { "count": 3, "avgDuration": 120, "maxDuration": 200, "errors": 0 } } }, "totalRequests": 18, "totalErrors": 0, "totalDuration": 8400 } ``` ## Complete Example ```typescript test('verify OData model state after form edit', async ({ ui5, ui5Navigation }) => { await ui5Navigation.navigateToApp('PurchaseOrder-manage'); // Wait for initial data load await ui5.odata.waitForLoad(); // Read model data const orders = await ui5.odata.getModelData('/PurchaseOrders'); expect(orders.length).toBeGreaterThan(0); // Navigate to first PO await ui5.click({ controlType: 'sap.m.ColumnListItem', ancestor: { id: 'poTable' }, }); // Edit a field await ui5.click({ id: 'editBtn' }); await ui5.fill({ id: 'noteField' }, 'Test note'); // Verify model has pending changes const dirty = await ui5.odata.hasPendingChanges(); expect(dirty).toBe(true); // Save await ui5.click({ id: 'saveBtn' }); // Verify model is clean const clean = await ui5.odata.hasPendingChanges(); expect(clean).toBe(false); }); test('seed and cleanup test data via HTTP', async ({ ui5 }) => { // Setup: create test PO via direct HTTP const po = await ui5.odata.createEntity('/sap/opu/odata/sap/API_PO_SRV', 'PurchaseOrders', { Vendor: '100001', PurchOrg: '1000', CompanyCode: '1000', }); // ... run test steps ... // Cleanup: delete the test PO await ui5.odata.deleteEntity( '/sap/opu/odata/sap/API_PO_SRV', 'PurchaseOrders', `'${po.PONumber}'`, ); }); ``` --- ## SAP Activate Alignment Maps Praman test patterns to SAP Activate methodology phases. This guide helps project teams align their automated testing strategy with SAP's implementation framework. ## SAP Activate Overview SAP Activate is SAP's methodology for implementing SAP S/4HANA and cloud solutions. It defines six phases, each with testing activities where Praman provides automation support. | Phase | Name | Testing Focus | | ----- | -------- | ----------------------------------------- | | 1 | Discover | Proof of concept, feasibility tests | | 2 | Prepare | Test strategy, environment setup | | 3 | Explore | Fit-to-standard, configuration validation | | 4 | Realize | Unit, integration, string tests | | 5 | Deploy | UAT, performance, regression | | 6 | Run | Production monitoring, regression packs | ## Phase 1: Discover **Goal**: Validate that Praman works with your SAP landscape. ### Praman Activities ```typescript // Proof of concept: verify basic connectivity and control discovery test('SAP system connectivity check', async ({ ui5, ui5Navigation, page }) => { await test.step('access Fiori Launchpad', async () => { await ui5Navigation.navigateToIntent({ semanticObject: 'Shell', action: 'home' }); await expect(page).toHaveURL(/fiorilaunchpad/); }); await test.step('verify UI5 runtime is available', async () => { const isUI5 = await page.evaluate( () => typeof sap !== 'undefined' && typeof sap.ui !== 'undefined', ); expect(isUI5).toBe(true); }); await test.step('discover controls on launchpad', async () => { const tiles = await ui5.controls({ controlType: 'sap.m.GenericTile', }); expect(tiles.length).toBeGreaterThan(0); }); }); ``` ### Deliverables - Connectivity test script (above) - Browser compatibility matrix (see [Cross-Browser Testing](./cross-browser.md)) - Tool evaluation report comparing Praman to Tosca, wdi5, UIVeri5 ## Phase 2: Prepare **Goal**: Establish test infrastructure and strategy. ### Praman Activities | Activity | Praman Artifact | | ---------------------- | ----------------------------------------------------------------- | | Test strategy document | `docs/test-strategy.md` | | Environment setup | `playwright.config.ts`, `praman.config.ts` | | Auth configuration | `tests/auth-setup.ts` (see [Authentication](./authentication.md)) | | CI/CD pipeline | `.github/workflows/e2e-tests.yml` | | Test data strategy | OData mock setup (see [OData Mocking](./odata-mocking.md)) | ### Project Structure ```text tests/ auth-setup.ts # Authentication setup project smoke/ # Phase 3: Fit-to-standard fit-to-standard.spec.ts integration/ # Phase 4: String tests p2p-flow.spec.ts o2c-flow.spec.ts regression/ # Phase 5+: Regression pack purchase-order.spec.ts sales-order.spec.ts performance/ # Phase 5: Performance baselines page-load.spec.ts playwright.config.ts praman.config.ts ``` ## Phase 3: Explore (Fit-to-Standard) **Goal**: Validate SAP standard configuration against business requirements. ### Fit-to-Standard Tests These tests verify that standard SAP processes work with your specific configuration (company codes, org units, master data). ```typescript test.describe('Fit-to-Standard: Procurement', () => { test('standard PO creation workflow matches requirements', async ({ ui5, ui5Navigation, fe }) => { await test.step('verify PO creation app is accessible', async () => { await ui5Navigation.navigateToIntent({ semanticObject: 'PurchaseOrder', action: 'create' }); }); await test.step('verify required fields match spec', async () => { // Check that configured mandatory fields are enforced const supplierField = await ui5.control({ controlType: 'sap.ui.comp.smartfield.SmartField', bindingPath: { path: '/Supplier' }, }); const isRequired = await supplierField.getProperty('mandatory'); expect(isRequired).toBe(true); }); await test.step('verify org structure defaults', async () => { // Company code should default from user parameters const companyCode = await ui5.control({ controlType: 'sap.ui.comp.smartfield.SmartField', bindingPath: { path: '/CompanyCode' }, }); const value = await companyCode.getProperty('value'); expect(value).toBe('1000'); // Expected default from user profile }); await test.step('verify approval workflow triggers', async () => { // Create PO above threshold to trigger approval await ui5.fill({ id: /Supplier/ }, '100001'); await ui5.fill({ id: /NetPrice/ }, '50000'); await fe.objectPage.clickSave(); const messageStrip = await ui5.control({ controlType: 'sap.m.MessageStrip', properties: { type: 'Success' }, }); await expect(messageStrip).toHaveUI5Text(/created/); // Verify status indicates approval required }); }); }); ``` ### Configuration Validation Matrix ```typescript const orgUnits = [ { companyCode: '1000', purchOrg: '1000', plant: '1000', name: 'US Operations' }, { companyCode: '2000', purchOrg: '2000', plant: '2000', name: 'EU Operations' }, { companyCode: '3000', purchOrg: '3000', plant: '3000', name: 'APAC Operations' }, ]; for (const org of orgUnits) { test(`fit-to-standard: PO creation for ${org.name}`, async ({ ui5, ui5Navigation, fe }) => { await ui5Navigation.navigateToIntent({ semanticObject: 'PurchaseOrder', action: 'create' }); await ui5.fill({ id: /CompanyCode/ }, org.companyCode); await ui5.fill({ id: /PurchasingOrganization/ }, org.purchOrg); await ui5.fill({ id: /Plant/ }, org.plant); await ui5.fill({ id: /Supplier/ }, '100001'); await fe.objectPage.clickSave(); const messageStrip = await ui5.control({ controlType: 'sap.m.MessageStrip', properties: { type: 'Success' }, }); await expect(messageStrip).toHaveUI5Text(/created/); }); } ``` ## Phase 4: Realize (Integration and String Tests) **Goal**: Validate end-to-end business processes and integrations. ### String Tests String tests connect multiple processes in sequence, validating the data flow between them. ```typescript test.describe('String Test: Procure-to-Pay', () => { test('P2P: Purchase Requisition to Payment', async ({ ui5Navigation, fe }) => { await test.step('1. Create Purchase Requisition', async () => { await ui5Navigation.navigateToIntent({ semanticObject: 'PurchaseRequisition', action: 'create', }); // Fill and save PR }); await test.step('2. Convert PR to Purchase Order', async () => { await ui5Navigation.navigateToIntent({ semanticObject: 'PurchaseOrder', action: 'create' }); // Reference the PR }); await test.step('3. Post Goods Receipt', async () => { await ui5Navigation.navigateToIntent({ semanticObject: 'GoodsReceipt', action: 'create' }); // Post GR against PO }); await test.step('4. Enter Invoice', async () => { await ui5Navigation.navigateToIntent({ semanticObject: 'SupplierInvoice', action: 'create' }); // Enter and post invoice }); await test.step('5. Verify Document Flow', async () => { // Navigate back to PO and check all documents are linked }); }); }); ``` See [Business Process Examples](./business-process-examples.md) for complete runnable examples. ### Test Execution Mapping | SAP Activate Activity | Praman Test Type | Config | | --------------------- | -------------------------------- | ------------------------ | | Unit Test (UT) | Component test with mocked OData | `workers: 4` | | Integration Test (IT) | Cross-app flow with real backend | `workers: 1` | | String Test (ST) | Multi-step P2P, O2C flows | `workers: 1, retries: 1` | | Security Test | Auth strategy validation | Setup project | ## Phase 5: Deploy (UAT and Regression) **Goal**: User acceptance, performance baselines, regression packs. ### Regression Pack Structure ```typescript // playwright.config.ts — regression project { name: 'regression', testDir: './tests/regression', dependencies: ['setup'], use: { storageState: '.auth/sap-session.json', trace: 'on-first-retry', screenshot: 'only-on-failure', }, retries: 2, } ``` ### Performance Baselines ```typescript test('page load performance baseline', async ({ ui5Navigation, page }) => { const startTime = Date.now(); await ui5Navigation.navigateToIntent({ semanticObject: 'PurchaseOrder', action: 'manage' }); const loadTime = Date.now() - startTime; // Attach timing to report await test.info().attach('performance', { body: JSON.stringify({ loadTime, threshold: 5000 }), contentType: 'application/json', }); expect(loadTime).toBeLessThan(5000); // 5-second threshold }); ``` ## Phase 6: Run (Production Monitoring) **Goal**: Continuous regression testing against production-like environments. ### Smoke Test Suite Run a minimal set of critical path tests on a schedule: ```yaml # .github/workflows/smoke-tests.yml name: SAP Smoke Tests on: schedule: - cron: '0 6 * * *' # Daily at 6 AM workflow_dispatch: jobs: smoke: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npx playwright install chromium - run: npx playwright test tests/smoke/ --project=regression env: SAP_CLOUD_BASE_URL: ${{ secrets.SAP_QA_URL }} SAP_CLOUD_USERNAME: ${{ secrets.SAP_QA_USER }} SAP_CLOUD_PASSWORD: ${{ secrets.SAP_QA_PASS }} ``` ## Phase-to-Praman Mapping Summary | Phase | Praman Feature | Key Config | | -------- | ---------------------------------------- | ------------------------------- | | Discover | UI5 runtime detection, control discovery | Basic `playwright.config.ts` | | Prepare | Auth setup, config, project structure | `praman.config.ts`, CI pipeline | | Explore | Fit-to-standard tests, org validation | Data-driven test loops | | Realize | String tests, integration flows | `workers: 1`, `test.step()` | | Deploy | Regression packs, performance baselines | `retries: 2`, trace on retry | | Run | Scheduled smoke tests, monitoring | Cron-triggered CI, alerts | --- ## SAP Control Cookbook A practical reference for interacting with the most common SAP UI5 controls in Praman tests. Each control includes its fully qualified type, recommended selectors, interaction examples, and known pitfalls. ## SmartField `sap.ui.comp.smartfield.SmartField` SmartField is a metadata-driven wrapper that renders different inner controls (Input, ComboBox, DatePicker, etc.) based on OData annotations. It appears throughout SAP Fiori Elements apps. ### Selector ```typescript // By ID const field = await ui5.control({ controlType: 'sap.ui.comp.smartfield.SmartField', id: /CompanyCode/, }); // By binding path (most reliable for SmartFields) const field = await ui5.control({ controlType: 'sap.ui.comp.smartfield.SmartField', bindingPath: { path: '/CompanyCode' }, }); ``` ### Interaction ```typescript await ui5.fill({ controlType: 'sap.ui.comp.smartfield.SmartField', id: /CompanyCode/ }, '1000'); // Read current value const value = await field.getProperty('value'); ``` ### Pitfalls - `getControlType()` returns `sap.ui.comp.smartfield.SmartField`, **not** the inner control type (e.g., not `sap.m.Input`). Do not selector-match by the inner type. - SmartField in **display mode** renders as `sap.m.Text` — calling `fill()` will fail. Check the `editable` property first. - Value help dialogs opened by SmartField have a different control tree. Use `searchOpenDialogs: true` to find controls inside them. ## SmartTable `sap.ui.comp.smarttable.SmartTable` Metadata-driven table that auto-generates columns from OData annotations. Used in most Fiori Elements List Report pages. ### Selector ```typescript const table = await ui5.control({ controlType: 'sap.ui.comp.smarttable.SmartTable', id: /LineItemsSmartTable/, }); ``` ### Interaction ```typescript // Get row count const rowCount = await table.getProperty('count'); // Click a specific row (inner table is sap.ui.table.Table or sap.m.Table) const rows = await ui5.controls({ controlType: 'sap.m.ColumnListItem', ancestor: { controlType: 'sap.ui.comp.smarttable.SmartTable', id: /LineItemsSmartTable/ }, }); await rows[0].click(); // Trigger table personalization await ui5.click({ controlType: 'sap.m.OverflowToolbarButton', properties: { icon: 'sap-icon://action-settings' }, ancestor: { controlType: 'sap.ui.comp.smarttable.SmartTable', id: /LineItemsSmartTable/ }, }); ``` ### Pitfalls - SmartTable wraps either `sap.m.Table` (responsive) or `sap.ui.table.Table` (grid). Row selectors differ by inner table type. - Row counts may reflect only loaded rows if server-side paging is active. Use `growing` property to check. - Column visibility depends on table personalization variant — test data may not be visible in default columns. ## SmartFilterBar `sap.ui.comp.smartfilterbar.SmartFilterBar` Renders filter fields from OData annotations. The main filter bar in Fiori Elements List Reports. ### Selector ```typescript const filterBar = await ui5.control({ controlType: 'sap.ui.comp.smartfilterbar.SmartFilterBar', id: /listReportFilter/, }); ``` ### Interaction ```typescript // Set a filter value await ui5.fill( { controlType: 'sap.ui.comp.smartfield.SmartField', ancestor: { controlType: 'sap.ui.comp.smartfilterbar.SmartFilterBar' }, id: /CompanyCode/, }, '1000', ); // Click Go / Search await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Go' }, ancestor: { controlType: 'sap.ui.comp.smartfilterbar.SmartFilterBar' }, }); // Expand filters (Adapt Filters dialog) await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Adapt Filters' }, ancestor: { controlType: 'sap.ui.comp.smartfilterbar.SmartFilterBar' }, }); ``` ### Pitfalls - Filter fields may not be visible by default. Use "Adapt Filters" to add fields before setting values. - SmartFilterBar auto-triggers search on initial load with default variant — your filter values may be overwritten. - Date range filters require `sap.m.DateRangeSelection` interaction, not simple `fill()`. ## OverflowToolbar `sap.m.OverflowToolbar` Toolbar that collapses items into an overflow popover when space is limited. ### Selector ```typescript const toolbar = await ui5.control({ controlType: 'sap.m.OverflowToolbar', id: /headerToolbar/, }); ``` ### Interaction ```typescript // Click a visible toolbar button await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Edit' }, ancestor: { controlType: 'sap.m.OverflowToolbar', id: /headerToolbar/ }, }); // Click button that may be in overflow await ui5.click({ controlType: 'sap.m.OverflowToolbarButton', properties: { text: 'Delete' }, ancestor: { controlType: 'sap.m.OverflowToolbar', id: /headerToolbar/ }, }); ``` ### Pitfalls - Overflowed buttons are in a separate popover — use `searchOpenDialogs: true` if the button is in the overflow area. - `sap.m.OverflowToolbarButton` vs `sap.m.Button` — buttons in overflow toolbars may use either type depending on configuration. ## IconTabBar `sap.m.IconTabBar` Tab strip used for section navigation in Object Pages and detail views. ### Selector ```typescript const tabBar = await ui5.control({ controlType: 'sap.m.IconTabBar', id: /detailTabBar/, }); ``` ### Interaction ```typescript // Switch to a specific tab by key await ui5.click({ controlType: 'sap.m.IconTabFilter', properties: { key: 'items' }, ancestor: { controlType: 'sap.m.IconTabBar', id: /detailTabBar/ }, }); // Switch by text await ui5.click({ controlType: 'sap.m.IconTabFilter', properties: { text: 'Line Items' }, }); ``` ### Pitfalls - Tab content is lazy-loaded by default. After switching tabs, wait for the content to render before interacting with controls inside it. - Icon-only tabs have no `text` property — use `key` or `icon` to select them. ## ObjectPageLayout `sap.uxap.ObjectPageLayout` The layout container for Fiori Elements Object Pages. Contains header, sections, and subsections. ### Selector ```typescript const objectPage = await ui5.control({ controlType: 'sap.uxap.ObjectPageLayout', }); ``` ### Interaction ```typescript // Scroll to a specific section await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'General Information' }, ancestor: { controlType: 'sap.uxap.AnchorBar' }, }); // Toggle header (collapse/expand) await ui5.click({ controlType: 'sap.m.Button', id: /expandHeaderBtn/, }); // Access a subsection const section = await ui5.control({ controlType: 'sap.uxap.ObjectPageSection', properties: { title: 'Pricing' }, }); ``` ### Pitfalls - Sections use lazy loading — controls in non-visible sections may not exist in the control tree until scrolled to. - Header facets are in `sap.uxap.ObjectPageHeaderContent`, not in the main sections. - `sap.uxap.AnchorBar` is the internal navigation bar — section names there may differ from actual section titles. ## ComboBox `sap.m.ComboBox` Single-selection dropdown with type-ahead filtering. ### Selector ```typescript const combo = await ui5.control({ controlType: 'sap.m.ComboBox', id: /countryCombo/, }); ``` ### Interaction ```typescript // Type and select await ui5.fill({ controlType: 'sap.m.ComboBox', id: /countryCombo/ }, 'Germany'); // Open dropdown and select by item key await ui5.click({ controlType: 'sap.m.ComboBox', id: /countryCombo/ }); await ui5.click({ controlType: 'sap.ui.core.ListItem', properties: { key: 'DE' }, searchOpenDialogs: true, }); ``` ### Pitfalls - `fill()` triggers type-ahead but may not select the item. Verify with `getProperty('selectedKey')`. - Dropdown items are rendered in a popup — use `searchOpenDialogs: true` to find them. ## MultiComboBox `sap.m.MultiComboBox` Multi-selection dropdown with token display. ### Selector ```typescript const multi = await ui5.control({ controlType: 'sap.m.MultiComboBox', id: /plantsMultiCombo/, }); ``` ### Interaction ```typescript // Select multiple items await ui5.click({ controlType: 'sap.m.MultiComboBox', id: /plantsMultiCombo/ }); await ui5.click({ controlType: 'sap.ui.core.ListItem', properties: { text: 'Plant 1000' }, searchOpenDialogs: true, }); await ui5.click({ controlType: 'sap.ui.core.ListItem', properties: { text: 'Plant 2000' }, searchOpenDialogs: true, }); // Close dropdown await ui5.click({ controlType: 'sap.m.MultiComboBox', id: /plantsMultiCombo/ }); // Read selected keys const selectedKeys = await multi.getProperty('selectedKeys'); ``` ### Pitfalls - Each click toggles an item. Clicking an already-selected item deselects it. - Tokens may overflow and show "N More" — use `getProperty('selectedKeys')` to verify selections instead of counting visible tokens. ## MultiInput `sap.m.MultiInput` Text input with tokenizer for multiple values and optional value help. ### Selector ```typescript const multiInput = await ui5.control({ controlType: 'sap.m.MultiInput', id: /materialMultiInput/, }); ``` ### Interaction ```typescript // Add tokens by typing await ui5.fill({ controlType: 'sap.m.MultiInput', id: /materialMultiInput/ }, 'MAT-001'); // Press Enter to confirm token (use keyboard interaction) await page.keyboard.press('Enter'); // Open value help dialog await ui5.click({ controlType: 'sap.ui.core.Icon', properties: { src: 'sap-icon://value-help' }, ancestor: { controlType: 'sap.m.MultiInput', id: /materialMultiInput/ }, }); ``` ### Pitfalls - Tokens require Enter key press to be committed — `fill()` alone is not sufficient. - Value help button is a separate `sap.ui.core.Icon` control, not part of the MultiInput control. - Removing tokens requires clicking the token's delete icon, not clearing the input. ## Select `sap.m.Select` Simple dropdown with no type-ahead (unlike ComboBox). ### Selector ```typescript const select = await ui5.control({ controlType: 'sap.m.Select', id: /currencySelect/, }); ``` ### Interaction ```typescript // Open and select an item await ui5.click({ controlType: 'sap.m.Select', id: /currencySelect/ }); await ui5.click({ controlType: 'sap.ui.core.ListItem', properties: { key: 'USD' }, searchOpenDialogs: true, }); // Direct selection via key (no UI interaction) const currentKey = await select.getProperty('selectedKey'); ``` ### Pitfalls - `sap.m.Select` does not support type-ahead. Use `click()` to open, then click the item. - List items in the dropdown popup are `sap.ui.core.ListItem`, not `sap.m.StandardListItem`. ## DatePicker `sap.m.DatePicker` Date input with calendar popup. ### Selector ```typescript const datePicker = await ui5.control({ controlType: 'sap.m.DatePicker', id: /deliveryDatePicker/, }); ``` ### Interaction ```typescript // Fill directly with a date string (preferred approach) await ui5.fill({ controlType: 'sap.m.DatePicker', id: /deliveryDatePicker/ }, '2025-03-15'); // Open calendar and select a date await ui5.click({ controlType: 'sap.ui.core.Icon', properties: { src: 'sap-icon://appointment-2' }, ancestor: { controlType: 'sap.m.DatePicker', id: /deliveryDatePicker/ }, }); // Select day from calendar popup await ui5.click({ controlType: 'sap.ui.unified.calendar.DatesRow', searchOpenDialogs: true, }); ``` ### Pitfalls - Date format depends on user locale settings. Always use ISO format (`YYYY-MM-DD`) with `fill()` — Praman normalizes it to the configured locale. - `sap.m.DateRangeSelection` is a separate control type for date ranges. Do not confuse it with `sap.m.DatePicker`. - Calendar popup controls are in `sap.ui.unified.calendar` namespace — different from `sap.m`. ## Quick Reference Table | Control | Type | Primary Selector Strategy | Key Method | | ---------------- | ------------------------------------------- | ------------------------- | ---------------------------- | | SmartField | `sap.ui.comp.smartfield.SmartField` | `bindingPath` | `fill()` | | SmartTable | `sap.ui.comp.smarttable.SmartTable` | `id` | `getProperty('count')` | | SmartFilterBar | `sap.ui.comp.smartfilterbar.SmartFilterBar` | `id` | `fill()` on child fields | | OverflowToolbar | `sap.m.OverflowToolbar` | `id` | `click()` on child buttons | | IconTabBar | `sap.m.IconTabBar` | `id` | `click()` on `IconTabFilter` | | ObjectPageLayout | `sap.uxap.ObjectPageLayout` | singleton (one per page) | scroll to section | | ComboBox | `sap.m.ComboBox` | `id` | `fill()` or click + select | | MultiComboBox | `sap.m.MultiComboBox` | `id` | click to toggle items | | MultiInput | `sap.m.MultiInput` | `id` | `fill()` + Enter key | | Select | `sap.m.Select` | `id` | click + select item | | DatePicker | `sap.m.DatePicker` | `id` | `fill()` with ISO date | --- ## SAP Transaction Mapping Maps 50+ SAP transaction codes to their Fiori equivalents and the corresponding Praman fixtures, intents, and semantic objects used for test navigation. ## How to Use This Reference In Praman, navigation uses **semantic objects and actions** (the Fiori Launchpad intent model) rather than transaction codes. This table helps you translate your existing SAP GUI knowledge into Fiori-native test navigation. ```typescript // Navigate by app semantic hash await ui5Navigation.navigateToApp('PurchaseOrder-create'); // Navigate by intent (semantic object + action) await ui5Navigation.navigateToIntent({ semanticObject: 'PurchaseOrder', action: 'create' }); ``` ## Materials Management (MM) | TCode | Description | Fiori App ID | Semantic Object | Action | Intent Hash | | ----- | ---------------------------- | ------------ | --------------------- | --------- | ------------------------------ | | ME21N | Create Purchase Order | F0842 | `PurchaseOrder` | `create` | `#PurchaseOrder-create` | | ME22N | Change Purchase Order | F0842 | `PurchaseOrder` | `change` | `#PurchaseOrder-change` | | ME23N | Display Purchase Order | F0842 | `PurchaseOrder` | `display` | `#PurchaseOrder-display` | | ME51N | Create Purchase Requisition | F1130 | `PurchaseRequisition` | `create` | `#PurchaseRequisition-create` | | ME52N | Change Purchase Requisition | F1130 | `PurchaseRequisition` | `change` | `#PurchaseRequisition-change` | | ME53N | Display Purchase Requisition | F1130 | `PurchaseRequisition` | `display` | `#PurchaseRequisition-display` | | ME2M | POs by Material | F0701 | `PurchaseOrder` | `manage` | `#PurchaseOrder-manage` | | ME2N | POs by PO Number | F0701 | `PurchaseOrder` | `manage` | `#PurchaseOrder-manage` | | MIGO | Goods Movement | F0843 | `GoodsReceipt` | `create` | `#GoodsReceipt-create` | | MIRO | Enter Incoming Invoice | F0859 | `SupplierInvoice` | `create` | `#SupplierInvoice-create` | | MM01 | Create Material | F1602 | `Material` | `create` | `#Material-create` | | MM02 | Change Material | F1602 | `Material` | `change` | `#Material-change` | | MM03 | Display Material | F1602 | `Material` | `display` | `#Material-display` | | MB52 | Warehouse Stocks | F0842A | `WarehouseStock` | `display` | `#WarehouseStock-display` | | MMBE | Stock Overview | F0842A | `MaterialStock` | `display` | `#MaterialStock-display` | ## Sales & Distribution (SD) | TCode | Description | Fiori App ID | Semantic Object | Action | Intent Hash | | ----- | ------------------------- | ------------ | ------------------ | --------- | --------------------------- | | VA01 | Create Sales Order | F0217 | `SalesOrder` | `create` | `#SalesOrder-create` | | VA02 | Change Sales Order | F0217 | `SalesOrder` | `change` | `#SalesOrder-change` | | VA03 | Display Sales Order | F0217 | `SalesOrder` | `display` | `#SalesOrder-display` | | VA05 | List of Sales Orders | F1814 | `SalesOrder` | `manage` | `#SalesOrder-manage` | | VL01N | Create Outbound Delivery | F0341 | `OutboundDelivery` | `create` | `#OutboundDelivery-create` | | VL02N | Change Outbound Delivery | F0341 | `OutboundDelivery` | `change` | `#OutboundDelivery-change` | | VL03N | Display Outbound Delivery | F0341 | `OutboundDelivery` | `display` | `#OutboundDelivery-display` | | VF01 | Create Billing Document | F0638 | `BillingDocument` | `create` | `#BillingDocument-create` | | VF02 | Change Billing Document | F0638 | `BillingDocument` | `change` | `#BillingDocument-change` | | VF03 | Display Billing Document | F0638 | `BillingDocument` | `display` | `#BillingDocument-display` | ## Finance (FI) | TCode | Description | Fiori App ID | Semantic Object | Action | Intent Hash | | ----- | ------------------------- | ------------ | ------------------- | --------- | ---------------------------- | | FB50 | Post G/L Account Document | F0718 | `JournalEntry` | `create` | `#JournalEntry-create` | | FB60 | Enter Incoming Invoices | F0859 | `SupplierInvoice` | `create` | `#SupplierInvoice-create` | | FB70 | Enter Outgoing Invoices | F2555 | `CustomerInvoice` | `create` | `#CustomerInvoice-create` | | FBL1N | Vendor Line Items | F0996 | `SupplierLineItem` | `display` | `#SupplierLineItem-display` | | FBL3N | G/L Account Line Items | F0997 | `GLAccountLineItem` | `display` | `#GLAccountLineItem-display` | | FBL5N | Customer Line Items | F0998 | `CustomerLineItem` | `display` | `#CustomerLineItem-display` | | F110 | Payment Run | F1645 | `PaymentRun` | `manage` | `#PaymentRun-manage` | | FK01 | Create Vendor | F0794 | `Supplier` | `create` | `#Supplier-create` | | FK02 | Change Vendor | F0794 | `Supplier` | `change` | `#Supplier-change` | | FK03 | Display Vendor | F0794 | `Supplier` | `display` | `#Supplier-display` | | FS00 | G/L Account Master | F2217 | `GLAccount` | `manage` | `#GLAccount-manage` | ## Controlling (CO) | TCode | Description | Fiori App ID | Semantic Object | Action | Intent Hash | | ----- | ---------------------------------- | ------------ | ---------------- | --------- | ------------------------ | | KS01 | Create Cost Center | F1605 | `CostCenter` | `create` | `#CostCenter-create` | | KS02 | Change Cost Center | F1605 | `CostCenter` | `change` | `#CostCenter-change` | | KS03 | Display Cost Center | F1605 | `CostCenter` | `display` | `#CostCenter-display` | | KP06 | Plan Cost Elements/Activity Inputs | F2741 | `CostCenterPlan` | `manage` | `#CostCenterPlan-manage` | | CJ20N | Project Builder | F2107 | `Project` | `manage` | `#Project-manage` | ## Plant Maintenance (PM) / Asset Management | TCode | Description | Fiori App ID | Semantic Object | Action | Intent Hash | | ----- | -------------------------- | ------------ | ------------------------- | -------- | --------------------------------- | | IW21 | Create Notification | F1704 | `MaintenanceNotification` | `create` | `#MaintenanceNotification-create` | | IW22 | Change Notification | F1704 | `MaintenanceNotification` | `change` | `#MaintenanceNotification-change` | | IW31 | Create Maintenance Order | F1705 | `MaintenanceOrder` | `create` | `#MaintenanceOrder-create` | | IW32 | Change Maintenance Order | F1705 | `MaintenanceOrder` | `change` | `#MaintenanceOrder-change` | | IE01 | Create Equipment | F3381 | `Equipment` | `create` | `#Equipment-create` | | IL01 | Create Functional Location | F3382 | `FunctionalLocation` | `create` | `#FunctionalLocation-create` | ## Human Capital Management (HCM) | TCode | Description | Fiori App ID | Semantic Object | Action | Intent Hash | | ----- | ----------------------- | ------------ | --------------- | --------- | ---------------------- | | PA20 | Display HR Master Data | F0711 | `Employee` | `display` | `#Employee-display` | | PA30 | Maintain HR Master Data | F0711 | `Employee` | `manage` | `#Employee-manage` | | PT01 | Create Work Schedule | F1560 | `WorkSchedule` | `manage` | `#WorkSchedule-manage` | | CATS | Time Sheet Entry | F1286 | `TimeEntry` | `create` | `#TimeEntry-create` | ## Cross-Application | TCode | Description | Fiori App ID | Semantic Object | Action | Intent Hash | | ----- | ------------------- | ------------ | ----------------- | -------- | ------------------------- | | BP | Business Partner | F1757 | `BusinessPartner` | `manage` | `#BusinessPartner-manage` | | NWBC | SAP Fiori Launchpad | - | `Shell` | `home` | `#Shell-home` | | SM37 | Job Overview | F1239 | `ApplicationJob` | `manage` | `#ApplicationJob-manage` | | SU01 | User Maintenance | F1302 | `User` | `manage` | `#User-manage` | ## Praman Navigation Patterns ### By App Semantic Hash ```typescript // Navigate by semantic object-action string await ui5Navigation.navigateToApp('PurchaseOrder-create'); ``` ### By Intent (Semantic Object + Action) ```typescript await ui5Navigation.navigateToIntent({ semanticObject: 'PurchaseOrder', action: 'create' }); ``` ### By Intent with Parameters ```typescript await ui5Navigation.navigateToIntent( { semanticObject: 'PurchaseOrder', action: 'display' }, { PurchaseOrder: '4500000001' }, ); ``` ### By Hash (Deep Link) ```typescript await ui5Navigation.navigateToHash("PurchaseOrder-display&/PurchaseOrders('4500000001')"); ``` ## Tips - **App IDs may differ** between S/4HANA versions. Always verify against your system's FLP content catalog. - **Custom Fiori apps** will have their own semantic objects not listed here. Check your FLP admin console for custom intent mappings. - **Transaction wrappers** (SAP GUI in Fiori) use the TCode directly but are rendered inside the FLP shell — same navigation pattern applies. - Use `ui5Navigation.getCurrentHash()` to verify you landed on the correct app after navigation. --- ## Typed Control Returns :::info[In this guide] - Get typed return values from `ui5.control()` with full autocomplete - See 15-40+ control-specific methods instead of 10 generic base methods - Understand when typing applies and when it falls back to `UI5ControlBase` - Use typed controls to eliminate AI agent hallucination of method names - Import `UI5ControlMap` for advanced generic helper functions ::: When you call `ui5.control()` with a `controlType` string, Praman narrows the return type to a control-specific TypeScript interface. Instead of a generic `UI5ControlBase` with 10 base methods, you get the exact API surface for that control — autocomplete for every getter, setter, and event method. This works for **all 199 SAP UI5 control types** that Praman ships typed interfaces for. ## How It Works ```typescript test('typed control example', async ({ ui5 }) => { // Typed: returns UI5Button with getText(), press(), getEnabled(), getIcon(), ... const btn = await ui5.control({ controlType: 'sap.m.Button', properties: { text: 'Save' }, }); const text = await btn.getText(); // string — full autocomplete const enabled = await btn.getEnabled(); // boolean — not `any` const icon = await btn.getIcon(); // string // Untyped: returns UI5ControlBase (existing behavior, still works) const ctrl = await ui5.control({ id: 'myControl' }); const value = await ctrl.getProperty('value'); // unknown }); ``` When `controlType` is provided as a **string literal** (e.g., `'sap.m.Button'`), TypeScript infers the specific return type. When only `id` or other selectors are used, the return type falls back to `UI5ControlBase` — exactly the same behavior as before. :::tip Zero Runtime Change This is purely a TypeScript type-level feature. The runtime behavior is identical — the same proxy object is returned. The difference is that your IDE and AI agents now know the exact methods available. ::: ## Why This Matters ### For Test Automation Engineers Without typed returns, `ui5.control()` returns `UI5ControlBase` with only 10 base methods visible in autocomplete. Every control-specific method like `getItems()`, `getSelectedKey()`, or `getRows()` is hidden behind a `[method: string]: (...args) => Promise` index signature. With typed returns, you see the **full API surface** — 15 to 40+ methods depending on the control: | Control Type | Methods Available | Examples | | ----------------------------------- | ----------------- | ----------------------------------------------------------------------- | | `sap.m.Button` | 20+ | `getText()`, `getIcon()`, `getEnabled()`, `getType()`, `press()` | | `sap.m.Input` | 30+ | `getValue()`, `getPlaceholder()`, `getValueState()`, `getDescription()` | | `sap.m.Table` | 40+ | `getItems()`, `getColumns()`, `getMode()`, `getHeaderText()` | | `sap.m.Select` | 25+ | `getSelectedKey()`, `getSelectedItem()`, `getItems()`, `getEnabled()` | | `sap.m.Dialog` | 25+ | `getTitle()`, `getButtons()`, `getContent()`, `isOpen()` | | `sap.ui.comp.smarttable.SmartTable` | 35+ | `getEntitySet()`, `getTable()`, `rebindTable()` | | `sap.ui.comp.smartfield.SmartField` | 25+ | `getValue()`, `getEditable()`, `getEntitySet()` | | `sap.ui.mdc.FilterField` | 20+ | `getConditions()`, `getOperators()`, `getValueHelp()` | ### For AI Agents AI agents (Claude, Copilot, Cursor, Codex) use TypeScript type information as **grounding data**. When the type system says `UI5Button` has a `getText()` method that returns `Promise`, the agent uses that — not hallucinated method names. Without typed returns, agents see: ```text control: UI5ControlBase .getId() → Promise .getVisible() → Promise .getProperty(name) → Promise ... 7 more base methods [method: string]: (...args: any[]) → Promise ← agents guess from here ``` With typed returns, agents see: ```text btn: UI5Button .getText() → Promise .getIcon() → Promise .getEnabled() → Promise .getType() → Promise .press() → Promise .getAriaDescribedBy() → Promise ... 15+ more typed methods ``` This eliminates an entire class of agent errors — calling methods that don't exist, passing wrong argument types, or misinterpreting return types. ## Supported Controls Praman ships typed interfaces for **199 SAP UI5 controls** from the following namespaces: | Namespace | Controls | Examples | | ----------------- | -------- | ---------------------------------------------------------------------- | | `sap.m.*` | 80+ | Button, Input, Table, Dialog, Select, ComboBox, List, Page, Panel, ... | | `sap.ui.comp.*` | 15+ | SmartField, SmartTable, SmartFilterBar, SmartChart, SmartForm, ... | | `sap.ui.mdc.*` | 10+ | FilterField, FilterBar, Table, ValueHelp, ... | | `sap.f.*` | 10+ | DynamicPage, SemanticPage, FlexibleColumnLayout, Avatar, Card, ... | | `sap.ui.layout.*` | 5+ | Grid, VerticalLayout, HorizontalLayout, Splitter, ... | | `sap.tnt.*` | 5+ | NavigationList, SideNavigation, ToolPage, InfoLabel, ... | | `sap.ui.table.*` | 5+ | Table, TreeTable, Column, Row, ... | | `sap.uxap.*` | 5+ | ObjectPageLayout, ObjectPageSection, ObjectPageSubSection, ... | The full list is available in the [API Reference](/docs/api). ## Usage Patterns ### Button Interactions ```typescript test('button operations', async ({ ui5 }) => { const saveBtn = await ui5.control({ controlType: 'sap.m.Button', properties: { text: 'Save' }, }); // All methods are typed — IDE shows autocomplete expect(await saveBtn.getEnabled()).toBe(true); expect(await saveBtn.getType()).toBe('Emphasized'); expect(await saveBtn.getIcon()).toBe('sap-icon://save'); await saveBtn.press(); }); ``` ### Input Fields ```typescript test('input field operations', async ({ ui5 }) => { const vendorInput = await ui5.control({ controlType: 'sap.m.Input', viewName: 'app.view.Detail', properties: { placeholder: 'Enter vendor' }, }); // Typed: getValue() returns Promise, not Promise const currentValue = await vendorInput.getValue(); expect(currentValue).toBe(''); // Typed: getValueState() returns Promise const state = await vendorInput.getValueState(); expect(state).toBe('None'); await vendorInput.enterText('100001'); }); ``` ### Table with Items ```typescript test('table operations', async ({ ui5 }) => { const table = await ui5.control({ controlType: 'sap.m.Table', id: /poTable/, }); // Typed: getItems() returns Promise const items = await table.getItems(); expect(items.length).toBeGreaterThan(0); // Typed: getMode() returns Promise const mode = await table.getMode(); expect(mode).toBe('SingleSelectMaster'); // Typed: getHeaderText() returns Promise const header = await table.getHeaderText(); expect(header).toContain('Purchase Orders'); }); ``` ### Select / ComboBox ```typescript test('dropdown selection', async ({ ui5 }) => { const select = await ui5.control({ controlType: 'sap.m.Select', id: 'purchOrgSelect', }); // Typed: getSelectedKey() returns Promise const currentKey = await select.getSelectedKey(); expect(currentKey).toBe('1000'); // Typed: getItems() returns Promise const options = await select.getItems(); expect(options.length).toBeGreaterThanOrEqual(3); }); ``` ### SmartField (Fiori Elements) ```typescript test('smart field in object page', async ({ ui5 }) => { const smartField = await ui5.control({ controlType: 'sap.ui.comp.smartfield.SmartField', bindingPath: { path: '/CompanyCode' }, }); // Typed: getEditable() returns Promise const editable = await smartField.getEditable(); // Typed: getValue() returns Promise const value = await smartField.getValue(); expect(value).toBe('1000'); }); ``` ### SmartTable with Filters ```typescript test('smart table entity set', async ({ ui5 }) => { const smartTable = await ui5.control({ controlType: 'sap.ui.comp.smarttable.SmartTable', id: /listReport/, }); // Typed: getEntitySet() returns Promise const entitySet = await smartTable.getEntitySet(); expect(entitySet).toBe('C_PurchaseOrderItem'); // Typed: getTable() returns proxied inner table const innerTable = await smartTable.getTable(); }); ``` ### Dialog Handling ```typescript test('dialog with typed access', async ({ ui5 }) => { // Trigger a dialog await ui5.click({ id: 'deleteBtn' }); const dialog = await ui5.control({ controlType: 'sap.m.Dialog', searchOpenDialogs: true, }); // Typed: getTitle() returns Promise const title = await dialog.getTitle(); expect(title).toContain('Confirm'); // Typed: getButtons() returns Promise const buttons = await dialog.getButtons(); expect(buttons.length).toBe(2); }); ``` ### MDC FilterField (S/4HANA Cloud) ```typescript test('mdc filter field', async ({ ui5 }) => { const filterField = await ui5.control({ controlType: 'sap.ui.mdc.FilterField', properties: { label: 'Company Code' }, }); // Typed: getConditions() returns Promise const conditions = await filterField.getConditions(); expect(conditions).toHaveLength(0); // Typed: getOperators() returns Promise const operators = await filterField.getOperators(); expect(operators).toContain('EQ'); }); ``` ## Combining with Selectors Typed returns work with all [selector fields](/docs/guides/selectors) — `id`, `viewName`, `properties`, `bindingPath`, `ancestor`, `descendant`, and `searchOpenDialogs`. The only requirement is that `controlType` is present as a string literal: ```typescript // All of these return typed UI5Input: await ui5.control({ controlType: 'sap.m.Input', id: 'vendorInput' }); await ui5.control({ controlType: 'sap.m.Input', viewName: 'app.view.Detail' }); await ui5.control({ controlType: 'sap.m.Input', bindingPath: { value: '/Vendor' } }); await ui5.control({ controlType: 'sap.m.Input', ancestor: { id: 'form1' } }); await ui5.control({ controlType: 'sap.m.Input', properties: { placeholder: 'Enter vendor' } }); ``` ## Importing the Type Map For advanced use cases (utility functions, generic test helpers), you can import `UI5ControlMap` directly: ```typescript // Extract a specific control type type MyButton = UI5ControlMap['sap.m.Button']; type MyInput = UI5ControlMap['sap.m.Input']; // Use in generic helpers async function getButtonText( ui5: { control: (s: any) => Promise }, text: string, ): Promise { const btn = await ui5.control({ controlType: 'sap.m.Button', properties: { text }, }); return btn.getText() as Promise; } ``` ## Fallback Behavior When `controlType` is **not** provided, or is a **dynamic string variable** (not a literal), the return type is `UI5ControlBase` — the same 10 base methods plus the `[method: string]` index signature: ```typescript // Falls back to UI5ControlBase (no controlType) const ctrl = await ui5.control({ id: 'someControl' }); // Falls back to UI5ControlBase (dynamic string, not literal) const type = getControlType(); // returns string const ctrl2 = await ui5.control({ controlType: type }); // To force typing with a dynamic string, use `as const`: const ctrl3 = await ui5.control({ controlType: 'sap.m.Button' as const }); ``` :::info Backwards Compatible All existing tests continue to work without any changes. The fallback to `UI5ControlBase` ensures that ID-based selectors, RegExp selectors, and dynamic controlType strings compile and behave exactly as before. ::: ## Custom / Third-Party Controls For SAP controls not in the built-in type map (custom controls, partner extensions), the return type falls back to `UI5ControlBase`. You can still call any method — they just won't have autocomplete: ```typescript // Custom control: falls back to UI5ControlBase const custom = await ui5.control({ controlType: 'com.mycompany.CustomInput' }); // Methods still work at runtime (dynamic proxy), just no autocomplete const value = await custom.getValue(); // type is Promise ``` :::warning[Common mistake] Do not use a variable for `controlType` and expect typed returns. TypeScript can only narrow the return type when `controlType` is a **string literal**: ```typescript // Wrong — variable type is `string`, no narrowing const type = 'sap.m.Button'; const btn = await ui5.control({ controlType: type }); // UI5ControlBase // Correct — string literal enables type narrowing const btn = await ui5.control({ controlType: 'sap.m.Button' }); // UI5Button ``` If you must use a variable, add `as const`: `const type = 'sap.m.Button' as const;` ::: ## Full Real-World Example A complete test using typed controls in a SAP Manage Purchase Orders scenario: ```typescript test('verify purchase order details', async ({ ui5, ui5Navigation }) => { await ui5Navigation.navigateToApp('PurchaseOrder-manage'); await test.step('Open first purchase order', async () => { const table = await ui5.control({ controlType: 'sap.m.Table', id: /poTable/, }); const items = await table.getItems(); expect(items.length).toBeGreaterThan(0); await ui5.click({ controlType: 'sap.m.ColumnListItem', ancestor: { id: /poTable/ }, }); }); await test.step('Verify header fields', async () => { const vendorField = await ui5.control({ controlType: 'sap.ui.comp.smartfield.SmartField', bindingPath: { path: '/Supplier' }, }); const vendor = await vendorField.getValue(); expect(vendor).toBeTruthy(); const statusField = await ui5.control({ controlType: 'sap.m.ObjectStatus', id: /objectPageHeaderStatus/, }); const status = await statusField.getText(); expect(['Draft', 'Active', 'Approved']).toContain(status); }); await test.step('Check item table', async () => { const itemTable = await ui5.control({ controlType: 'sap.ui.table.Table', id: /itemTable/, }); const rowCount = await itemTable.getVisibleRowCount(); expect(rowCount).toBeGreaterThan(0); }); await test.step('Edit and save', async () => { await ui5.click({ controlType: 'sap.m.Button', properties: { text: 'Edit' }, }); await ui5.fill( { controlType: 'sap.m.Input', id: /noteInput/, }, 'Updated by automated test', ); const saveBtn = await ui5.control({ controlType: 'sap.m.Button', properties: { text: 'Save' }, }); expect(await saveBtn.getEnabled()).toBe(true); await saveBtn.press(); }); }); ``` ## FAQ
Do typed returns change the runtime behavior? No. Typed returns are purely a TypeScript type-level feature. The same dynamic proxy object is returned at runtime. The only difference is that your IDE and AI agents see the exact methods available for the specific control type, giving you autocomplete and type checking.
What happens if I call a method that does not exist on the control? The dynamic proxy forwards the call to the browser via `page.evaluate()`. If the method does not exist on the UI5 control, the call returns `undefined` or throws a runtime error. With typed returns, your IDE warns you about invalid method names at compile time, catching these errors before you run the test.
How do I get typed returns for a control not in the built-in type map? Custom or third-party controls fall back to `UI5ControlBase`. Methods still work at runtime via the dynamic proxy, but you will not get autocomplete. You can extend `UI5ControlMap` with module augmentation if needed, but for most cases the base methods plus `getProperty('name')` are sufficient.
:::tip[Next steps] - **[Selector Reference →](./selectors.md)** — All UI5Selector fields for finding controls - **[Control Interactions →](./control-interactions.md)** — Click, fill, and read controls with the `ui5` fixture - **[Custom Matchers →](./custom-matchers.md)** — Assert UI5 control state with 10 built-in matchers ::: --- ## Vocabulary System The `playwright-praman/vocabulary` sub-path provides a controlled vocabulary system for SAP S/4HANA business term resolution. It maps natural-language terms to UI5 selectors with fuzzy matching, synonym resolution, and cross-domain search. ## Overview ```typescript const svc = createVocabularyService(); await svc.loadDomain('procurement'); const results = await svc.search('vendor'); ``` ## SAP Domain JSON Files The vocabulary system ships with 6 SAP domain JSON files in `src/vocabulary/domains/`: | File | Domain | SAP Module | Description | | -------------------- | ------------- | ---------- | -------------------------------------------------- | | `procurement.json` | procurement | MM | Materials Management -- vendors, POs, GR | | `sales.json` | sales | SD | Sales & Distribution -- customers, SOs, deliveries | | `finance.json` | finance | FI | Financial Accounting -- GL, AP, AR | | `manufacturing.json` | manufacturing | PP | Production Planning -- orders, BOMs, routings | | `warehouse.json` | warehouse | WM/EWM | Warehouse Management -- storage, picking | | `quality.json` | quality | QM | Quality Management -- inspections, lots | Each JSON file contains vocabulary terms with synonyms and optional UI5 selectors: ```json { "supplier": { "name": "supplier", "synonyms": ["vendor", "vendorId", "supplierId", "LIFNR"], "domain": "procurement", "sapField": "LIFNR", "selector": { "controlType": "sap.m.Input", "properties": { "labelFor": "Supplier" } } } } ``` ## VocabularyService API ### Creating the Service ```typescript const svc: VocabularyService = createVocabularyService(); ``` ### Loading Domains Domains are loaded lazily on demand: ```typescript await svc.loadDomain('procurement'); await svc.loadDomain('finance'); // Only procurement and finance terms are in memory ``` ### Searching Terms ```typescript // Search across all loaded domains const results = await svc.search('vendor'); // Search within a specific domain const domainResults = await svc.search('vendor', 'procurement'); ``` Returns `VocabularySearchResult[]` sorted by confidence descending: ```typescript interface VocabularySearchResult { readonly term: string; // 'supplier' readonly synonyms: string[]; // ['vendor', 'vendorId', 'supplierId'] readonly confidence: number; // 0.0 to 1.0 readonly domain: SAPDomain; // 'procurement' readonly sapField?: string; // 'LIFNR' readonly selector?: UI5Selector; } ``` ### Resolving Field Selectors When a term matches with high enough confidence (>= 0.85), you can get its UI5 selector directly: ```typescript const selector = await svc.getFieldSelector('vendor', 'procurement'); if (selector) { // { controlType: 'sap.m.Input', properties: { labelFor: 'Supplier' } } await ui5.fill(selector, '100001'); } ``` Returns `undefined` when: - No match reaches the 0.85 confidence threshold. - Multiple matches score above 0.7 but below 0.85 (ambiguous). ### Getting Suggestions For autocomplete and disambiguation: ```typescript const suggestions = await svc.getSuggestions('sup'); // ['supplier', 'supplierName', 'supplyPlant', ...] // Limit results const top3 = await svc.getSuggestions('sup', 3); ``` ### Service Statistics ```typescript const stats = svc.getStats(); // { // loadedDomains: ['procurement', 'sales'], // totalTerms: 142, // cacheHits: 37, // cacheMisses: 5, // } ``` ## Normalization and Matching Algorithm The vocabulary matcher uses a tiered scoring system: ### Match Tiers | Tier | Confidence | Criteria | | ------- | ---------- | ------------------------------------------------------------- | | Exact | 1.0 | Query exactly matches term name or synonym (case-insensitive) | | Prefix | 0.9 | Term name starts with the query string | | Partial | 0.7 | Term name contains the query string | | Fuzzy | 0.5 | Levenshtein distance <= 3 characters | ### Normalization Before matching, both the query and term names are normalized: - Converted to lowercase - Leading/trailing whitespace trimmed ### Levenshtein Distance The fuzzy matching tier uses Levenshtein edit distance (standard DP matrix approach). Terms within 3 edits of the query are considered fuzzy matches: ```text levenshtein('vendor', 'vendro') = 2 --> Fuzzy match (0.5) levenshtein('vendor', 'vend') = 2 --> Fuzzy match (0.5) levenshtein('vendor', 'xyz') = 6 --> No match ``` ### Scoring Priority When multiple terms match, results are sorted by: 1. Confidence score (descending) 2. Within the same confidence tier, exact matches on synonyms score the same as exact matches on the canonical name ### Disambiguation The `getFieldSelector()` method includes disambiguation logic. If there are multiple high-confidence matches (0.7-0.85 range) without a clear winner, the method returns `undefined` rather than guessing, allowing the caller to present options to the user or AI agent. ## Integration with Intents The vocabulary system integrates with the intent API through the `VocabLookup` interface: ```typescript const vocab = createVocabularyService(); await vocab.loadDomain('procurement'); // fillField uses vocab to resolve 'Vendor' to a UI5 selector await fillField(ui5, vocab, 'Vendor', '100001', { domain: 'procurement' }); ``` ## Supported Domain Types ```typescript type SAPDomain = | 'procurement' // MM - Materials Management | 'sales' // SD - Sales & Distribution | 'finance' // FI - Financial Accounting | 'manufacturing' // PP - Production Planning | 'warehouse' // WM/EWM - Warehouse Management | 'quality'; // QM - Quality Management ``` ## Extending the Vocabulary To add terms to an existing domain, edit the corresponding JSON file in `src/vocabulary/domains/`. Each term must have: - `name` -- canonical term name (used as the Map key) - `synonyms` -- array of alternative names - `domain` -- must match one of the 6 `SAPDomain` values - `sapField` (optional) -- the underlying ABAP field name - `selector` (optional) -- UI5 selector for direct control discovery