leapfrog-mcp 0.6.2 → 0.6.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/dist/adaptive-wait.d.ts +72 -0
- package/dist/adaptive-wait.js +695 -0
- package/dist/api-intelligence.d.ts +82 -0
- package/dist/api-intelligence.js +575 -0
- package/dist/captcha-solver.d.ts +26 -0
- package/dist/captcha-solver.js +547 -0
- package/dist/cdp-connector.d.ts +33 -0
- package/dist/cdp-connector.js +176 -0
- package/dist/consent-dismiss.d.ts +33 -0
- package/dist/consent-dismiss.js +358 -0
- package/dist/crash-recovery.d.ts +74 -0
- package/dist/crash-recovery.js +242 -0
- package/dist/domain-knowledge.d.ts +149 -0
- package/dist/domain-knowledge.js +449 -0
- package/dist/harness-intelligence.d.ts +65 -0
- package/dist/harness-intelligence.js +432 -0
- package/dist/humanize-fingerprint.d.ts +42 -0
- package/dist/humanize-fingerprint.js +161 -0
- package/dist/humanize-mouse.d.ts +95 -0
- package/dist/humanize-mouse.js +275 -0
- package/dist/humanize-pause.d.ts +48 -0
- package/dist/humanize-pause.js +111 -0
- package/dist/humanize-scroll.d.ts +67 -0
- package/dist/humanize-scroll.js +185 -0
- package/dist/humanize-typing.d.ts +60 -0
- package/dist/humanize-typing.js +258 -0
- package/dist/humanize-utils.d.ts +62 -0
- package/dist/humanize-utils.js +100 -0
- package/dist/intervention.d.ts +65 -0
- package/dist/intervention.js +591 -0
- package/dist/logger.d.ts +13 -0
- package/dist/logger.js +47 -0
- package/dist/network-intelligence.d.ts +70 -0
- package/dist/network-intelligence.js +424 -0
- package/dist/page-classifier.d.ts +33 -0
- package/dist/page-classifier.js +1000 -0
- package/dist/paginate.d.ts +42 -0
- package/dist/paginate.js +693 -0
- package/dist/recording.d.ts +72 -0
- package/dist/recording.js +934 -0
- package/dist/script-executor.d.ts +31 -0
- package/dist/script-executor.js +249 -0
- package/dist/session-hud.d.ts +20 -0
- package/dist/session-hud.js +134 -0
- package/dist/snapshot-differ.d.ts +26 -0
- package/dist/snapshot-differ.js +225 -0
- package/dist/ssrf.d.ts +28 -0
- package/dist/ssrf.js +290 -0
- package/dist/stealth-audit.d.ts +27 -0
- package/dist/stealth-audit.js +719 -0
- package/dist/stealth.d.ts +195 -0
- package/dist/stealth.js +1157 -0
- package/dist/tab-manager.d.ts +14 -0
- package/dist/tab-manager.js +306 -0
- package/dist/tiles-coordinator.d.ts +106 -0
- package/dist/tiles-coordinator.js +358 -0
- package/package.json +1 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Session } from "./types.js";
|
|
2
|
+
export interface ExecuteOptions {
|
|
3
|
+
script: string;
|
|
4
|
+
timeout?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface ExecuteResult {
|
|
7
|
+
returnValue: string;
|
|
8
|
+
console: string[];
|
|
9
|
+
duration: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Sanitize a stack trace to remove:
|
|
13
|
+
* 1. Server install paths (e.g., /Users/ted/Projects/leapfrog/dist/...)
|
|
14
|
+
* 2. The async wrapper function frame added by the executor
|
|
15
|
+
* 3. Adjust line numbers to be relative to the user's script (subtract 1 for wrapper)
|
|
16
|
+
*/
|
|
17
|
+
declare function sanitizeStack(stack: string): string;
|
|
18
|
+
export declare class ScriptExecutor {
|
|
19
|
+
/**
|
|
20
|
+
* Whether the execute tool is enabled (LEAP_ALLOW_EXECUTE env var).
|
|
21
|
+
* Defaults to true when unset.
|
|
22
|
+
*/
|
|
23
|
+
static isEnabled(): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Execute a user-provided script string in a sandboxed vm context,
|
|
26
|
+
* with access to the session's Playwright page and browser context.
|
|
27
|
+
*/
|
|
28
|
+
static execute(session: Session, options: ExecuteOptions): Promise<ExecuteResult>;
|
|
29
|
+
}
|
|
30
|
+
export { sanitizeStack };
|
|
31
|
+
export default ScriptExecutor;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// ─── Script Executor ──────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Code-first scripting mode: runs Playwright scripts inside a Node.js vm
|
|
4
|
+
// sandbox. Agents send a JS function body string and get back the return
|
|
5
|
+
// value + captured console output.
|
|
6
|
+
//
|
|
7
|
+
// Kill switch: LEAP_ALLOW_EXECUTE (default "true"). Set to "false" to
|
|
8
|
+
// disable entirely.
|
|
9
|
+
import * as vm from "node:vm";
|
|
10
|
+
import { logger } from "./logger.js";
|
|
11
|
+
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
12
|
+
const DEFAULT_TIMEOUT = 60_000;
|
|
13
|
+
const MAX_TIMEOUT = 300_000;
|
|
14
|
+
const BLOCKED_GLOBALS = [
|
|
15
|
+
"require",
|
|
16
|
+
"process",
|
|
17
|
+
"fs",
|
|
18
|
+
"child_process",
|
|
19
|
+
"net",
|
|
20
|
+
"http",
|
|
21
|
+
"https",
|
|
22
|
+
"global",
|
|
23
|
+
"globalThis",
|
|
24
|
+
];
|
|
25
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
26
|
+
function clampTimeout(value) {
|
|
27
|
+
if (value === undefined || value <= 0)
|
|
28
|
+
return DEFAULT_TIMEOUT;
|
|
29
|
+
return Math.min(value, MAX_TIMEOUT);
|
|
30
|
+
}
|
|
31
|
+
function createBlockedProxy(name) {
|
|
32
|
+
// Use a function target so both call-style (require("x")) and
|
|
33
|
+
// property-access-style (process.pid) throw the correct error.
|
|
34
|
+
const msg = `Access to '${name}' is not allowed in execute scripts`;
|
|
35
|
+
const trap = () => {
|
|
36
|
+
throw new Error(msg);
|
|
37
|
+
};
|
|
38
|
+
return new Proxy(trap, {
|
|
39
|
+
get() {
|
|
40
|
+
throw new Error(msg);
|
|
41
|
+
},
|
|
42
|
+
set() {
|
|
43
|
+
throw new Error(msg);
|
|
44
|
+
},
|
|
45
|
+
apply() {
|
|
46
|
+
throw new Error(msg);
|
|
47
|
+
},
|
|
48
|
+
construct() {
|
|
49
|
+
throw new Error(msg);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function safeStringify(value) {
|
|
54
|
+
if (value === undefined) {
|
|
55
|
+
return "Script completed (no return value)";
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
return JSON.stringify(value);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Non-serializable (circular refs, functions, etc.)
|
|
62
|
+
return String(value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// ─── Stack trace sanitization ─────────────────────────────────────────────
|
|
66
|
+
/** Pattern matching file paths that leak server install location */
|
|
67
|
+
const PATH_LEAK_RE = /[A-Za-z]:[\\\/]|\/[Uu]sers\/[^\s:)]+|\/home\/[^\s:)]+|\/tmp\/[^\s:)]+|node_modules\/[^\s:)]+/g;
|
|
68
|
+
/** Pattern matching the async IIFE wrapper frame we inject */
|
|
69
|
+
const WRAPPER_FRAME_RE = /\s*at\s+(async\s+)?execute-script\.js:\d+:\d+\s*$/gm;
|
|
70
|
+
/**
|
|
71
|
+
* Sanitize a stack trace to remove:
|
|
72
|
+
* 1. Server install paths (e.g., /Users/ted/Projects/leapfrog/dist/...)
|
|
73
|
+
* 2. The async wrapper function frame added by the executor
|
|
74
|
+
* 3. Adjust line numbers to be relative to the user's script (subtract 1 for wrapper)
|
|
75
|
+
*/
|
|
76
|
+
function sanitizeStack(stack) {
|
|
77
|
+
let sanitized = stack;
|
|
78
|
+
// Strip file paths that leak server location
|
|
79
|
+
sanitized = sanitized.replace(PATH_LEAK_RE, "[leapfrog]");
|
|
80
|
+
// Remove the async IIFE wrapper frames
|
|
81
|
+
sanitized = sanitized.replace(WRAPPER_FRAME_RE, "");
|
|
82
|
+
// Adjust line numbers in "execute-script.js:N:M" — subtract 1 for the async wrapper line
|
|
83
|
+
sanitized = sanitized.replace(/execute-script\.js:(\d+):(\d+)/g, (_match, line, col) => {
|
|
84
|
+
const adjustedLine = Math.max(1, Number(line) - 1);
|
|
85
|
+
return `execute-script.js:${adjustedLine}:${col}`;
|
|
86
|
+
});
|
|
87
|
+
// Remove empty lines left after stripping
|
|
88
|
+
sanitized = sanitized
|
|
89
|
+
.split("\n")
|
|
90
|
+
.filter((line) => line.trim() !== "")
|
|
91
|
+
.join("\n");
|
|
92
|
+
return sanitized;
|
|
93
|
+
}
|
|
94
|
+
// ─── ScriptExecutor ────────────────────────────────────────────────────────
|
|
95
|
+
export class ScriptExecutor {
|
|
96
|
+
/**
|
|
97
|
+
* Whether the execute tool is enabled (LEAP_ALLOW_EXECUTE env var).
|
|
98
|
+
* Defaults to true when unset.
|
|
99
|
+
*/
|
|
100
|
+
static isEnabled() {
|
|
101
|
+
const val = process.env.LEAP_ALLOW_EXECUTE;
|
|
102
|
+
// Only explicitly "false" disables it
|
|
103
|
+
return val !== "false";
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Execute a user-provided script string in a sandboxed vm context,
|
|
107
|
+
* with access to the session's Playwright page and browser context.
|
|
108
|
+
*/
|
|
109
|
+
static async execute(session, options) {
|
|
110
|
+
// ── Kill switch ──────────────────────────────────────────────────
|
|
111
|
+
if (!ScriptExecutor.isEnabled()) {
|
|
112
|
+
throw new Error("execute tool is disabled. Set LEAP_ALLOW_EXECUTE=true to enable.");
|
|
113
|
+
}
|
|
114
|
+
const timeout = clampTimeout(options.timeout);
|
|
115
|
+
const consoleOutput = [];
|
|
116
|
+
const start = Date.now();
|
|
117
|
+
// ── Console proxy ────────────────────────────────────────────────
|
|
118
|
+
const consoleProxy = {
|
|
119
|
+
log: (...args) => {
|
|
120
|
+
consoleOutput.push(args.map(String).join(" "));
|
|
121
|
+
},
|
|
122
|
+
warn: (...args) => {
|
|
123
|
+
consoleOutput.push(`[WARN] ${args.map(String).join(" ")}`);
|
|
124
|
+
},
|
|
125
|
+
error: (...args) => {
|
|
126
|
+
consoleOutput.push(`[ERROR] ${args.map(String).join(" ")}`);
|
|
127
|
+
},
|
|
128
|
+
info: (...args) => {
|
|
129
|
+
consoleOutput.push(`[INFO] ${args.map(String).join(" ")}`);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
// ── Build sandbox context ────────────────────────────────────────
|
|
133
|
+
const sandbox = {
|
|
134
|
+
// Playwright objects
|
|
135
|
+
page: session.page,
|
|
136
|
+
context: session.context,
|
|
137
|
+
// Console proxy
|
|
138
|
+
console: consoleProxy,
|
|
139
|
+
// Safe built-ins
|
|
140
|
+
setTimeout,
|
|
141
|
+
clearTimeout,
|
|
142
|
+
Promise,
|
|
143
|
+
JSON,
|
|
144
|
+
Array,
|
|
145
|
+
Object,
|
|
146
|
+
Map,
|
|
147
|
+
Set,
|
|
148
|
+
Date,
|
|
149
|
+
Math,
|
|
150
|
+
RegExp,
|
|
151
|
+
Error,
|
|
152
|
+
URL,
|
|
153
|
+
URLSearchParams,
|
|
154
|
+
};
|
|
155
|
+
// Wire up blocked globals as throwing proxies
|
|
156
|
+
for (const name of BLOCKED_GLOBALS) {
|
|
157
|
+
sandbox[name] = createBlockedProxy(name);
|
|
158
|
+
}
|
|
159
|
+
const vmContext = vm.createContext(sandbox);
|
|
160
|
+
// The vm module sets globalThis to the context object itself, which
|
|
161
|
+
// overrides our proxy. Re-define it as a throwing getter.
|
|
162
|
+
// Note: `global` is kept as a proxy in the sandbox (not a vm built-in),
|
|
163
|
+
// so it doesn't need special handling here.
|
|
164
|
+
Object.defineProperty(vmContext, "globalThis", {
|
|
165
|
+
get() {
|
|
166
|
+
throw new Error("Access to 'globalThis' is not allowed in execute scripts");
|
|
167
|
+
},
|
|
168
|
+
configurable: true,
|
|
169
|
+
});
|
|
170
|
+
// ── Compile the script ───────────────────────────────────────────
|
|
171
|
+
// Wrap the user's script in an async IIFE so `await` works at top level.
|
|
172
|
+
// The user provides the function body, so we wrap it:
|
|
173
|
+
// (async () => { <script> })()
|
|
174
|
+
const wrappedSource = `(async () => {\n${options.script}\n})()`;
|
|
175
|
+
let compiledScript;
|
|
176
|
+
try {
|
|
177
|
+
compiledScript = new vm.Script(wrappedSource, {
|
|
178
|
+
filename: "execute-script.js",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
const syntaxError = err;
|
|
183
|
+
logger.warn("script_executor.syntax_error", {
|
|
184
|
+
message: syntaxError.message,
|
|
185
|
+
});
|
|
186
|
+
// Extract line info — subtract 1 for the async wrapper we added
|
|
187
|
+
const lineMatch = syntaxError.stack?.match(/:(\d+)/);
|
|
188
|
+
const line = lineMatch ? Math.max(1, Number(lineMatch[1]) - 1) : undefined;
|
|
189
|
+
const lineInfo = line ? ` (line ${line})` : "";
|
|
190
|
+
throw new Error(`Script syntax error${lineInfo}: ${syntaxError.message}`);
|
|
191
|
+
}
|
|
192
|
+
// ── Execute ──────────────────────────────────────────────────────
|
|
193
|
+
logger.debug("script_executor.start", { timeout });
|
|
194
|
+
const ac = new AbortController();
|
|
195
|
+
const timer = setTimeout(() => ac.abort(), timeout);
|
|
196
|
+
try {
|
|
197
|
+
// Run in the vm context. The script returns a Promise (async IIFE).
|
|
198
|
+
const resultPromise = compiledScript.runInContext(vmContext, {
|
|
199
|
+
timeout,
|
|
200
|
+
displayErrors: true,
|
|
201
|
+
});
|
|
202
|
+
// Race the promise result against our AbortController timeout
|
|
203
|
+
const result = await Promise.race([
|
|
204
|
+
resultPromise,
|
|
205
|
+
new Promise((_, reject) => {
|
|
206
|
+
ac.signal.addEventListener("abort", () => {
|
|
207
|
+
reject(new Error(`Script execution timed out after ${timeout}ms`));
|
|
208
|
+
});
|
|
209
|
+
}),
|
|
210
|
+
]);
|
|
211
|
+
const duration = Date.now() - start;
|
|
212
|
+
logger.debug("script_executor.complete", { duration });
|
|
213
|
+
return {
|
|
214
|
+
returnValue: safeStringify(result),
|
|
215
|
+
console: consoleOutput,
|
|
216
|
+
duration,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
const duration = Date.now() - start;
|
|
221
|
+
const error = err;
|
|
222
|
+
// Check for vm timeout (throws "Script execution timed out")
|
|
223
|
+
if (error.message?.includes("Script execution timed out") ||
|
|
224
|
+
error.message?.includes("timed out")) {
|
|
225
|
+
logger.warn("script_executor.timeout", { timeout, duration });
|
|
226
|
+
throw new Error(`Script execution timed out after ${timeout}ms`);
|
|
227
|
+
}
|
|
228
|
+
// Check for sandbox escape
|
|
229
|
+
if (error.message?.includes("is not allowed in execute scripts")) {
|
|
230
|
+
logger.warn("script_executor.blocked_access", {
|
|
231
|
+
message: error.message,
|
|
232
|
+
});
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
// Generic runtime error — include sanitized stack context
|
|
236
|
+
logger.warn("script_executor.runtime_error", {
|
|
237
|
+
message: error.message,
|
|
238
|
+
});
|
|
239
|
+
const rawStack = error.stack ?? "";
|
|
240
|
+
const stack = sanitizeStack(rawStack);
|
|
241
|
+
throw new Error(`Script runtime error: ${error.message}\n${stack}`);
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
export { sanitizeStack };
|
|
249
|
+
export default ScriptExecutor;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type HUDStatus = "active" | "loading" | "waiting" | "error" | "complete";
|
|
2
|
+
/**
|
|
3
|
+
* Returns the JavaScript string to inject via page.addInitScript().
|
|
4
|
+
* Creates the ripple container, ripple CSS, and the click ripple function.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getHUDInitScript(sessionName: string): string;
|
|
7
|
+
/** Returns empty string (HUD status bar removed). Kept for API compatibility. */
|
|
8
|
+
export declare function getHUDUpdateScript(_status: HUDStatus, _label?: string): string;
|
|
9
|
+
/** Returns JS for click ripple at coordinates. */
|
|
10
|
+
export declare function getClickRippleScript(x: number, y: number): string;
|
|
11
|
+
/**
|
|
12
|
+
* Zoom-to-target: two sync scripts with a Playwright waitForTimeout in between.
|
|
13
|
+
* getScrollToTargetZoomIn zooms the page 2.5x and highlights the element.
|
|
14
|
+
* getScrollToTargetZoomOut restores normal zoom.
|
|
15
|
+
* The caller awaits a real delay between them so the human can see.
|
|
16
|
+
*/
|
|
17
|
+
export declare function getScrollToTargetZoomIn(selector: string): string;
|
|
18
|
+
export declare function getScrollToTargetZoomOut(selector: string): string;
|
|
19
|
+
/** Legacy single-call version (sync scroll only, no zoom). */
|
|
20
|
+
export declare function getScrollToTargetScript(selector: string): string;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// ─── Session HUD Overlay ──────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Injects a minimal visual HUD into browser pages: click ripple animation.
|
|
4
|
+
// All functions return JavaScript strings to evaluate in the browser context
|
|
5
|
+
// via page.addInitScript() or page.evaluate().
|
|
6
|
+
//
|
|
7
|
+
// Opt-in via LEAP_HUD=true env var (caller checks, not this module).
|
|
8
|
+
//
|
|
9
|
+
// Integration point: import { getHUDInitScript, ... } from "./session-hud.js"
|
|
10
|
+
//
|
|
11
|
+
import { logger } from "./logger.js";
|
|
12
|
+
// ─── Init Script ───────────────────────────────────────────────────────────
|
|
13
|
+
/**
|
|
14
|
+
* Returns the JavaScript string to inject via page.addInitScript().
|
|
15
|
+
* Creates the ripple container, ripple CSS, and the click ripple function.
|
|
16
|
+
*/
|
|
17
|
+
export function getHUDInitScript(sessionName) {
|
|
18
|
+
logger.debug(`Generating HUD init script for session: ${sessionName}`);
|
|
19
|
+
return `(function() {
|
|
20
|
+
if (window.__leapfrog_hud_initialized) return;
|
|
21
|
+
window.__leapfrog_hud_initialized = true;
|
|
22
|
+
|
|
23
|
+
// ── CSS (ripple only) ────────────────────────────────────────────────
|
|
24
|
+
var style = document.createElement('style');
|
|
25
|
+
style.setAttribute('data-leapfrog', 'true');
|
|
26
|
+
style.textContent = \`
|
|
27
|
+
#leapfrog-ripple-container {
|
|
28
|
+
position: fixed;
|
|
29
|
+
top: 0;
|
|
30
|
+
left: 0;
|
|
31
|
+
width: 100%;
|
|
32
|
+
height: 100%;
|
|
33
|
+
z-index: 2147483646;
|
|
34
|
+
pointer-events: none;
|
|
35
|
+
overflow: hidden;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@keyframes leapfrog-ripple {
|
|
39
|
+
0% {
|
|
40
|
+
width: 20px;
|
|
41
|
+
height: 20px;
|
|
42
|
+
opacity: 0.6;
|
|
43
|
+
}
|
|
44
|
+
100% {
|
|
45
|
+
width: 80px;
|
|
46
|
+
height: 80px;
|
|
47
|
+
opacity: 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.leapfrog-ripple {
|
|
52
|
+
position: absolute;
|
|
53
|
+
border-radius: 50%;
|
|
54
|
+
background: rgba(34, 197, 94, 0.4);
|
|
55
|
+
animation: leapfrog-ripple 0.6s ease-out forwards;
|
|
56
|
+
pointer-events: none;
|
|
57
|
+
transform: translate(-50%, -50%);
|
|
58
|
+
}
|
|
59
|
+
\`;
|
|
60
|
+
document.head.appendChild(style);
|
|
61
|
+
|
|
62
|
+
document.documentElement.setAttribute('data-leapfrog', 'true');
|
|
63
|
+
|
|
64
|
+
// ── Ripple Container ─────────────────────────────────────────────────
|
|
65
|
+
var rippleContainer = document.createElement('div');
|
|
66
|
+
rippleContainer.id = 'leapfrog-ripple-container';
|
|
67
|
+
rippleContainer.setAttribute('data-leapfrog', 'true');
|
|
68
|
+
document.body.appendChild(rippleContainer);
|
|
69
|
+
|
|
70
|
+
// ── Control Functions ────────────────────────────────────────────────
|
|
71
|
+
window.__leapfrog_clickRipple = function(x, y) {
|
|
72
|
+
var container = document.getElementById('leapfrog-ripple-container');
|
|
73
|
+
if (!container) return;
|
|
74
|
+
var ripple = document.createElement('div');
|
|
75
|
+
ripple.className = 'leapfrog-ripple';
|
|
76
|
+
ripple.setAttribute('data-leapfrog', 'true');
|
|
77
|
+
ripple.style.left = x + 'px';
|
|
78
|
+
ripple.style.top = y + 'px';
|
|
79
|
+
container.appendChild(ripple);
|
|
80
|
+
ripple.addEventListener('animationend', function() { ripple.remove(); });
|
|
81
|
+
};
|
|
82
|
+
})();`;
|
|
83
|
+
}
|
|
84
|
+
// ─── Live Update Scripts ───────────────────────────────────────────────────
|
|
85
|
+
/** Returns empty string (HUD status bar removed). Kept for API compatibility. */
|
|
86
|
+
export function getHUDUpdateScript(_status, _label) {
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
/** Returns JS for click ripple at coordinates. */
|
|
90
|
+
export function getClickRippleScript(x, y) {
|
|
91
|
+
return `window.__leapfrog_clickRipple && window.__leapfrog_clickRipple(${x}, ${y});`;
|
|
92
|
+
}
|
|
93
|
+
// ─── Scroll-to-Target ─────────────────────────────────────────────────────
|
|
94
|
+
/**
|
|
95
|
+
* Zoom-to-target: two sync scripts with a Playwright waitForTimeout in between.
|
|
96
|
+
* getScrollToTargetZoomIn zooms the page 2.5x and highlights the element.
|
|
97
|
+
* getScrollToTargetZoomOut restores normal zoom.
|
|
98
|
+
* The caller awaits a real delay between them so the human can see.
|
|
99
|
+
*/
|
|
100
|
+
export function getScrollToTargetZoomIn(selector) {
|
|
101
|
+
const escaped = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
102
|
+
return `(function() {
|
|
103
|
+
var el = document.querySelector('${escaped}');
|
|
104
|
+
if (!el) return false;
|
|
105
|
+
el.scrollIntoView({ block: 'center' });
|
|
106
|
+
document.body.style.zoom = '2.5';
|
|
107
|
+
el.scrollIntoView({ block: 'center' });
|
|
108
|
+
el.style.outline = '3px solid #22c55e';
|
|
109
|
+
el.style.outlineOffset = '4px';
|
|
110
|
+
el.style.backgroundColor = 'rgba(34, 197, 94, 0.1)';
|
|
111
|
+
return true;
|
|
112
|
+
})()`;
|
|
113
|
+
}
|
|
114
|
+
export function getScrollToTargetZoomOut(selector) {
|
|
115
|
+
const escaped = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
116
|
+
return `(function() {
|
|
117
|
+
var el = document.querySelector('${escaped}');
|
|
118
|
+
document.body.style.zoom = '1';
|
|
119
|
+
if (el) {
|
|
120
|
+
el.style.outline = '';
|
|
121
|
+
el.style.outlineOffset = '';
|
|
122
|
+
el.style.backgroundColor = '';
|
|
123
|
+
el.scrollIntoView({ block: 'center' });
|
|
124
|
+
}
|
|
125
|
+
})()`;
|
|
126
|
+
}
|
|
127
|
+
/** Legacy single-call version (sync scroll only, no zoom). */
|
|
128
|
+
export function getScrollToTargetScript(selector) {
|
|
129
|
+
const escaped = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
130
|
+
return `(function() {
|
|
131
|
+
var el = document.querySelector('${escaped}');
|
|
132
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
133
|
+
})()`;
|
|
134
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SnapshotResult } from "./types.js";
|
|
2
|
+
export interface SnapshotDiff {
|
|
3
|
+
/** Number of changes detected */
|
|
4
|
+
changeCount: number;
|
|
5
|
+
/** Human-readable diff text */
|
|
6
|
+
diffText: string;
|
|
7
|
+
/** Whether this is the first snapshot (no diff available) */
|
|
8
|
+
isFirst: boolean;
|
|
9
|
+
/** Token estimate for the diff vs full snapshot */
|
|
10
|
+
diffTokenEstimate: number;
|
|
11
|
+
fullTokenEstimate: number;
|
|
12
|
+
}
|
|
13
|
+
export declare class SnapshotDiffer {
|
|
14
|
+
/** Compare current snapshot with cached version, update cache */
|
|
15
|
+
static diff(sessionId: string, pageUrl: string, current: SnapshotResult): SnapshotDiff;
|
|
16
|
+
/** Clear cached snapshots for a session */
|
|
17
|
+
static clearSession(sessionId: string): void;
|
|
18
|
+
/** Clear all cached snapshots */
|
|
19
|
+
static clearAll(): void;
|
|
20
|
+
/** Get cache stats */
|
|
21
|
+
static stats(): {
|
|
22
|
+
size: number;
|
|
23
|
+
maxSize: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export default SnapshotDiffer;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { logger } from "./logger.js";
|
|
2
|
+
// ─── Constants ────────────────────────────────────────────────────────────
|
|
3
|
+
const MAX_CACHE_SIZE = 100;
|
|
4
|
+
const REF_LINE_RE = /^(\s*)(@e\d+)\s+(.*)$/;
|
|
5
|
+
// ─── Internal cache ───────────────────────────────────────────────────────
|
|
6
|
+
const cache = new Map();
|
|
7
|
+
function cacheKey(sessionId, pageUrl) {
|
|
8
|
+
return `${sessionId}:${pageUrl}`;
|
|
9
|
+
}
|
|
10
|
+
function evictLRU() {
|
|
11
|
+
if (cache.size < MAX_CACHE_SIZE)
|
|
12
|
+
return;
|
|
13
|
+
let oldestKey = "";
|
|
14
|
+
let oldestTime = Infinity;
|
|
15
|
+
for (const [key, entry] of cache) {
|
|
16
|
+
if (entry.accessedAt < oldestTime) {
|
|
17
|
+
oldestTime = entry.accessedAt;
|
|
18
|
+
oldestKey = key;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (oldestKey) {
|
|
22
|
+
cache.delete(oldestKey);
|
|
23
|
+
logger.debug("snapshot_differ_evict", { key: oldestKey });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Extract type and accessible name from the content portion after the ref */
|
|
27
|
+
function parseContent(content) {
|
|
28
|
+
// content looks like: 'link "Home"' or 'main' or 'navigation "Main Nav"'
|
|
29
|
+
const nameMatch = content.match(/^(\S+)\s+"(.*)"$/);
|
|
30
|
+
if (nameMatch) {
|
|
31
|
+
return { type: nameMatch[1].toLowerCase(), name: nameMatch[2].toLowerCase().trim() };
|
|
32
|
+
}
|
|
33
|
+
// No quoted name — just a type like "main"
|
|
34
|
+
const typeOnly = content.trim().split(/\s+/)[0];
|
|
35
|
+
return { type: (typeOnly || "unknown").toLowerCase(), name: "" };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build a fingerprint map from snapshot text.
|
|
39
|
+
* Fingerprint = `${type}:${name}` for named elements, `${type}:${lineIndex}` for unnamed.
|
|
40
|
+
* Duplicates are disambiguated with `_N` suffix.
|
|
41
|
+
*/
|
|
42
|
+
function parseFingerprintMap(text) {
|
|
43
|
+
const lines = text.split("\n");
|
|
44
|
+
const elements = [];
|
|
45
|
+
for (let i = 0; i < lines.length; i++) {
|
|
46
|
+
const line = lines[i];
|
|
47
|
+
const m = line.match(REF_LINE_RE);
|
|
48
|
+
if (m) {
|
|
49
|
+
const ref = m[2];
|
|
50
|
+
const desc = m[3];
|
|
51
|
+
const { type, name } = parseContent(desc);
|
|
52
|
+
elements.push({ ref, type, name, fullLine: line, desc });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Build raw fingerprints — type:name for named, type: for unnamed
|
|
56
|
+
// Duplicates (both named and unnamed) are disambiguated below with _N suffix
|
|
57
|
+
const rawFingerprints = elements.map((el) => {
|
|
58
|
+
if (el.name) {
|
|
59
|
+
return `${el.type}:${el.name}`;
|
|
60
|
+
}
|
|
61
|
+
return `${el.type}:`;
|
|
62
|
+
});
|
|
63
|
+
// Count occurrences of each fingerprint to find duplicates
|
|
64
|
+
const countMap = new Map();
|
|
65
|
+
for (const fp of rawFingerprints) {
|
|
66
|
+
countMap.set(fp, (countMap.get(fp) || 0) + 1);
|
|
67
|
+
}
|
|
68
|
+
// Assign final fingerprints with _N suffix for duplicates
|
|
69
|
+
const occurrenceTracker = new Map();
|
|
70
|
+
const result = new Map();
|
|
71
|
+
for (let i = 0; i < elements.length; i++) {
|
|
72
|
+
let fp = rawFingerprints[i];
|
|
73
|
+
const count = countMap.get(fp) || 1;
|
|
74
|
+
if (count > 1) {
|
|
75
|
+
const occurrence = occurrenceTracker.get(fp) || 0;
|
|
76
|
+
occurrenceTracker.set(fp, occurrence + 1);
|
|
77
|
+
fp = `${fp}_${occurrence}`;
|
|
78
|
+
}
|
|
79
|
+
result.set(fp, elements[i]);
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
/** Estimate tokens from character count (chars / 4) */
|
|
84
|
+
function estimateTokens(text) {
|
|
85
|
+
return Math.ceil(text.length / 4);
|
|
86
|
+
}
|
|
87
|
+
// ─── Differ ───────────────────────────────────────────────────────────────
|
|
88
|
+
export class SnapshotDiffer {
|
|
89
|
+
/** Compare current snapshot with cached version, update cache */
|
|
90
|
+
static diff(sessionId, pageUrl, current) {
|
|
91
|
+
const key = cacheKey(sessionId, pageUrl);
|
|
92
|
+
const prev = cache.get(key);
|
|
93
|
+
const fullTokenEstimate = estimateTokens(current.text);
|
|
94
|
+
// Store / update cache (only evict if this is a genuinely new key)
|
|
95
|
+
if (!cache.has(key))
|
|
96
|
+
evictLRU();
|
|
97
|
+
cache.set(key, { text: current.text, accessedAt: Date.now() });
|
|
98
|
+
// First snapshot — no previous to compare against
|
|
99
|
+
if (!prev) {
|
|
100
|
+
logger.debug("snapshot_differ_first", { sessionId, pageUrl });
|
|
101
|
+
return {
|
|
102
|
+
changeCount: 0,
|
|
103
|
+
diffText: "",
|
|
104
|
+
isFirst: true,
|
|
105
|
+
diffTokenEstimate: 0,
|
|
106
|
+
fullTokenEstimate,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// Parse both snapshots into fingerprint -> element maps
|
|
110
|
+
const oldMap = parseFingerprintMap(prev.text);
|
|
111
|
+
const newMap = parseFingerprintMap(current.text);
|
|
112
|
+
const added = [];
|
|
113
|
+
const removed = [];
|
|
114
|
+
const changed = [];
|
|
115
|
+
// Track which fingerprints have been matched
|
|
116
|
+
const matchedOld = new Set();
|
|
117
|
+
const matchedNew = new Set();
|
|
118
|
+
// Pass 1: Exact fingerprint matches (same type + same name)
|
|
119
|
+
for (const [fp, newEl] of newMap) {
|
|
120
|
+
const oldEl = oldMap.get(fp);
|
|
121
|
+
if (oldEl !== undefined) {
|
|
122
|
+
matchedOld.add(fp);
|
|
123
|
+
matchedNew.add(fp);
|
|
124
|
+
// Same identity — check for structural/content changes (ignoring ref ID shifts)
|
|
125
|
+
const oldStripped = oldEl.fullLine.replace(/@e\d+/, "");
|
|
126
|
+
const newStripped = newEl.fullLine.replace(/@e\d+/, "");
|
|
127
|
+
if (oldStripped !== newStripped) {
|
|
128
|
+
changed.push(`~ ${newEl.ref} ${oldEl.desc} → ${newEl.desc} (changed)`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Pass 2: Pair unmatched elements of the same type as "changed"
|
|
133
|
+
// (handles cases like heading text changing from "Welcome" to "New Title")
|
|
134
|
+
const unmatchedOld = new Map();
|
|
135
|
+
for (const [fp, el] of oldMap) {
|
|
136
|
+
if (!matchedOld.has(fp)) {
|
|
137
|
+
const list = unmatchedOld.get(el.type) || [];
|
|
138
|
+
list.push({ fp, el });
|
|
139
|
+
unmatchedOld.set(el.type, list);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const unmatchedNew = new Map();
|
|
143
|
+
for (const [fp, el] of newMap) {
|
|
144
|
+
if (!matchedNew.has(fp)) {
|
|
145
|
+
const list = unmatchedNew.get(el.type) || [];
|
|
146
|
+
list.push({ fp, el });
|
|
147
|
+
unmatchedNew.set(el.type, list);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Pair unmatched elements of the same type (in order) as changes
|
|
151
|
+
for (const [type, oldList] of unmatchedOld) {
|
|
152
|
+
const newList = unmatchedNew.get(type) || [];
|
|
153
|
+
const pairCount = Math.min(oldList.length, newList.length);
|
|
154
|
+
for (let i = 0; i < pairCount; i++) {
|
|
155
|
+
const oldEl = oldList[i].el;
|
|
156
|
+
const newEl = newList[i].el;
|
|
157
|
+
matchedOld.add(oldList[i].fp);
|
|
158
|
+
matchedNew.add(newList[i].fp);
|
|
159
|
+
changed.push(`~ ${newEl.ref} ${oldEl.desc} → ${newEl.desc} (changed)`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Remaining unmatched = pure adds / removes
|
|
163
|
+
for (const [fp, newEl] of newMap) {
|
|
164
|
+
if (!matchedNew.has(fp)) {
|
|
165
|
+
added.push(`+ ${newEl.ref} ${newEl.desc} (new)`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const [fp, oldEl] of oldMap) {
|
|
169
|
+
if (!matchedOld.has(fp)) {
|
|
170
|
+
removed.push(`- ${oldEl.ref} ${oldEl.desc} (removed)`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const changeCount = added.length + removed.length + changed.length;
|
|
174
|
+
if (changeCount === 0) {
|
|
175
|
+
return {
|
|
176
|
+
changeCount: 0,
|
|
177
|
+
diffText: "[INCREMENTAL SNAPSHOT — 0 changes since last snapshot]",
|
|
178
|
+
isFirst: false,
|
|
179
|
+
diffTokenEstimate: estimateTokens("[INCREMENTAL SNAPSHOT — 0 changes since last snapshot]"),
|
|
180
|
+
fullTokenEstimate,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const lines = [
|
|
184
|
+
`[INCREMENTAL SNAPSHOT — ${changeCount} change${changeCount === 1 ? "" : "s"} since last snapshot]`,
|
|
185
|
+
...added,
|
|
186
|
+
...changed,
|
|
187
|
+
...removed,
|
|
188
|
+
];
|
|
189
|
+
const diffText = lines.join("\n");
|
|
190
|
+
logger.debug("snapshot_differ_diff", {
|
|
191
|
+
sessionId,
|
|
192
|
+
pageUrl,
|
|
193
|
+
added: added.length,
|
|
194
|
+
changed: changed.length,
|
|
195
|
+
removed: removed.length,
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
changeCount,
|
|
199
|
+
diffText,
|
|
200
|
+
isFirst: false,
|
|
201
|
+
diffTokenEstimate: estimateTokens(diffText),
|
|
202
|
+
fullTokenEstimate,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** Clear cached snapshots for a session */
|
|
206
|
+
static clearSession(sessionId) {
|
|
207
|
+
const prefix = `${sessionId}:`;
|
|
208
|
+
for (const key of cache.keys()) {
|
|
209
|
+
if (key.startsWith(prefix)) {
|
|
210
|
+
cache.delete(key);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
logger.debug("snapshot_differ_clear_session", { sessionId });
|
|
214
|
+
}
|
|
215
|
+
/** Clear all cached snapshots */
|
|
216
|
+
static clearAll() {
|
|
217
|
+
cache.clear();
|
|
218
|
+
logger.debug("snapshot_differ_clear_all");
|
|
219
|
+
}
|
|
220
|
+
/** Get cache stats */
|
|
221
|
+
static stats() {
|
|
222
|
+
return { size: cache.size, maxSize: MAX_CACHE_SIZE };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
export default SnapshotDiffer;
|