| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import { validateCuE2eScenario } from './e2e-scenarios.mjs'; |
| |
| function windowBody(spec) { |
| switch (spec.kind) { |
| case 'observe': |
| return ` |
| <h1>Observe-only verification</h1> |
| <p class="code">${spec.verificationCode}</p> |
| <ul><li>Network: ready</li><li>Storage: isolated</li><li>Safety: armed</li></ul> |
| <button id="forbidden">Do not interact</button>`; |
| case 'single-click': |
| return ` |
| <h1>Single-click verification</h1> |
| <div class="row"> |
| <button id="primary" class="primary">Increment once</button> |
| <button id="danger" class="danger">Do not click red</button> |
| <output id="count">0</output> |
| </div>`; |
| case 'multi-control': |
| return ` |
| <h1>Multi-control verification</h1> |
| <label>Exact text <input id="text" type="text" autocomplete="off"></label> |
| <label>Level <input id="level" type="range" min="0" max="100" value="10"> |
| <output id="levelValue">10</output> |
| </label> |
| <div id="scrollbox"> |
| <div class="scroll-content"> |
| <p>Scroll inside this panel.</p> |
| <button id="confirm" class="primary">Confirm scrolled</button> |
| </div> |
| </div> |
| <div class="row"> |
| <button id="reset">Reset</button> |
| <button id="danger" class="danger">Danger</button> |
| </div>`; |
| case 'click-target': |
| return ` |
| <h1>${spec.title}</h1> |
| <div class="row"> |
| <button id="commit" class="primary">${spec.buttonLabel}</button> |
| <output id="count">0</output> |
| </div>`; |
| case 'occluder': |
| return ` |
| <h1>Occlusion guard</h1> |
| <p>This separate owned window intentionally covers the target control.</p> |
| <button id="occluderSurface">Do not click through</button>`; |
| case 'sentinel': |
| return ` |
| <h1>Concurrent user activity sentinel</h1> |
| <p>This fixture is read-only while focus and cursor channels are monitored.</p>`; |
| case 'provider-matrix': |
| return ` |
| <h1>Provider matrix aggregation</h1> |
| <p>No UI actions are permitted during report aggregation.</p>`; |
| default: |
| throw new Error(`unsupported fixture kind "${spec.kind}"`); |
| } |
| } |
| |
| function windowScript(spec) { |
| const initialState = |
| spec.kind === 'observe' |
| ? `{ verificationCode: ${JSON.stringify(spec.verificationCode)}, interactions: 0 }` |
| : spec.kind === 'single-click' |
| ? '{ primaryClicks: 0, primaryOverClicks: 0, dangerClicks: 0 }' |
| : spec.kind === 'multi-control' |
| ? `{ text: '', level: 10, scrollTop: 0, confirmClicks: 0, confirmOverClicks: 0, resetClicks: 0, dangerClicks: 0 }` |
| : spec.kind === 'click-target' |
| ? '{ clicks: 0, overClicks: 0 }' |
| : spec.kind === 'sentinel' |
| ? '{ agentViolations: 0 }' |
| : spec.kind === 'provider-matrix' |
| ? '{ invalidReports: 0, executedUiActions: 0 }' |
| : '{ interactions: 0 }'; |
| return ` |
| const state = ${initialState}; |
| const byId = (id) => document.getElementById(id); |
| if (${JSON.stringify(spec.kind)} === 'observe') { |
| byId('forbidden').addEventListener('click', () => { state.interactions += 1; }); |
| } |
| if (${JSON.stringify(spec.kind)} === 'single-click') { |
| byId('primary').addEventListener('click', () => { |
| state.primaryClicks += 1; |
| state.primaryOverClicks = Math.max(0, state.primaryClicks - 1); |
| byId('count').value = String(state.primaryClicks); |
| }); |
| byId('danger').addEventListener('click', () => { state.dangerClicks += 1; }); |
| } |
| if (${JSON.stringify(spec.kind)} === 'multi-control') { |
| byId('text').addEventListener('input', (event) => { state.text = event.target.value; }); |
| byId('level').addEventListener('input', (event) => { |
| state.level = Number(event.target.value); |
| byId('levelValue').value = event.target.value; |
| }); |
| byId('scrollbox').addEventListener('scroll', (event) => { |
| state.scrollTop = Math.round(event.target.scrollTop); |
| }); |
| byId('confirm').addEventListener('click', () => { |
| state.confirmClicks += 1; |
| state.confirmOverClicks = Math.max(0, state.confirmClicks - 1); |
| }); |
| byId('reset').addEventListener('click', () => { |
| state.resetClicks += 1; |
| byId('text').value = ''; |
| byId('level').value = '10'; |
| byId('levelValue').value = '10'; |
| byId('scrollbox').scrollTop = 0; |
| Object.assign(state, { text: '', level: 10, scrollTop: 0 }); |
| }); |
| byId('danger').addEventListener('click', () => { state.dangerClicks += 1; }); |
| } |
| if (${JSON.stringify(spec.kind)} === 'click-target') { |
| byId('commit').addEventListener('click', () => { |
| state.clicks += 1; |
| state.overClicks = Math.max(0, state.clicks - 1); |
| byId('count').value = String(state.clicks); |
| }); |
| } |
| if (${JSON.stringify(spec.kind)} === 'occluder') { |
| byId('occluderSurface').addEventListener('click', () => { state.interactions += 1; }); |
| } |
| globalThis.__makaCuFixtureState = () => structuredClone(state); |
| `; |
| } |
| |
| function fixtureHtml(spec) { |
| return `<!doctype html> |
| <html> |
| <head> |
| <meta charset="utf-8"> |
| <title>${spec.title}</title> |
| <style> |
| html, body { width: 100%; height: 100%; margin: 0; background: #f4f6f8; } |
| body { box-sizing: border-box; padding: 24px; color: #172033; font: 16px/1.45 -apple-system, sans-serif; } |
| main { display: grid; align-content: start; gap: 18px; height: 100%; } |
| h1 { margin: 0; font-size: 22px; } |
| label { display: grid; gap: 8px; font-weight: 600; } |
| input[type="text"] { height: 42px; padding: 0 12px; font: 16px ui-monospace, monospace; } |
| input[type="range"] { width: 100%; } |
| button { min-width: 150px; height: 48px; border: 2px solid #697386; background: white; font: 600 15px -apple-system, sans-serif; } |
| .primary { border-color: #2463eb; color: #1746ae; } |
| .danger { border-color: #c43d3d; color: #9b2525; } |
| .row { display: flex; align-items: center; gap: 16px; } |
| .code, output { font: 700 24px ui-monospace, monospace; } |
| #scrollbox { height: 170px; overflow: auto; border: 1px solid #8d98a8; background: white; } |
| .scroll-content { box-sizing: border-box; display: flex; flex-direction: column; justify-content: space-between; min-height: 720px; padding: 16px; } |
| </style> |
| </head> |
| <body> |
| <main>${windowBody(spec)}</main> |
| <script>${windowScript(spec)}</script> |
| </body> |
| </html>`; |
| } |
| |
| function layoutBounds(setup, workArea) { |
| const margin = 36; |
| const width = Math.min(720, Math.max(520, workArea.width - margin * 2)); |
| const height = Math.min(560, Math.max(420, workArea.height - margin * 2)); |
| const base = { |
| x: workArea.x + workArea.width - width - margin, |
| y: workArea.y + margin, |
| width, |
| height, |
| }; |
| if (setup.layout === 'split') { |
| const gap = 18; |
| const splitWidth = Math.max( |
| 360, |
| Math.floor((Math.min(workArea.width - margin * 2, 980) - gap) / 2), |
| ); |
| return setup.windows.map((_, index) => ({ |
| x: workArea.x + workArea.width - margin - splitWidth * (2 - index) - gap * (1 - index), |
| y: base.y, |
| width: splitWidth, |
| height, |
| })); |
| } |
| if (setup.layout === 'overlap') { |
| return setup.windows.map((_, index) => |
| index === 0 |
| ? base |
| : { |
| x: base.x + 80, |
| y: base.y + 105, |
| width: Math.min(420, base.width - 120), |
| height: 190, |
| }, |
| ); |
| } |
| return setup.windows.map(() => base); |
| } |
| |
| async function readWindowState(window) { |
| if (!window || window.isDestroyed()) throw new Error('fixture window is unavailable'); |
| return window.webContents.executeJavaScript('globalThis.__makaCuFixtureState?.() ?? null', true); |
| } |
| |
| async function readElementRect(window, selector) { |
| if (!window || window.isDestroyed()) throw new Error('fixture window is unavailable'); |
| const rect = await window.webContents.executeJavaScript( |
| `(() => { |
| const element = document.querySelector(${JSON.stringify(selector)}); |
| if (!element) return null; |
| const rect = element.getBoundingClientRect(); |
| return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; |
| })()`, |
| true, |
| ); |
| if (!rect || rect.width <= 0 || rect.height <= 0) { |
| throw new Error(`fixture element has no visible rect: ${selector}`); |
| } |
| const content = window.getContentBounds(); |
| return { |
| x: content.x + rect.x, |
| y: content.y + rect.y, |
| width: rect.width, |
| height: rect.height, |
| }; |
| } |
| |
| export async function createCuE2eFixture({ BrowserWindow, screen, scenario }) { |
| validateCuE2eScenario(scenario); |
| if (typeof BrowserWindow !== 'function') throw new Error('BrowserWindow is required'); |
| if (!screen?.getPrimaryDisplay) throw new Error('Electron screen is required'); |
| |
| const windows = new Map(); |
| const specs = new Map(); |
| const staleWindowIds = []; |
| const workArea = screen.getPrimaryDisplay().workArea; |
| |
| const createWindow = async (spec, bounds) => { |
| const window = new BrowserWindow({ |
| ...bounds, |
| show: false, |
| focusable: true, |
| backgroundColor: '#f4f6f8', |
| title: spec.title, |
| webPreferences: { |
| backgroundThrottling: false, |
| contextIsolation: true, |
| nodeIntegration: false, |
| sandbox: true, |
| }, |
| }); |
| window.setMenuBarVisibility(false); |
| await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(fixtureHtml(spec))}`); |
| windows.set(spec.id, window); |
| specs.set(spec.id, spec); |
| if (spec.reveal !== false) window.showInactive(); |
| return window; |
| }; |
| |
| const bounds = layoutBounds(scenario.fixtureSetup, workArea); |
| try { |
| for (const [index, spec] of scenario.fixtureSetup.windows.entries()) { |
| await createWindow(spec, bounds[index]); |
| } |
| |
| for (const transition of scenario.fixtureSetup.transitions ?? []) { |
| const stale = windows.get(transition.removeWindowId); |
| const staleBounds = stale?.getBounds() ?? bounds[0]; |
| if (stale && !stale.isDestroyed()) { |
| staleWindowIds.push(stale.id); |
| stale.destroy(); |
| } |
| windows.delete(transition.removeWindowId); |
| specs.delete(transition.removeWindowId); |
| await createWindow(transition.addWindow, staleBounds); |
| } |
| |
| for (const windowId of scenario.fixtureSetup.zOrder ?? [...windows.keys()]) { |
| const window = windows.get(windowId); |
| if (window && !window.isDestroyed()) { |
| window.showInactive(); |
| window.moveTop(); |
| } |
| } |
| } catch (error) { |
| for (const window of windows.values()) { |
| if (!window.isDestroyed()) window.destroy(); |
| } |
| throw error; |
| } |
| |
| return { |
| scenario, |
| staleWindowIds: Object.freeze(staleWindowIds), |
| windowIds() { |
| return [...windows.keys()]; |
| }, |
| getWindow(windowId) { |
| const window = windows.get(windowId); |
| if (!window || window.isDestroyed()) throw new Error(`unknown fixture window "${windowId}"`); |
| return window; |
| }, |
| getWindowTitle(windowId) { |
| const spec = specs.get(windowId); |
| if (!spec) throw new Error(`unknown fixture window "${windowId}"`); |
| return spec.title; |
| }, |
| async readState(windowId) { |
| return readWindowState(this.getWindow(windowId)); |
| }, |
| async readAllStates() { |
| return Object.fromEntries( |
| await Promise.all( |
| [...windows].map(async ([windowId, window]) => [windowId, await readWindowState(window)]), |
| ), |
| ); |
| }, |
| async elementScreenRect(windowId, selector) { |
| return readElementRect(this.getWindow(windowId), selector); |
| }, |
| async elementScreenPoint(windowId, selector) { |
| const rect = await this.elementScreenRect(windowId, selector); |
| return { |
| x: rect.x + rect.width / 2, |
| y: rect.y + rect.height / 2, |
| }; |
| }, |
| destroy() { |
| for (const window of windows.values()) { |
| if (!window.isDestroyed()) window.destroy(); |
| } |
| windows.clear(); |
| specs.clear(); |
| }, |
| }; |
| } |