/** * Layer-3 parity E2E spec. * * Drives BOTH the React frontend (`frontend/dist/`, served statically) * AND the standalone (`standalone_interface.html`, loaded via * `file://`) through an identical canonical gesture sequence. Both * runs hit a MOCKED backend via `page.route('/api/** / *', ...)` — so * the spec is independent of pypowsybl / expert_op4grid_recommender, * and can run in any CI environment where Playwright can install a * browser. * * After each run the spec captures: * - The ordered list of `interactionLogger.record(...)` events. * - The `session.json` payload that `buildSessionResult` writes * when the user clicks Save. * * It then diffs the two runs along three axes: * 1. Event-type sequence (order-sensitive). * 2. `details` keys per event (order-insensitive). * 3. `session.json` shape (top-level + nested field presence; * values are ignored because timestamps / durations differ). * * The spec is the authoritative "behavioural" check that complements * the static Layer-1 (`scripts/check_standalone_parity.py`) and the * session-reload fidelity Layer-2 (`scripts/check_session_fidelity.py`). * * ---------------------------------------------------------------- * Running locally * ---------------------------------------------------------------- * * # Prereq: a browser for Playwright to drive. * cd scripts/parity_e2e * npm install * npx playwright install chromium * * # Build the React app once (so we can serve its /dist statically). * cd ../../frontend * npm install && npm run build * * cd ../scripts/parity_e2e * npx playwright test * * ---------------------------------------------------------------- * CI wiring * ---------------------------------------------------------------- * * - uses: actions/checkout@v4 * - uses: actions/setup-node@v4 * - run: cd frontend && npm ci && npm run build * - run: cd scripts/parity_e2e && npm ci * - run: cd scripts/parity_e2e && npx playwright install --with-deps chromium * - run: cd scripts/parity_e2e && npx playwright test * * The spec is deliberately kept at per-PR cost (~90 s) — see * `scripts/PARITY_README.md` for why we don't run it on every * commit and what it catches that Layers 1 + 2 miss. */ import { test, expect, type Page, type Route, type Browser } from '@playwright/test'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; // --------------------------------------------------------------------- // Mock backend — minimal, just enough that both UIs can run the // canonical gesture sequence without crashing. // --------------------------------------------------------------------- const MOCK_BRANCHES = ['LINE_A', 'LINE_B', 'LINE_C']; const MOCK_VLS = ['VL_1', 'VL_2']; const MOCK_SVG = ` `; async function registerMockBackend(page: Page): Promise { await page.route('**/api/config', (route: Route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'success', message: 'loaded', total_lines_count: MOCK_BRANCHES.length, monitored_lines_count: MOCK_BRANCHES.length, action_dict_file_name: 'mock_actions.json', action_dict_stats: { reco: 1, disco: 2, pst: 0, open_coupling: 0, close_coupling: 0, total: 3 }, }), }) ); await page.route('**/api/branches', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ branches: MOCK_BRANCHES, name_map: Object.fromEntries(MOCK_BRANCHES.map(b => [b, b])), }), }) ); await page.route('**/api/voltage-levels', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ voltage_levels: MOCK_VLS, name_map: Object.fromEntries(MOCK_VLS.map(v => [v, v])), }), }) ); await page.route('**/api/nominal-voltages', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ mapping: Object.fromEntries(MOCK_VLS.map(v => [v, 400])), unique_kv: [400], }), }) ); // Base NAD — React uses `format=text` so the body is a JSON header // line + raw SVG body (see api.ts::getNetworkDiagram). await page.route('**/api/network-diagram**', (route) => { const header = JSON.stringify({ metadata: {}, lines_overloaded: [], lines_overloaded_rho: [], }); const body = `${header}\n${MOCK_SVG}`; route.fulfill({ status: 200, contentType: 'text/plain; charset=utf-8', body }); }); await page.route('**/api/contingency-diagram', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ svg: MOCK_SVG, metadata: {}, lines_overloaded: ['LINE_B'], lines_overloaded_rho: [1.15], flow_deltas: {}, reactive_flow_deltas: {}, asset_deltas: {}, lf_converged: true, lf_status: 'CONVERGED', }), }) ); await page.route('**/api/run-analysis-step1', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ lines_overloaded: ['LINE_B'], message: '1 overloaded line detected', can_proceed: true, }), }) ); // NDJSON stream — two events. await page.route('**/api/run-analysis-step2', (route) => { const pdfEvent = JSON.stringify({ type: 'pdf', pdf_url: '/results/pdf/mock.pdf' }); const resultEvent = JSON.stringify({ type: 'result', actions: { disco_LINE_C: { description_unitaire: 'Disconnect LINE_C', rho_before: [1.15], rho_after: [0.85], max_rho: 0.85, max_rho_line: 'LINE_B', is_rho_reduction: true, }, }, action_scores: {}, lines_overloaded: ['LINE_B'], combined_actions: {}, message: '1 action found', dc_fallback: false, }); route.fulfill({ status: 200, contentType: 'application/x-ndjson', body: `${pdfEvent}\n${resultEvent}\n`, }); }); await page.route('**/api/action-variant-diagram', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ svg: MOCK_SVG, metadata: {}, action_id: 'disco_LINE_C', flow_deltas: {}, reactive_flow_deltas: {}, asset_deltas: {}, lf_converged: true, lf_status: 'CONVERGED', }), }) ); await page.route('**/api/save-session', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ session_folder: '/tmp/session_mock', pdf_copied: false, }), }) ); await page.route('**/api/user-config', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ network_path: '', action_file_path: '', layout_path: '', output_folder_path: '', lines_monitoring_path: '', min_line_reconnections: 2.0, min_close_coupling: 3.0, min_open_coupling: 2.0, min_line_disconnections: 3.0, min_pst: 1.0, min_load_shedding: 0.0, min_renewable_curtailment_actions: 0.0, n_prioritized_actions: 10, monitoring_factor: 0.95, pre_existing_overload_threshold: 0.02, ignore_reconnections: false, pypowsybl_fast_mode: true, }), }) ); await page.route('**/api/config-file-path', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ config_file_path: '/tmp/config.json' }), }) ); } // --------------------------------------------------------------------- // Interaction-log capture // --------------------------------------------------------------------- async function captureInteractionLog(page: Page): Promise>> { // Both UIs expose `interactionLogger` on `window` (React via a // `useEffect` bridge, standalone via its inline class definition). // If the React app doesn't expose it, uncomment the helper below // and add `window.__interactionLogger = interactionLogger` inside // a `useEffect` in App.tsx guarded by `process.env.VITEST === 'e2e'`. return await page.evaluate(() => { // @ts-expect-error — interactionLogger is a runtime singleton. const logger = window.interactionLogger || window.__interactionLogger; return logger ? logger.getLog() : []; }); } // --------------------------------------------------------------------- // Canonical gesture sequence — the same 11 steps on both UIs. // --------------------------------------------------------------------- async function runCanonicalSession(page: Page): Promise<{ events: Array>; sessionPayload: unknown; }> { // 1. Load Study — the UI reads a cached config on mount and (in // the React app) auto-opens Settings if paths are blank. For // this test we preset paths via localStorage then click Load. await page.evaluate(() => { localStorage.setItem('networkPath', '/mock/network.xiidm'); localStorage.setItem('actionPath', '/mock/actions.json'); localStorage.setItem('layoutPath', '/mock/grid_layout.json'); localStorage.setItem('outputFolderPath', '/mock/output'); }); await page.reload(); await page.getByRole('button', { name: /Load Study/i }).click(); await page.waitForResponse((r) => r.url().includes('/api/network-diagram')); // 2. Select contingency LINE_A. const contingencyInput = page.getByPlaceholder(/Search line\/bus/i); await contingencyInput.fill('LINE_A'); await contingencyInput.press('Enter'); await page.waitForResponse((r) => r.url().includes('/api/contingency-diagram')); // 3. Run step 1. await page.getByRole('button', { name: /Detect Overloads/i }).click(); await page.waitForResponse((r) => r.url().includes('/api/run-analysis-step1')); // 4. Toggle overload LINE_B off then on (exercises the checkbox). const olCheckbox = page.getByLabel(/LINE_B/); await olCheckbox.uncheck(); await olCheckbox.check(); // 5. Run step 2. await page.getByRole('button', { name: /Resolve|Run Analysis/i }).click(); await page.waitForResponse((r) => r.url().includes('/api/run-analysis-step2')); // 6. Display prioritized actions (if a separate button exists; // otherwise the action feed populates automatically). const displayBtn = page.getByRole('button', { name: /Display Prioritized/i }); if (await displayBtn.count() > 0) await displayBtn.click(); // 7. Select (click) the first action card. const actionCard = page.locator('[data-action-id="disco_LINE_C"]').first(); if (await actionCard.count() > 0) { await actionCard.click(); await page.waitForResponse((r) => r.url().includes('/api/action-variant-diagram')); } // 8. Favorite the action. const starBtn = page.locator('[aria-label*="favorite"]').first(); if (await starBtn.count() > 0) await starBtn.click(); // 9. Change tab to N, then back to N-1. await page.getByRole('button', { name: /^N$/ }).click(); await page.getByRole('button', { name: /^N-1$/ }).click(); // 10. Zoom in/out on the active tab. const zoomIn = page.locator('[aria-label*="zoom in" i]').first(); const zoomOut = page.locator('[aria-label*="zoom out" i]').first(); if (await zoomIn.count() > 0) await zoomIn.click(); if (await zoomOut.count() > 0) await zoomOut.click(); // 11. Save session. await page.getByRole('button', { name: /Save Results|Save Session/i }).click(); const saveResponse = await page.waitForResponse((r) => r.url().includes('/api/save-session') && r.request().method() === 'POST' ); const requestBody = saveResponse.request().postData(); const sessionPayload = requestBody ? JSON.parse(JSON.parse(requestBody).json_content || '{}') : null; const events = await captureInteractionLog(page); return { events, sessionPayload }; } // --------------------------------------------------------------------- // Normaliser + diff helpers // --------------------------------------------------------------------- interface NormalisedEvent { type: string; detailKeys: string[]; } function normaliseEvents(events: Array>): NormalisedEvent[] { return events.map((e) => ({ type: String(e.type), detailKeys: Object.keys((e.details ?? {}) as Record).sort(), })); } function collectFieldPaths(obj: unknown, prefix = ''): string[] { if (obj === null || typeof obj !== 'object') return []; const out: string[] = []; for (const [k, v] of Object.entries(obj as Record)) { const p = prefix ? `${prefix}.${k}` : k; out.push(p); if (v && typeof v === 'object' && !Array.isArray(v)) { out.push(...collectFieldPaths(v, p)); } } return out.sort(); } // --------------------------------------------------------------------- // Tests — one spec, two runs, three assertions. // --------------------------------------------------------------------- interface Artefacts { events: NormalisedEvent[]; sessionFields: string[]; } async function runUI(browser: Browser, url: string): Promise { const page = await browser.newPage(); await registerMockBackend(page); await page.goto(url); const { events, sessionPayload } = await runCanonicalSession(page); await page.close(); return { events: normaliseEvents(events), sessionFields: collectFieldPaths(sessionPayload), }; } test.describe('Layer-3 parity: React frontend vs standalone HTML', () => { test('identical event-type sequence on the canonical gesture script', async ({ browser }) => { const REACT_URL = process.env.REACT_URL ?? 'http://localhost:4173/'; const STANDALONE_URL = process.env.STANDALONE_URL ?? `file://${path.resolve(__dirname, '../../standalone_interface.html')}`; const react = await runUI(browser, REACT_URL); const standalone = await runUI(browser, STANDALONE_URL); // Persist artefacts for post-mortem. await fs.writeFile('artefacts.json', JSON.stringify({ react, standalone }, null, 2)); // 1. Event-type sequence — order-sensitive. const reactSeq = react.events.map((e) => e.type); const standaloneSeq = standalone.events.map((e) => e.type); expect.soft(standaloneSeq).toEqual(reactSeq); // 2. Details-keys per event — order-insensitive. for (let i = 0; i < Math.min(react.events.length, standalone.events.length); i++) { expect.soft(standalone.events[i].detailKeys, `event #${i} (${react.events[i].type})`) .toEqual(react.events[i].detailKeys); } // 3. Session-payload field paths. expect.soft(standalone.sessionFields).toEqual(react.sessionFields); }); });