/* * 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. */ /** * A Chrome DevTools Protocol client for the running dev app (#4109). * * Small on purpose: `/json/list` to find the renderer target, one WebSocket, * `Runtime.evaluate` and `Input.dispatchMouseEvent`. Nothing here is specific * to a measurement — the probes in this directory are. * * Clicks must go through `Input.dispatchMouseEvent`. A synthesised * `element.click()` does not produce the event sequence Astryx's SideNavItem * listens for, so the row never activates and the measurement silently * describes an app that did nothing. */ import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const WebSocket = require('ws'); export const DEFAULT_PORT = 9334; export async function targets(port = DEFAULT_PORT) { const response = await fetch(`http://127.0.0.1:${port}/json/list`); return response.json(); } export async function connectRenderer(port = DEFAULT_PORT, match = /index\.html|maka/i) { const list = await targets(port); const page = list.find( (target) => target.type === 'page' && (match.test(target.url) || match.test(target.title)), ); if (!page) { const seen = list.map((target) => `${target.type} ${target.title} ${target.url}`).join('\n '); throw new Error(`no renderer target on port ${port}. Saw:\n ${seen}`); } return connect(page.webSocketDebuggerUrl); } export function connect(url) { const socket = new WebSocket(url, { perMessageDeflate: false, maxPayload: 512 * 1024 * 1024 }); let nextId = 0; const pending = new Map(); const listeners = []; const ready = new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); }); socket.on('message', (data) => { const message = JSON.parse(data.toString()); if (message.id === undefined) { for (const listener of listeners) listener(message); return; } const waiter = pending.get(message.id); pending.delete(message.id); if (!waiter) return; if (message.error) waiter.reject(new Error(JSON.stringify(message.error))); else waiter.resolve(message.result); }); const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++nextId; pending.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })); }); const evaluate = async (expression, options = {}) => { const result = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true, ...options, }); if (result.exceptionDetails) { throw new Error( JSON.stringify(result.exceptionDetails.exception?.description ?? result.exceptionDetails), ); } return result.result.value; }; return { socket, send, evaluate, on: (listener) => listeners.push(listener), ready, close: () => socket.close(), }; } export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** * Click the nth Session row by real input events, at the centre of its box. * * Scoped to the rail and refused when the row is not on screen. A click at * coordinates outside the viewport lands on whatever is there instead — one of * them opened Settings mid-run and the measurement that followed described an * app that was not showing the rail at all. */ export async function clickSessionRow(client, index) { const box = JSON.parse( await client.evaluate( `(() => { const rail = document.querySelector('nav.maka-session-panel'); if (!rail) throw new Error('the session rail is not mounted'); const row = [...rail.querySelectorAll('[data-session-id]')][${index}]; if (!row) throw new Error('no session row at index ${index}'); const b = row.getBoundingClientRect(); const x = b.x + b.width / 2; const y = b.y + b.height / 2; if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) { throw new Error('session row ${index} is off screen (' + x + ',' + y + ')'); } return JSON.stringify({ x, y }); })()`, ), ); const point = { x: Math.round(box.x), y: Math.round(box.y), button: 'left', clickCount: 1, buttons: 1, }; await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point, buttons: 0 }); await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point }); await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, buttons: 0 }); } export function median(values) { const sorted = [...values].sort((a, b) => a - b); return sorted[Math.floor(sorted.length / 2)]; }