Files
agent-radar 7be535059e
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 19:05:14 +08:00

156 lines
5.3 KiB
JavaScript

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