chrome-bridge-mcp 1.25.1 → 1.27.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/README.md +13 -4
- package/extension/lib/emulation.js +61 -0
- package/extension/lib/motion.js +356 -0
- package/extension/lib/page-fingerprint.js +1 -1
- package/extension/lib/trusted-input.js +154 -0
- package/extension/manifest.json +4 -3
- package/extension/service-worker.js +311 -4
- package/package.json +10 -2
- package/server/heap.js +111 -0
- package/server/index.js +5 -2
- package/server/launcher.js +20 -2
- package/server/lighthouse.js +72 -0
- package/server/protocol.js +5 -1
- package/server/tools.js +412 -15
- package/server/trace-analysis.js +208 -0
- package/server/video.js +58 -0
- package/skills/chrome-bridge/SKILL.md +4 -1
- package/tools/cws-upload.mjs +10 -3
package/README.md
CHANGED
|
@@ -164,12 +164,12 @@ user-script toggle isn't available.
|
|
|
164
164
|
|
|
165
165
|
## Tools
|
|
166
166
|
|
|
167
|
-
|
|
168
|
-
default; the other
|
|
167
|
+
66 in total. `core` (43 tools, every tool used in 101 real sessions) loads by
|
|
168
|
+
default; the other 23 sit in seven optional caps that the agent switches on
|
|
169
169
|
mid-session with `get_status({enable: ["visual"]})`, with no restart
|
|
170
170
|
(`--caps` still sets them at startup).
|
|
171
171
|
|
|
172
|
-

|
|
173
173
|
|
|
174
174
|
| Group | N | What's in it |
|
|
175
175
|
|---|---|---|
|
|
@@ -180,6 +180,7 @@ mid-session with `get_status({enable: ["visual"]})`, with no restart
|
|
|
180
180
|
| Visual & Responsive | 5 | `screenshot_diff`, viewport and zoom, media emulation |
|
|
181
181
|
| Audits | 2 | `audit` (a11y, keyboard, SEO, security, links, vitals, css, resources, cache in one call), `cookie_audit` |
|
|
182
182
|
| State, Storage & Files | 9 | storage, fixtures, MHTML, recording, `assert` |
|
|
183
|
+
| Motion & Performance | 6 | `animations`, `frames` (long frames, CLS, INP), `perf_trace`, `screencast`, `lighthouse` and `heap_snapshot` (launch mode) |
|
|
183
184
|
|
|
184
185
|
Every tool, with the notes that matter: [docs/TOOLS.md](docs/TOOLS.md).
|
|
185
186
|
|
|
@@ -236,6 +237,14 @@ do with them.
|
|
|
236
237
|
|
|
237
238
|
- **Who can connect:** see above — loopback bind, `chrome-extension://` origin
|
|
238
239
|
check, `CHROME_BRIDGE_TOKEN` on both handshakes.
|
|
240
|
+
- **Launch mode also opens a DevTools port** on 127.0.0.1 (chosen by the
|
|
241
|
+
system), for `lighthouse` and `heap_snapshot`. CDP has no authentication:
|
|
242
|
+
any local process can drive that dedicated browser while it runs. Its profile
|
|
243
|
+
is temporary and holds nothing of yours; your own Chrome never gets a port.
|
|
244
|
+
- **The debugger bar is the signal.** `click`/`press_key` with `trusted`,
|
|
245
|
+
`emulate_media via: "debugger"`, `perf_trace` and `screencast` attach
|
|
246
|
+
`chrome.debugger` to one tab only for that work; Chrome shows its "started
|
|
247
|
+
debugging this browser" bar meanwhile, and cancelling it ends the work.
|
|
239
248
|
- **The real risk is prompt injection.** A page can ask the agent to run
|
|
240
249
|
JavaScript, send a request with your cookies, submit a form or upload a file.
|
|
241
250
|
Treat every page you automate as untrusted input, and review what the agent
|
|
@@ -304,7 +313,7 @@ count.
|
|
|
304
313
|
|
|
305
314
|
## Documentation
|
|
306
315
|
|
|
307
|
-
- [docs/TOOLS.md](docs/TOOLS.md) — all
|
|
316
|
+
- [docs/TOOLS.md](docs/TOOLS.md) — all 66 tools, by group
|
|
308
317
|
- [docs/CAPABILITIES.md](docs/CAPABILITIES.md) — what the bridge gets past and what it does not, one dated state per wall
|
|
309
318
|
- [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
|
|
310
319
|
- [docs/PERFORMANCE.md](docs/PERFORMANCE.md) — latency per tool on the real path, `npm run bench:latency`
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* emulate_media via chrome.debugger: dalle opzioni del tool ai comandi CDP.
|
|
3
|
+
*
|
|
4
|
+
* Con il debugger l'emulazione è quella di DevTools: le regole
|
|
5
|
+
* @media (prefers-reduced-motion) dei fogli del sito valgono davvero, mentre
|
|
6
|
+
* il ripiego nella pagina cambia solo matchMedia e azzera tutte le durate.
|
|
7
|
+
* CPU, rete, viewport con DPR e touch esistono solo qui.
|
|
8
|
+
*
|
|
9
|
+
* Pura: restituisce coppie [metodo, parametri], così i test la provano senza
|
|
10
|
+
* browser. Il service worker le manda in ordine sulla scheda tenuta.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Profili di rete di DevTools (Chrome 154): latenza in ms, banda in byte/s,
|
|
14
|
+
// già scalate come le applica DevTools.
|
|
15
|
+
export const NETWORK_PROFILES = {
|
|
16
|
+
offline: { offline: true, latency: 0, downloadThroughput: 0, uploadThroughput: 0 },
|
|
17
|
+
'3g': { offline: false, latency: 2000, downloadThroughput: 45000, uploadThroughput: 45000 },
|
|
18
|
+
'slow-4g': { offline: false, latency: 562.5, downloadThroughput: 180000, uploadThroughput: 84375 },
|
|
19
|
+
'fast-4g': { offline: false, latency: 165, downloadThroughput: 1012500, uploadThroughput: 168750 },
|
|
20
|
+
none: { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} o - colorScheme, reducedMotion, contrast, printMode,
|
|
25
|
+
* cpu_throttle, network, device {width,height,dpr,mobile}, touch
|
|
26
|
+
* @returns {{ commands: Array<[string, object]>, emulated: object }}
|
|
27
|
+
*/
|
|
28
|
+
export function emulationCommands(o = {}) {
|
|
29
|
+
const commands = [];
|
|
30
|
+
const emulated = {};
|
|
31
|
+
const features = [];
|
|
32
|
+
if (o.colorScheme) { features.push({ name: 'prefers-color-scheme', value: o.colorScheme }); emulated.colorScheme = o.colorScheme; }
|
|
33
|
+
if (o.reducedMotion) { features.push({ name: 'prefers-reduced-motion', value: o.reducedMotion }); emulated.reducedMotion = o.reducedMotion; }
|
|
34
|
+
if (o.contrast) { features.push({ name: 'prefers-contrast', value: o.contrast }); emulated.contrast = o.contrast; }
|
|
35
|
+
if (features.length || o.printMode) {
|
|
36
|
+
commands.push(['Emulation.setEmulatedMedia', { media: o.printMode ? 'print' : '', features }]);
|
|
37
|
+
if (o.printMode) emulated.printMode = true;
|
|
38
|
+
}
|
|
39
|
+
if (o.cpu_throttle != null) {
|
|
40
|
+
const rate = Math.max(1, Number(o.cpu_throttle));
|
|
41
|
+
commands.push(['Emulation.setCPUThrottlingRate', { rate }]);
|
|
42
|
+
emulated.cpu_throttle = rate;
|
|
43
|
+
}
|
|
44
|
+
if (o.network) {
|
|
45
|
+
const p = NETWORK_PROFILES[o.network];
|
|
46
|
+
if (!p) throw new Error(`Unknown network profile ${o.network}: use ${Object.keys(NETWORK_PROFILES).join(', ')}`);
|
|
47
|
+
commands.push(['Network.emulateNetworkConditions', p]);
|
|
48
|
+
emulated.network = o.network;
|
|
49
|
+
}
|
|
50
|
+
if (o.device) {
|
|
51
|
+
const { width, height, dpr = 1, mobile = false } = o.device;
|
|
52
|
+
if (!(width > 0 && height > 0)) throw new Error('device needs width and height in CSS px');
|
|
53
|
+
commands.push(['Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: dpr, mobile }]);
|
|
54
|
+
emulated.device = { width, height, dpr, mobile };
|
|
55
|
+
}
|
|
56
|
+
if (o.touch != null) {
|
|
57
|
+
commands.push(['Emulation.setTouchEmulationEnabled', { enabled: Boolean(o.touch), ...(o.touch && { maxTouchPoints: 5 }) }]);
|
|
58
|
+
emulated.touch = Boolean(o.touch);
|
|
59
|
+
}
|
|
60
|
+
return { commands, emulated };
|
|
61
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Misure di movimento nella pagina, per i tool `animations` e `frames`.
|
|
3
|
+
*
|
|
4
|
+
* Tutto da script di pagina: il dominio CDP `Animation` e
|
|
5
|
+
* `PerformanceTimeline` non sono ammessi a chrome.debugger da un'estensione
|
|
6
|
+
* (prova del 01/10/2026, Chromium 154), quindi si leggono
|
|
7
|
+
* document.getAnimations() e i PerformanceObserver come farebbe il sito.
|
|
8
|
+
*
|
|
9
|
+
* Tre operazioni su globalThis.__cbMotion:
|
|
10
|
+
* - snapshot(opts): le animazioni presenti adesso;
|
|
11
|
+
* - start(opts): apre una finestra di registrazione (animazioni viste a ogni
|
|
12
|
+
* fotogramma, Long Animation Frames, layout shift, interazioni, ritmo rAF);
|
|
13
|
+
* - stop(): chiude la finestra e restituisce la sintesi.
|
|
14
|
+
* Lo stato della finestra vive nella pagina: una navigazione lo perde, e stop
|
|
15
|
+
* lo dice.
|
|
16
|
+
*
|
|
17
|
+
* Non è un modulo ES: chrome.scripting serializza solo la funzione iniettata,
|
|
18
|
+
* quindi il service worker inietta questo file (files:, world MAIN) e poi
|
|
19
|
+
* chiama __cbMotion. Nei test Node si importa per side effect, come
|
|
20
|
+
* element-label.js.
|
|
21
|
+
*/
|
|
22
|
+
(() => {
|
|
23
|
+
// Proprietà che il compositor di Chrome anima senza il thread principale.
|
|
24
|
+
// È una stima: will-change, layer e dimensioni possono cambiare l'esito.
|
|
25
|
+
const COMPOSITABLE = new Set(['transform', 'translate', 'rotate', 'scale', 'opacity', 'filter', 'backdrop-filter']);
|
|
26
|
+
// Una «dissolvenza»: con prefers-reduced-motion restano accettabili.
|
|
27
|
+
const FADE = new Set(['opacity', 'visibility']);
|
|
28
|
+
const KF_META = new Set(['offset', 'computedOffset', 'easing', 'composite']);
|
|
29
|
+
const EASING_MAX = 200;
|
|
30
|
+
|
|
31
|
+
const round = (n, d = 0) => (typeof n === 'number' && Number.isFinite(n) ? Math.round(n * 10 ** d) / 10 ** d : n);
|
|
32
|
+
const kebab = (p) => (p.startsWith('--') ? p : p.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`));
|
|
33
|
+
// linear() di una molla ha decine di punti: si taglia il testo e si dice
|
|
34
|
+
// quanti punti aveva, così due curve restano distinguibili.
|
|
35
|
+
const shortEasing = (e) => {
|
|
36
|
+
const s = String(e);
|
|
37
|
+
if (s.length <= EASING_MAX) return s;
|
|
38
|
+
const points = s.startsWith('linear(') ? s.slice(7, -1).split(',').length : null;
|
|
39
|
+
return `${s.slice(0, EASING_MAX)}…${points ? ` (${points} points)` : ''}`;
|
|
40
|
+
};
|
|
41
|
+
// Durate e tempi: numeri in ms, oppure CSSNumericValue (percentuali delle
|
|
42
|
+
// timeline di scroll) e 'auto', resi come testo.
|
|
43
|
+
const timeValue = (v) => {
|
|
44
|
+
if (v == null) return null;
|
|
45
|
+
if (typeof v === 'number') return Number.isFinite(v) ? round(v) : 'infinite';
|
|
46
|
+
return String(v);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const stableSelector = (el) => {
|
|
50
|
+
if (!el || el.nodeType !== 1) return null;
|
|
51
|
+
if (el.id && /^[A-Za-z][\w-]*$/.test(el.id)) return `#${el.id}`;
|
|
52
|
+
const testid = el.getAttribute && el.getAttribute('data-testid');
|
|
53
|
+
if (testid) return `[data-testid="${testid.replace(/"/g, '\\"')}"]`;
|
|
54
|
+
const parts = [];
|
|
55
|
+
let node = el;
|
|
56
|
+
const root = el.ownerDocument && el.ownerDocument.documentElement;
|
|
57
|
+
while (node && node.nodeType === 1 && node !== root) {
|
|
58
|
+
let part = node.tagName.toLowerCase();
|
|
59
|
+
if (node.id && /^[A-Za-z][\w-]*$/.test(node.id)) { parts.unshift(`#${node.id}`); break; }
|
|
60
|
+
const parent = node.parentElement;
|
|
61
|
+
if (parent) {
|
|
62
|
+
const sameTag = [...parent.children].filter((c) => c.tagName === node.tagName);
|
|
63
|
+
if (sameTag.length > 1) part += `:nth-of-type(${sameTag.indexOf(node) + 1})`;
|
|
64
|
+
}
|
|
65
|
+
parts.unshift(part);
|
|
66
|
+
node = node.parentElement;
|
|
67
|
+
}
|
|
68
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const timelineKind = (a) => {
|
|
72
|
+
const t = a.timeline;
|
|
73
|
+
if (t == null) return 'none';
|
|
74
|
+
if (typeof ViewTimeline !== 'undefined' && t instanceof ViewTimeline) return 'view';
|
|
75
|
+
if (typeof ScrollTimeline !== 'undefined' && t instanceof ScrollTimeline) return 'scroll';
|
|
76
|
+
if (typeof document !== 'undefined' && t === document.timeline) return 'document';
|
|
77
|
+
return 'other';
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function describe(a) {
|
|
81
|
+
const effect = a.effect || null;
|
|
82
|
+
const target = effect && effect.target;
|
|
83
|
+
const pseudo = (effect && effect.pseudoElement) || null;
|
|
84
|
+
let keyframes = [];
|
|
85
|
+
try { keyframes = effect && effect.getKeyframes ? effect.getKeyframes() : []; } catch { /* effetto senza keyframe leggibili */ }
|
|
86
|
+
const props = new Set();
|
|
87
|
+
for (const k of keyframes) for (const p of Object.keys(k)) if (!KF_META.has(p)) props.add(kebab(p));
|
|
88
|
+
if (!props.size && a.transitionProperty) props.add(a.transitionProperty);
|
|
89
|
+
const properties = [...props];
|
|
90
|
+
|
|
91
|
+
const timing = effect && effect.getTiming ? effect.getTiming() : {};
|
|
92
|
+
const computed = effect && effect.getComputedTiming ? effect.getComputedTiming() : {};
|
|
93
|
+
// Animazioni CSS: la curva sta nei keyframe (animation-timing-function), e
|
|
94
|
+
// la timing dell'effetto è 'linear'. Transizioni e WAAPI: nella timing.
|
|
95
|
+
const kfEasings = [...new Set(keyframes.map((k) => k.easing).filter((e) => e && e !== 'linear'))];
|
|
96
|
+
let easing = timing.easing || 'linear';
|
|
97
|
+
if (easing === 'linear' && kfEasings.length) easing = kfEasings.length === 1 ? kfEasings[0] : kfEasings;
|
|
98
|
+
easing = Array.isArray(easing) ? easing.map(shortEasing) : shortEasing(easing);
|
|
99
|
+
|
|
100
|
+
const kind = a.constructor && a.constructor.name ? a.constructor.name : 'Animation';
|
|
101
|
+
const out = {
|
|
102
|
+
kind,
|
|
103
|
+
name: a.animationName || a.transitionProperty || a.id || null,
|
|
104
|
+
selector: stableSelector(target),
|
|
105
|
+
...(pseudo && { pseudo }),
|
|
106
|
+
properties,
|
|
107
|
+
duration_ms: timeValue(timing.duration),
|
|
108
|
+
delay_ms: timeValue(timing.delay),
|
|
109
|
+
...(timing.endDelay ? { end_delay_ms: timeValue(timing.endDelay) } : {}),
|
|
110
|
+
easing,
|
|
111
|
+
iterations: timing.iterations === Infinity ? 'infinite' : timing.iterations,
|
|
112
|
+
...(timing.direction && timing.direction !== 'normal' ? { direction: timing.direction } : {}),
|
|
113
|
+
...(timing.fill && timing.fill !== 'auto' && timing.fill !== 'none' ? { fill: timing.fill } : {}),
|
|
114
|
+
play_state: a.playState,
|
|
115
|
+
progress: computed.progress == null ? null : round(computed.progress, 2),
|
|
116
|
+
timeline: timelineKind(a),
|
|
117
|
+
view_transition: Boolean(pseudo && pseudo.startsWith('::view-transition')),
|
|
118
|
+
composited_estimate: properties.length > 0 && properties.every((p) => COMPOSITABLE.has(p)),
|
|
119
|
+
fade_only: properties.length > 0 && properties.every((p) => FADE.has(p)),
|
|
120
|
+
};
|
|
121
|
+
if (a.playbackRate != null && a.playbackRate !== 1) out.playback_rate = a.playbackRate;
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const inScope = (a, scope) => {
|
|
126
|
+
if (!scope) return true;
|
|
127
|
+
const t = a.effect && a.effect.target;
|
|
128
|
+
try { return Boolean(t && t.closest && t.closest(scope)); } catch { return false; }
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function summarize(list) {
|
|
132
|
+
const by = (key) => list.reduce((m, x) => { m[x[key]] = (m[x[key]] || 0) + 1; return m; }, {});
|
|
133
|
+
const props = {};
|
|
134
|
+
for (const x of list) for (const p of x.properties) props[p] = (props[p] || 0) + 1;
|
|
135
|
+
const durations = list.map((x) => x.duration_ms).filter((d) => typeof d === 'number');
|
|
136
|
+
const easings = [...new Set(list.flatMap((x) => (Array.isArray(x.easing) ? x.easing : [x.easing])))];
|
|
137
|
+
return {
|
|
138
|
+
count: list.length,
|
|
139
|
+
by_kind: by('kind'),
|
|
140
|
+
by_play_state: by('play_state'),
|
|
141
|
+
infinite: list.filter((x) => x.iterations === 'infinite').length,
|
|
142
|
+
max_duration_ms: durations.length ? Math.max(...durations) : null,
|
|
143
|
+
properties: props,
|
|
144
|
+
not_composited_estimate: list.filter((x) => !x.composited_estimate).length,
|
|
145
|
+
not_fade_only: list.filter((x) => !x.fade_only).length,
|
|
146
|
+
view_transitions: list.filter((x) => x.view_transition).length,
|
|
147
|
+
easings,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const reducedMotion = () => {
|
|
152
|
+
try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return null; }
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
function snapshot({ scope = null, limit = 50 } = {}) {
|
|
156
|
+
const list = document.getAnimations().filter((a) => inScope(a, scope)).map(describe);
|
|
157
|
+
return {
|
|
158
|
+
prefers_reduced_motion: reducedMotion(),
|
|
159
|
+
summary: summarize(list),
|
|
160
|
+
animations: list.slice(0, limit),
|
|
161
|
+
...(list.length > limit ? { truncated: list.length - limit } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- finestra di registrazione ---
|
|
166
|
+
|
|
167
|
+
const supported = () => (typeof PerformanceObserver !== 'undefined' && PerformanceObserver.supportedEntryTypes) || [];
|
|
168
|
+
|
|
169
|
+
function start({ scope = null } = {}) {
|
|
170
|
+
if (globalThis.__cbMotionRec) stopObservers(globalThis.__cbMotionRec);
|
|
171
|
+
const t0 = performance.now();
|
|
172
|
+
const rec = {
|
|
173
|
+
t0, scope, observers: [], seen: new Map(), loaf: [], shifts: [], events: [],
|
|
174
|
+
frames: [], raf: 0, active: true, hiddenDuring: false,
|
|
175
|
+
};
|
|
176
|
+
globalThis.__cbMotionRec = rec;
|
|
177
|
+
const types = supported();
|
|
178
|
+
const observe = (type, opts, onEntry) => {
|
|
179
|
+
if (!types.includes(type)) return false;
|
|
180
|
+
const po = new PerformanceObserver((l) => { for (const e of l.getEntries()) onEntry(e); });
|
|
181
|
+
try { po.observe({ type, ...opts }); } catch { return false; }
|
|
182
|
+
rec.observers.push({ po, onEntry });
|
|
183
|
+
return true;
|
|
184
|
+
};
|
|
185
|
+
rec.support = {
|
|
186
|
+
long_animation_frame: observe('long-animation-frame', {}, (e) => { if (e.startTime >= t0) rec.loaf.push(e); }),
|
|
187
|
+
layout_shift: observe('layout-shift', {}, (e) => { if (e.startTime >= t0) rec.shifts.push(e); }),
|
|
188
|
+
event_timing: observe('event', { durationThreshold: 16 }, (e) => { if (e.startTime >= t0) rec.events.push(e); }),
|
|
189
|
+
};
|
|
190
|
+
const sample = () => {
|
|
191
|
+
if (!rec.active) return;
|
|
192
|
+
const now = performance.now();
|
|
193
|
+
rec.frames.push(now);
|
|
194
|
+
for (const a of document.getAnimations()) {
|
|
195
|
+
if (!rec.seen.has(a) && inScope(a, scope)) rec.seen.set(a, round(now - t0));
|
|
196
|
+
}
|
|
197
|
+
rec.raf = requestAnimationFrame(sample);
|
|
198
|
+
};
|
|
199
|
+
rec.onVisibility = () => { if (document.visibilityState === 'hidden') rec.hiddenDuring = true; };
|
|
200
|
+
document.addEventListener('visibilitychange', rec.onVisibility);
|
|
201
|
+
// Una transizione di 150 ms può nascere e finire fra due campioni rAF solo
|
|
202
|
+
// se la pagina perde fotogrammi: transitionrun e animationstart la
|
|
203
|
+
// segnalano comunque, e il campione successivo la trova nel documento.
|
|
204
|
+
rec.onStartEvent = () => {
|
|
205
|
+
for (const a of document.getAnimations()) {
|
|
206
|
+
if (!rec.seen.has(a) && inScope(a, scope)) rec.seen.set(a, round(performance.now() - t0));
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
document.addEventListener('transitionrun', rec.onStartEvent, true);
|
|
210
|
+
document.addEventListener('animationstart', rec.onStartEvent, true);
|
|
211
|
+
sample();
|
|
212
|
+
return { recording: true, support: rec.support };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function stopObservers(rec) {
|
|
216
|
+
rec.active = false;
|
|
217
|
+
cancelAnimationFrame(rec.raf);
|
|
218
|
+
// takeRecords: le voci già prodotte ma non ancora consegnate al callback
|
|
219
|
+
// (l'ultimo fotogramma della finestra) non vanno perse.
|
|
220
|
+
for (const { po, onEntry } of rec.observers) {
|
|
221
|
+
try { for (const e of po.takeRecords()) onEntry(e); po.disconnect(); } catch { /* già scollegato */ }
|
|
222
|
+
}
|
|
223
|
+
document.removeEventListener('visibilitychange', rec.onVisibility);
|
|
224
|
+
document.removeEventListener('transitionrun', rec.onStartEvent, true);
|
|
225
|
+
document.removeEventListener('animationstart', rec.onStartEvent, true);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Ritmo dei fotogrammi del thread principale. L'intervallo tipico (mediana)
|
|
229
|
+
// stima la frequenza dello schermo; un intervallo oltre 1,5 volte la mediana
|
|
230
|
+
// è almeno un fotogramma perso. Le animazioni composte possono restare
|
|
231
|
+
// fluide anche quando il thread principale salta.
|
|
232
|
+
function frameStats(times) {
|
|
233
|
+
if (times.length < 3) return { count: times.length, note: 'Too few frames: was the page hidden or the window too short?' };
|
|
234
|
+
const gaps = [];
|
|
235
|
+
for (let i = 1; i < times.length; i++) gaps.push(times[i] - times[i - 1]);
|
|
236
|
+
const sorted = [...gaps].sort((x, y) => x - y);
|
|
237
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
238
|
+
const span = times[times.length - 1] - times[0];
|
|
239
|
+
const dropped = gaps.reduce((n, g) => n + (g > median * 1.5 ? Math.round(g / median) - 1 : 0), 0);
|
|
240
|
+
return {
|
|
241
|
+
count: times.length,
|
|
242
|
+
fps: round((gaps.length * 1000) / span, 1),
|
|
243
|
+
refresh_estimate_hz: round(1000 / median),
|
|
244
|
+
max_gap_ms: round(sorted[sorted.length - 1]),
|
|
245
|
+
dropped_estimate: dropped,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function loafEntry(e) {
|
|
250
|
+
const scripts = (e.scripts || [])
|
|
251
|
+
.map((s) => ({
|
|
252
|
+
invoker: s.invoker || null,
|
|
253
|
+
type: s.invokerType || null,
|
|
254
|
+
source: s.sourceURL ? `${s.sourceURL}${s.sourceFunctionName ? ` ${s.sourceFunctionName}` : ''}${s.sourceCharPosition >= 0 ? `:${s.sourceCharPosition}` : ''}` : (s.sourceFunctionName || null),
|
|
255
|
+
duration_ms: round(s.duration),
|
|
256
|
+
...(s.forcedStyleAndLayoutDuration ? { forced_layout_ms: round(s.forcedStyleAndLayoutDuration) } : {}),
|
|
257
|
+
}))
|
|
258
|
+
.sort((x, y) => y.duration_ms - x.duration_ms)
|
|
259
|
+
.slice(0, 3);
|
|
260
|
+
const start = e.startTime;
|
|
261
|
+
return {
|
|
262
|
+
duration_ms: round(e.duration),
|
|
263
|
+
blocking_ms: round(e.blockingDuration),
|
|
264
|
+
// Fasi del fotogramma: script prima del rendering, poi stile e layout.
|
|
265
|
+
...(e.renderStart ? { render_start_ms: round(e.renderStart - start) } : {}),
|
|
266
|
+
...(e.styleAndLayoutStart ? { style_layout_start_ms: round(e.styleAndLayoutStart - start) } : {}),
|
|
267
|
+
scripts,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function shiftsSummary(entries) {
|
|
272
|
+
const counted = entries.filter((e) => !e.hadRecentInput);
|
|
273
|
+
const cls = counted.reduce((s, e) => s + e.value, 0);
|
|
274
|
+
const top = [...counted].sort((x, y) => y.value - x.value).slice(0, 5).map((e) => ({
|
|
275
|
+
value: round(e.value, 4),
|
|
276
|
+
sources: (e.sources || []).slice(0, 3).map((s) => ({
|
|
277
|
+
selector: stableSelector(s.node),
|
|
278
|
+
...(s.previousRect && s.currentRect ? { moved_px: { x: round(s.currentRect.x - s.previousRect.x), y: round(s.currentRect.y - s.previousRect.y) } } : {}),
|
|
279
|
+
})),
|
|
280
|
+
}));
|
|
281
|
+
return {
|
|
282
|
+
cls: round(cls, 4),
|
|
283
|
+
count: counted.length,
|
|
284
|
+
...(entries.length > counted.length ? { after_input_excluded: entries.length - counted.length } : {}),
|
|
285
|
+
top,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// INP sulla finestra: la più lunga fra le interazioni (interactionId > 0).
|
|
290
|
+
// Con meno di 50 interazioni è anche il valore che web-vitals riporterebbe.
|
|
291
|
+
// Le durate degli Event Timing sono arrotondate a 8 ms dal browser. Le fasi
|
|
292
|
+
// si calcolano sull'insieme delle voci dell'interazione (pointerdown,
|
|
293
|
+
// pointerup, click…), come web-vitals/attribution: la voce più lunga da
|
|
294
|
+
// sola è spesso un pointerup senza handler e senza target.
|
|
295
|
+
function interactionsSummary(entries) {
|
|
296
|
+
const byId = new Map();
|
|
297
|
+
for (const e of entries) {
|
|
298
|
+
if (!e.interactionId) continue;
|
|
299
|
+
if (!byId.has(e.interactionId)) byId.set(e.interactionId, []);
|
|
300
|
+
byId.get(e.interactionId).push(e);
|
|
301
|
+
}
|
|
302
|
+
const list = [...byId.values()].map((group) => ({ group, duration: Math.max(...group.map((e) => e.duration)) }))
|
|
303
|
+
.sort((x, y) => y.duration - x.duration);
|
|
304
|
+
if (!list.length) return { count: 0, inp_ms: null, note: 'No interaction in the window. Synthetic events have no interactionId: use action.trusted for INP.' };
|
|
305
|
+
const { group, duration } = list[0];
|
|
306
|
+
const start = Math.min(...group.map((e) => e.startTime));
|
|
307
|
+
const procStart = Math.min(...group.map((e) => e.processingStart));
|
|
308
|
+
const procEnd = Math.max(...group.map((e) => e.processingEnd));
|
|
309
|
+
const main = [...group].sort((x, y) => (y.processingEnd - y.processingStart) - (x.processingEnd - x.processingStart))
|
|
310
|
+
.find((e) => e.target) || group[0];
|
|
311
|
+
return {
|
|
312
|
+
count: list.length,
|
|
313
|
+
inp_ms: round(duration),
|
|
314
|
+
worst: {
|
|
315
|
+
event: main.name,
|
|
316
|
+
events: [...new Set(group.map((e) => e.name))],
|
|
317
|
+
selector: stableSelector(main.target),
|
|
318
|
+
input_delay_ms: round(procStart - start),
|
|
319
|
+
processing_ms: round(procEnd - procStart),
|
|
320
|
+
presentation_delay_ms: round(Math.max(0, start + duration - procEnd)),
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function stop({ threshold_ms = 50, limit = 10 } = {}) {
|
|
326
|
+
const rec = globalThis.__cbMotionRec;
|
|
327
|
+
if (!rec) return { error: 'No recording in this page: it was never started, or the page navigated and lost it.' };
|
|
328
|
+
stopObservers(rec);
|
|
329
|
+
delete globalThis.__cbMotionRec;
|
|
330
|
+
const t0 = rec.t0;
|
|
331
|
+
const animations = [...rec.seen.entries()].map(([a, at]) => ({ first_seen_ms: at, ...describe(a) }));
|
|
332
|
+
const long = rec.loaf.filter((e) => e.duration >= threshold_ms).sort((x, y) => y.duration - x.duration);
|
|
333
|
+
return {
|
|
334
|
+
window_ms: round(performance.now() - t0),
|
|
335
|
+
prefers_reduced_motion: reducedMotion(),
|
|
336
|
+
...(rec.hiddenDuring ? { page_hidden_during: true } : {}),
|
|
337
|
+
support: rec.support,
|
|
338
|
+
frames: frameStats(rec.frames),
|
|
339
|
+
long_frames: {
|
|
340
|
+
threshold_ms,
|
|
341
|
+
count: long.length,
|
|
342
|
+
total_blocking_ms: round(long.reduce((s, e) => s + (e.blockingDuration || 0), 0)),
|
|
343
|
+
worst: long.slice(0, limit).map((e) => ({ ...loafEntry(e), at_ms: round(e.startTime - t0) })),
|
|
344
|
+
},
|
|
345
|
+
layout_shifts: shiftsSummary(rec.shifts),
|
|
346
|
+
interactions: interactionsSummary(rec.events),
|
|
347
|
+
animations: {
|
|
348
|
+
summary: summarize(animations),
|
|
349
|
+
list: animations.slice(0, limit * 3),
|
|
350
|
+
...(animations.length > limit * 3 ? { truncated: animations.length - limit * 3 } : {}),
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
globalThis.__cbMotion = { snapshot, start, stop, _internals: { describe, summarize, frameStats, shiftsSummary, interactionsSummary, loafEntry, shortEasing } };
|
|
356
|
+
})();
|
|
@@ -29,7 +29,7 @@ export function pageFingerprint(doc, settle) {
|
|
|
29
29
|
resolve({ ...pageFingerprint(d), settled_ms: Math.round(now - t0) });
|
|
30
30
|
} else setTimeout(tick, 10);
|
|
31
31
|
};
|
|
32
|
-
setTimeout(tick, quiet);
|
|
32
|
+
setTimeout(tick, Math.min(quiet, max));
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
35
|
const count = (sel) => { try { return d.querySelectorAll(sel).length; } catch { return 0; } };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input fidato via chrome.debugger: eventi con isTrusted=true, generati dal
|
|
3
|
+
* browser e non dalla pagina. Serve dove l'evento sintetico non basta: widget
|
|
4
|
+
* che scartano gli eventi non fidati, editor che leggono solo input nativo,
|
|
5
|
+
* scorciatoie di tastiera gestite dal browser.
|
|
6
|
+
*
|
|
7
|
+
* Il debugger si aggancia solo per la durata dell'azione e si stacca subito:
|
|
8
|
+
* la barra «sta eseguendo il debug» compare solo in quel momento. Eccezione:
|
|
9
|
+
* una scheda «tenuta» (holdDebugger, per l'emulazione di emulate_media, che
|
|
10
|
+
* vive solo finché il debugger è agganciato) resta agganciata fino al
|
|
11
|
+
* rilascio, e withDebugger usa quell'aggancio senza staccarlo.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Tasti con nome: code e keyCode di Windows che Input.dispatchKeyEvent vuole
|
|
15
|
+
// per produrre gli effetti di default (Enter invia, Tab sposta il fuoco).
|
|
16
|
+
const NAMED = {
|
|
17
|
+
Enter: { code: 'Enter', keyCode: 13, text: '\r' },
|
|
18
|
+
Tab: { code: 'Tab', keyCode: 9 },
|
|
19
|
+
Escape: { code: 'Escape', keyCode: 27 },
|
|
20
|
+
Backspace: { code: 'Backspace', keyCode: 8 },
|
|
21
|
+
Delete: { code: 'Delete', keyCode: 46 },
|
|
22
|
+
Space: { code: 'Space', keyCode: 32, text: ' ', key: ' ' },
|
|
23
|
+
' ': { code: 'Space', keyCode: 32, text: ' ' },
|
|
24
|
+
ArrowUp: { code: 'ArrowUp', keyCode: 38 },
|
|
25
|
+
ArrowDown: { code: 'ArrowDown', keyCode: 40 },
|
|
26
|
+
ArrowLeft: { code: 'ArrowLeft', keyCode: 37 },
|
|
27
|
+
ArrowRight: { code: 'ArrowRight', keyCode: 39 },
|
|
28
|
+
Home: { code: 'Home', keyCode: 36 },
|
|
29
|
+
End: { code: 'End', keyCode: 35 },
|
|
30
|
+
PageUp: { code: 'PageUp', keyCode: 33 },
|
|
31
|
+
PageDown: { code: 'PageDown', keyCode: 34 },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Bitmask dei modificatori di CDP: Alt=1, Ctrl=2, Meta=4, Shift=8. */
|
|
35
|
+
export function modifierMask({ ctrl = false, shift = false, alt = false, meta = false } = {}) {
|
|
36
|
+
return (alt ? 1 : 0) | (ctrl ? 2 : 0) | (meta ? 4 : 0) | (shift ? 8 : 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parametri di keyDown e keyUp per Input.dispatchKeyEvent. Con un modificatore
|
|
41
|
+
* diverso da Shift il tasto non produce testo: Ctrl+A seleziona, non scrive «a».
|
|
42
|
+
*/
|
|
43
|
+
export function keyEvents(key, mods = {}) {
|
|
44
|
+
const modifiers = modifierMask(mods);
|
|
45
|
+
const named = NAMED[key];
|
|
46
|
+
let base;
|
|
47
|
+
if (named) {
|
|
48
|
+
base = { key: named.key ?? key, code: named.code, windowsVirtualKeyCode: named.keyCode, text: named.text };
|
|
49
|
+
} else if (typeof key === 'string' && key.length === 1) {
|
|
50
|
+
const upper = key.toUpperCase();
|
|
51
|
+
const isLetter = /[A-Z]/.test(upper);
|
|
52
|
+
const isDigit = /[0-9]/.test(key);
|
|
53
|
+
base = {
|
|
54
|
+
key,
|
|
55
|
+
code: isLetter ? `Key${upper}` : isDigit ? `Digit${key}` : '',
|
|
56
|
+
windowsVirtualKeyCode: isLetter || isDigit ? upper.charCodeAt(0) : 0,
|
|
57
|
+
text: key,
|
|
58
|
+
};
|
|
59
|
+
} else {
|
|
60
|
+
base = { key, code: key, windowsVirtualKeyCode: 0 };
|
|
61
|
+
}
|
|
62
|
+
const printable = base.text != null && !(modifiers & (1 | 2 | 4));
|
|
63
|
+
const down = { type: printable ? 'keyDown' : 'rawKeyDown', modifiers, key: base.key, code: base.code, windowsVirtualKeyCode: base.windowsVirtualKeyCode };
|
|
64
|
+
if (printable) { down.text = base.text; down.unmodifiedText = base.text; }
|
|
65
|
+
const up = { type: 'keyUp', modifiers, key: base.key, code: base.code, windowsVirtualKeyCode: base.windowsVirtualKeyCode };
|
|
66
|
+
return [down, up];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Sequenza di un click fidato al punto (x, y) in CSS px del viewport. */
|
|
70
|
+
export function mouseClickEvents(x, y, { button = 'left', count = 1 } = {}) {
|
|
71
|
+
const events = [{ type: 'mouseMoved', x, y }];
|
|
72
|
+
for (let i = 1; i <= Math.max(1, count); i++) {
|
|
73
|
+
events.push({ type: 'mousePressed', x, y, button, clickCount: i });
|
|
74
|
+
events.push({ type: 'mouseReleased', x, y, button, clickCount: i });
|
|
75
|
+
}
|
|
76
|
+
return events;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Schede tenute agganciate: tabId → insieme dei motivi ('emulation', …).
|
|
80
|
+
// Chrome stacca il debugger da solo quando l'utente preme «Annulla» sulla
|
|
81
|
+
// barra o la scheda si chiude: onDetach toglie la voce, e chi la teneva lo
|
|
82
|
+
// scopre con debuggerHeld.
|
|
83
|
+
const held = new Map();
|
|
84
|
+
let detachListener = false;
|
|
85
|
+
|
|
86
|
+
function listenDetach() {
|
|
87
|
+
if (detachListener || !globalThis.chrome?.debugger?.onDetach) return;
|
|
88
|
+
detachListener = true;
|
|
89
|
+
chrome.debugger.onDetach.addListener((source, reason) => {
|
|
90
|
+
if (source.tabId == null || !held.has(source.tabId)) return;
|
|
91
|
+
held.delete(source.tabId);
|
|
92
|
+
lastDetach.set(source.tabId, reason);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const lastDetach = new Map();
|
|
96
|
+
|
|
97
|
+
async function attach(tabId, purpose) {
|
|
98
|
+
try {
|
|
99
|
+
await chrome.debugger.attach({ tabId }, '1.3');
|
|
100
|
+
} catch (e) {
|
|
101
|
+
const msg = String(e?.message ?? e);
|
|
102
|
+
if (/already attached/i.test(msg)) throw new Error(`${purpose} unavailable: another debugger (DevTools?) is attached to this tab — close it, or retry without it`);
|
|
103
|
+
throw new Error(`${purpose} unavailable: ${msg}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Tiene il debugger agganciato alla scheda per un motivo, finché releaseDebugger. */
|
|
108
|
+
export async function holdDebugger(tabId, why, purpose = why) {
|
|
109
|
+
listenDetach();
|
|
110
|
+
let reasons = held.get(tabId);
|
|
111
|
+
if (!reasons) {
|
|
112
|
+
await attach(tabId, purpose);
|
|
113
|
+
reasons = new Set();
|
|
114
|
+
held.set(tabId, reasons);
|
|
115
|
+
lastDetach.delete(tabId);
|
|
116
|
+
}
|
|
117
|
+
reasons.add(why);
|
|
118
|
+
return (method, params) => chrome.debugger.sendCommand({ tabId }, method, params);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Toglie un motivo; senza più motivi il debugger si stacca e la barra sparisce. */
|
|
122
|
+
export async function releaseDebugger(tabId, why) {
|
|
123
|
+
const reasons = held.get(tabId);
|
|
124
|
+
if (!reasons) return false;
|
|
125
|
+
reasons.delete(why);
|
|
126
|
+
if (reasons.size) return true;
|
|
127
|
+
held.delete(tabId);
|
|
128
|
+
await chrome.debugger.detach({ tabId }).catch(() => {});
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Stato della tenuta: held, i motivi, o il motivo dello stacco (canceled_by_user, target_closed). */
|
|
133
|
+
export function debuggerHeld(tabId) {
|
|
134
|
+
const reasons = held.get(tabId);
|
|
135
|
+
if (reasons) return { held: true, reasons: [...reasons] };
|
|
136
|
+
return { held: false, ...(lastDetach.has(tabId) && { detached: lastDetach.get(tabId) }) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Aggancia il debugger alla scheda, esegue fn(send) e si stacca sempre, anche
|
|
141
|
+
* su errore. Un DevTools già aperto sulla scheda impedisce l'aggancio: lo dice.
|
|
142
|
+
* Su una scheda tenuta usa l'aggancio esistente e non lo stacca.
|
|
143
|
+
*/
|
|
144
|
+
export async function withDebugger(tabId, fn) {
|
|
145
|
+
const target = { tabId };
|
|
146
|
+
const send = (method, params) => chrome.debugger.sendCommand(target, method, params);
|
|
147
|
+
if (held.has(tabId)) return fn(send);
|
|
148
|
+
await attach(tabId, 'trusted input');
|
|
149
|
+
try {
|
|
150
|
+
return await fn(send);
|
|
151
|
+
} finally {
|
|
152
|
+
await chrome.debugger.detach(target).catch(() => {});
|
|
153
|
+
}
|
|
154
|
+
}
|
package/extension/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Chrome Bridge for Claude Code",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "Bridge your browser to Claude Code:
|
|
4
|
+
"version": "1.27.0",
|
|
5
|
+
"description": "Bridge your browser to Claude Code: 66 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
|
|
6
6
|
"homepage_url": "https://github.com/frsorrentino/chrome-bridge",
|
|
7
7
|
"minimum_chrome_version": "135",
|
|
8
8
|
"permissions": [
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"clipboardWrite",
|
|
20
20
|
"downloads",
|
|
21
21
|
"pageCapture",
|
|
22
|
-
"webRequestAuthProvider"
|
|
22
|
+
"webRequestAuthProvider",
|
|
23
|
+
"debugger"
|
|
23
24
|
],
|
|
24
25
|
"host_permissions": [
|
|
25
26
|
"<all_urls>"
|