electron-verify-mcp 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +172 -0
- package/dist/core/config.d.ts +74 -0
- package/dist/core/config.js +55 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.js +15 -0
- package/dist/core/interact.d.ts +40 -0
- package/dist/core/interact.js +98 -0
- package/dist/core/launch.d.ts +29 -0
- package/dist/core/launch.js +98 -0
- package/dist/core/readiness.d.ts +23 -0
- package/dist/core/readiness.js +109 -0
- package/dist/core/screenshot.d.ts +30 -0
- package/dist/core/screenshot.js +176 -0
- package/dist/core/session.d.ts +54 -0
- package/dist/core/session.js +148 -0
- package/dist/core/signals/blankScreen.d.ts +30 -0
- package/dist/core/signals/blankScreen.js +106 -0
- package/dist/core/signals/collect.d.ts +21 -0
- package/dist/core/signals/collect.js +38 -0
- package/dist/core/signals/console.d.ts +62 -0
- package/dist/core/signals/console.js +335 -0
- package/dist/core/signals/layout.d.ts +14 -0
- package/dist/core/signals/layout.js +32 -0
- package/dist/core/signals/mainProcess.d.ts +17 -0
- package/dist/core/signals/mainProcess.js +61 -0
- package/dist/core/signals/resources.d.ts +24 -0
- package/dist/core/signals/resources.js +91 -0
- package/dist/core/signals/staleBuild.d.ts +18 -0
- package/dist/core/signals/staleBuild.js +109 -0
- package/dist/core/types.d.ts +80 -0
- package/dist/core/types.js +5 -0
- package/dist/core/verdict.d.ts +23 -0
- package/dist/core/verdict.js +87 -0
- package/dist/core/verify.d.ts +52 -0
- package/dist/core/verify.js +182 -0
- package/dist/core/windowCapture.d.ts +27 -0
- package/dist/core/windowCapture.js +236 -0
- package/dist/core/windows.d.ts +29 -0
- package/dist/core/windows.js +94 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +578 -0
- package/package.json +74 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ElectronApplication } from "playwright-core";
|
|
2
|
+
export interface LaunchOptions {
|
|
3
|
+
/** Path to the app's built main entry, relative to `cwd` (e.g. "dist/main/index.js"). */
|
|
4
|
+
appPath: string;
|
|
5
|
+
/** App project root. Defaults to the directory of `appPath`. */
|
|
6
|
+
cwd?: string;
|
|
7
|
+
/** Extra args appended after the main entry. */
|
|
8
|
+
args?: string[];
|
|
9
|
+
/** Extra environment for the launched app. */
|
|
10
|
+
env?: Record<string, string>;
|
|
11
|
+
/** Optional command run in `cwd` before launch so we never verify a stale build. */
|
|
12
|
+
buildCommand?: string;
|
|
13
|
+
/** Explicit Electron binary. If omitted we resolve the app's own, then ours. */
|
|
14
|
+
executablePath?: string;
|
|
15
|
+
/** ms to wait for the app to open its first window. */
|
|
16
|
+
launchTimeoutMs?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the Electron binary to launch with. Playwright resolves Electron relative to
|
|
20
|
+
* ITS OWN install, which will miss the target app's version — so we explicitly resolve the
|
|
21
|
+
* app's `node_modules/electron` first, then fall back to this tool's bundled Electron.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveElectron(cwd: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Run a build command with stdout/stderr captured — never write to fd 1.
|
|
26
|
+
* MCP uses stdout as the JSON-RPC channel; build noise must stay on stderr.
|
|
27
|
+
*/
|
|
28
|
+
export declare function runBuildCommand(cmd: string, cwd: string, timeoutMs?: number): void;
|
|
29
|
+
export declare function launchApp(opts: LaunchOptions): Promise<ElectronApplication>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { _electron as electron } from "playwright-core";
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the Electron binary to launch with. Playwright resolves Electron relative to
|
|
8
|
+
* ITS OWN install, which will miss the target app's version — so we explicitly resolve the
|
|
9
|
+
* app's `node_modules/electron` first, then fall back to this tool's bundled Electron.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveElectron(cwd) {
|
|
12
|
+
// 1. The target app's own Electron (correct version / native ABI match).
|
|
13
|
+
try {
|
|
14
|
+
const appRequire = createRequire(path.join(cwd, "package.json"));
|
|
15
|
+
const bin = appRequire("electron");
|
|
16
|
+
if (typeof bin === "string")
|
|
17
|
+
return bin;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
/* fall through */
|
|
21
|
+
}
|
|
22
|
+
// 2. This tool's bundled Electron (used by fixtures and apps without their own).
|
|
23
|
+
const ourRequire = createRequire(import.meta.url);
|
|
24
|
+
const bin = ourRequire("electron");
|
|
25
|
+
if (typeof bin !== "string") {
|
|
26
|
+
throw new Error("Could not resolve an Electron binary from the app or from electron-verify-mcp.");
|
|
27
|
+
}
|
|
28
|
+
return bin;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the main entry to an absolute path.
|
|
32
|
+
* Accepts either a path relative to `cwd` (e.g. "dist/main/index.js") or a path
|
|
33
|
+
* relative to the process CWD (e.g. "test/fixtures/good-app/main.js" with a cwd of
|
|
34
|
+
* the fixture dir) so both CLI forms work without double-joining.
|
|
35
|
+
*/
|
|
36
|
+
function resolveMainEntry(appPath, cwd) {
|
|
37
|
+
if (path.isAbsolute(appPath))
|
|
38
|
+
return appPath;
|
|
39
|
+
const fromProcess = path.resolve(appPath);
|
|
40
|
+
if (fs.existsSync(fromProcess))
|
|
41
|
+
return fromProcess;
|
|
42
|
+
const fromCwd = path.resolve(cwd, appPath);
|
|
43
|
+
if (fs.existsSync(fromCwd))
|
|
44
|
+
return fromCwd;
|
|
45
|
+
// Prefer cwd-relative for the error message / launch attempt (matches docs).
|
|
46
|
+
return fromCwd;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Run a build command with stdout/stderr captured — never write to fd 1.
|
|
50
|
+
* MCP uses stdout as the JSON-RPC channel; build noise must stay on stderr.
|
|
51
|
+
*/
|
|
52
|
+
export function runBuildCommand(cmd, cwd, timeoutMs = 120_000) {
|
|
53
|
+
try {
|
|
54
|
+
const out = execSync(cmd, {
|
|
55
|
+
cwd,
|
|
56
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
57
|
+
timeout: timeoutMs,
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
60
|
+
});
|
|
61
|
+
if (out)
|
|
62
|
+
process.stderr.write(out);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
const e = err;
|
|
66
|
+
const stdout = bufToStr(e.stdout);
|
|
67
|
+
const stderr = bufToStr(e.stderr);
|
|
68
|
+
if (stdout)
|
|
69
|
+
process.stderr.write(stdout);
|
|
70
|
+
if (stderr)
|
|
71
|
+
process.stderr.write(stderr);
|
|
72
|
+
const combined = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
73
|
+
const tail = combined.length > 4000 ? combined.slice(-4000) : combined;
|
|
74
|
+
throw new Error(`buildCommand failed${e.status != null ? ` (exit ${e.status})` : ""}: ${cmd}` +
|
|
75
|
+
(tail ? `\n--- build output ---\n${tail}` : `\n${e.message ?? String(err)}`));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function bufToStr(v) {
|
|
79
|
+
if (v == null)
|
|
80
|
+
return "";
|
|
81
|
+
return typeof v === "string" ? v : v.toString("utf8");
|
|
82
|
+
}
|
|
83
|
+
export async function launchApp(opts) {
|
|
84
|
+
const cwd = path.resolve(opts.cwd ?? path.dirname(path.resolve(opts.appPath)));
|
|
85
|
+
const mainEntry = resolveMainEntry(opts.appPath, cwd);
|
|
86
|
+
if (opts.buildCommand) {
|
|
87
|
+
// Build synchronously; capture all output → stderr only (never MCP stdout).
|
|
88
|
+
runBuildCommand(opts.buildCommand, cwd);
|
|
89
|
+
}
|
|
90
|
+
const executablePath = opts.executablePath ?? resolveElectron(cwd);
|
|
91
|
+
return electron.launch({
|
|
92
|
+
executablePath,
|
|
93
|
+
args: [mainEntry, ...(opts.args ?? [])],
|
|
94
|
+
cwd,
|
|
95
|
+
env: { ...process.env, ...(opts.env ?? {}) },
|
|
96
|
+
timeout: opts.launchTimeoutMs ?? 30_000,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Page } from "playwright-core";
|
|
2
|
+
import type { ReadinessSignal } from "./types.js";
|
|
3
|
+
export interface ReadinessOptions {
|
|
4
|
+
/** Max time to wait for pixel stability. Default 10s. */
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
/** Interval between samples. Default 150ms. */
|
|
7
|
+
intervalMs?: number;
|
|
8
|
+
/** Consecutive stable samples required. Default 3. */
|
|
9
|
+
stableSamples?: number;
|
|
10
|
+
/** Max fraction of pixels that may change and still count as stable. Default 0.005 (0.5%). */
|
|
11
|
+
changeThreshold?: number;
|
|
12
|
+
/** Width of the downscaled comparison frame. Default 320. */
|
|
13
|
+
sampleWidth?: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Wait until the renderer is pixel-stable (not merely "loaded").
|
|
17
|
+
*
|
|
18
|
+
* Captures tiny consecutive screenshots and counts changed pixels. Stable when
|
|
19
|
+
* change stays below ~0.5% for N consecutive samples. On timeout returns
|
|
20
|
+
* `{stable:false, timedOut:true}` — never throws. Timeout is a WARN, not FAIL
|
|
21
|
+
* (animations / spinners must not hard-fail verification).
|
|
22
|
+
*/
|
|
23
|
+
export declare function waitUntilReady(page: Page, opts?: ReadinessOptions): Promise<ReadinessSignal>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { PNG } from "pngjs";
|
|
2
|
+
/**
|
|
3
|
+
* Wait until the renderer is pixel-stable (not merely "loaded").
|
|
4
|
+
*
|
|
5
|
+
* Captures tiny consecutive screenshots and counts changed pixels. Stable when
|
|
6
|
+
* change stays below ~0.5% for N consecutive samples. On timeout returns
|
|
7
|
+
* `{stable:false, timedOut:true}` — never throws. Timeout is a WARN, not FAIL
|
|
8
|
+
* (animations / spinners must not hard-fail verification).
|
|
9
|
+
*/
|
|
10
|
+
export async function waitUntilReady(page, opts = {}) {
|
|
11
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
12
|
+
const intervalMs = opts.intervalMs ?? 150;
|
|
13
|
+
const stableSamples = opts.stableSamples ?? 3;
|
|
14
|
+
const changeThreshold = opts.changeThreshold ?? 0.005;
|
|
15
|
+
const sampleWidth = opts.sampleWidth ?? 320;
|
|
16
|
+
const started = Date.now();
|
|
17
|
+
try {
|
|
18
|
+
await page.waitForLoadState("load", { timeout: Math.min(timeoutMs, 15_000) });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Load may never fire on some shells; still try pixel stability.
|
|
22
|
+
}
|
|
23
|
+
let prev = null;
|
|
24
|
+
let streak = 0;
|
|
25
|
+
while (Date.now() - started < timeoutMs) {
|
|
26
|
+
let frame;
|
|
27
|
+
try {
|
|
28
|
+
const raw = await page.screenshot({ type: "png" });
|
|
29
|
+
frame = downscalePng(PNG.sync.read(raw), sampleWidth);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Page may be mid-navigation; wait and retry.
|
|
33
|
+
await sleep(intervalMs);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (prev && sameSize(prev, frame)) {
|
|
37
|
+
const changed = changedPixelFraction(prev, frame);
|
|
38
|
+
if (changed < changeThreshold) {
|
|
39
|
+
streak += 1;
|
|
40
|
+
if (streak >= stableSamples) {
|
|
41
|
+
return {
|
|
42
|
+
stable: true,
|
|
43
|
+
waitedMs: Date.now() - started,
|
|
44
|
+
timedOut: false,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
streak = 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
streak = 0;
|
|
54
|
+
}
|
|
55
|
+
prev = frame;
|
|
56
|
+
await sleep(intervalMs);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
stable: false,
|
|
60
|
+
waitedMs: Date.now() - started,
|
|
61
|
+
timedOut: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function sleep(ms) {
|
|
65
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
66
|
+
}
|
|
67
|
+
function sameSize(a, b) {
|
|
68
|
+
return a.width === b.width && a.height === b.height;
|
|
69
|
+
}
|
|
70
|
+
/** Fraction of pixels whose RGB differs by more than a small epsilon. */
|
|
71
|
+
function changedPixelFraction(a, b) {
|
|
72
|
+
const n = a.width * a.height;
|
|
73
|
+
if (n === 0)
|
|
74
|
+
return 0;
|
|
75
|
+
let changed = 0;
|
|
76
|
+
const ad = a.data;
|
|
77
|
+
const bd = b.data;
|
|
78
|
+
for (let i = 0; i < n; i++) {
|
|
79
|
+
const o = i << 2;
|
|
80
|
+
if (Math.abs(ad[o] - bd[o]) > 2 ||
|
|
81
|
+
Math.abs(ad[o + 1] - bd[o + 1]) > 2 ||
|
|
82
|
+
Math.abs(ad[o + 2] - bd[o + 2]) > 2) {
|
|
83
|
+
changed++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return changed / n;
|
|
87
|
+
}
|
|
88
|
+
/** Nearest-neighbor downscale to a target max width (keeps aspect). */
|
|
89
|
+
function downscalePng(src, maxWidth) {
|
|
90
|
+
if (src.width <= maxWidth)
|
|
91
|
+
return src;
|
|
92
|
+
const scale = maxWidth / src.width;
|
|
93
|
+
const dstW = maxWidth;
|
|
94
|
+
const dstH = Math.max(1, Math.round(src.height * scale));
|
|
95
|
+
const dst = new PNG({ width: dstW, height: dstH });
|
|
96
|
+
for (let y = 0; y < dstH; y++) {
|
|
97
|
+
const sy = Math.min(src.height - 1, Math.floor(y / scale));
|
|
98
|
+
for (let x = 0; x < dstW; x++) {
|
|
99
|
+
const sx = Math.min(src.width - 1, Math.floor(x / scale));
|
|
100
|
+
const si = (src.width * sy + sx) << 2;
|
|
101
|
+
const di = (dstW * y + x) << 2;
|
|
102
|
+
dst.data[di] = src.data[si];
|
|
103
|
+
dst.data[di + 1] = src.data[si + 1];
|
|
104
|
+
dst.data[di + 2] = src.data[si + 2];
|
|
105
|
+
dst.data[di + 3] = src.data[si + 3];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return dst;
|
|
109
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Page } from "playwright-core";
|
|
2
|
+
import { PNG } from "pngjs";
|
|
3
|
+
import type { HoleRect } from "./types.js";
|
|
4
|
+
export interface CaptureOptions {
|
|
5
|
+
/** Max width of the returned PNG. Wider captures are downscaled. Default 1280. */
|
|
6
|
+
maxWidth?: number;
|
|
7
|
+
/**
|
|
8
|
+
* BrowserView (or other native) rectangles in the *source* screenshot coordinate
|
|
9
|
+
* space. Annotated with an outline + label so agents don't judge the void as a bug.
|
|
10
|
+
*/
|
|
11
|
+
holes?: HoleRect[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Capture a window screenshot as a PNG Buffer, downscale if needed, and annotate
|
|
15
|
+
* any BrowserView "holes" so the agent can see them as intentional exclusions.
|
|
16
|
+
*/
|
|
17
|
+
export declare function captureScreenshot(page: Page, opts?: CaptureOptions): Promise<Buffer>;
|
|
18
|
+
/**
|
|
19
|
+
* Capture raw PNG without annotation (for blank-screen pixel analysis with hole exclusion).
|
|
20
|
+
*/
|
|
21
|
+
export declare function captureRawScreenshot(page: Page): Promise<Buffer>;
|
|
22
|
+
/**
|
|
23
|
+
* Draw a hatch fill + dashed outline + label on each hole rectangle.
|
|
24
|
+
* Coordinates are in the raw (pre-downscale) screenshot space.
|
|
25
|
+
*/
|
|
26
|
+
export declare function annotateHoles(src: PNG, holes: HoleRect[]): PNG;
|
|
27
|
+
/** Test whether pixel (x,y) falls inside any hole rectangle. */
|
|
28
|
+
export declare function isInsideHole(x: number, y: number, holes: HoleRect[]): boolean;
|
|
29
|
+
/** Nearest-neighbor downscale when the PNG is wider than maxWidth. */
|
|
30
|
+
export declare function downscaleIfNeeded(pngBuf: Buffer, maxWidth: number): Buffer;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { PNG } from "pngjs";
|
|
2
|
+
/**
|
|
3
|
+
* Capture a window screenshot as a PNG Buffer, downscale if needed, and annotate
|
|
4
|
+
* any BrowserView "holes" so the agent can see them as intentional exclusions.
|
|
5
|
+
*/
|
|
6
|
+
export async function captureScreenshot(page, opts = {}) {
|
|
7
|
+
const maxWidth = opts.maxWidth ?? 1280;
|
|
8
|
+
const raw = await page.screenshot({ type: "png" });
|
|
9
|
+
const holes = opts.holes ?? [];
|
|
10
|
+
const rawPng = PNG.sync.read(raw);
|
|
11
|
+
const srcW = rawPng.width;
|
|
12
|
+
const png = holes.length > 0 ? annotateHoles(rawPng, holes) : rawPng;
|
|
13
|
+
let buf = PNG.sync.write(png);
|
|
14
|
+
if (srcW > maxWidth) {
|
|
15
|
+
buf = downscaleIfNeeded(buf, maxWidth);
|
|
16
|
+
}
|
|
17
|
+
return buf;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Capture raw PNG without annotation (for blank-screen pixel analysis with hole exclusion).
|
|
21
|
+
*/
|
|
22
|
+
export async function captureRawScreenshot(page) {
|
|
23
|
+
return page.screenshot({ type: "png" });
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Draw a hatch fill + dashed outline + label on each hole rectangle.
|
|
27
|
+
* Coordinates are in the raw (pre-downscale) screenshot space.
|
|
28
|
+
*/
|
|
29
|
+
export function annotateHoles(src, holes) {
|
|
30
|
+
const out = new PNG({ width: src.width, height: src.height });
|
|
31
|
+
src.data.copy(out.data);
|
|
32
|
+
for (const hole of holes) {
|
|
33
|
+
const x0 = Math.max(0, Math.floor(hole.x));
|
|
34
|
+
const y0 = Math.max(0, Math.floor(hole.y));
|
|
35
|
+
const x1 = Math.min(src.width, Math.ceil(hole.x + hole.width));
|
|
36
|
+
const y1 = Math.min(src.height, Math.ceil(hole.y + hole.height));
|
|
37
|
+
if (x1 <= x0 || y1 <= y0)
|
|
38
|
+
continue;
|
|
39
|
+
for (let y = y0; y < y1; y++) {
|
|
40
|
+
for (let x = x0; x < x1; x++) {
|
|
41
|
+
const i = (src.width * y + x) << 2;
|
|
42
|
+
const hatch = (x + y) % 8 < 2;
|
|
43
|
+
if (hatch) {
|
|
44
|
+
out.data[i] = Math.min(255, out.data[i] + 40);
|
|
45
|
+
out.data[i + 1] = Math.min(255, out.data[i + 1] + 30);
|
|
46
|
+
out.data[i + 2] = Math.min(255, out.data[i + 2] + 10);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const outline = { r: 255, g: 140, b: 0, a: 255 };
|
|
51
|
+
const t = 3;
|
|
52
|
+
for (let y = y0; y < y1; y++) {
|
|
53
|
+
for (let x = x0; x < x1; x++) {
|
|
54
|
+
const onEdge = x < x0 + t || x >= x1 - t || y < y0 + t || y >= y1 - t;
|
|
55
|
+
if (!onEdge)
|
|
56
|
+
continue;
|
|
57
|
+
const along = x < x0 + t || x >= x1 - t ? y : x;
|
|
58
|
+
if (Math.floor(along / 8) % 2 === 1)
|
|
59
|
+
continue;
|
|
60
|
+
setPx(out, x, y, outline);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
drawLabelBar(out, x0, y0, x1 - x0, "embedded view - not captured");
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/** Test whether pixel (x,y) falls inside any hole rectangle. */
|
|
68
|
+
export function isInsideHole(x, y, holes) {
|
|
69
|
+
for (const h of holes) {
|
|
70
|
+
if (x >= h.x && x < h.x + h.width && y >= h.y && y < h.y + h.height) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
/** Nearest-neighbor downscale when the PNG is wider than maxWidth. */
|
|
77
|
+
export function downscaleIfNeeded(pngBuf, maxWidth) {
|
|
78
|
+
const src = PNG.sync.read(pngBuf);
|
|
79
|
+
if (src.width <= maxWidth)
|
|
80
|
+
return pngBuf;
|
|
81
|
+
const scale = maxWidth / src.width;
|
|
82
|
+
const dstW = maxWidth;
|
|
83
|
+
const dstH = Math.max(1, Math.round(src.height * scale));
|
|
84
|
+
const dst = new PNG({ width: dstW, height: dstH });
|
|
85
|
+
for (let y = 0; y < dstH; y++) {
|
|
86
|
+
const sy = Math.min(src.height - 1, Math.floor(y / scale));
|
|
87
|
+
for (let x = 0; x < dstW; x++) {
|
|
88
|
+
const sx = Math.min(src.width - 1, Math.floor(x / scale));
|
|
89
|
+
const si = (src.width * sy + sx) << 2;
|
|
90
|
+
const di = (dstW * y + x) << 2;
|
|
91
|
+
dst.data[di] = src.data[si];
|
|
92
|
+
dst.data[di + 1] = src.data[si + 1];
|
|
93
|
+
dst.data[di + 2] = src.data[si + 2];
|
|
94
|
+
dst.data[di + 3] = src.data[si + 3];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return PNG.sync.write(dst);
|
|
98
|
+
}
|
|
99
|
+
function setPx(img, x, y, c) {
|
|
100
|
+
if (x < 0 || y < 0 || x >= img.width || y >= img.height)
|
|
101
|
+
return;
|
|
102
|
+
const i = (img.width * y + x) << 2;
|
|
103
|
+
img.data[i] = c.r;
|
|
104
|
+
img.data[i + 1] = c.g;
|
|
105
|
+
img.data[i + 2] = c.b;
|
|
106
|
+
img.data[i + 3] = c.a;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Solid filled label bar just inside the top edge of the hole.
|
|
110
|
+
* Uses 2×-scaled 5×7 glyphs so the text stays legible after downscale.
|
|
111
|
+
*/
|
|
112
|
+
function drawLabelBar(img, x0, y0, maxW, text) {
|
|
113
|
+
const scale = 2; // 5×7 → 10×14 glyphs
|
|
114
|
+
const pad = 6;
|
|
115
|
+
const charW = 5 * scale + 2; // 2px letter spacing
|
|
116
|
+
const charH = 7 * scale;
|
|
117
|
+
const barH = charH + pad * 2;
|
|
118
|
+
const neededW = pad * 2 + text.length * charW;
|
|
119
|
+
const barW = Math.min(maxW, Math.max(80, neededW));
|
|
120
|
+
// Inset 1px so the bar sits clearly inside the dashed outline.
|
|
121
|
+
const bx0 = x0 + 1;
|
|
122
|
+
const by0 = y0 + 1;
|
|
123
|
+
const x1 = Math.min(img.width, bx0 + barW);
|
|
124
|
+
const y1 = Math.min(img.height, by0 + barH);
|
|
125
|
+
// Fully opaque solid bar so text is readable over any app content.
|
|
126
|
+
for (let y = by0; y < y1; y++) {
|
|
127
|
+
for (let x = bx0; x < x1; x++) {
|
|
128
|
+
setPx(img, x, y, { r: 40, g: 24, b: 0, a: 255 });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
let cx = bx0 + pad;
|
|
132
|
+
const cy = by0 + pad;
|
|
133
|
+
for (const ch of text) {
|
|
134
|
+
if (cx + 5 * scale > x1)
|
|
135
|
+
break;
|
|
136
|
+
drawChar(img, cx, cy, ch, { r: 255, g: 190, b: 70, a: 255 }, scale);
|
|
137
|
+
cx += charW;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function drawChar(img, x, y, ch, color, scale = 1) {
|
|
141
|
+
const g = GLYPHS[ch] ?? GLYPHS["?"];
|
|
142
|
+
for (let row = 0; row < 7; row++) {
|
|
143
|
+
const bits = g[row] ?? 0;
|
|
144
|
+
for (let col = 0; col < 5; col++) {
|
|
145
|
+
if (bits & (1 << (4 - col))) {
|
|
146
|
+
for (let dy = 0; dy < scale; dy++) {
|
|
147
|
+
for (let dx = 0; dx < scale; dx++) {
|
|
148
|
+
setPx(img, x + col * scale + dx, y + row * scale + dy, color);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** 5x7 glyphs (7 rows of 5-bit masks). Enough for the hole label alphabet. */
|
|
156
|
+
const GLYPHS = {
|
|
157
|
+
" ": [0, 0, 0, 0, 0, 0, 0],
|
|
158
|
+
"-": [0, 0, 0, 0x1f, 0, 0, 0],
|
|
159
|
+
"?": [0x0e, 0x11, 0x01, 0x02, 0x04, 0, 0x04],
|
|
160
|
+
a: [0, 0x0e, 0x01, 0x0f, 0x11, 0x0f, 0],
|
|
161
|
+
b: [0x10, 0x10, 0x1e, 0x11, 0x11, 0x1e, 0],
|
|
162
|
+
c: [0, 0x0e, 0x11, 0x10, 0x11, 0x0e, 0],
|
|
163
|
+
d: [0x01, 0x01, 0x0f, 0x11, 0x11, 0x0f, 0],
|
|
164
|
+
e: [0, 0x0e, 0x11, 0x1f, 0x10, 0x0e, 0],
|
|
165
|
+
i: [0x04, 0, 0x0c, 0x04, 0x04, 0x0e, 0],
|
|
166
|
+
m: [0, 0x1b, 0x15, 0x15, 0x15, 0x15, 0],
|
|
167
|
+
n: [0, 0x1e, 0x11, 0x11, 0x11, 0x11, 0],
|
|
168
|
+
o: [0, 0x0e, 0x11, 0x11, 0x11, 0x0e, 0],
|
|
169
|
+
p: [0, 0x1e, 0x11, 0x1e, 0x10, 0x10, 0],
|
|
170
|
+
r: [0, 0x16, 0x19, 0x10, 0x10, 0x10, 0],
|
|
171
|
+
s: [0, 0x0f, 0x10, 0x0e, 0x01, 0x1e, 0],
|
|
172
|
+
t: [0x08, 0x1c, 0x08, 0x08, 0x08, 0x06, 0],
|
|
173
|
+
u: [0, 0x11, 0x11, 0x11, 0x13, 0x0d, 0],
|
|
174
|
+
v: [0, 0x11, 0x11, 0x11, 0x0a, 0x04, 0],
|
|
175
|
+
w: [0, 0x11, 0x11, 0x15, 0x15, 0x0a, 0],
|
|
176
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ElectronApplication, Page } from "playwright-core";
|
|
2
|
+
import { type LaunchOptions } from "./launch.js";
|
|
3
|
+
import { ConsoleCollector } from "./signals/console.js";
|
|
4
|
+
import { MainProcessCollector } from "./signals/mainProcess.js";
|
|
5
|
+
import { ResourcesCollector } from "./signals/resources.js";
|
|
6
|
+
export interface SessionLaunchOptions extends LaunchOptions {
|
|
7
|
+
/** Force target window URL substring. */
|
|
8
|
+
windowUrlMatch?: string;
|
|
9
|
+
/** Extra console ignore patterns (appended to defaults). */
|
|
10
|
+
ignoreConsolePatterns?: string[];
|
|
11
|
+
/** Selectors that must be present + non-zero size (WARN if missing). */
|
|
12
|
+
criticalSelectors?: string[];
|
|
13
|
+
/** Source globs for stale-build detection. */
|
|
14
|
+
srcGlobs?: string[];
|
|
15
|
+
/** Promote bare console.error to hard-FAIL (default: WARN). */
|
|
16
|
+
strictConsole?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* One live Electron app under inspection.
|
|
20
|
+
* Signal collectors attach at launch so they accumulate across the session.
|
|
21
|
+
*/
|
|
22
|
+
export declare class AppSession {
|
|
23
|
+
readonly app: ElectronApplication;
|
|
24
|
+
private mainPage;
|
|
25
|
+
readonly consoleCollector: ConsoleCollector;
|
|
26
|
+
readonly mainProcessCollector: MainProcessCollector;
|
|
27
|
+
readonly resourcesCollector: ResourcesCollector;
|
|
28
|
+
readonly launchedAt: number;
|
|
29
|
+
readonly cwd: string;
|
|
30
|
+
readonly windowUrlMatch?: string;
|
|
31
|
+
readonly criticalSelectors?: string[];
|
|
32
|
+
readonly srcGlobs?: string[];
|
|
33
|
+
readonly strictConsole: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* True when app_click / app_type / mutating app_evaluate ran since last verify.
|
|
36
|
+
* Used to default freshLoad=true when re-verifying an unchanged app.
|
|
37
|
+
*/
|
|
38
|
+
interactionSinceVerify: boolean;
|
|
39
|
+
private constructor();
|
|
40
|
+
static launch(opts: SessionLaunchOptions): Promise<AppSession>;
|
|
41
|
+
/** The selected app window's renderer. */
|
|
42
|
+
page(): Page;
|
|
43
|
+
/** Update the target page (e.g. after re-select during verify). */
|
|
44
|
+
setPage(page: Page): void;
|
|
45
|
+
/** Mark that a mutating interaction happened (affects freshLoad default). */
|
|
46
|
+
noteInteraction(): void;
|
|
47
|
+
/** Call after verify completes so the next pass can re-check cleanly. */
|
|
48
|
+
clearInteractionFlag(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Close the app. Races graceful close against a 5s timeout; on hang or throw,
|
|
51
|
+
* force-kills the process tree (Windows taskkill / Unix SIGKILL).
|
|
52
|
+
*/
|
|
53
|
+
close(): Promise<void>;
|
|
54
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { launchApp } from "./launch.js";
|
|
2
|
+
import { ConsoleCollector } from "./signals/console.js";
|
|
3
|
+
import { MainProcessCollector } from "./signals/mainProcess.js";
|
|
4
|
+
import { ResourcesCollector } from "./signals/resources.js";
|
|
5
|
+
import { selectTargetWindow } from "./windows.js";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
const CLOSE_TIMEOUT_MS = 5_000;
|
|
9
|
+
/**
|
|
10
|
+
* One live Electron app under inspection.
|
|
11
|
+
* Signal collectors attach at launch so they accumulate across the session.
|
|
12
|
+
*/
|
|
13
|
+
export class AppSession {
|
|
14
|
+
app;
|
|
15
|
+
mainPage;
|
|
16
|
+
consoleCollector;
|
|
17
|
+
mainProcessCollector;
|
|
18
|
+
resourcesCollector;
|
|
19
|
+
launchedAt;
|
|
20
|
+
cwd;
|
|
21
|
+
windowUrlMatch;
|
|
22
|
+
criticalSelectors;
|
|
23
|
+
srcGlobs;
|
|
24
|
+
strictConsole;
|
|
25
|
+
/**
|
|
26
|
+
* True when app_click / app_type / mutating app_evaluate ran since last verify.
|
|
27
|
+
* Used to default freshLoad=true when re-verifying an unchanged app.
|
|
28
|
+
*/
|
|
29
|
+
interactionSinceVerify = false;
|
|
30
|
+
constructor(app, mainPage, extras) {
|
|
31
|
+
this.app = app;
|
|
32
|
+
this.mainPage = mainPage;
|
|
33
|
+
this.consoleCollector = extras.consoleCollector;
|
|
34
|
+
this.mainProcessCollector = extras.mainProcessCollector;
|
|
35
|
+
this.resourcesCollector = extras.resourcesCollector;
|
|
36
|
+
this.launchedAt = extras.launchedAt;
|
|
37
|
+
this.cwd = extras.cwd;
|
|
38
|
+
this.windowUrlMatch = extras.windowUrlMatch;
|
|
39
|
+
this.criticalSelectors = extras.criticalSelectors;
|
|
40
|
+
this.srcGlobs = extras.srcGlobs;
|
|
41
|
+
this.strictConsole = extras.strictConsole === true;
|
|
42
|
+
}
|
|
43
|
+
static async launch(opts) {
|
|
44
|
+
const launchedAt = Date.now();
|
|
45
|
+
const cwd = path.resolve(opts.cwd ?? path.dirname(path.resolve(opts.appPath)));
|
|
46
|
+
const consoleCollector = new ConsoleCollector({
|
|
47
|
+
ignoreConsolePatterns: opts.ignoreConsolePatterns,
|
|
48
|
+
});
|
|
49
|
+
const mainProcessCollector = new MainProcessCollector();
|
|
50
|
+
const resourcesCollector = new ResourcesCollector();
|
|
51
|
+
const app = await launchApp(opts);
|
|
52
|
+
// Attach process collectors ASAP (before firstWindow) so boot stderr is captured.
|
|
53
|
+
mainProcessCollector.attach(app);
|
|
54
|
+
await consoleCollector.attachApp(app);
|
|
55
|
+
resourcesCollector.attachApp(app);
|
|
56
|
+
// Wait for at least one window, then pick the app-owned target (not BrowserView).
|
|
57
|
+
await app.firstWindow({ timeout: opts.launchTimeoutMs ?? 30_000 });
|
|
58
|
+
// Brief yield so multi-window shells (Pursuit) register both surfaces.
|
|
59
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
60
|
+
const selected = await selectTargetWindow(app, {
|
|
61
|
+
windowUrlMatch: opts.windowUrlMatch,
|
|
62
|
+
});
|
|
63
|
+
// Ensure the selected page also has collectors (in case it appeared after attachApp).
|
|
64
|
+
consoleCollector.attachPage(selected.page);
|
|
65
|
+
resourcesCollector.attachPage(selected.page);
|
|
66
|
+
if (selected.warning) {
|
|
67
|
+
console.error(`[electron-verify] window select warning: ${selected.warning}`);
|
|
68
|
+
}
|
|
69
|
+
return new AppSession(app, selected.page, {
|
|
70
|
+
consoleCollector,
|
|
71
|
+
mainProcessCollector,
|
|
72
|
+
resourcesCollector,
|
|
73
|
+
launchedAt,
|
|
74
|
+
cwd,
|
|
75
|
+
windowUrlMatch: opts.windowUrlMatch,
|
|
76
|
+
criticalSelectors: opts.criticalSelectors,
|
|
77
|
+
srcGlobs: opts.srcGlobs,
|
|
78
|
+
strictConsole: opts.strictConsole,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** The selected app window's renderer. */
|
|
82
|
+
page() {
|
|
83
|
+
return this.mainPage;
|
|
84
|
+
}
|
|
85
|
+
/** Update the target page (e.g. after re-select during verify). */
|
|
86
|
+
setPage(page) {
|
|
87
|
+
this.mainPage = page;
|
|
88
|
+
this.consoleCollector.attachPage(page);
|
|
89
|
+
this.resourcesCollector.attachPage(page);
|
|
90
|
+
}
|
|
91
|
+
/** Mark that a mutating interaction happened (affects freshLoad default). */
|
|
92
|
+
noteInteraction() {
|
|
93
|
+
this.interactionSinceVerify = true;
|
|
94
|
+
}
|
|
95
|
+
/** Call after verify completes so the next pass can re-check cleanly. */
|
|
96
|
+
clearInteractionFlag() {
|
|
97
|
+
this.interactionSinceVerify = false;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Close the app. Races graceful close against a 5s timeout; on hang or throw,
|
|
101
|
+
* force-kills the process tree (Windows taskkill / Unix SIGKILL).
|
|
102
|
+
*/
|
|
103
|
+
async close() {
|
|
104
|
+
let pid;
|
|
105
|
+
try {
|
|
106
|
+
pid = this.app.process()?.pid;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
pid = undefined;
|
|
110
|
+
}
|
|
111
|
+
const forceKill = () => {
|
|
112
|
+
if (pid == null)
|
|
113
|
+
return;
|
|
114
|
+
try {
|
|
115
|
+
if (process.platform === "win32") {
|
|
116
|
+
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" });
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
try {
|
|
120
|
+
process.kill(pid, "SIGKILL");
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* already dead */
|
|
124
|
+
}
|
|
125
|
+
// Also try via ElectronApplication process handle if still alive.
|
|
126
|
+
try {
|
|
127
|
+
this.app.process()?.kill("SIGKILL");
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* ignore */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* best-effort */
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
try {
|
|
139
|
+
await Promise.race([
|
|
140
|
+
this.app.close(),
|
|
141
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("app.close timed out")), CLOSE_TIMEOUT_MS)),
|
|
142
|
+
]);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
forceKill();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|