@mehmoodqureshi/chrome-mcp 0.9.2 → 0.9.3

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/README.md CHANGED
@@ -502,5 +502,7 @@ the fallback.
502
502
  > `chrome.debugger` is used only where it's needed and worth it: `trusted: true`
503
503
  > input (real OS-level events on React/Vue inputs) and `screenshot` (captures a
504
504
  > specific tab **without** activating it — safe under parallel `batch` — with
505
- > true full-page and element capture). Those ops briefly show the debug banner
506
- > while attached.
505
+ > true full-page and element capture). Those ops show the debug banner while
506
+ > attached; the session lingers 1.5s after the last op so a burst of them
507
+ > attaches once. Screenshots are JPEG (quality 70) at CSS-pixel size by
508
+ > default — pass `format: "png"`, `quality`, or `scale` to change that.
@@ -87,8 +87,6 @@ function pageOp(a) {
87
87
  return { dx, dy, exact: true, scrollX: win.scrollX, scrollY: win.scrollY };
88
88
  };
89
89
  const sel = typeof a.selector === 'string' && a.selector.length > 0 ? a.selector : null;
90
- const el = sel ? deepQuery(sel) : null;
91
- const missing = sel !== null && el === null;
92
90
  /** Set a value the way React/Vue see it (they patch the instance setter). */
93
91
  const setValue = (node, next) => {
94
92
  const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(node), 'value')?.set;
@@ -98,195 +96,223 @@ function pageOp(a) {
98
96
  node.value = next;
99
97
  node.dispatchEvent(new Event('input', { bubbles: true }));
100
98
  };
101
- switch (a.op) {
102
- case 'probe':
103
- return { found: true, url: location.href, title: document.title };
104
- case 'text': {
105
- if (missing)
106
- return { found: false };
107
- const root = el ?? document.body;
108
- return { found: true, text: root ? root.innerText ?? '' : '' };
109
- }
110
- case 'html': {
111
- if (missing)
112
- return { found: false };
113
- const root = el ?? document.documentElement;
114
- if (!root)
115
- return { found: true, html: '' };
116
- const outer = a.outer === true || !sel;
117
- return { found: true, html: outer ? root.outerHTML : root.innerHTML };
118
- }
119
- case 'click': {
120
- if (!el)
121
- return { found: false };
122
- el.scrollIntoView({ block: 'center' });
123
- el.click();
124
- return { found: true };
125
- }
126
- case 'type': {
127
- const node = el;
128
- if (!node)
129
- return { found: false };
130
- node.focus();
131
- const next = (a.clear ? '' : node.value ?? '') + (a.text ?? '');
132
- setValue(node, next);
133
- node.dispatchEvent(new Event('change', { bubbles: true }));
134
- return { found: true };
135
- }
136
- case 'focus': {
137
- const node = el;
138
- if (!node)
139
- return { found: false };
140
- node.focus();
141
- if (a.clear)
142
- setValue(node, '');
143
- return { found: true };
144
- }
145
- case 'point': {
146
- if (!el)
147
- return { found: false };
148
- el.scrollIntoView({ block: 'center', inline: 'center' });
149
- const r = el.getBoundingClientRect();
150
- const off = frameOffset();
151
- return {
152
- found: true,
153
- x: r.left + r.width / 2 + off.dx,
154
- y: r.top + r.height / 2 + off.dy,
155
- // false => this frame's coordinates cannot be mapped to the top viewport.
156
- exact: off.exact,
157
- };
158
- }
159
- case 'hover': {
160
- if (!el)
161
- return { found: false };
162
- el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
163
- el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
164
- return { found: true };
165
- }
166
- case 'select': {
167
- const node = el;
168
- if (!node || !node.options)
169
- return { found: false };
170
- const want = new Set(a.values ?? []);
171
- let matched = false;
172
- for (const opt of Array.from(node.options)) {
173
- const on = want.has(opt.value) || want.has(opt.label) || want.has(opt.text);
174
- opt.selected = on;
175
- if (on)
176
- matched = true;
99
+ /** The op proper, against an element already resolved (or null). */
100
+ const perform = (el) => {
101
+ const missing = sel !== null && el === null;
102
+ switch (a.op) {
103
+ case 'probe':
104
+ return { found: true, url: location.href, title: document.title };
105
+ case 'text': {
106
+ if (missing)
107
+ return { found: false };
108
+ const root = el ?? document.body;
109
+ return { found: true, text: root ? root.innerText ?? '' : '' };
177
110
  }
178
- node.dispatchEvent(new Event('input', { bubbles: true }));
179
- node.dispatchEvent(new Event('change', { bubbles: true }));
180
- return { found: true, matched };
181
- }
182
- case 'measure': {
183
- const d = document.documentElement;
184
- const dims = {
185
- w: window.innerWidth,
186
- h: window.innerHeight,
187
- fullW: Math.max(d.scrollWidth, d.clientWidth),
188
- fullH: Math.max(d.scrollHeight, d.clientHeight),
189
- };
190
- if (!sel)
191
- return { found: true, dims, element: null, missing: false };
192
- if (!el)
193
- return { found: true, dims, element: null, missing: true };
194
- el.scrollIntoView({ block: 'center', inline: 'center' });
195
- const r = el.getBoundingClientRect();
196
- const off = frameOffset();
197
- // viewport rect + this frame's offset + the TOP document's scroll ->
198
- // document coordinates of the page the screenshot actually captures.
199
- return {
200
- found: true,
201
- dims,
202
- element: {
203
- x: r.left + off.dx + off.scrollX,
204
- y: r.top + off.dy + off.scrollY,
205
- w: r.width,
206
- h: r.height,
207
- },
208
- missing: false,
209
- exact: off.exact,
210
- };
211
- }
212
- case 'scroll': {
213
- if (el)
214
- el.scrollIntoView({ block: 'center' });
215
- else if (a.x != null || a.y != null)
216
- window.scrollTo(a.x ?? 0, a.y ?? 0);
217
- else
218
- window.scrollBy(a.deltaX ?? 0, a.deltaY ?? 0);
219
- return { found: !missing };
220
- }
221
- case 'storage': {
222
- const store = a.session ? window.sessionStorage : window.localStorage;
223
- const op = a.storageOp;
224
- if (op === 'set') {
225
- store.setItem(String(a.key), String(a.value ?? ''));
226
- return { found: true, ok: true };
111
+ case 'html': {
112
+ if (missing)
113
+ return { found: false };
114
+ const root = el ?? document.documentElement;
115
+ if (!root)
116
+ return { found: true, html: '' };
117
+ const outer = a.outer === true || !sel;
118
+ return { found: true, html: outer ? root.outerHTML : root.innerHTML };
227
119
  }
228
- if (op === 'remove') {
229
- store.removeItem(String(a.key));
230
- return { found: true, ok: true };
120
+ case 'click': {
121
+ if (!el)
122
+ return { found: false };
123
+ el.scrollIntoView({ block: 'center' });
124
+ el.click();
125
+ return { found: true };
231
126
  }
232
- if (op === 'clear') {
233
- store.clear();
234
- return { found: true, ok: true };
127
+ case 'type': {
128
+ const node = el;
129
+ if (!node)
130
+ return { found: false };
131
+ node.focus();
132
+ const next = (a.clear ? '' : node.value ?? '') + (a.text ?? '');
133
+ setValue(node, next);
134
+ node.dispatchEvent(new Event('change', { bubbles: true }));
135
+ return { found: true };
235
136
  }
236
- if (a.key)
237
- return { found: true, ok: true, value: store.getItem(a.key) };
238
- const entries = {};
239
- for (let i = 0; i < store.length; i++) {
240
- const k = store.key(i);
241
- if (k)
242
- entries[k] = store.getItem(k) ?? '';
137
+ case 'focus': {
138
+ const node = el;
139
+ if (!node)
140
+ return { found: false };
141
+ node.focus();
142
+ if (a.clear)
143
+ setValue(node, '');
144
+ return { found: true };
243
145
  }
244
- return { found: true, ok: true, entries };
245
- }
246
- // -- the two polling ops: one injection that resolves in-page, rather than
247
- // one executeScript round-trip per tick --
248
- case 'waitSelector': {
249
- const deadline = Date.now() + (a.timeoutMs ?? 5_000);
250
- const every = a.interval ?? 120;
251
- return new Promise((resolve) => {
252
- const tick = () => {
253
- if (sel && deepQuery(sel))
254
- return resolve({ found: true });
255
- if (Date.now() > deadline)
256
- return resolve({ found: false });
257
- setTimeout(tick, every);
146
+ case 'point': {
147
+ if (!el)
148
+ return { found: false };
149
+ el.scrollIntoView({ block: 'center', inline: 'center' });
150
+ const r = el.getBoundingClientRect();
151
+ const off = frameOffset();
152
+ return {
153
+ found: true,
154
+ x: r.left + r.width / 2 + off.dx,
155
+ y: r.top + r.height / 2 + off.dy,
156
+ // false => this frame's coordinates cannot be mapped to the top viewport.
157
+ exact: off.exact,
258
158
  };
259
- tick();
260
- });
261
- }
262
- case 'waitFor': {
263
- const deadline = Date.now() + (a.timeoutMs ?? 30_000);
264
- const every = a.interval ?? 150;
265
- const want = typeof a.textContains === 'string' && a.textContains.length > 0 ? a.textContains : null;
266
- const gone = a.gone === true;
267
- return new Promise((resolve) => {
268
- const hit = () => {
269
- let present;
270
- if (sel)
271
- present = !!deepQuery(sel);
272
- else if (want)
273
- present = (document.body?.innerText ?? '').includes(want);
274
- else
275
- present = true;
276
- return gone ? !present : present;
159
+ }
160
+ case 'hover': {
161
+ if (!el)
162
+ return { found: false };
163
+ el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
164
+ el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
165
+ return { found: true };
166
+ }
167
+ case 'select': {
168
+ const node = el;
169
+ if (!node || !node.options)
170
+ return { found: false };
171
+ const want = new Set(a.values ?? []);
172
+ let matched = false;
173
+ for (const opt of Array.from(node.options)) {
174
+ const on = want.has(opt.value) || want.has(opt.label) || want.has(opt.text);
175
+ opt.selected = on;
176
+ if (on)
177
+ matched = true;
178
+ }
179
+ node.dispatchEvent(new Event('input', { bubbles: true }));
180
+ node.dispatchEvent(new Event('change', { bubbles: true }));
181
+ return { found: true, matched };
182
+ }
183
+ case 'measure': {
184
+ const d = document.documentElement;
185
+ const dims = {
186
+ w: window.innerWidth,
187
+ h: window.innerHeight,
188
+ fullW: Math.max(d.scrollWidth, d.clientWidth),
189
+ fullH: Math.max(d.scrollHeight, d.clientHeight),
190
+ dpr: window.devicePixelRatio || 1,
191
+ scrollX: window.scrollX,
192
+ scrollY: window.scrollY,
277
193
  };
278
- const tick = () => {
279
- if (hit())
280
- return resolve({ found: true, matched: true });
281
- if (Date.now() > deadline)
282
- return resolve({ found: true, matched: false });
283
- setTimeout(tick, every);
194
+ if (!sel)
195
+ return { found: true, dims, element: null, missing: false };
196
+ if (!el)
197
+ return { found: true, dims, element: null, missing: true };
198
+ el.scrollIntoView({ block: 'center', inline: 'center' });
199
+ const r = el.getBoundingClientRect();
200
+ const off = frameOffset();
201
+ // viewport rect + this frame's offset + the TOP document's scroll ->
202
+ // document coordinates of the page the screenshot actually captures.
203
+ return {
204
+ found: true,
205
+ dims,
206
+ element: {
207
+ x: r.left + off.dx + off.scrollX,
208
+ y: r.top + off.dy + off.scrollY,
209
+ w: r.width,
210
+ h: r.height,
211
+ },
212
+ missing: false,
213
+ exact: off.exact,
284
214
  };
285
- tick();
286
- });
215
+ }
216
+ case 'scroll': {
217
+ if (el)
218
+ el.scrollIntoView({ block: 'center' });
219
+ else if (a.x != null || a.y != null)
220
+ window.scrollTo(a.x ?? 0, a.y ?? 0);
221
+ else
222
+ window.scrollBy(a.deltaX ?? 0, a.deltaY ?? 0);
223
+ return { found: !missing };
224
+ }
225
+ case 'storage': {
226
+ const store = a.session ? window.sessionStorage : window.localStorage;
227
+ const op = a.storageOp;
228
+ if (op === 'set') {
229
+ store.setItem(String(a.key), String(a.value ?? ''));
230
+ return { found: true, ok: true };
231
+ }
232
+ if (op === 'remove') {
233
+ store.removeItem(String(a.key));
234
+ return { found: true, ok: true };
235
+ }
236
+ if (op === 'clear') {
237
+ store.clear();
238
+ return { found: true, ok: true };
239
+ }
240
+ if (a.key)
241
+ return { found: true, ok: true, value: store.getItem(a.key) };
242
+ const entries = {};
243
+ for (let i = 0; i < store.length; i++) {
244
+ const k = store.key(i);
245
+ if (k)
246
+ entries[k] = store.getItem(k) ?? '';
247
+ }
248
+ return { found: true, ok: true, entries };
249
+ }
250
+ // -- the two polling ops: one injection that resolves in-page, rather than
251
+ // one executeScript round-trip per tick --
252
+ case 'waitSelector': {
253
+ const deadline = Date.now() + (a.timeoutMs ?? 5_000);
254
+ const every = a.interval ?? 120;
255
+ return new Promise((resolve) => {
256
+ const tick = () => {
257
+ if (sel && deepQuery(sel))
258
+ return resolve({ found: true });
259
+ if (Date.now() > deadline)
260
+ return resolve({ found: false });
261
+ setTimeout(tick, every);
262
+ };
263
+ tick();
264
+ });
265
+ }
266
+ case 'waitFor': {
267
+ const deadline = Date.now() + (a.timeoutMs ?? 30_000);
268
+ const every = a.interval ?? 150;
269
+ const want = typeof a.textContains === 'string' && a.textContains.length > 0 ? a.textContains : null;
270
+ const gone = a.gone === true;
271
+ return new Promise((resolve) => {
272
+ const hit = () => {
273
+ let present;
274
+ if (sel)
275
+ present = !!deepQuery(sel);
276
+ else if (want)
277
+ present = (document.body?.innerText ?? '').includes(want);
278
+ else
279
+ present = true;
280
+ return gone ? !present : present;
281
+ };
282
+ const tick = () => {
283
+ if (hit())
284
+ return resolve({ found: true, matched: true });
285
+ if (Date.now() > deadline)
286
+ return resolve({ found: true, matched: false });
287
+ setTimeout(tick, every);
288
+ };
289
+ tick();
290
+ });
291
+ }
292
+ default:
293
+ return { found: false, error: `unknown page op: ${String(a.op)}` };
287
294
  }
288
- default:
289
- return { found: false, error: `unknown page op: ${String(a.op)}` };
295
+ };
296
+ const el = sel ? deepQuery(sel) : null;
297
+ // Element ops wait for their target IN THIS SAME injection when `timeoutMs`
298
+ // is set: a not-yet-rendered button costs a few in-page ticks, not a second
299
+ // executeScript round-trip (the old waitSelector-then-act pair).
300
+ const waits = a.op === 'click' || a.op === 'type' || a.op === 'focus' || a.op === 'point' || a.op === 'hover' || a.op === 'select';
301
+ if (waits && sel && !el && a.timeoutMs && a.timeoutMs > 0) {
302
+ const deadline = Date.now() + a.timeoutMs;
303
+ const every = a.interval ?? 120;
304
+ return new Promise((resolve) => {
305
+ const tick = () => {
306
+ const hit = deepQuery(sel);
307
+ if (hit)
308
+ return resolve(perform(hit));
309
+ if (Date.now() > deadline)
310
+ return resolve({ found: false });
311
+ setTimeout(tick, every);
312
+ };
313
+ setTimeout(tick, every);
314
+ });
290
315
  }
316
+ return perform(el);
291
317
  }
292
318
  //# sourceMappingURL=page-fns.js.map
@@ -14,7 +14,20 @@ export interface PageDims {
14
14
  /** Full content box (document) width/height. */
15
15
  fullW: number;
16
16
  fullH: number;
17
+ /** window.devicePixelRatio (default 1). Output pixels = CSS px * dpr * clip.scale. */
18
+ dpr?: number;
19
+ /** Current scroll offset of the top document (default 0,0); a viewport clip starts here. */
20
+ scrollX?: number;
21
+ scrollY?: number;
17
22
  }
23
+ export type ScreenshotFormat = 'png' | 'jpeg';
24
+ /** Default encoding: JPEG at this quality is ~5-10x smaller than PNG on a
25
+ * typical page and still perfectly legible to a vision model. */
26
+ export declare const DEFAULT_SCREENSHOT_FORMAT: ScreenshotFormat;
27
+ export declare const DEFAULT_JPEG_QUALITY = 70;
28
+ /** Output size multiplier relative to CSS pixels. 1 = CSS px (so a Retina
29
+ * viewport is NOT captured at 2x); 2 = device px on a 2x display. */
30
+ export declare const DEFAULT_SCREENSHOT_SCALE = 1;
18
31
  /** An element's box in DOCUMENT coordinates (viewport rect + scroll offset), CSS px. */
19
32
  export interface ElementRect {
20
33
  x: number;
@@ -55,4 +68,5 @@ export declare const MAX_CAPTURE_PX = 16384;
55
68
  export declare function planScreenshot(dims: PageDims, opts?: {
56
69
  fullPage?: boolean;
57
70
  element?: ElementRect | null;
71
+ scale?: number;
58
72
  }): ScreenshotPlan;
@@ -8,8 +8,15 @@
8
8
  * `ScreenshotResult` reports. No chrome.* calls — just arithmetic.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.MAX_CAPTURE_PX = void 0;
11
+ exports.MAX_CAPTURE_PX = exports.DEFAULT_SCREENSHOT_SCALE = exports.DEFAULT_JPEG_QUALITY = exports.DEFAULT_SCREENSHOT_FORMAT = void 0;
12
12
  exports.planScreenshot = planScreenshot;
13
+ /** Default encoding: JPEG at this quality is ~5-10x smaller than PNG on a
14
+ * typical page and still perfectly legible to a vision model. */
15
+ exports.DEFAULT_SCREENSHOT_FORMAT = 'jpeg';
16
+ exports.DEFAULT_JPEG_QUALITY = 70;
17
+ /** Output size multiplier relative to CSS pixels. 1 = CSS px (so a Retina
18
+ * viewport is NOT captured at 2x); 2 = device px on a 2x display. */
19
+ exports.DEFAULT_SCREENSHOT_SCALE = 1;
13
20
  /**
14
21
  * Practical single-capture height ceiling. Skia/CDP cannot encode arbitrarily
15
22
  * tall images; beyond this we clamp the clip and flag `truncated`.
@@ -20,11 +27,15 @@ exports.MAX_CAPTURE_PX = 16384;
20
27
  * viewport capture. Heights are clamped to MAX_CAPTURE_PX with `truncated` set.
21
28
  */
22
29
  function planScreenshot(dims, opts = {}) {
30
+ // CDP's clip.scale multiplies on top of the device scale factor, so dividing
31
+ // by the DPR yields exactly `opts.scale` output pixels per CSS pixel.
32
+ const dpr = dims.dpr && dims.dpr > 0 ? dims.dpr : 1;
33
+ const scale = (opts.scale && opts.scale > 0 ? opts.scale : exports.DEFAULT_SCREENSHOT_SCALE) / dpr;
23
34
  if (opts.element) {
24
35
  const realH = Math.max(1, Math.round(opts.element.h));
25
36
  const clipH = Math.min(opts.element.h, exports.MAX_CAPTURE_PX);
26
37
  return {
27
- clip: { x: opts.element.x, y: opts.element.y, width: opts.element.w, height: clipH, scale: 1 },
38
+ clip: { x: opts.element.x, y: opts.element.y, width: opts.element.w, height: clipH, scale },
28
39
  captureBeyondViewport: true,
29
40
  width: Math.max(1, Math.round(opts.element.w)),
30
41
  height: Math.min(realH, exports.MAX_CAPTURE_PX),
@@ -35,7 +46,7 @@ function planScreenshot(dims, opts = {}) {
35
46
  if (opts.fullPage) {
36
47
  const clipH = Math.min(dims.fullH, exports.MAX_CAPTURE_PX);
37
48
  return {
38
- clip: { x: 0, y: 0, width: dims.fullW, height: clipH, scale: 1 },
49
+ clip: { x: 0, y: 0, width: dims.fullW, height: clipH, scale },
39
50
  captureBeyondViewport: true,
40
51
  width: dims.fullW,
41
52
  height: clipH,
@@ -43,8 +54,12 @@ function planScreenshot(dims, opts = {}) {
43
54
  fullHeight: dims.fullH,
44
55
  };
45
56
  }
46
- // Plain viewport: no clip, capture what's visible.
57
+ // Plain viewport: capture what's visible. A clip is only needed to apply a
58
+ // scale; at scale 1 on a 1x display the bare capture is identical and cheaper.
47
59
  return {
60
+ ...(scale !== 1
61
+ ? { clip: { x: dims.scrollX ?? 0, y: dims.scrollY ?? 0, width: dims.w, height: dims.h, scale } }
62
+ : {}),
48
63
  captureBeyondViewport: false,
49
64
  width: dims.w,
50
65
  height: dims.h,
@@ -23,6 +23,28 @@ export interface RawSnapshot {
23
23
  title: string;
24
24
  nodes: RawSnapshotNode[];
25
25
  truncated: boolean;
26
+ /** Locator mode, nothing matched: up to 8 "role \"name\"" strings of the
27
+ * same role, so the caller can say what WAS there without a second read. */
28
+ nearby?: string[];
26
29
  }
27
- /** Runs IN THE PAGE. Returns interactive (and optionally landmark) elements with fresh refs. */
28
- export declare function collectSnapshot(interactiveOnly?: boolean, max?: number): RawSnapshot;
30
+ /** A role/name query resolved IN the page (see `collectSnapshot`). */
31
+ export interface SnapshotLocator {
32
+ role?: string;
33
+ name?: string;
34
+ }
35
+ /**
36
+ * Runs IN THE PAGE. Returns interactive (and optionally landmark) elements with
37
+ * fresh refs.
38
+ *
39
+ * With a `locator`, the walk is the same but the SCORING happens here: only the
40
+ * strongest-tier matches come back (and only they get a ref), so resolving
41
+ * "the Sign in button" ships a handful of nodes instead of the whole tree and
42
+ * touches one or two DOM attributes instead of hundreds. The tiers mirror
43
+ * `src/mcp/locate.ts` exactly (exact, case-insensitive, prefix, contains), and
44
+ * the server re-scores what it receives, so both ends always agree.
45
+ *
46
+ * Reads (visibility, names, values) all happen BEFORE the ref attributes are
47
+ * written: interleaving them made every `innerText` after a `setAttribute`
48
+ * re-run style, which on a big page is most of the snapshot's cost.
49
+ */
50
+ export declare function collectSnapshot(interactiveOnly?: boolean, max?: number, locator?: SnapshotLocator | null): RawSnapshot;