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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +155 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +172 -0
  5. package/dist/core/config.d.ts +74 -0
  6. package/dist/core/config.js +55 -0
  7. package/dist/core/index.d.ts +16 -0
  8. package/dist/core/index.js +15 -0
  9. package/dist/core/interact.d.ts +40 -0
  10. package/dist/core/interact.js +98 -0
  11. package/dist/core/launch.d.ts +29 -0
  12. package/dist/core/launch.js +98 -0
  13. package/dist/core/readiness.d.ts +23 -0
  14. package/dist/core/readiness.js +109 -0
  15. package/dist/core/screenshot.d.ts +30 -0
  16. package/dist/core/screenshot.js +176 -0
  17. package/dist/core/session.d.ts +54 -0
  18. package/dist/core/session.js +148 -0
  19. package/dist/core/signals/blankScreen.d.ts +30 -0
  20. package/dist/core/signals/blankScreen.js +106 -0
  21. package/dist/core/signals/collect.d.ts +21 -0
  22. package/dist/core/signals/collect.js +38 -0
  23. package/dist/core/signals/console.d.ts +62 -0
  24. package/dist/core/signals/console.js +335 -0
  25. package/dist/core/signals/layout.d.ts +14 -0
  26. package/dist/core/signals/layout.js +32 -0
  27. package/dist/core/signals/mainProcess.d.ts +17 -0
  28. package/dist/core/signals/mainProcess.js +61 -0
  29. package/dist/core/signals/resources.d.ts +24 -0
  30. package/dist/core/signals/resources.js +91 -0
  31. package/dist/core/signals/staleBuild.d.ts +18 -0
  32. package/dist/core/signals/staleBuild.js +109 -0
  33. package/dist/core/types.d.ts +80 -0
  34. package/dist/core/types.js +5 -0
  35. package/dist/core/verdict.d.ts +23 -0
  36. package/dist/core/verdict.js +87 -0
  37. package/dist/core/verify.d.ts +52 -0
  38. package/dist/core/verify.js +182 -0
  39. package/dist/core/windowCapture.d.ts +27 -0
  40. package/dist/core/windowCapture.js +236 -0
  41. package/dist/core/windows.d.ts +29 -0
  42. package/dist/core/windows.js +94 -0
  43. package/dist/mcp.d.ts +2 -0
  44. package/dist/mcp.js +578 -0
  45. package/package.json +74 -0
@@ -0,0 +1,61 @@
1
+ const DEFAULT_TAIL = 8_000;
2
+ /**
3
+ * Capture main-process stderr into a rolling tail and watch for exit.
4
+ * If the main process throws on boot this is the key diagnosis signal.
5
+ */
6
+ export class MainProcessCollector {
7
+ chunks = [];
8
+ totalLen = 0;
9
+ exited = false;
10
+ exitCode = null;
11
+ attached = false;
12
+ maxTail;
13
+ constructor(maxTail = DEFAULT_TAIL) {
14
+ this.maxTail = maxTail;
15
+ }
16
+ attach(app) {
17
+ if (this.attached)
18
+ return;
19
+ this.attached = true;
20
+ const proc = app.process();
21
+ if (!proc)
22
+ return;
23
+ const onData = (buf) => {
24
+ const s = typeof buf === "string" ? buf : buf.toString("utf8");
25
+ this.chunks.push(s);
26
+ this.totalLen += s.length;
27
+ // Trim from the front when over budget.
28
+ while (this.totalLen > this.maxTail && this.chunks.length > 1) {
29
+ const dropped = this.chunks.shift();
30
+ this.totalLen -= dropped.length;
31
+ }
32
+ if (this.totalLen > this.maxTail && this.chunks.length === 1) {
33
+ const only = this.chunks[0];
34
+ this.chunks[0] = only.slice(only.length - this.maxTail);
35
+ this.totalLen = this.chunks[0].length;
36
+ }
37
+ };
38
+ proc.stderr?.on("data", onData);
39
+ // Some Electron builds also dump diagnostics to stdout.
40
+ proc.stdout?.on("data", (buf) => {
41
+ // Keep stdout quieter: only store if it looks like an error line.
42
+ const s = typeof buf === "string" ? buf : buf.toString("utf8");
43
+ if (/error|exception|fatal|throw/i.test(s))
44
+ onData(s);
45
+ });
46
+ proc.on("exit", (code, _signal) => {
47
+ this.exited = true;
48
+ this.exitCode = typeof code === "number" ? code : null;
49
+ });
50
+ proc.on("error", (err) => {
51
+ onData(`process error: ${err.message}\n`);
52
+ });
53
+ }
54
+ snapshot() {
55
+ return {
56
+ exited: this.exited,
57
+ exitCode: this.exitCode,
58
+ stderrTail: this.chunks.join("").slice(-this.maxTail),
59
+ };
60
+ }
61
+ }
@@ -0,0 +1,24 @@
1
+ import type { Page } from "playwright-core";
2
+ import type { ResourceSignal } from "../types.js";
3
+ /**
4
+ * Network + image resource collector. Failures are tagged per page and
5
+ * windowed per-verify (same marker pattern as console).
6
+ */
7
+ export declare class ResourcesCollector {
8
+ private failed;
9
+ private attachedPages;
10
+ private pageIds;
11
+ private nextPageId;
12
+ private verifyMarker;
13
+ private readonly maxEntries;
14
+ pageId(page: Page): number;
15
+ attachPage(page: Page): void;
16
+ attachApp(app: {
17
+ on: (ev: "window", cb: (p: Page) => void) => void;
18
+ windows: () => Page[];
19
+ }): void;
20
+ /** Call after each verify so the next pass only sees subsequent failures. */
21
+ markVerifyStart(): void;
22
+ snapshot(page: Page): Promise<ResourceSignal>;
23
+ private push;
24
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Network + image resource collector. Failures are tagged per page and
3
+ * windowed per-verify (same marker pattern as console).
4
+ */
5
+ export class ResourcesCollector {
6
+ failed = [];
7
+ attachedPages = new WeakSet();
8
+ pageIds = new WeakMap();
9
+ nextPageId = 1;
10
+ verifyMarker = 0;
11
+ maxEntries = 100;
12
+ pageId(page) {
13
+ let id = this.pageIds.get(page);
14
+ if (id === undefined) {
15
+ id = this.nextPageId++;
16
+ this.pageIds.set(page, id);
17
+ }
18
+ return id;
19
+ }
20
+ attachPage(page) {
21
+ if (this.attachedPages.has(page))
22
+ return;
23
+ this.attachedPages.add(page);
24
+ const pid = this.pageId(page);
25
+ page.on("response", (res) => {
26
+ const status = res.status();
27
+ if (status >= 400) {
28
+ this.push(`${shortUrl(res.url())} ${status}`, pid);
29
+ }
30
+ });
31
+ page.on("requestfailed", (req) => {
32
+ const failure = req.failure();
33
+ const err = failure?.errorText ?? "requestfailed";
34
+ // Abort is often intentional (navigation); skip pure aborts.
35
+ if (/ERR_ABORTED|net::ERR_ABORTED/i.test(err))
36
+ return;
37
+ this.push(`${shortUrl(req.url())} ${err}`, pid);
38
+ });
39
+ }
40
+ attachApp(app) {
41
+ app.on("window", (page) => this.attachPage(page));
42
+ for (const page of app.windows()) {
43
+ this.attachPage(page);
44
+ }
45
+ }
46
+ /** Call after each verify so the next pass only sees subsequent failures. */
47
+ markVerifyStart() {
48
+ this.verifyMarker = this.failed.length;
49
+ }
50
+ async snapshot(page) {
51
+ let brokenImages = 0;
52
+ try {
53
+ brokenImages = await page.evaluate(() => {
54
+ const imgs = Array.from(document.images ?? []);
55
+ return imgs.filter((img) => {
56
+ return img.complete && img.naturalWidth === 0 && !!img.src;
57
+ }).length;
58
+ });
59
+ }
60
+ catch {
61
+ brokenImages = 0;
62
+ }
63
+ const pid = this.pageId(page);
64
+ const newSlice = this.failed
65
+ .slice(this.verifyMarker)
66
+ .filter((f) => f.pageId === pid)
67
+ .map((f) => f.entry);
68
+ return {
69
+ brokenImages,
70
+ failedRequests: newSlice.slice(-this.maxEntries),
71
+ };
72
+ }
73
+ push(entry, pageId) {
74
+ this.failed.push({ entry: entry.slice(0, 500), pageId });
75
+ if (this.failed.length > this.maxEntries * 2) {
76
+ this.failed = this.failed.slice(-this.maxEntries);
77
+ }
78
+ }
79
+ }
80
+ function shortUrl(url) {
81
+ if (url.startsWith("data:"))
82
+ return "data:…";
83
+ try {
84
+ const u = new URL(url);
85
+ const path = u.pathname.length > 60 ? u.pathname.slice(0, 57) + "…" : u.pathname;
86
+ return `${u.origin}${path}`;
87
+ }
88
+ catch {
89
+ return url.slice(0, 120);
90
+ }
91
+ }
@@ -0,0 +1,18 @@
1
+ import type { StaleBuildSignal } from "../types.js";
2
+ export interface StaleBuildOptions {
3
+ /** Working directory for globs (app cwd). */
4
+ cwd: string;
5
+ /** Epoch ms when the session launched. */
6
+ launchedAt: number;
7
+ /**
8
+ * Source globs relative to cwd. If empty/undefined, skip (stale=false).
9
+ * Supports a minimal subset: recursive **, single-dir *, and directory prefixes.
10
+ */
11
+ srcGlobs?: string[];
12
+ }
13
+ /**
14
+ * Compare newest mtime under configured source globs vs the launch timestamp.
15
+ * If source is newer than launch, the running app may be a stale build.
16
+ * Default: skip (not stale) when globs are not configured.
17
+ */
18
+ export declare function detectStaleBuild(opts: StaleBuildOptions): StaleBuildSignal;
@@ -0,0 +1,109 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Compare newest mtime under configured source globs vs the launch timestamp.
5
+ * If source is newer than launch, the running app may be a stale build.
6
+ * Default: skip (not stale) when globs are not configured.
7
+ */
8
+ export function detectStaleBuild(opts) {
9
+ const globs = opts.srcGlobs ?? [];
10
+ if (globs.length === 0) {
11
+ return { stale: false };
12
+ }
13
+ let newest = 0;
14
+ for (const pattern of globs) {
15
+ for (const file of expandGlob(opts.cwd, pattern)) {
16
+ try {
17
+ const st = fs.statSync(file);
18
+ if (st.isFile() && st.mtimeMs > newest)
19
+ newest = st.mtimeMs;
20
+ }
21
+ catch {
22
+ /* ignore missing */
23
+ }
24
+ }
25
+ }
26
+ if (newest === 0)
27
+ return { stale: false };
28
+ // Small grace: files touched during the same second as launch aren't "stale".
29
+ return { stale: newest > opts.launchedAt + 1_000 };
30
+ }
31
+ /**
32
+ * Minimal glob expander without adding a dependency.
33
+ * Supports literal relative paths, recursive **, and single-dir wildcards (e.g. *.ts).
34
+ */
35
+ function expandGlob(cwd, pattern) {
36
+ const norm = pattern.replace(/\\/g, "/");
37
+ const abs = path.resolve(cwd, norm);
38
+ // Recursive **
39
+ if (norm.includes("**")) {
40
+ const [prefix] = norm.split("**");
41
+ const root = path.resolve(cwd, prefix || ".");
42
+ return walkFiles(root);
43
+ }
44
+ // Single-dir wildcard: foo/*.ts
45
+ if (norm.includes("*")) {
46
+ const dir = path.dirname(abs);
47
+ const filePat = path.basename(norm);
48
+ const re = wildcardToRegExp(filePat);
49
+ try {
50
+ return fs
51
+ .readdirSync(dir)
52
+ .filter((n) => re.test(n))
53
+ .map((n) => path.join(dir, n))
54
+ .filter((p) => {
55
+ try {
56
+ return fs.statSync(p).isFile();
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ });
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ }
67
+ // Literal path (file or directory).
68
+ try {
69
+ const st = fs.statSync(abs);
70
+ if (st.isFile())
71
+ return [abs];
72
+ if (st.isDirectory())
73
+ return walkFiles(abs);
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ return [];
79
+ }
80
+ function walkFiles(root) {
81
+ const out = [];
82
+ const stack = [root];
83
+ while (stack.length) {
84
+ const dir = stack.pop();
85
+ let entries;
86
+ try {
87
+ entries = fs.readdirSync(dir, { withFileTypes: true });
88
+ }
89
+ catch {
90
+ continue;
91
+ }
92
+ for (const e of entries) {
93
+ if (e.name === "node_modules" || e.name === ".git" || e.name === "dist")
94
+ continue;
95
+ const full = path.join(dir, e.name);
96
+ if (e.isDirectory())
97
+ stack.push(full);
98
+ else if (e.isFile())
99
+ out.push(full);
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+ function wildcardToRegExp(pat) {
105
+ const escaped = pat
106
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
107
+ .replace(/\*/g, ".*");
108
+ return new RegExp(`^${escaped}$`, "i");
109
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Shared signal / verdict types for electron-verify-mcp.
3
+ * structuredContent shape (PNG is never embedded here — only in the image content block).
4
+ */
5
+ export type Verdict = "PASS" | "WARN" | "FAIL";
6
+ /** How the human-facing screenshot was produced. */
7
+ export type CaptureModeUsed = "window" | "content" | "content-fallback" | "os-window" | "os-window-fallback:window";
8
+ /** Requested capture mode for screenshot / verify. */
9
+ export type CaptureMode = "content" | "window" | "os-window" | "both";
10
+ export interface ReadinessSignal {
11
+ stable: boolean;
12
+ waitedMs: number;
13
+ timedOut: boolean;
14
+ }
15
+ export interface ConsoleMessage {
16
+ type: string;
17
+ text: string;
18
+ /** Epoch ms when captured. */
19
+ ts: number;
20
+ }
21
+ export interface ConsoleSignal {
22
+ /** Non-ignored hard/soft errors since the last verify marker (selected page). */
23
+ newErrors: number;
24
+ /** pageerror / crash count since marker (selected page). */
25
+ newHardErrors: number;
26
+ /** console.error count since marker (selected page). */
27
+ newConsoleErrors: number;
28
+ /** unhandledrejection count since marker (selected page). */
29
+ newUnhandledRejections: number;
30
+ /** Non-ignored errors across the whole session (selected page). */
31
+ sessionErrors: number;
32
+ /** Messages dropped by the ignore-list. */
33
+ ignored: number;
34
+ /** Non-ignored error / pageerror messages (recent, selected page). */
35
+ messages: ConsoleMessage[];
36
+ rendererCrashed: boolean;
37
+ }
38
+ export interface MainProcessSignal {
39
+ exited: boolean;
40
+ exitCode: number | null;
41
+ stderrTail: string;
42
+ }
43
+ export interface BlankScreenSignal {
44
+ isBlank: boolean;
45
+ reason: string | null;
46
+ }
47
+ export interface LayoutSignal {
48
+ documentOverflow: boolean;
49
+ criticalMissing: string[];
50
+ }
51
+ export interface ResourceSignal {
52
+ brokenImages: number;
53
+ failedRequests: string[];
54
+ }
55
+ export interface StaleBuildSignal {
56
+ stale: boolean;
57
+ }
58
+ export interface Signals {
59
+ readiness: ReadinessSignal;
60
+ console: ConsoleSignal;
61
+ mainProcess: MainProcessSignal;
62
+ blankScreen: BlankScreenSignal;
63
+ layout: LayoutSignal;
64
+ resources: ResourceSignal;
65
+ staleBuild: StaleBuildSignal;
66
+ }
67
+ export interface VerdictResult {
68
+ verdict: Verdict;
69
+ hardFails: string[];
70
+ warns: string[];
71
+ signals: Signals;
72
+ /** Echo of the caller's intent string, if provided. */
73
+ expect?: string;
74
+ }
75
+ export interface HoleRect {
76
+ x: number;
77
+ y: number;
78
+ width: number;
79
+ height: number;
80
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Shared signal / verdict types for electron-verify-mcp.
3
+ * structuredContent shape (PNG is never embedded here — only in the image content block).
4
+ */
5
+ export {};
@@ -0,0 +1,23 @@
1
+ import type { Signals, VerdictResult } from "./types.js";
2
+ export interface VerdictOptions {
3
+ expect?: string;
4
+ /**
5
+ * When true, bare console.error (type=error) is a hard-FAIL.
6
+ * Default false: console.error → WARN; pageerror/crash remain FAIL.
7
+ */
8
+ strictConsole?: boolean;
9
+ }
10
+ /**
11
+ * Map collected signals → PASS | WARN | FAIL.
12
+ *
13
+ * hard-FAIL (high precision only):
14
+ * rendererCrashed · uncaught pageerror · mainProcess.exited
15
+ * · blankScreen.isBlank
16
+ * · console.error when strictConsole
17
+ *
18
+ * WARN:
19
+ * bare console.error (default) · unhandledrejection
20
+ * · console.staleErrors (session still has prior errors)
21
+ * · layout / resources / staleBuild / readiness
22
+ */
23
+ export declare function computeVerdict(signals: Signals, expectOrOpts?: string | VerdictOptions): VerdictResult;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Map collected signals → PASS | WARN | FAIL.
3
+ *
4
+ * hard-FAIL (high precision only):
5
+ * rendererCrashed · uncaught pageerror · mainProcess.exited
6
+ * · blankScreen.isBlank
7
+ * · console.error when strictConsole
8
+ *
9
+ * WARN:
10
+ * bare console.error (default) · unhandledrejection
11
+ * · console.staleErrors (session still has prior errors)
12
+ * · layout / resources / staleBuild / readiness
13
+ */
14
+ export function computeVerdict(signals, expectOrOpts) {
15
+ const opts = typeof expectOrOpts === "string"
16
+ ? { expect: expectOrOpts }
17
+ : (expectOrOpts ?? {});
18
+ const expect = opts.expect;
19
+ const strictConsole = opts.strictConsole === true;
20
+ const hardFails = [];
21
+ const warns = [];
22
+ if (signals.console.rendererCrashed) {
23
+ hardFails.push("rendererCrashed");
24
+ }
25
+ // Uncaught exceptions in the verify window → hard-FAIL.
26
+ // newHardErrors covers pageerror (+ crash); crash also sets rendererCrashed.
27
+ if ((signals.console.newHardErrors ?? 0) > 0) {
28
+ hardFails.push("pageerror");
29
+ }
30
+ if (signals.mainProcess.exited) {
31
+ hardFails.push("mainProcess.exited");
32
+ }
33
+ // Bare console.error: WARN by default; FAIL when strictConsole.
34
+ if ((signals.console.newConsoleErrors ?? 0) > 0) {
35
+ if (strictConsole) {
36
+ hardFails.push("console.newErrors");
37
+ }
38
+ else {
39
+ warns.push("console.errors");
40
+ }
41
+ }
42
+ // unhandledrejection → WARN (distinct from pageerror).
43
+ if ((signals.console.newUnhandledRejections ?? 0) > 0) {
44
+ warns.push("console.unhandledrejection");
45
+ }
46
+ if (signals.blankScreen.isBlank) {
47
+ hardFails.push("blankScreen.isBlank");
48
+ }
49
+ // Persistent state: prior errors still exist even if this window is clean.
50
+ if (signals.console.sessionErrors > signals.console.newErrors) {
51
+ warns.push("console.staleErrors");
52
+ }
53
+ if (signals.layout.documentOverflow) {
54
+ warns.push("layout.documentOverflow");
55
+ }
56
+ if (signals.layout.criticalMissing.length > 0) {
57
+ warns.push("layout.criticalMissing");
58
+ }
59
+ if (signals.resources.brokenImages > 0) {
60
+ warns.push("resources.brokenImages");
61
+ }
62
+ if (signals.resources.failedRequests.length > 0) {
63
+ warns.push("resources.failedRequests");
64
+ }
65
+ if (signals.staleBuild.stale) {
66
+ warns.push("staleBuild.stale");
67
+ }
68
+ if (signals.readiness.timedOut) {
69
+ warns.push("readiness.timedOut");
70
+ }
71
+ const hard = [...new Set(hardFails)];
72
+ const soft = [...new Set(warns)];
73
+ let verdict;
74
+ if (hard.length > 0)
75
+ verdict = "FAIL";
76
+ else if (soft.length > 0)
77
+ verdict = "WARN";
78
+ else
79
+ verdict = "PASS";
80
+ return {
81
+ verdict,
82
+ hardFails: hard,
83
+ warns: soft,
84
+ signals,
85
+ ...(expect !== undefined ? { expect } : {}),
86
+ };
87
+ }
@@ -0,0 +1,52 @@
1
+ import type { AppSession } from "./session.js";
2
+ import type { CaptureMode, CaptureModeUsed, HoleRect, VerdictResult } from "./types.js";
3
+ export interface VerifyOptions {
4
+ expect?: string;
5
+ windowUrlMatch?: string;
6
+ criticalSelectors?: string[];
7
+ srcGlobs?: string[];
8
+ readinessTimeoutMs?: number;
9
+ maxScreenshotWidth?: number;
10
+ blankCoverage?: number;
11
+ changeThreshold?: number;
12
+ strictConsole?: boolean;
13
+ /**
14
+ * When true, install the persistent renderer error buffer and reload so
15
+ * load-time sync throws are captured. When omitted:
16
+ * - if no interaction since last verify → true (re-check unchanged app)
17
+ * - if interaction happened → false (preserve click/type state)
18
+ */
19
+ freshLoad?: boolean;
20
+ /**
21
+ * Human-facing capture: "window" (default) | "content" | "os-window" | "both".
22
+ * Signal analysis ALWAYS uses content (page.screenshot).
23
+ */
24
+ captureMode?: CaptureMode;
25
+ }
26
+ export interface VerifyResult {
27
+ verdict: VerdictResult["verdict"];
28
+ hardFails: string[];
29
+ warns: string[];
30
+ signals: VerdictResult["signals"];
31
+ expect?: string;
32
+ /** Primary human-facing screenshot (window or content-fallback or content). */
33
+ screenshot: Buffer;
34
+ /** Optional second image when captureMode === "both" (window capture). */
35
+ windowScreenshot?: Buffer;
36
+ /** Content-only annotated image when captureMode === "both". */
37
+ contentScreenshot?: Buffer;
38
+ captureMode: CaptureModeUsed;
39
+ captureNote?: string;
40
+ /** Window selection diagnostics. */
41
+ windowStrategy?: string;
42
+ windowWarning?: string;
43
+ holes: HoleRect[];
44
+ }
45
+ /**
46
+ * Orchestrate one verification pass:
47
+ * select window → (optional freshLoad) → readiness → drain error buffer →
48
+ * capture content (for signals) + optional window capture → collect → verdict.
49
+ */
50
+ export declare function verify(session: AppSession, opts?: VerifyOptions): Promise<VerifyResult>;
51
+ /** Multiply DIP hole rects by devicePixelRatio for physical-pixel screenshot space. */
52
+ export declare function scaleHolesToPhysical(holes: HoleRect[], dpr: number): HoleRect[];