| /* | |
| * 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(); | |
| }, | |
| }; | |
| } |