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,30 @@
|
|
|
1
|
+
import type { Page } from "playwright-core";
|
|
2
|
+
import type { BlankScreenSignal, HoleRect } from "../types.js";
|
|
3
|
+
export interface BlankScreenOptions {
|
|
4
|
+
/** PNG buffer of the *raw* (or annotated) screenshot in page coordinates. */
|
|
5
|
+
png: Buffer;
|
|
6
|
+
/**
|
|
7
|
+
* BrowserView holes in the same coordinate space as `png` *before* any
|
|
8
|
+
* downscale applied for token savings. Prefer analyzing pre-downscale when
|
|
9
|
+
* possible; if the buffer was downscaled, pass holes already scaled to match.
|
|
10
|
+
*/
|
|
11
|
+
holes?: HoleRect[];
|
|
12
|
+
/** Dominant-color coverage above this → pixel-blank. Default 0.98. */
|
|
13
|
+
coverageThreshold?: number;
|
|
14
|
+
/**
|
|
15
|
+
* When false (readiness timed out / not stable), do not hard-FAIL blank for
|
|
16
|
+
* sparse DOM (spinner splash). Default true if omitted.
|
|
17
|
+
*/
|
|
18
|
+
readinessStable?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Blank-screen detector: pixels near-uniform AND DOM effectively empty.
|
|
22
|
+
* Hole regions (BrowserViews) are excluded from the pixel histogram so the
|
|
23
|
+
* native-view void does not trigger a false blank FAIL.
|
|
24
|
+
*
|
|
25
|
+
* When visibleElements < 2, only call blank if there are also zero interactive
|
|
26
|
+
* elements AND readiness stabilized — a single spinner on a solid bg must not FAIL.
|
|
27
|
+
*
|
|
28
|
+
* dominantColor / coverage stay INTERNAL — only isBlank + reason are reported.
|
|
29
|
+
*/
|
|
30
|
+
export declare function detectBlankScreen(page: Page, opts: BlankScreenOptions): Promise<BlankScreenSignal>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { PNG } from "pngjs";
|
|
2
|
+
import { isInsideHole } from "../screenshot.js";
|
|
3
|
+
/**
|
|
4
|
+
* Blank-screen detector: pixels near-uniform AND DOM effectively empty.
|
|
5
|
+
* Hole regions (BrowserViews) are excluded from the pixel histogram so the
|
|
6
|
+
* native-view void does not trigger a false blank FAIL.
|
|
7
|
+
*
|
|
8
|
+
* When visibleElements < 2, only call blank if there are also zero interactive
|
|
9
|
+
* elements AND readiness stabilized — a single spinner on a solid bg must not FAIL.
|
|
10
|
+
*
|
|
11
|
+
* dominantColor / coverage stay INTERNAL — only isBlank + reason are reported.
|
|
12
|
+
*/
|
|
13
|
+
export async function detectBlankScreen(page, opts) {
|
|
14
|
+
const holes = opts.holes ?? [];
|
|
15
|
+
const coverageThreshold = opts.coverageThreshold ?? 0.98;
|
|
16
|
+
const readinessStable = opts.readinessStable !== false;
|
|
17
|
+
const pixelBlank = isPixelNearUniform(opts.png, holes, coverageThreshold);
|
|
18
|
+
const dom = await readDomEmptiness(page);
|
|
19
|
+
// Blank only when there is essentially no UI: no text, zero sized elements,
|
|
20
|
+
// zero interactive controls, and readiness stabilized.
|
|
21
|
+
// A single spinner (visibleEls=1) on a solid bg must NOT count as empty —
|
|
22
|
+
// the old `visible < 2` threshold false-FAILed splash screens.
|
|
23
|
+
const domEmpty = dom.textLen < 2 &&
|
|
24
|
+
dom.visible === 0 &&
|
|
25
|
+
dom.interactive === 0 &&
|
|
26
|
+
readinessStable;
|
|
27
|
+
if (pixelBlank.nearUniform && domEmpty) {
|
|
28
|
+
return {
|
|
29
|
+
isBlank: true,
|
|
30
|
+
reason: `pixel-uniform (${(pixelBlank.coverage * 100).toFixed(1)}% one color) and DOM empty (${dom.detail})`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return { isBlank: false, reason: null };
|
|
34
|
+
}
|
|
35
|
+
function isPixelNearUniform(pngBuf, holes, threshold) {
|
|
36
|
+
const img = PNG.sync.read(pngBuf);
|
|
37
|
+
// Quantize RGB to 4-bit/channel buckets so anti-aliasing doesn't fragment the histogram.
|
|
38
|
+
const hist = new Map();
|
|
39
|
+
let counted = 0;
|
|
40
|
+
for (let y = 0; y < img.height; y++) {
|
|
41
|
+
for (let x = 0; x < img.width; x++) {
|
|
42
|
+
if (holes.length && isInsideHole(x, y, holes))
|
|
43
|
+
continue;
|
|
44
|
+
const i = (img.width * y + x) << 2;
|
|
45
|
+
if (img.data[i + 3] < 16)
|
|
46
|
+
continue;
|
|
47
|
+
const r = img.data[i] >> 4;
|
|
48
|
+
const g = img.data[i + 1] >> 4;
|
|
49
|
+
const b = img.data[i + 2] >> 4;
|
|
50
|
+
const key = (r << 8) | (g << 4) | b;
|
|
51
|
+
hist.set(key, (hist.get(key) ?? 0) + 1);
|
|
52
|
+
counted++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (counted === 0) {
|
|
56
|
+
return { nearUniform: true, coverage: 1 };
|
|
57
|
+
}
|
|
58
|
+
let max = 0;
|
|
59
|
+
for (const n of hist.values())
|
|
60
|
+
if (n > max)
|
|
61
|
+
max = n;
|
|
62
|
+
const coverage = max / counted;
|
|
63
|
+
return { nearUniform: coverage >= threshold, coverage };
|
|
64
|
+
}
|
|
65
|
+
async function readDomEmptiness(page) {
|
|
66
|
+
try {
|
|
67
|
+
const result = await page.evaluate(() => {
|
|
68
|
+
const body = document.body;
|
|
69
|
+
if (!body)
|
|
70
|
+
return { textLen: 0, visible: 0, interactive: 0 };
|
|
71
|
+
const textLen = (body.innerText || "").trim().length;
|
|
72
|
+
let visible = 0;
|
|
73
|
+
let interactive = 0;
|
|
74
|
+
const interactiveSel = "a[href], button, input, select, textarea, [role=button], [role=link], [tabindex]";
|
|
75
|
+
const all = body.querySelectorAll("*");
|
|
76
|
+
for (const el of all) {
|
|
77
|
+
const style = window.getComputedStyle(el);
|
|
78
|
+
if (style.display === "none" || style.visibility === "hidden")
|
|
79
|
+
continue;
|
|
80
|
+
if (parseFloat(style.opacity || "1") === 0)
|
|
81
|
+
continue;
|
|
82
|
+
const r = el.getBoundingClientRect();
|
|
83
|
+
if (r.width > 1 && r.height > 1) {
|
|
84
|
+
visible++;
|
|
85
|
+
if (el.matches(interactiveSel))
|
|
86
|
+
interactive++;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { textLen, visible, interactive };
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
textLen: result.textLen,
|
|
93
|
+
visible: result.visible,
|
|
94
|
+
interactive: result.interactive,
|
|
95
|
+
detail: `textLen=${result.textLen}, visibleEls=${result.visible}, interactive=${result.interactive}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
return {
|
|
100
|
+
textLen: 0,
|
|
101
|
+
visible: 0,
|
|
102
|
+
interactive: 0,
|
|
103
|
+
detail: `dom-evaluate-failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Page } from "playwright-core";
|
|
2
|
+
import type { AppSession } from "../session.js";
|
|
3
|
+
import type { HoleRect, ReadinessSignal, Signals } from "../types.js";
|
|
4
|
+
export interface CollectOptions {
|
|
5
|
+
readiness: ReadinessSignal;
|
|
6
|
+
/** Raw (pre-annotation preferred) PNG for blank-screen pixel analysis. */
|
|
7
|
+
png: Buffer;
|
|
8
|
+
holes?: HoleRect[];
|
|
9
|
+
criticalSelectors?: string[];
|
|
10
|
+
srcGlobs?: string[];
|
|
11
|
+
/** Override page (defaults to session.page()). */
|
|
12
|
+
page?: Page;
|
|
13
|
+
/** Dominant-color coverage threshold for blank detection. */
|
|
14
|
+
blankCoverage?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Run all signal readers against a live session → Signals object.
|
|
18
|
+
* Console / main / resources were attached at launch; we snapshot them here
|
|
19
|
+
* filtered to the selected page.
|
|
20
|
+
*/
|
|
21
|
+
export declare function collectSignals(session: AppSession, opts: CollectOptions): Promise<Signals>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { detectBlankScreen } from "./blankScreen.js";
|
|
2
|
+
import { detectLayout } from "./layout.js";
|
|
3
|
+
import { detectStaleBuild } from "./staleBuild.js";
|
|
4
|
+
/**
|
|
5
|
+
* Run all signal readers against a live session → Signals object.
|
|
6
|
+
* Console / main / resources were attached at launch; we snapshot them here
|
|
7
|
+
* filtered to the selected page.
|
|
8
|
+
*/
|
|
9
|
+
export async function collectSignals(session, opts) {
|
|
10
|
+
const page = opts.page ?? session.page();
|
|
11
|
+
await session.consoleCollector.pollMainCrashes(session.app, page);
|
|
12
|
+
const consoleSig = session.consoleCollector.snapshot(page);
|
|
13
|
+
const mainProcess = session.mainProcessCollector.snapshot();
|
|
14
|
+
const resources = await session.resourcesCollector.snapshot(page);
|
|
15
|
+
const blankScreen = await detectBlankScreen(page, {
|
|
16
|
+
png: opts.png,
|
|
17
|
+
holes: opts.holes,
|
|
18
|
+
coverageThreshold: opts.blankCoverage,
|
|
19
|
+
readinessStable: opts.readiness.stable && !opts.readiness.timedOut,
|
|
20
|
+
});
|
|
21
|
+
const layout = await detectLayout(page, {
|
|
22
|
+
criticalSelectors: opts.criticalSelectors,
|
|
23
|
+
});
|
|
24
|
+
const staleBuild = detectStaleBuild({
|
|
25
|
+
cwd: session.cwd,
|
|
26
|
+
launchedAt: session.launchedAt,
|
|
27
|
+
srcGlobs: opts.srcGlobs,
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
readiness: opts.readiness,
|
|
31
|
+
console: consoleSig,
|
|
32
|
+
mainProcess,
|
|
33
|
+
blankScreen,
|
|
34
|
+
layout,
|
|
35
|
+
resources,
|
|
36
|
+
staleBuild,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ElectronApplication, Page } from "playwright-core";
|
|
2
|
+
import type { ConsoleSignal } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Default ignore-list for known-benign Electron / Chromium console noise.
|
|
5
|
+
* Extend via `ignoreConsolePatterns` (substring or regex source strings).
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_IGNORE_PATTERNS: string[];
|
|
8
|
+
export interface ConsoleCollectorOptions {
|
|
9
|
+
ignoreConsolePatterns?: string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Live console / pageerror / crash collector.
|
|
13
|
+
* Messages are tagged per page; {@link snapshot} filters to the selected page.
|
|
14
|
+
* Each verify call windows "new" errors via {@link markVerifyStart}.
|
|
15
|
+
*/
|
|
16
|
+
export declare class ConsoleCollector {
|
|
17
|
+
private readonly ignore;
|
|
18
|
+
private readonly errors;
|
|
19
|
+
private ignored;
|
|
20
|
+
private rendererCrashed;
|
|
21
|
+
/** Index into `errors` at last markVerifyStart (session-wide; filtering is per-page). */
|
|
22
|
+
private verifyMarker;
|
|
23
|
+
private attachedPages;
|
|
24
|
+
private errorBufferInstalled;
|
|
25
|
+
private pageIds;
|
|
26
|
+
private nextPageId;
|
|
27
|
+
private mainHooksInstalled;
|
|
28
|
+
constructor(opts?: ConsoleCollectorOptions);
|
|
29
|
+
pageId(page: Page): number;
|
|
30
|
+
/** Attach page-level listeners. Safe to call for every new window. */
|
|
31
|
+
attachPage(page: Page): void;
|
|
32
|
+
/**
|
|
33
|
+
* Install a persistent renderer error buffer via addInitScript.
|
|
34
|
+
* Only installs once per page (addInitScript stacks if called repeatedly).
|
|
35
|
+
*/
|
|
36
|
+
installErrorBuffer(page: Page): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Read window.__evErrors into the collector, then clear.
|
|
39
|
+
* Dedupes PER-VERIFY (against messages since the marker for this page only),
|
|
40
|
+
* so a recurring error is not swallowed on later verifies, and the same throw
|
|
41
|
+
* is not double-counted from pageerror + buffer.
|
|
42
|
+
*/
|
|
43
|
+
drainBufferedErrors(page: Page): Promise<number>;
|
|
44
|
+
/**
|
|
45
|
+
* Install main-process `render-process-gone` hooks tagged by webContents id.
|
|
46
|
+
* Also listens for newly created windows on the ElectronApplication.
|
|
47
|
+
*/
|
|
48
|
+
attachApp(app: ElectronApplication): Promise<void>;
|
|
49
|
+
/** Call at the start of each verify so newErrors is a delta. */
|
|
50
|
+
markVerifyStart(): void;
|
|
51
|
+
/**
|
|
52
|
+
* Pull main-process crash records for the selected page's webContents only.
|
|
53
|
+
*/
|
|
54
|
+
pollMainCrashes(app: ElectronApplication, selectedPage?: Page): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Snapshot errors for the selected page only.
|
|
57
|
+
* When `page` is omitted, includes all pages (legacy / tests).
|
|
58
|
+
*/
|
|
59
|
+
snapshot(page?: Page): ConsoleSignal;
|
|
60
|
+
private record;
|
|
61
|
+
private shouldIgnore;
|
|
62
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default ignore-list for known-benign Electron / Chromium console noise.
|
|
3
|
+
* Extend via `ignoreConsolePatterns` (substring or regex source strings).
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULT_IGNORE_PATTERNS = [
|
|
6
|
+
// DevTools Autofill protocol (Chromium noise when DevTools domains missing)
|
|
7
|
+
"Autofill.enable",
|
|
8
|
+
"Autofill.setAddresses",
|
|
9
|
+
"Request Autofill",
|
|
10
|
+
// Electron security / deprecation warnings (not app bugs)
|
|
11
|
+
"Electron Security Warning",
|
|
12
|
+
"Insecure Content-Security-Policy",
|
|
13
|
+
// Chromium GPU / disk cache noise
|
|
14
|
+
"GPU process",
|
|
15
|
+
"gpu_process",
|
|
16
|
+
"disk_cache",
|
|
17
|
+
"DiskCache",
|
|
18
|
+
"Failed to create GLES",
|
|
19
|
+
"GPU stall",
|
|
20
|
+
"SharedImageManager",
|
|
21
|
+
// Common harmless preload / favicon noise
|
|
22
|
+
"was preloaded using link preload but not used",
|
|
23
|
+
"Failed to load resource: the server responded with a status of 404", // favicon etc.; resources signal covers real failures
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Live console / pageerror / crash collector.
|
|
27
|
+
* Messages are tagged per page; {@link snapshot} filters to the selected page.
|
|
28
|
+
* Each verify call windows "new" errors via {@link markVerifyStart}.
|
|
29
|
+
*/
|
|
30
|
+
export class ConsoleCollector {
|
|
31
|
+
ignore;
|
|
32
|
+
errors = [];
|
|
33
|
+
ignored = 0;
|
|
34
|
+
rendererCrashed = false;
|
|
35
|
+
/** Index into `errors` at last markVerifyStart (session-wide; filtering is per-page). */
|
|
36
|
+
verifyMarker = 0;
|
|
37
|
+
attachedPages = new WeakSet();
|
|
38
|
+
errorBufferInstalled = new WeakSet();
|
|
39
|
+
pageIds = new WeakMap();
|
|
40
|
+
nextPageId = 1;
|
|
41
|
+
mainHooksInstalled = false;
|
|
42
|
+
constructor(opts = {}) {
|
|
43
|
+
const patterns = [
|
|
44
|
+
...DEFAULT_IGNORE_PATTERNS,
|
|
45
|
+
...(opts.ignoreConsolePatterns ?? []),
|
|
46
|
+
];
|
|
47
|
+
this.ignore = patterns.map((p) => {
|
|
48
|
+
try {
|
|
49
|
+
return new RegExp(p, "i");
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return new RegExp(escapeRegExp(p), "i");
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
pageId(page) {
|
|
57
|
+
let id = this.pageIds.get(page);
|
|
58
|
+
if (id === undefined) {
|
|
59
|
+
id = this.nextPageId++;
|
|
60
|
+
this.pageIds.set(page, id);
|
|
61
|
+
}
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
64
|
+
/** Attach page-level listeners. Safe to call for every new window. */
|
|
65
|
+
attachPage(page) {
|
|
66
|
+
if (this.attachedPages.has(page))
|
|
67
|
+
return;
|
|
68
|
+
this.attachedPages.add(page);
|
|
69
|
+
const pid = this.pageId(page);
|
|
70
|
+
// Fire-and-forget install; verify() awaits installErrorBuffer for deterministic capture.
|
|
71
|
+
void this.installErrorBuffer(page);
|
|
72
|
+
page.on("console", (msg) => {
|
|
73
|
+
if (msg.type() !== "error")
|
|
74
|
+
return;
|
|
75
|
+
this.record("error", msg.text(), pid);
|
|
76
|
+
});
|
|
77
|
+
page.on("pageerror", (err) => {
|
|
78
|
+
this.record("pageerror", err.message || String(err), pid);
|
|
79
|
+
});
|
|
80
|
+
page.on("crash", () => {
|
|
81
|
+
this.rendererCrashed = true;
|
|
82
|
+
this.record("crash", "Renderer process crashed", pid);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Install a persistent renderer error buffer via addInitScript.
|
|
87
|
+
* Only installs once per page (addInitScript stacks if called repeatedly).
|
|
88
|
+
*/
|
|
89
|
+
async installErrorBuffer(page) {
|
|
90
|
+
if (this.errorBufferInstalled.has(page))
|
|
91
|
+
return;
|
|
92
|
+
this.errorBufferInstalled.add(page);
|
|
93
|
+
await page.addInitScript(() => {
|
|
94
|
+
const w = window;
|
|
95
|
+
w.__evErrors = w.__evErrors ?? [];
|
|
96
|
+
if (window.__evErrorsHooked)
|
|
97
|
+
return;
|
|
98
|
+
window.__evErrorsHooked = true;
|
|
99
|
+
window.addEventListener("error", (e) => {
|
|
100
|
+
const msg = (e.error instanceof Error ? e.error.message : null) ||
|
|
101
|
+
e.message ||
|
|
102
|
+
String(e.error ?? "error");
|
|
103
|
+
w.__evErrors.push(msg);
|
|
104
|
+
});
|
|
105
|
+
window.addEventListener("unhandledrejection", (e) => {
|
|
106
|
+
const reason = e.reason;
|
|
107
|
+
const msg = reason instanceof Error
|
|
108
|
+
? reason.message
|
|
109
|
+
: reason != null
|
|
110
|
+
? String(reason)
|
|
111
|
+
: "unknown";
|
|
112
|
+
// Distinct prefix so drain can tag type=unhandledrejection.
|
|
113
|
+
w.__evErrors.push(`unhandledrejection: ${msg}`);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Read window.__evErrors into the collector, then clear.
|
|
119
|
+
* Dedupes PER-VERIFY (against messages since the marker for this page only),
|
|
120
|
+
* so a recurring error is not swallowed on later verifies, and the same throw
|
|
121
|
+
* is not double-counted from pageerror + buffer.
|
|
122
|
+
*/
|
|
123
|
+
async drainBufferedErrors(page) {
|
|
124
|
+
let buffered = [];
|
|
125
|
+
try {
|
|
126
|
+
buffered = await page.evaluate(() => {
|
|
127
|
+
const w = window;
|
|
128
|
+
const list = Array.isArray(w.__evErrors) ? [...w.__evErrors] : [];
|
|
129
|
+
w.__evErrors = [];
|
|
130
|
+
return list;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
const pid = this.pageId(page);
|
|
137
|
+
const windowTexts = new Set(this.errors
|
|
138
|
+
.slice(this.verifyMarker)
|
|
139
|
+
.filter((m) => m.pageId === pid)
|
|
140
|
+
.map((m) => normalizeErrorText(m.text)));
|
|
141
|
+
let added = 0;
|
|
142
|
+
for (const text of buffered) {
|
|
143
|
+
if (!text)
|
|
144
|
+
continue;
|
|
145
|
+
const isRejection = /^unhandledrejection:\s*/i.test(text);
|
|
146
|
+
const type = isRejection ? "unhandledrejection" : "pageerror";
|
|
147
|
+
// Strip prefix for rejection body; keep full text for storage/display.
|
|
148
|
+
const sliced = text.slice(0, 2000);
|
|
149
|
+
const norm = normalizeErrorText(sliced);
|
|
150
|
+
if (windowTexts.has(norm))
|
|
151
|
+
continue;
|
|
152
|
+
// Also match bare body against "unhandledrejection: body" already recorded.
|
|
153
|
+
const bare = normalizeErrorText(sliced.replace(/^unhandledrejection:\s*/i, ""));
|
|
154
|
+
if (windowTexts.has(bare) || [...windowTexts].some((t) => t.includes(bare) || bare.includes(t))) {
|
|
155
|
+
// Skip only when a close match exists in this verify window.
|
|
156
|
+
if (windowTexts.has(bare) ||
|
|
157
|
+
[...windowTexts].some((t) => t === bare || t.endsWith(bare) || bare.endsWith(t))) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const before = this.errors.length;
|
|
162
|
+
this.record(type, sliced, pid);
|
|
163
|
+
if (this.errors.length > before) {
|
|
164
|
+
windowTexts.add(normalizeErrorText(sliced));
|
|
165
|
+
windowTexts.add(bare);
|
|
166
|
+
added += 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return added;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Install main-process `render-process-gone` hooks tagged by webContents id.
|
|
173
|
+
* Also listens for newly created windows on the ElectronApplication.
|
|
174
|
+
*/
|
|
175
|
+
async attachApp(app) {
|
|
176
|
+
app.on("window", (page) => this.attachPage(page));
|
|
177
|
+
for (const page of app.windows()) {
|
|
178
|
+
this.attachPage(page);
|
|
179
|
+
}
|
|
180
|
+
if (this.mainHooksInstalled)
|
|
181
|
+
return;
|
|
182
|
+
this.mainHooksInstalled = true;
|
|
183
|
+
try {
|
|
184
|
+
await app.evaluate(({ app: electronApp, webContents }) => {
|
|
185
|
+
const g = globalThis;
|
|
186
|
+
g.__evVerifyCrashes = g.__evVerifyCrashes ?? [];
|
|
187
|
+
const track = (contents) => {
|
|
188
|
+
contents.on("render-process-gone", (_event, details) => {
|
|
189
|
+
const d = details;
|
|
190
|
+
g.__evVerifyCrashes.push({
|
|
191
|
+
reason: d?.reason ?? "unknown",
|
|
192
|
+
exitCode: d?.exitCode ?? -1,
|
|
193
|
+
webContentsId: contents.id,
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
electronApp.on("web-contents-created", (_e, contents) => {
|
|
198
|
+
track(contents);
|
|
199
|
+
});
|
|
200
|
+
for (const wc of webContents.getAllWebContents()) {
|
|
201
|
+
track(wc);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// Some Electron/Playwright combos reject evaluate early; page crash still works.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Call at the start of each verify so newErrors is a delta. */
|
|
210
|
+
markVerifyStart() {
|
|
211
|
+
this.verifyMarker = this.errors.length;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Pull main-process crash records for the selected page's webContents only.
|
|
215
|
+
*/
|
|
216
|
+
async pollMainCrashes(app, selectedPage) {
|
|
217
|
+
let selectedWcId = null;
|
|
218
|
+
if (selectedPage) {
|
|
219
|
+
try {
|
|
220
|
+
const url = selectedPage.url();
|
|
221
|
+
selectedWcId = await app.evaluate(({ BrowserWindow, webContents }, pageUrl) => {
|
|
222
|
+
const byUrl = BrowserWindow.getAllWindows().find((w) => w.webContents.getURL() === pageUrl);
|
|
223
|
+
if (byUrl)
|
|
224
|
+
return byUrl.webContents.id;
|
|
225
|
+
// Fallback: match any webContents with this URL (BrowserView case rare here).
|
|
226
|
+
for (const wc of webContents.getAllWebContents()) {
|
|
227
|
+
try {
|
|
228
|
+
if (wc.getURL() === pageUrl)
|
|
229
|
+
return wc.id;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
/* ignore */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}, url);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
selectedWcId = null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const crashes = await app.evaluate(() => {
|
|
244
|
+
const g = globalThis;
|
|
245
|
+
const list = g.__evVerifyCrashes ?? [];
|
|
246
|
+
g.__evVerifyCrashes = [];
|
|
247
|
+
return list;
|
|
248
|
+
});
|
|
249
|
+
for (const c of crashes ?? []) {
|
|
250
|
+
// Scope to selected window when we know its webContents id.
|
|
251
|
+
if (selectedWcId != null && c.webContentsId !== selectedWcId) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
this.rendererCrashed = true;
|
|
255
|
+
const pid = selectedPage ? this.pageId(selectedPage) : 0;
|
|
256
|
+
this.record("crash", `render-process-gone: ${c.reason} (exit ${c.exitCode})`, pid, c.webContentsId);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// App may already be gone.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Snapshot errors for the selected page only.
|
|
265
|
+
* When `page` is omitted, includes all pages (legacy / tests).
|
|
266
|
+
*/
|
|
267
|
+
snapshot(page) {
|
|
268
|
+
const pid = page ? this.pageId(page) : null;
|
|
269
|
+
const forPage = (m) => (pid == null ? true : m.pageId === pid);
|
|
270
|
+
const sessionMsgs = this.errors.filter(forPage);
|
|
271
|
+
const newSlice = this.errors.slice(this.verifyMarker).filter(forPage);
|
|
272
|
+
// Deduplicate within the verify window by normalized text (pageerror + buffer).
|
|
273
|
+
const dedupedNew = dedupeMessages(newSlice);
|
|
274
|
+
const hard = dedupedNew.filter((m) => m.type === "pageerror" || m.type === "crash");
|
|
275
|
+
const consoleErrs = dedupedNew.filter((m) => m.type === "error");
|
|
276
|
+
const rejections = dedupedNew.filter((m) => m.type === "unhandledrejection");
|
|
277
|
+
// Crash on any page still surfaces if it was scoped to selected; page-level crash
|
|
278
|
+
// already sets rendererCrashed only when that page fires — but other pages also
|
|
279
|
+
// set it. Recompute: crashed only if a crash message is in this page's session.
|
|
280
|
+
const pageCrashed = sessionMsgs.some((m) => m.type === "crash") ||
|
|
281
|
+
(page ? false : this.rendererCrashed);
|
|
282
|
+
return {
|
|
283
|
+
newErrors: dedupedNew.length,
|
|
284
|
+
newHardErrors: hard.length,
|
|
285
|
+
newConsoleErrors: consoleErrs.length,
|
|
286
|
+
newUnhandledRejections: rejections.length,
|
|
287
|
+
sessionErrors: sessionMsgs.length,
|
|
288
|
+
ignored: this.ignored,
|
|
289
|
+
messages: sessionMsgs.slice(-50).map(({ type, text, ts }) => ({ type, text, ts })),
|
|
290
|
+
rendererCrashed: pageCrashed,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
record(type, text, pageId, webContentsId) {
|
|
294
|
+
if (this.shouldIgnore(text)) {
|
|
295
|
+
this.ignored += 1;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
this.errors.push({
|
|
299
|
+
type,
|
|
300
|
+
text: text.slice(0, 2000),
|
|
301
|
+
ts: Date.now(),
|
|
302
|
+
pageId,
|
|
303
|
+
webContentsId,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
shouldIgnore(text) {
|
|
307
|
+
return this.ignore.some((re) => re.test(text));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function normalizeErrorText(text) {
|
|
311
|
+
return text
|
|
312
|
+
.replace(/^unhandledrejection:\s*/i, "")
|
|
313
|
+
.replace(/\s+/g, " ")
|
|
314
|
+
.trim()
|
|
315
|
+
.toLowerCase()
|
|
316
|
+
.slice(0, 500);
|
|
317
|
+
}
|
|
318
|
+
function dedupeMessages(msgs) {
|
|
319
|
+
const seen = new Set();
|
|
320
|
+
const out = [];
|
|
321
|
+
for (const m of msgs) {
|
|
322
|
+
const key = `${m.type === "pageerror" || m.type === "unhandledrejection" || m.type === "error" ? "e" : m.type}:${normalizeErrorText(m.text)}`;
|
|
323
|
+
// Collapse pageerror + buffer duplicate of same throw.
|
|
324
|
+
const normKey = normalizeErrorText(m.text);
|
|
325
|
+
if (seen.has(normKey))
|
|
326
|
+
continue;
|
|
327
|
+
seen.add(normKey);
|
|
328
|
+
seen.add(key);
|
|
329
|
+
out.push(m);
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
function escapeRegExp(s) {
|
|
334
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
335
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Page } from "playwright-core";
|
|
2
|
+
import type { LayoutSignal } from "../types.js";
|
|
3
|
+
export interface LayoutOptions {
|
|
4
|
+
/** Selectors that must exist and have non-zero size. Missing → WARN. */
|
|
5
|
+
criticalSelectors?: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Robust layout checks only:
|
|
9
|
+
* - horizontal document overflow (scrollWidth > clientWidth)
|
|
10
|
+
* - criticalSelectors missing or zero-size
|
|
11
|
+
*
|
|
12
|
+
* No generic zero-size / offscreen sweep (too noisy per plan).
|
|
13
|
+
*/
|
|
14
|
+
export declare function detectLayout(page: Page, opts?: LayoutOptions): Promise<LayoutSignal>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robust layout checks only:
|
|
3
|
+
* - horizontal document overflow (scrollWidth > clientWidth)
|
|
4
|
+
* - criticalSelectors missing or zero-size
|
|
5
|
+
*
|
|
6
|
+
* No generic zero-size / offscreen sweep (too noisy per plan).
|
|
7
|
+
*/
|
|
8
|
+
export async function detectLayout(page, opts = {}) {
|
|
9
|
+
const selectors = opts.criticalSelectors ?? [];
|
|
10
|
+
try {
|
|
11
|
+
return await page.evaluate((critical) => {
|
|
12
|
+
const doc = document.documentElement;
|
|
13
|
+
const documentOverflow = !!doc && doc.scrollWidth > doc.clientWidth + 1; // +1 tolerance for subpixel
|
|
14
|
+
const criticalMissing = [];
|
|
15
|
+
for (const sel of critical) {
|
|
16
|
+
const el = document.querySelector(sel);
|
|
17
|
+
if (!el) {
|
|
18
|
+
criticalMissing.push(sel);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const r = el.getBoundingClientRect();
|
|
22
|
+
if (r.width < 1 || r.height < 1) {
|
|
23
|
+
criticalMissing.push(sel);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { documentOverflow, criticalMissing };
|
|
27
|
+
}, selectors);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { documentOverflow: false, criticalMissing: [] };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ElectronApplication } from "playwright-core";
|
|
2
|
+
import type { MainProcessSignal } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Capture main-process stderr into a rolling tail and watch for exit.
|
|
5
|
+
* If the main process throws on boot this is the key diagnosis signal.
|
|
6
|
+
*/
|
|
7
|
+
export declare class MainProcessCollector {
|
|
8
|
+
private chunks;
|
|
9
|
+
private totalLen;
|
|
10
|
+
private exited;
|
|
11
|
+
private exitCode;
|
|
12
|
+
private attached;
|
|
13
|
+
private readonly maxTail;
|
|
14
|
+
constructor(maxTail?: number);
|
|
15
|
+
attach(app: ElectronApplication): void;
|
|
16
|
+
snapshot(): MainProcessSignal;
|
|
17
|
+
}
|