eyeballs-cli 0.1.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dane Hesseldahl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # eyeballs
2
+
3
+ Visual monitoring for AI agents and humans. Take screenshots, detect visual changes, track what matters.
4
+
5
+ Ships as both a **CLI tool** and an **MCP server** in one package.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g eyeballs
11
+ ```
12
+
13
+ This installs Chromium automatically (~400MB on first install).
14
+
15
+ ## CLI Usage
16
+
17
+ ```bash
18
+ # Take a screenshot
19
+ eyeballs screenshot https://example.com
20
+ eyeballs screenshot https://example.com --viewport 1440x900 -o homepage.png
21
+
22
+ # Check for visual changes (captures baseline on first run)
23
+ eyeballs check https://example.com
24
+ eyeballs check https://example.com --threshold 10 --region 0,100,1280,500
25
+
26
+ # Re-check (diffs against baseline)
27
+ eyeballs check https://example.com
28
+
29
+ # Accept current state as new baseline
30
+ eyeballs check https://example.com --reset
31
+
32
+ # List watched URLs
33
+ eyeballs list
34
+
35
+ # Remove a watch
36
+ eyeballs remove <id>
37
+ ```
38
+
39
+ ## MCP Server
40
+
41
+ eyeballs works as an MCP server for AI agents (Claude Desktop, Claude Code, Cursor, etc.).
42
+
43
+ ### Claude Desktop
44
+
45
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
46
+
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "eyeballs": {
51
+ "command": "eyeballs-mcp"
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ ### Claude Code
58
+
59
+ ```bash
60
+ claude mcp add eyeballs eyeballs-mcp
61
+ ```
62
+
63
+ ### Tools
64
+
65
+ | Tool | Description |
66
+ |------|-------------|
67
+ | `screenshot` | Take a screenshot of a URL. Returns the image directly. |
68
+ | `check_url` | Check a URL for visual changes against a stored baseline. |
69
+ | `list_watches` | List all monitored URLs and their status. |
70
+ | `remove_watch` | Remove a watch and its screenshots. |
71
+
72
+ ### Example: screenshot
73
+
74
+ ```
75
+ screenshot({ url: "https://example.com", viewport: { width: 1440, height: 900 } })
76
+ ```
77
+
78
+ Returns the screenshot as an image the agent can see, plus metadata (dimensions, load time).
79
+
80
+ ### Example: check_url
81
+
82
+ ```
83
+ check_url({ url: "https://example.com", threshold: 5, region: { x: 0, y: 100, width: 1280, height: 500 } })
84
+ ```
85
+
86
+ First call captures a baseline. Subsequent calls diff against it and report the percentage of pixels that changed. Use `reset_baseline: true` to accept the current state.
87
+
88
+ ## How It Works
89
+
90
+ - **Screenshots** via Playwright (headless Chromium)
91
+ - **Diffing** via pixelmatch (deterministic pixel comparison, no AI needed)
92
+ - **Storage** at `~/.eyeballs/` (baselines + screenshots as PNG files)
93
+ - **Threshold** default 5% — configurable per watch to reduce noise from dynamic content
94
+ - **Region crop** — monitor just the part of the page you care about
95
+
96
+ ## Requirements
97
+
98
+ - Node.js 18+
99
+ - ~400MB disk for Chromium (installed automatically)
100
+
101
+ ## License
102
+
103
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import { exec } from 'child_process';
6
+ import { platform } from 'os';
7
+ import { capture, checkUrl, listWatches, removeWatch, shutdownBrowser, EyeballsError, } from './core.js';
8
+ import { writeFileSync } from 'fs';
9
+ import { join } from 'path';
10
+ const program = new Command();
11
+ program
12
+ .name('eyeballs')
13
+ .description('Visual monitoring for AI agents and humans')
14
+ .version('0.1.0');
15
+ // --- screenshot ---
16
+ program
17
+ .command('screenshot')
18
+ .description('Take a screenshot of a URL')
19
+ .argument('<url>', 'URL to screenshot')
20
+ .option('--viewport <size>', 'Viewport size (e.g., 1280x720)', '1280x720')
21
+ .option('-o, --output <path>', 'Output file path')
22
+ .option('--no-open', 'Do not open the screenshot after saving')
23
+ .action(async (url, opts) => {
24
+ const spinner = ora('Capturing screenshot...').start();
25
+ try {
26
+ const [w, h] = opts.viewport.split('x').map(Number);
27
+ const viewport = { width: w || 1280, height: h || 720 };
28
+ const result = await capture(url, viewport);
29
+ const output = opts.output || `screenshot-${Date.now()}.png`;
30
+ writeFileSync(output, result.buffer);
31
+ spinner.succeed(`Screenshot saved: ${chalk.cyan(output)} (${result.width}x${result.height}, ${result.loadTimeMs}ms)`);
32
+ if (opts.open) {
33
+ openFile(output);
34
+ }
35
+ }
36
+ catch (err) {
37
+ handleError(spinner, err);
38
+ }
39
+ finally {
40
+ await shutdownBrowser();
41
+ }
42
+ });
43
+ // --- check ---
44
+ program
45
+ .command('check')
46
+ .description('Check a URL for visual changes')
47
+ .argument('<url>', 'URL to check')
48
+ .option('--viewport <size>', 'Viewport size (e.g., 1280x720)', '1280x720')
49
+ .option('--threshold <percent>', 'Diff threshold percentage', '5')
50
+ .option('--region <coords>', 'Crop region: x,y,width,height')
51
+ .option('--reset', 'Reset baseline to current state')
52
+ .action(async (url, opts) => {
53
+ const spinner = ora('Checking URL...').start();
54
+ try {
55
+ const [w, h] = opts.viewport.split('x').map(Number);
56
+ const viewport = { width: w || 1280, height: h || 720 };
57
+ const threshold = parseFloat(opts.threshold);
58
+ let region = undefined;
59
+ if (opts.region) {
60
+ const [x, y, rw, rh] = opts.region.split(',').map(Number);
61
+ region = { x, y, width: rw, height: rh };
62
+ }
63
+ const result = await checkUrl({
64
+ url,
65
+ viewport,
66
+ threshold,
67
+ region,
68
+ resetBaseline: opts.reset,
69
+ });
70
+ if (result.message === 'Baseline captured') {
71
+ spinner.succeed(`Baseline captured for ${chalk.cyan(url)} (watch ID: ${chalk.yellow(result.watch.id)})`);
72
+ }
73
+ else if (result.message === 'Baseline updated') {
74
+ spinner.succeed(`Baseline updated for ${chalk.cyan(url)}`);
75
+ }
76
+ else if (result.changed) {
77
+ spinner.warn(`${chalk.red('CHANGED')}: ${chalk.cyan(url)} — ${chalk.yellow(result.diffPercent + '%')} pixels differ (threshold: ${result.threshold}%)`);
78
+ }
79
+ else {
80
+ spinner.succeed(`${chalk.green('No change')}: ${chalk.cyan(url)} — ${chalk.dim(result.diffPercent + '%')} pixels differ`);
81
+ }
82
+ }
83
+ catch (err) {
84
+ handleError(spinner, err);
85
+ }
86
+ finally {
87
+ await shutdownBrowser();
88
+ }
89
+ });
90
+ // --- list ---
91
+ program
92
+ .command('list')
93
+ .description('List watched URLs')
94
+ .action(() => {
95
+ const watches = listWatches();
96
+ if (watches.length === 0) {
97
+ console.log(chalk.dim('No watches. Run `eyeballs check <url>` to start monitoring.'));
98
+ return;
99
+ }
100
+ console.log(chalk.bold(`\n${watches.length} watch${watches.length === 1 ? '' : 'es'}:\n`));
101
+ for (const w of watches) {
102
+ const diffColor = w.lastDiffPercent > (w.config.threshold || 5) ? chalk.red : chalk.green;
103
+ console.log(` ${chalk.yellow(w.id)} ${chalk.cyan(w.url)}`);
104
+ console.log(` ${chalk.dim('viewport:')} ${w.viewport.width}x${w.viewport.height} ${chalk.dim('threshold:')} ${w.config.threshold}% ${chalk.dim('last diff:')} ${diffColor(w.lastDiffPercent + '%')}`);
105
+ if (w.lastCheckAt) {
106
+ console.log(` ${chalk.dim('last check:')} ${w.lastCheckAt}`);
107
+ }
108
+ console.log();
109
+ }
110
+ });
111
+ // --- remove ---
112
+ program
113
+ .command('remove')
114
+ .description('Remove a watch and its screenshots')
115
+ .argument('<id>', 'Watch ID to remove')
116
+ .action((id) => {
117
+ try {
118
+ removeWatch(id);
119
+ console.log(chalk.green(`Watch ${id} removed.`));
120
+ }
121
+ catch (err) {
122
+ if (err instanceof EyeballsError) {
123
+ console.error(chalk.red(`${err.code}: ${err.message}`));
124
+ process.exit(1);
125
+ }
126
+ throw err;
127
+ }
128
+ });
129
+ // --- helpers ---
130
+ function handleError(spinner, err) {
131
+ if (err instanceof EyeballsError) {
132
+ spinner.fail(chalk.red(`${err.code}: ${err.message}`));
133
+ }
134
+ else {
135
+ spinner.fail(chalk.red(err.message));
136
+ }
137
+ process.exit(1);
138
+ }
139
+ function openFile(path) {
140
+ const cmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open';
141
+ exec(`${cmd} ${JSON.stringify(join(process.cwd(), path))}`);
142
+ }
143
+ program.parse();
144
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
package/dist/core.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ export interface Viewport {
2
+ width: number;
3
+ height: number;
4
+ }
5
+ export interface Region {
6
+ x: number;
7
+ y: number;
8
+ width: number;
9
+ height: number;
10
+ }
11
+ export interface CaptureResult {
12
+ buffer: Buffer;
13
+ width: number;
14
+ height: number;
15
+ loadTimeMs: number;
16
+ }
17
+ export interface DiffResult {
18
+ changed: boolean;
19
+ diffPercent: number;
20
+ threshold: number;
21
+ diffBuffer?: Buffer;
22
+ }
23
+ export interface Watch {
24
+ id: string;
25
+ url: string;
26
+ viewport: Viewport;
27
+ mode: 'pixel';
28
+ config: {
29
+ threshold: number;
30
+ region: Region | null;
31
+ };
32
+ baselinePath: string;
33
+ createdAt: string;
34
+ lastCheckAt: string | null;
35
+ lastDiffPercent: number;
36
+ }
37
+ export interface WatchesFile {
38
+ watches: Watch[];
39
+ }
40
+ export interface CheckResult {
41
+ changed: boolean;
42
+ diffPercent: number;
43
+ threshold: number;
44
+ message: string;
45
+ screenshotBuffer: Buffer;
46
+ diffBuffer?: Buffer;
47
+ watch: Watch;
48
+ }
49
+ export type EyeballsErrorCode = 'TIMEOUT' | 'LOAD_FAILED' | 'INVALID_URL' | 'BROWSER_NOT_INSTALLED' | 'BROWSER_VERSION_MISMATCH' | 'BROWSER_LAUNCH_FAILED' | 'STORAGE_FAILED' | 'NOT_FOUND';
50
+ export declare class EyeballsError extends Error {
51
+ code: EyeballsErrorCode;
52
+ constructor(code: EyeballsErrorCode, message: string);
53
+ }
54
+ export declare function loadWatches(): WatchesFile;
55
+ export declare function shutdownBrowser(): Promise<void>;
56
+ export declare function capture(url: string, viewport?: Viewport): Promise<CaptureResult>;
57
+ export declare function diff(baseline: Buffer, current: Buffer, threshold?: number, region?: Region | null): DiffResult;
58
+ export declare function checkUrl(options: {
59
+ url: string;
60
+ viewport?: Viewport;
61
+ threshold?: number;
62
+ region?: Region | null;
63
+ resetBaseline?: boolean;
64
+ }): Promise<CheckResult>;
65
+ export declare function listWatches(): Watch[];
66
+ export declare function removeWatch(id: string): void;
67
+ //# sourceMappingURL=core.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,MAAM;IACrB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,KAAK;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,QAAQ,CAAC;IACnB,IAAI,EAAE,OAAO,CAAC;IACd,MAAM,EAAE;QACN,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;KACvB,CAAC;IACF,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,KAAK,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,KAAK,CAAC;CACd;AAID,MAAM,MAAM,iBAAiB,GACzB,SAAS,GACT,aAAa,GACb,aAAa,GACb,uBAAuB,GACvB,0BAA0B,GAC1B,uBAAuB,GACvB,gBAAgB,GAChB,WAAW,CAAC;AAEhB,qBAAa,aAAc,SAAQ,KAAK;IAE7B,IAAI,EAAE,iBAAiB;gBAAvB,IAAI,EAAE,iBAAiB,EAC9B,OAAO,EAAE,MAAM;CAKlB;AAiBD,wBAAgB,WAAW,IAAI,WAAW,CAWzC;AAoDD,wBAAsB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC,CAKrD;AA8BD,wBAAsB,OAAO,CAC3B,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,QAAuC,GAChD,OAAO,CAAC,aAAa,CAAC,CA4CxB;AAID,wBAAgB,IAAI,CAClB,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,SAAS,GAAE,MAAU,EACrB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,GACrB,UAAU,CA0CZ;AAmBD,wBAAsB,QAAQ,CAAC,OAAO,EAAE;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,GAAG,OAAO,CAAC,WAAW,CAAC,CAoFvB;AAID,wBAAgB,WAAW,IAAI,KAAK,EAAE,CAErC;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAsB5C"}
package/dist/core.js ADDED
@@ -0,0 +1,301 @@
1
+ import { chromium } from 'playwright';
2
+ import pixelmatch from 'pixelmatch';
3
+ import { PNG } from 'pngjs';
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, readdirSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { homedir } from 'os';
7
+ import { randomUUID } from 'crypto';
8
+ export class EyeballsError extends Error {
9
+ code;
10
+ constructor(code, message) {
11
+ super(message);
12
+ this.code = code;
13
+ this.name = 'EyeballsError';
14
+ }
15
+ }
16
+ // --- Storage ---
17
+ const EYEBALLS_DIR = join(homedir(), '.eyeballs');
18
+ const SCREENSHOTS_DIR = join(EYEBALLS_DIR, 'screenshots');
19
+ const WATCHES_FILE = join(EYEBALLS_DIR, 'watches.json');
20
+ function ensureDirs() {
21
+ try {
22
+ mkdirSync(EYEBALLS_DIR, { recursive: true });
23
+ mkdirSync(SCREENSHOTS_DIR, { recursive: true });
24
+ }
25
+ catch {
26
+ throw new EyeballsError('STORAGE_FAILED', 'Could not create ~/.eyeballs directory');
27
+ }
28
+ }
29
+ export function loadWatches() {
30
+ ensureDirs();
31
+ if (!existsSync(WATCHES_FILE)) {
32
+ return { watches: [] };
33
+ }
34
+ try {
35
+ const raw = readFileSync(WATCHES_FILE, 'utf-8');
36
+ return JSON.parse(raw);
37
+ }
38
+ catch {
39
+ return { watches: [] };
40
+ }
41
+ }
42
+ function saveWatches(data) {
43
+ ensureDirs();
44
+ try {
45
+ writeFileSync(WATCHES_FILE, JSON.stringify(data, null, 2));
46
+ }
47
+ catch {
48
+ throw new EyeballsError('STORAGE_FAILED', 'Could not write watches.json');
49
+ }
50
+ }
51
+ function saveScreenshot(id, suffix, buffer) {
52
+ ensureDirs();
53
+ const filename = `${id}-${suffix}.png`;
54
+ const filepath = join(SCREENSHOTS_DIR, filename);
55
+ try {
56
+ writeFileSync(filepath, buffer);
57
+ }
58
+ catch {
59
+ throw new EyeballsError('STORAGE_FAILED', 'Could not write screenshot');
60
+ }
61
+ return filepath;
62
+ }
63
+ // --- Browser ---
64
+ let browser = null;
65
+ async function getBrowser() {
66
+ if (browser && browser.isConnected()) {
67
+ return browser;
68
+ }
69
+ try {
70
+ browser = await chromium.launch();
71
+ return browser;
72
+ }
73
+ catch (err) {
74
+ const msg = err instanceof Error ? err.message : String(err);
75
+ if (msg.includes('Executable doesn\'t exist') || msg.includes('browserType.launch')) {
76
+ if (msg.includes('Chromium') && msg.includes('revision')) {
77
+ throw new EyeballsError('BROWSER_VERSION_MISMATCH', 'Chromium version mismatch. Run: npx playwright install chromium');
78
+ }
79
+ throw new EyeballsError('BROWSER_NOT_INSTALLED', 'Run: npx playwright install chromium');
80
+ }
81
+ throw new EyeballsError('BROWSER_LAUNCH_FAILED', `Chromium cannot start: ${msg}`);
82
+ }
83
+ }
84
+ export async function shutdownBrowser() {
85
+ if (browser) {
86
+ await browser.close().catch(() => { });
87
+ browser = null;
88
+ }
89
+ }
90
+ // Cleanup on exit
91
+ process.on('exit', () => {
92
+ browser?.close().catch(() => { });
93
+ });
94
+ process.on('SIGINT', async () => {
95
+ await shutdownBrowser();
96
+ process.exit(0);
97
+ });
98
+ process.on('SIGTERM', async () => {
99
+ await shutdownBrowser();
100
+ process.exit(0);
101
+ });
102
+ // --- URL validation ---
103
+ function validateUrl(url) {
104
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
105
+ throw new EyeballsError('INVALID_URL', 'URL must start with http:// or https://');
106
+ }
107
+ try {
108
+ new URL(url);
109
+ }
110
+ catch {
111
+ throw new EyeballsError('INVALID_URL', `Invalid URL: ${url}`);
112
+ }
113
+ }
114
+ // --- Capture ---
115
+ export async function capture(url, viewport = { width: 1280, height: 720 }) {
116
+ validateUrl(url);
117
+ const b = await getBrowser();
118
+ const context = await b.newContext({ viewport });
119
+ const page = await context.newPage();
120
+ const start = Date.now();
121
+ try {
122
+ const response = await page.goto(url, {
123
+ waitUntil: 'networkidle',
124
+ timeout: 30000,
125
+ });
126
+ if (response) {
127
+ const status = response.status();
128
+ if (status >= 400) {
129
+ throw new EyeballsError('LOAD_FAILED', `Page returned ${status}`);
130
+ }
131
+ }
132
+ // Wait for paint to settle
133
+ await page.waitForTimeout(2000);
134
+ const buffer = await page.screenshot({ type: 'png' });
135
+ const loadTimeMs = Date.now() - start;
136
+ return {
137
+ buffer: Buffer.from(buffer),
138
+ width: viewport.width,
139
+ height: viewport.height,
140
+ loadTimeMs,
141
+ };
142
+ }
143
+ catch (err) {
144
+ if (err instanceof EyeballsError)
145
+ throw err;
146
+ const msg = err instanceof Error ? err.message : String(err);
147
+ if (msg.includes('Timeout') || msg.includes('timeout')) {
148
+ throw new EyeballsError('TIMEOUT', 'Page failed to load within 30s');
149
+ }
150
+ throw new EyeballsError('LOAD_FAILED', `Failed to load page: ${msg}`);
151
+ }
152
+ finally {
153
+ await page.close().catch(() => { });
154
+ await context.close().catch(() => { });
155
+ }
156
+ }
157
+ // --- Diff ---
158
+ export function diff(baseline, current, threshold = 5, region) {
159
+ let img1 = PNG.sync.read(baseline);
160
+ let img2 = PNG.sync.read(current);
161
+ // Region crop if specified
162
+ if (region) {
163
+ img1 = cropPng(img1, region);
164
+ img2 = cropPng(img2, region);
165
+ }
166
+ // Ensure same dimensions
167
+ if (img1.width !== img2.width || img1.height !== img2.height) {
168
+ return {
169
+ changed: true,
170
+ diffPercent: 100,
171
+ threshold,
172
+ diffBuffer: undefined,
173
+ };
174
+ }
175
+ const { width, height } = img1;
176
+ const diffPng = new PNG({ width, height });
177
+ const numDiffPixels = pixelmatch(img1.data, img2.data, diffPng.data, width, height, { threshold: 0.1 });
178
+ const totalPixels = width * height;
179
+ const diffPercent = (numDiffPixels / totalPixels) * 100;
180
+ const changed = diffPercent > threshold;
181
+ return {
182
+ changed,
183
+ diffPercent: Math.round(diffPercent * 100) / 100,
184
+ threshold,
185
+ diffBuffer: changed ? Buffer.from(PNG.sync.write(diffPng)) : undefined,
186
+ };
187
+ }
188
+ function cropPng(png, region) {
189
+ const cropped = new PNG({ width: region.width, height: region.height });
190
+ for (let y = 0; y < region.height; y++) {
191
+ for (let x = 0; x < region.width; x++) {
192
+ const srcIdx = ((region.y + y) * png.width + (region.x + x)) << 2;
193
+ const dstIdx = (y * region.width + x) << 2;
194
+ cropped.data[dstIdx] = png.data[srcIdx];
195
+ cropped.data[dstIdx + 1] = png.data[srcIdx + 1];
196
+ cropped.data[dstIdx + 2] = png.data[srcIdx + 2];
197
+ cropped.data[dstIdx + 3] = png.data[srcIdx + 3];
198
+ }
199
+ }
200
+ return cropped;
201
+ }
202
+ // --- Check URL (baseline + diff) ---
203
+ export async function checkUrl(options) {
204
+ const { url, viewport = { width: 1280, height: 720 }, threshold = 5, region = null, resetBaseline = false } = options;
205
+ const data = loadWatches();
206
+ let watch = data.watches.find((w) => w.url === url);
207
+ // Capture current screenshot
208
+ const result = await capture(url, viewport);
209
+ // Reset baseline
210
+ if (watch && resetBaseline) {
211
+ const baselinePath = saveScreenshot(watch.id, 'baseline', result.buffer);
212
+ watch.baselinePath = baselinePath;
213
+ watch.viewport = viewport;
214
+ watch.config = { threshold, region };
215
+ watch.lastCheckAt = new Date().toISOString();
216
+ watch.lastDiffPercent = 0;
217
+ saveWatches(data);
218
+ return {
219
+ changed: false,
220
+ diffPercent: 0,
221
+ threshold,
222
+ message: 'Baseline updated',
223
+ screenshotBuffer: result.buffer,
224
+ watch,
225
+ };
226
+ }
227
+ // No baseline yet, store it
228
+ if (!watch) {
229
+ const id = randomUUID().slice(0, 8);
230
+ const baselinePath = saveScreenshot(id, 'baseline', result.buffer);
231
+ watch = {
232
+ id,
233
+ url,
234
+ viewport,
235
+ mode: 'pixel',
236
+ config: { threshold, region },
237
+ baselinePath,
238
+ createdAt: new Date().toISOString(),
239
+ lastCheckAt: new Date().toISOString(),
240
+ lastDiffPercent: 0,
241
+ };
242
+ data.watches.push(watch);
243
+ saveWatches(data);
244
+ return {
245
+ changed: false,
246
+ diffPercent: 0,
247
+ threshold,
248
+ message: 'Baseline captured',
249
+ screenshotBuffer: result.buffer,
250
+ watch,
251
+ };
252
+ }
253
+ // Diff against baseline
254
+ const baseline = readFileSync(watch.baselinePath);
255
+ const diffResult = diff(baseline, result.buffer, threshold, region);
256
+ // Save current screenshot
257
+ saveScreenshot(watch.id, 'latest', result.buffer);
258
+ if (diffResult.diffBuffer) {
259
+ saveScreenshot(watch.id, 'diff', diffResult.diffBuffer);
260
+ }
261
+ watch.lastCheckAt = new Date().toISOString();
262
+ watch.lastDiffPercent = diffResult.diffPercent;
263
+ saveWatches(data);
264
+ return {
265
+ changed: diffResult.changed,
266
+ diffPercent: diffResult.diffPercent,
267
+ threshold,
268
+ message: diffResult.changed
269
+ ? `Changed: ${diffResult.diffPercent}% pixels differ (threshold: ${threshold}%)`
270
+ : `No change: ${diffResult.diffPercent}% pixels differ (threshold: ${threshold}%)`,
271
+ screenshotBuffer: result.buffer,
272
+ diffBuffer: diffResult.diffBuffer,
273
+ watch,
274
+ };
275
+ }
276
+ // --- List / Remove watches ---
277
+ export function listWatches() {
278
+ return loadWatches().watches;
279
+ }
280
+ export function removeWatch(id) {
281
+ const data = loadWatches();
282
+ const idx = data.watches.findIndex((w) => w.id === id);
283
+ if (idx === -1) {
284
+ throw new EyeballsError('NOT_FOUND', `Watch ${id} not found`);
285
+ }
286
+ data.watches.splice(idx, 1);
287
+ saveWatches(data);
288
+ // Clean up screenshot files for this watch
289
+ try {
290
+ const files = readdirSync(SCREENSHOTS_DIR);
291
+ for (const file of files) {
292
+ if (file.startsWith(`${id}-`)) {
293
+ unlinkSync(join(SCREENSHOTS_DIR, file));
294
+ }
295
+ }
296
+ }
297
+ catch {
298
+ // Best effort cleanup
299
+ }
300
+ }
301
+ //# sourceMappingURL=core.js.map