@treelocator/runtime 0.4.7 → 0.6.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/.eslintignore +1 -0
- package/dist/_generated_styles.d.ts +1 -1
- package/dist/_generated_styles.js +20 -0
- package/dist/_generated_tree_icon.d.ts +1 -1
- package/dist/adapters/HtmlElementTreeNode.d.ts +2 -2
- package/dist/adapters/HtmlElementTreeNode.js +4 -6
- package/dist/adapters/createTreeNode.js +17 -44
- package/dist/adapters/detectFramework.d.ts +8 -0
- package/dist/adapters/detectFramework.js +25 -0
- package/dist/adapters/detectFramework.test.d.ts +1 -0
- package/dist/adapters/detectFramework.test.js +60 -0
- package/dist/adapters/jsx/jsxAdapter.js +54 -89
- package/dist/adapters/jsx/jsxAdapter.test.d.ts +1 -0
- package/dist/adapters/jsx/jsxAdapter.test.js +273 -0
- package/dist/adapters/nextjs/parseNextjsDataAttributes.js +1 -1
- package/dist/adapters/nextjs/parseNextjsDataAttributes.test.d.ts +1 -0
- package/dist/adapters/nextjs/parseNextjsDataAttributes.test.js +158 -0
- package/dist/adapters/react/findFiberByHtmlElement.d.ts +1 -1
- package/dist/adapters/react/findFiberByHtmlElement.js +1 -1
- package/dist/adapters/react/getAllParentsElementsAndRootComponent.js +4 -0
- package/dist/adapters/resolveAdapter.d.ts +1 -1
- package/dist/adapters/resolveAdapter.js +4 -8
- package/dist/adapters/svelte/svelteAdapter.test.d.ts +1 -0
- package/dist/adapters/svelte/svelteAdapter.test.js +280 -0
- package/dist/adapters/vue/vueAdapter.test.d.ts +1 -0
- package/dist/adapters/vue/vueAdapter.test.js +222 -0
- package/dist/browserApi.d.ts +148 -0
- package/dist/browserApi.js +146 -5
- package/dist/browserApi.test.d.ts +1 -0
- package/dist/browserApi.test.js +287 -0
- package/dist/components/RecordingPillButton.d.ts +11 -0
- package/dist/components/RecordingPillButton.js +202 -0
- package/dist/components/RecordingResults.d.ts +2 -0
- package/dist/components/RecordingResults.js +213 -78
- package/dist/components/Runtime.js +161 -554
- package/dist/components/SettingsPanel.d.ts +5 -0
- package/dist/components/SettingsPanel.js +312 -0
- package/dist/consoleCapture.d.ts +9 -0
- package/dist/consoleCapture.js +95 -0
- package/dist/dejitter/recorder.d.ts +7 -1
- package/dist/dejitter/recorder.js +64 -1
- package/dist/functions/cssRuleInspector.d.ts +83 -0
- package/dist/functions/cssRuleInspector.js +608 -0
- package/dist/functions/cssRuleInspector.test.d.ts +1 -0
- package/dist/functions/cssRuleInspector.test.js +439 -0
- package/dist/functions/deduplicateLabels.test.d.ts +1 -0
- package/dist/functions/deduplicateLabels.test.js +178 -0
- package/dist/functions/enrichAncestrySourceMaps.js +0 -1
- package/dist/functions/extractComputedStyles.d.ts +51 -0
- package/dist/functions/extractComputedStyles.js +447 -0
- package/dist/functions/extractComputedStyles.test.d.ts +1 -0
- package/dist/functions/extractComputedStyles.test.js +549 -0
- package/dist/functions/formatAncestryChain.d.ts +8 -0
- package/dist/functions/formatAncestryChain.js +21 -1
- package/dist/functions/formatAncestryChain.test.js +18 -0
- package/dist/functions/getUsableName.test.d.ts +1 -0
- package/dist/functions/getUsableName.test.js +219 -0
- package/dist/functions/isCombinationModifiersPressed.test.d.ts +1 -0
- package/dist/functions/isCombinationModifiersPressed.test.js +192 -0
- package/dist/functions/mergeRects.test.js +210 -1
- package/dist/functions/namedSnapshots.d.ts +52 -0
- package/dist/functions/namedSnapshots.js +161 -0
- package/dist/functions/namedSnapshots.test.d.ts +1 -0
- package/dist/functions/namedSnapshots.test.js +85 -0
- package/dist/functions/normalizeFilePath.test.d.ts +1 -0
- package/dist/functions/normalizeFilePath.test.js +66 -0
- package/dist/functions/parseDataId.test.d.ts +1 -0
- package/dist/functions/parseDataId.test.js +101 -0
- package/dist/hooks/getStorage.d.ts +3 -0
- package/dist/hooks/getStorage.js +17 -0
- package/dist/hooks/useEventListeners.d.ts +15 -0
- package/dist/hooks/useEventListeners.js +56 -0
- package/dist/hooks/useLocatorStorage.d.ts +18 -0
- package/dist/hooks/useLocatorStorage.js +41 -0
- package/dist/hooks/useLocatorStorage.test.d.ts +1 -0
- package/dist/hooks/useLocatorStorage.test.js +124 -0
- package/dist/hooks/useRecordingState.d.ts +43 -0
- package/dist/hooks/useRecordingState.js +387 -0
- package/dist/hooks/useSettings.d.ts +13 -0
- package/dist/hooks/useSettings.js +66 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -2
- package/dist/initRuntime.d.ts +3 -1
- package/dist/initRuntime.js +4 -1
- package/dist/mcpBridge.d.ts +61 -0
- package/dist/mcpBridge.js +534 -0
- package/dist/mcpBridge.test.d.ts +1 -0
- package/dist/mcpBridge.test.js +248 -0
- package/dist/output.css +20 -0
- package/dist/visualDiff/diff.d.ts +9 -0
- package/dist/visualDiff/diff.js +209 -0
- package/dist/visualDiff/diff.test.d.ts +1 -0
- package/dist/visualDiff/diff.test.js +253 -0
- package/dist/visualDiff/settle.d.ts +3 -0
- package/dist/visualDiff/settle.js +50 -0
- package/dist/visualDiff/settle.test.d.ts +1 -0
- package/dist/visualDiff/settle.test.js +65 -0
- package/dist/visualDiff/snapshot.d.ts +4 -0
- package/dist/visualDiff/snapshot.js +84 -0
- package/dist/visualDiff/snapshot.test.d.ts +1 -0
- package/dist/visualDiff/snapshot.test.js +245 -0
- package/dist/visualDiff/types.d.ts +37 -0
- package/dist/visualDiff/types.js +1 -0
- package/package.json +2 -2
- package/scripts/wrapCSS.js +1 -1
- package/scripts/wrapImage.js +1 -1
- package/src/_generated_styles.ts +21 -1
- package/src/_generated_tree_icon.ts +1 -1
- package/src/adapters/HtmlElementTreeNode.ts +10 -7
- package/src/adapters/createTreeNode.ts +12 -51
- package/src/adapters/detectFramework.test.ts +73 -0
- package/src/adapters/detectFramework.ts +28 -0
- package/src/adapters/jsx/jsxAdapter.test.ts +240 -0
- package/src/adapters/jsx/jsxAdapter.ts +53 -106
- package/src/adapters/nextjs/parseNextjsDataAttributes.test.ts +212 -0
- package/src/adapters/nextjs/parseNextjsDataAttributes.ts +1 -1
- package/src/adapters/react/findDebugSource.ts +5 -6
- package/src/adapters/react/findFiberByHtmlElement.ts +3 -3
- package/src/adapters/react/getAllParentsElementsAndRootComponent.ts +3 -0
- package/src/adapters/react/reactAdapter.ts +1 -2
- package/src/adapters/resolveAdapter.ts +4 -14
- package/src/adapters/svelte/svelteAdapter.test.ts +334 -0
- package/src/adapters/vue/vueAdapter.test.ts +259 -0
- package/src/browserApi.test.ts +329 -0
- package/src/browserApi.ts +351 -4
- package/src/components/RecordingPillButton.tsx +301 -0
- package/src/components/RecordingResults.tsx +114 -13
- package/src/components/Runtime.tsx +176 -621
- package/src/components/SettingsPanel.tsx +339 -0
- package/src/consoleCapture.ts +113 -0
- package/src/dejitter/recorder.ts +67 -3
- package/src/functions/cssRuleInspector.test.ts +517 -0
- package/src/functions/cssRuleInspector.ts +708 -0
- package/src/functions/deduplicateLabels.test.ts +115 -0
- package/src/functions/enrichAncestrySourceMaps.ts +6 -3
- package/src/functions/extractComputedStyles.test.ts +681 -0
- package/src/functions/extractComputedStyles.ts +768 -0
- package/src/functions/formatAncestryChain.test.ts +23 -1
- package/src/functions/formatAncestryChain.ts +22 -1
- package/src/functions/getUsableName.test.ts +242 -0
- package/src/functions/isCombinationModifiersPressed.test.ts +156 -0
- package/src/functions/mergeRects.test.ts +111 -1
- package/src/functions/namedSnapshots.test.ts +106 -0
- package/src/functions/namedSnapshots.ts +232 -0
- package/src/functions/normalizeFilePath.test.ts +80 -0
- package/src/functions/parseDataId.test.ts +125 -0
- package/src/hooks/getStorage.ts +26 -0
- package/src/hooks/useEventListeners.ts +97 -0
- package/src/hooks/useLocatorStorage.test.ts +127 -0
- package/src/hooks/useLocatorStorage.ts +60 -0
- package/src/hooks/useRecordingState.ts +516 -0
- package/src/hooks/useSettings.ts +83 -0
- package/src/index.ts +10 -5
- package/src/initRuntime.ts +5 -0
- package/src/mcpBridge.test.ts +260 -0
- package/src/mcpBridge.ts +677 -0
- package/src/visualDiff/diff.test.ts +167 -0
- package/src/visualDiff/diff.ts +242 -0
- package/src/visualDiff/settle.test.ts +77 -0
- package/src/visualDiff/settle.ts +62 -0
- package/src/visualDiff/snapshot.test.ts +200 -0
- package/src/visualDiff/snapshot.ts +119 -0
- package/src/visualDiff/types.ts +40 -0
- package/tsconfig.json +3 -1
- package/vitest.config.ts +18 -0
- package/jest.config.ts +0 -195
package/src/mcpBridge.ts
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
import type { LocatorJSAPI } from "./browserApi";
|
|
2
|
+
import { getConsoleEntries, installConsoleCapture } from "./consoleCapture";
|
|
3
|
+
|
|
4
|
+
export const MCP_BRIDGE_DEFAULT_URL = "wss://127.0.0.1:7463/treelocator";
|
|
5
|
+
export const MCP_BRIDGE_FALLBACK_URL = "wss://localhost:7463/treelocator";
|
|
6
|
+
|
|
7
|
+
const HEARTBEAT_MS = 20_000;
|
|
8
|
+
const MAX_RECONNECT_MS = 5 * 60_000;
|
|
9
|
+
const QUIET_RETRY_AFTER_FAILURES = 2;
|
|
10
|
+
const QUIET_RETRY_MS = 5 * 60_000;
|
|
11
|
+
|
|
12
|
+
export type BridgeCommandName =
|
|
13
|
+
| "get_path"
|
|
14
|
+
| "get_ancestry"
|
|
15
|
+
| "get_path_data"
|
|
16
|
+
| "get_styles"
|
|
17
|
+
| "get_css_rules"
|
|
18
|
+
| "get_css_report"
|
|
19
|
+
| "take_snapshot"
|
|
20
|
+
| "get_snapshot_diff"
|
|
21
|
+
| "clear_snapshot"
|
|
22
|
+
| "click"
|
|
23
|
+
| "hover"
|
|
24
|
+
| "type"
|
|
25
|
+
| "execute_js"
|
|
26
|
+
| "get_console";
|
|
27
|
+
|
|
28
|
+
export interface HelloMessage {
|
|
29
|
+
type: "hello";
|
|
30
|
+
sessionId: string;
|
|
31
|
+
url: string;
|
|
32
|
+
title: string;
|
|
33
|
+
runtimeVersion: string;
|
|
34
|
+
capabilities: BridgeCommandName[];
|
|
35
|
+
connectedAt: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CommandRequest {
|
|
39
|
+
type: "command";
|
|
40
|
+
id: string;
|
|
41
|
+
command: BridgeCommandName;
|
|
42
|
+
args?: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CommandResponse {
|
|
46
|
+
type: "response";
|
|
47
|
+
id: string;
|
|
48
|
+
ok: boolean;
|
|
49
|
+
result?: unknown;
|
|
50
|
+
error?: {
|
|
51
|
+
code: string;
|
|
52
|
+
message: string;
|
|
53
|
+
details?: unknown;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface PingMessage {
|
|
58
|
+
type: "ping";
|
|
59
|
+
timestamp: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface PongMessage {
|
|
63
|
+
type: "pong";
|
|
64
|
+
timestamp: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type BridgeInboundMessage = CommandRequest | PongMessage;
|
|
68
|
+
type BridgeOutboundMessage = HelloMessage | CommandResponse | PingMessage;
|
|
69
|
+
|
|
70
|
+
export interface MCPBridgeConfig {
|
|
71
|
+
enabled?: boolean;
|
|
72
|
+
bridgeUrl?: string;
|
|
73
|
+
reconnectMs?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
class BridgeRuntimeError extends Error {
|
|
77
|
+
readonly code: string;
|
|
78
|
+
readonly details?: unknown;
|
|
79
|
+
|
|
80
|
+
constructor(code: string, message: string, details?: unknown) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = "BridgeRuntimeError";
|
|
83
|
+
this.code = code;
|
|
84
|
+
this.details = details;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const SESSION_ID_STORAGE_KEY = "treelocator:sessionId";
|
|
89
|
+
|
|
90
|
+
function generateSessionId(): string {
|
|
91
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
92
|
+
return crypto.randomUUID();
|
|
93
|
+
}
|
|
94
|
+
return `tl-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function createSessionId(): string {
|
|
98
|
+
// Persist per tab via sessionStorage so reloads keep the same id — the
|
|
99
|
+
// broker accepts rehello on an existing id and just swaps the socket, so
|
|
100
|
+
// MCP callers don't need to re-run connect_session after a page refresh.
|
|
101
|
+
let storage: Storage | null = null;
|
|
102
|
+
try {
|
|
103
|
+
storage = typeof sessionStorage !== "undefined" ? sessionStorage : null;
|
|
104
|
+
} catch {
|
|
105
|
+
storage = null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (storage) {
|
|
109
|
+
try {
|
|
110
|
+
const existing = storage.getItem(SESSION_ID_STORAGE_KEY);
|
|
111
|
+
if (existing) return existing;
|
|
112
|
+
} catch {
|
|
113
|
+
// Access can throw under strict privacy settings — fall through.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const fresh = generateSessionId();
|
|
118
|
+
if (storage) {
|
|
119
|
+
try {
|
|
120
|
+
storage.setItem(SESSION_ID_STORAGE_KEY, fresh);
|
|
121
|
+
} catch {
|
|
122
|
+
// Quota or privacy mode — not fatal, we just won't persist.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return fresh;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseMessage(raw: unknown): BridgeInboundMessage | null {
|
|
129
|
+
if (typeof raw !== "string") return null;
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(raw) as BridgeInboundMessage;
|
|
132
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
133
|
+
if (
|
|
134
|
+
"type" in parsed &&
|
|
135
|
+
(parsed.type === "command" || parsed.type === "pong")
|
|
136
|
+
) {
|
|
137
|
+
return parsed;
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolveElement(selector: string, index = 0): HTMLElement {
|
|
146
|
+
let elements: NodeListOf<Element>;
|
|
147
|
+
try {
|
|
148
|
+
elements = document.querySelectorAll(selector);
|
|
149
|
+
} catch {
|
|
150
|
+
throw new BridgeRuntimeError("invalid_selector", `Invalid selector: ${selector}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const element = elements.item(index);
|
|
154
|
+
if (!(element instanceof HTMLElement)) {
|
|
155
|
+
throw new BridgeRuntimeError(
|
|
156
|
+
"element_not_found",
|
|
157
|
+
`No HTMLElement found for selector "${selector}" at index ${index}`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return element;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getTargetArgs(args?: Record<string, unknown>): {
|
|
165
|
+
selector: string;
|
|
166
|
+
index: number;
|
|
167
|
+
} {
|
|
168
|
+
const selector = typeof args?.selector === "string" ? args.selector : "";
|
|
169
|
+
if (!selector) {
|
|
170
|
+
throw new BridgeRuntimeError("invalid_args", "selector is required");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const rawIndex = args?.index;
|
|
174
|
+
const index =
|
|
175
|
+
typeof rawIndex === "number" && Number.isInteger(rawIndex) && rawIndex >= 0
|
|
176
|
+
? rawIndex
|
|
177
|
+
: 0;
|
|
178
|
+
|
|
179
|
+
return { selector, index };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function dispatchHover(element: HTMLElement): void {
|
|
183
|
+
const eventWindow = element.ownerDocument.defaultView;
|
|
184
|
+
const MouseEventCtor = eventWindow?.MouseEvent || MouseEvent;
|
|
185
|
+
const eventInit: MouseEventInit = {
|
|
186
|
+
bubbles: true,
|
|
187
|
+
cancelable: true,
|
|
188
|
+
composed: true,
|
|
189
|
+
};
|
|
190
|
+
element.dispatchEvent(new MouseEventCtor("mouseenter", eventInit));
|
|
191
|
+
element.dispatchEvent(new MouseEventCtor("mouseover", eventInit));
|
|
192
|
+
element.dispatchEvent(new MouseEventCtor("mousemove", eventInit));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function dispatchType(
|
|
196
|
+
element: HTMLElement,
|
|
197
|
+
text: string,
|
|
198
|
+
submit: boolean
|
|
199
|
+
): { value: string } {
|
|
200
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
201
|
+
element.focus();
|
|
202
|
+
element.value = text;
|
|
203
|
+
element.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
|
|
204
|
+
element.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
|
|
205
|
+
|
|
206
|
+
if (submit) {
|
|
207
|
+
element.dispatchEvent(
|
|
208
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
|
|
209
|
+
);
|
|
210
|
+
element.dispatchEvent(
|
|
211
|
+
new KeyboardEvent("keyup", { key: "Enter", bubbles: true })
|
|
212
|
+
);
|
|
213
|
+
if (element.form && typeof element.form.requestSubmit === "function") {
|
|
214
|
+
element.form.requestSubmit();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { value: element.value };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (element.isContentEditable) {
|
|
222
|
+
element.focus();
|
|
223
|
+
element.textContent = text;
|
|
224
|
+
element.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
|
|
225
|
+
return { value: element.textContent || "" };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
throw new BridgeRuntimeError(
|
|
229
|
+
"unsupported_target",
|
|
230
|
+
"type target must be input, textarea, or contenteditable"
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function safeSerialize(value: unknown): unknown {
|
|
235
|
+
const seen = new WeakSet<object>();
|
|
236
|
+
const walk = (v: unknown): unknown => {
|
|
237
|
+
if (v === null || v === undefined) return v ?? null;
|
|
238
|
+
const t = typeof v;
|
|
239
|
+
if (t === "string" || t === "number" || t === "boolean") return v;
|
|
240
|
+
if (t === "bigint") return (v as bigint).toString();
|
|
241
|
+
if (t === "function") {
|
|
242
|
+
const name = (v as { name?: string }).name || "anonymous";
|
|
243
|
+
return `[Function: ${name}]`;
|
|
244
|
+
}
|
|
245
|
+
if (t === "symbol") return (v as symbol).toString();
|
|
246
|
+
if (v instanceof Error) {
|
|
247
|
+
return { name: v.name, message: v.message, stack: v.stack };
|
|
248
|
+
}
|
|
249
|
+
if (typeof Element !== "undefined" && v instanceof Element) {
|
|
250
|
+
const el = v as Element;
|
|
251
|
+
return `[Element: <${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}>]`;
|
|
252
|
+
}
|
|
253
|
+
if (t === "object") {
|
|
254
|
+
if (seen.has(v as object)) return "[Circular]";
|
|
255
|
+
seen.add(v as object);
|
|
256
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
257
|
+
const out: Record<string, unknown> = {};
|
|
258
|
+
for (const key of Object.keys(v as object)) {
|
|
259
|
+
try {
|
|
260
|
+
out[key] = walk((v as Record<string, unknown>)[key]);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
out[key] = `[Unreadable: ${(err as Error).message}]`;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
return String(v);
|
|
268
|
+
};
|
|
269
|
+
return walk(value);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const AsyncFunctionCtor = Object.getPrototypeOf(async function () {}).constructor as {
|
|
273
|
+
new (...args: string[]): (...fnArgs: unknown[]) => Promise<unknown>;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
async function runUserCode(code: string): Promise<unknown> {
|
|
277
|
+
let fn: (...a: unknown[]) => Promise<unknown>;
|
|
278
|
+
try {
|
|
279
|
+
fn = new AsyncFunctionCtor(code);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
throw new BridgeRuntimeError(
|
|
282
|
+
"compile_error",
|
|
283
|
+
err instanceof Error ? err.message : "Failed to compile code"
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
return await fn();
|
|
288
|
+
} catch (err) {
|
|
289
|
+
throw new BridgeRuntimeError(
|
|
290
|
+
"runtime_error",
|
|
291
|
+
err instanceof Error ? err.message : "Execution failed",
|
|
292
|
+
err instanceof Error ? { stack: err.stack } : undefined
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function executeBridgeCommand(
|
|
298
|
+
api: LocatorJSAPI,
|
|
299
|
+
command: BridgeCommandName,
|
|
300
|
+
args?: Record<string, unknown>
|
|
301
|
+
): Promise<unknown> {
|
|
302
|
+
switch (command) {
|
|
303
|
+
case "get_path": {
|
|
304
|
+
const { selector } = getTargetArgs(args);
|
|
305
|
+
return await api.getPath(selector);
|
|
306
|
+
}
|
|
307
|
+
case "get_ancestry": {
|
|
308
|
+
const { selector } = getTargetArgs(args);
|
|
309
|
+
return await api.getAncestry(selector);
|
|
310
|
+
}
|
|
311
|
+
case "get_path_data": {
|
|
312
|
+
const { selector } = getTargetArgs(args);
|
|
313
|
+
return await api.getPathData(selector);
|
|
314
|
+
}
|
|
315
|
+
case "get_styles": {
|
|
316
|
+
const { selector } = getTargetArgs(args);
|
|
317
|
+
const options =
|
|
318
|
+
args && typeof args.options === "object" && args.options !== null
|
|
319
|
+
? (args.options as Record<string, unknown>)
|
|
320
|
+
: undefined;
|
|
321
|
+
return api.getStyles(selector, {
|
|
322
|
+
includeDefaults:
|
|
323
|
+
typeof options?.includeDefaults === "boolean"
|
|
324
|
+
? options.includeDefaults
|
|
325
|
+
: undefined,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
case "get_css_rules": {
|
|
329
|
+
const { selector } = getTargetArgs(args);
|
|
330
|
+
return api.getCSSRules(selector);
|
|
331
|
+
}
|
|
332
|
+
case "get_css_report": {
|
|
333
|
+
const { selector } = getTargetArgs(args);
|
|
334
|
+
const properties = Array.isArray(args?.properties)
|
|
335
|
+
? args?.properties.filter((item): item is string => typeof item === "string")
|
|
336
|
+
: undefined;
|
|
337
|
+
return api.getCSSReport(selector, properties ? { properties } : undefined);
|
|
338
|
+
}
|
|
339
|
+
case "take_snapshot": {
|
|
340
|
+
const selector = typeof args?.selector === "string" ? args.selector : "";
|
|
341
|
+
const snapshotId =
|
|
342
|
+
typeof args?.snapshotId === "string" ? args.snapshotId : "";
|
|
343
|
+
if (!selector) {
|
|
344
|
+
throw new BridgeRuntimeError("invalid_args", "selector is required");
|
|
345
|
+
}
|
|
346
|
+
if (!snapshotId) {
|
|
347
|
+
throw new BridgeRuntimeError("invalid_args", "snapshotId is required");
|
|
348
|
+
}
|
|
349
|
+
const index =
|
|
350
|
+
typeof args?.index === "number" && Number.isInteger(args.index) && args.index >= 0
|
|
351
|
+
? args.index
|
|
352
|
+
: 0;
|
|
353
|
+
const label = typeof args?.label === "string" ? args.label : undefined;
|
|
354
|
+
return api.takeSnapshot(selector, snapshotId, { index, label });
|
|
355
|
+
}
|
|
356
|
+
case "get_snapshot_diff": {
|
|
357
|
+
const snapshotId =
|
|
358
|
+
typeof args?.snapshotId === "string" ? args.snapshotId : "";
|
|
359
|
+
if (!snapshotId) {
|
|
360
|
+
throw new BridgeRuntimeError("invalid_args", "snapshotId is required");
|
|
361
|
+
}
|
|
362
|
+
return api.getSnapshotDiff(snapshotId);
|
|
363
|
+
}
|
|
364
|
+
case "clear_snapshot": {
|
|
365
|
+
const snapshotId =
|
|
366
|
+
typeof args?.snapshotId === "string" ? args.snapshotId : "";
|
|
367
|
+
if (!snapshotId) {
|
|
368
|
+
throw new BridgeRuntimeError("invalid_args", "snapshotId is required");
|
|
369
|
+
}
|
|
370
|
+
api.clearSnapshot(snapshotId);
|
|
371
|
+
return { ok: true };
|
|
372
|
+
}
|
|
373
|
+
case "click": {
|
|
374
|
+
const { selector, index } = getTargetArgs(args);
|
|
375
|
+
const element = resolveElement(selector, index);
|
|
376
|
+
if (typeof element.click === "function") {
|
|
377
|
+
element.click();
|
|
378
|
+
} else {
|
|
379
|
+
const eventWindow = element.ownerDocument.defaultView;
|
|
380
|
+
const MouseEventCtor = eventWindow?.MouseEvent || MouseEvent;
|
|
381
|
+
element.dispatchEvent(
|
|
382
|
+
new MouseEventCtor("click", {
|
|
383
|
+
bubbles: true,
|
|
384
|
+
cancelable: true,
|
|
385
|
+
composed: true,
|
|
386
|
+
})
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
return { ok: true };
|
|
390
|
+
}
|
|
391
|
+
case "hover": {
|
|
392
|
+
const { selector, index } = getTargetArgs(args);
|
|
393
|
+
const element = resolveElement(selector, index);
|
|
394
|
+
dispatchHover(element);
|
|
395
|
+
return { ok: true };
|
|
396
|
+
}
|
|
397
|
+
case "type": {
|
|
398
|
+
const { selector, index } = getTargetArgs(args);
|
|
399
|
+
const element = resolveElement(selector, index);
|
|
400
|
+
const text = typeof args?.text === "string" ? args.text : "";
|
|
401
|
+
if (!text) {
|
|
402
|
+
throw new BridgeRuntimeError("invalid_args", "text is required for type");
|
|
403
|
+
}
|
|
404
|
+
const submit = typeof args?.submit === "boolean" ? args.submit : false;
|
|
405
|
+
return dispatchType(element, text, submit);
|
|
406
|
+
}
|
|
407
|
+
case "execute_js": {
|
|
408
|
+
const code = typeof args?.code === "string" ? args.code : "";
|
|
409
|
+
if (!code) {
|
|
410
|
+
throw new BridgeRuntimeError("invalid_args", "code is required for execute_js");
|
|
411
|
+
}
|
|
412
|
+
const result = await runUserCode(code);
|
|
413
|
+
return {
|
|
414
|
+
type: result === null ? "null" : typeof result,
|
|
415
|
+
value: safeSerialize(result),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
case "get_console": {
|
|
419
|
+
const rawLast = args?.last;
|
|
420
|
+
const last =
|
|
421
|
+
typeof rawLast === "number" && Number.isFinite(rawLast) && rawLast > 0
|
|
422
|
+
? Math.floor(rawLast)
|
|
423
|
+
: undefined;
|
|
424
|
+
const captured = getConsoleEntries(last);
|
|
425
|
+
return { count: captured.length, entries: captured };
|
|
426
|
+
}
|
|
427
|
+
default:
|
|
428
|
+
throw new BridgeRuntimeError("unsupported_command", `Unsupported command: ${command}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function getBridgeUrls(config?: MCPBridgeConfig): string[] {
|
|
433
|
+
const urls: string[] = [];
|
|
434
|
+
|
|
435
|
+
if (!config?.bridgeUrl) {
|
|
436
|
+
urls.push(MCP_BRIDGE_DEFAULT_URL, MCP_BRIDGE_FALLBACK_URL);
|
|
437
|
+
return urls;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
urls.push(config.bridgeUrl);
|
|
441
|
+
try {
|
|
442
|
+
const parsed = new URL(config.bridgeUrl);
|
|
443
|
+
if (parsed.hostname === "127.0.0.1") {
|
|
444
|
+
parsed.hostname = "localhost";
|
|
445
|
+
urls.push(parsed.toString());
|
|
446
|
+
} else if (parsed.hostname === "localhost") {
|
|
447
|
+
parsed.hostname = "127.0.0.1";
|
|
448
|
+
urls.push(parsed.toString());
|
|
449
|
+
}
|
|
450
|
+
} catch {
|
|
451
|
+
// Keep the configured URL only when parsing fails.
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return urls;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export class TreeLocatorMCPBridgeClient {
|
|
458
|
+
private readonly config: MCPBridgeConfig;
|
|
459
|
+
private readonly sessionId: string;
|
|
460
|
+
private readonly runtimeVersion: string;
|
|
461
|
+
private readonly getApi: () => LocatorJSAPI | undefined;
|
|
462
|
+
|
|
463
|
+
private socket: WebSocket | null = null;
|
|
464
|
+
private heartbeatTimer: number | null = null;
|
|
465
|
+
private reconnectTimer: number | null = null;
|
|
466
|
+
private reconnectAttempts = 0;
|
|
467
|
+
private consecutiveFailures = 0;
|
|
468
|
+
private urlIndex = 0;
|
|
469
|
+
private stopped = false;
|
|
470
|
+
|
|
471
|
+
constructor(
|
|
472
|
+
getApi: () => LocatorJSAPI | undefined,
|
|
473
|
+
config?: MCPBridgeConfig,
|
|
474
|
+
runtimeVersion = "unknown"
|
|
475
|
+
) {
|
|
476
|
+
this.getApi = getApi;
|
|
477
|
+
this.config = config || {};
|
|
478
|
+
this.sessionId = createSessionId();
|
|
479
|
+
this.runtimeVersion = runtimeVersion;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
start(): void {
|
|
483
|
+
if (this.config.enabled === false) return;
|
|
484
|
+
if (typeof window === "undefined" || typeof WebSocket === "undefined") return;
|
|
485
|
+
this.connect();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
stop(): void {
|
|
489
|
+
this.stopped = true;
|
|
490
|
+
this.clearTimers();
|
|
491
|
+
if (this.socket) {
|
|
492
|
+
this.socket.close();
|
|
493
|
+
this.socket = null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private connect(): void {
|
|
498
|
+
if (this.stopped || this.config.enabled === false || this.socket) return;
|
|
499
|
+
|
|
500
|
+
const urls = getBridgeUrls(this.config);
|
|
501
|
+
const url = urls[this.urlIndex % urls.length];
|
|
502
|
+
if (!url) return;
|
|
503
|
+
|
|
504
|
+
const socket = new WebSocket(url);
|
|
505
|
+
this.socket = socket;
|
|
506
|
+
|
|
507
|
+
socket.addEventListener("open", () => {
|
|
508
|
+
this.reconnectAttempts = 0;
|
|
509
|
+
this.consecutiveFailures = 0;
|
|
510
|
+
this.sendHello();
|
|
511
|
+
this.startHeartbeat();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
socket.addEventListener("message", (event) => {
|
|
515
|
+
void this.handleMessage(event.data);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
socket.addEventListener("close", () => {
|
|
519
|
+
this.socket = null;
|
|
520
|
+
this.clearHeartbeat();
|
|
521
|
+
this.scheduleReconnect();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
socket.addEventListener("error", () => {
|
|
525
|
+
socket.close();
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private sendMessage(message: BridgeOutboundMessage): void {
|
|
530
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
|
|
531
|
+
this.socket.send(JSON.stringify(message));
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private sendHello(): void {
|
|
535
|
+
const hello: HelloMessage = {
|
|
536
|
+
type: "hello",
|
|
537
|
+
sessionId: this.sessionId,
|
|
538
|
+
url: window.location.href,
|
|
539
|
+
title: document.title || "",
|
|
540
|
+
runtimeVersion: this.runtimeVersion,
|
|
541
|
+
capabilities: [
|
|
542
|
+
"get_path",
|
|
543
|
+
"get_ancestry",
|
|
544
|
+
"get_path_data",
|
|
545
|
+
"get_styles",
|
|
546
|
+
"get_css_rules",
|
|
547
|
+
"get_css_report",
|
|
548
|
+
"take_snapshot",
|
|
549
|
+
"get_snapshot_diff",
|
|
550
|
+
"clear_snapshot",
|
|
551
|
+
"click",
|
|
552
|
+
"hover",
|
|
553
|
+
"type",
|
|
554
|
+
"execute_js",
|
|
555
|
+
"get_console",
|
|
556
|
+
],
|
|
557
|
+
connectedAt: new Date().toISOString(),
|
|
558
|
+
};
|
|
559
|
+
this.sendMessage(hello);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private async handleMessage(data: unknown): Promise<void> {
|
|
563
|
+
const message = parseMessage(data);
|
|
564
|
+
if (!message) return;
|
|
565
|
+
|
|
566
|
+
if (message.type === "pong") {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const api = this.getApi();
|
|
571
|
+
if (!api) {
|
|
572
|
+
this.sendMessage({
|
|
573
|
+
type: "response",
|
|
574
|
+
id: message.id,
|
|
575
|
+
ok: false,
|
|
576
|
+
error: {
|
|
577
|
+
code: "api_unavailable",
|
|
578
|
+
message: "window.__treelocator__ is not available",
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
const result = await executeBridgeCommand(api, message.command, message.args);
|
|
586
|
+
this.sendMessage({
|
|
587
|
+
type: "response",
|
|
588
|
+
id: message.id,
|
|
589
|
+
ok: true,
|
|
590
|
+
result,
|
|
591
|
+
});
|
|
592
|
+
} catch (error) {
|
|
593
|
+
const bridgeError =
|
|
594
|
+
error instanceof BridgeRuntimeError
|
|
595
|
+
? error
|
|
596
|
+
: new BridgeRuntimeError(
|
|
597
|
+
"runtime_error",
|
|
598
|
+
error instanceof Error ? error.message : "Unknown bridge error"
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
this.sendMessage({
|
|
602
|
+
type: "response",
|
|
603
|
+
id: message.id,
|
|
604
|
+
ok: false,
|
|
605
|
+
error: {
|
|
606
|
+
code: bridgeError.code,
|
|
607
|
+
message: bridgeError.message,
|
|
608
|
+
details: bridgeError.details,
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private startHeartbeat(): void {
|
|
615
|
+
this.clearHeartbeat();
|
|
616
|
+
this.heartbeatTimer = window.setInterval(() => {
|
|
617
|
+
this.sendMessage({
|
|
618
|
+
type: "ping",
|
|
619
|
+
timestamp: Date.now(),
|
|
620
|
+
});
|
|
621
|
+
}, HEARTBEAT_MS);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private clearHeartbeat(): void {
|
|
625
|
+
if (this.heartbeatTimer !== null) {
|
|
626
|
+
window.clearInterval(this.heartbeatTimer);
|
|
627
|
+
this.heartbeatTimer = null;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private scheduleReconnect(): void {
|
|
632
|
+
if (this.stopped || this.config.enabled === false || this.reconnectTimer !== null) {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const baseReconnect = this.config.reconnectMs && this.config.reconnectMs > 0
|
|
637
|
+
? this.config.reconnectMs
|
|
638
|
+
: 1_000;
|
|
639
|
+
const delay =
|
|
640
|
+
this.consecutiveFailures >= QUIET_RETRY_AFTER_FAILURES
|
|
641
|
+
? QUIET_RETRY_MS
|
|
642
|
+
: Math.min(
|
|
643
|
+
baseReconnect * Math.pow(2, this.reconnectAttempts),
|
|
644
|
+
MAX_RECONNECT_MS
|
|
645
|
+
);
|
|
646
|
+
this.reconnectAttempts += 1;
|
|
647
|
+
this.consecutiveFailures += 1;
|
|
648
|
+
this.urlIndex += 1;
|
|
649
|
+
|
|
650
|
+
this.reconnectTimer = window.setTimeout(() => {
|
|
651
|
+
this.reconnectTimer = null;
|
|
652
|
+
this.connect();
|
|
653
|
+
}, delay);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private clearTimers(): void {
|
|
657
|
+
this.clearHeartbeat();
|
|
658
|
+
if (this.reconnectTimer !== null) {
|
|
659
|
+
window.clearTimeout(this.reconnectTimer);
|
|
660
|
+
this.reconnectTimer = null;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export function startMCPBridge(config?: MCPBridgeConfig): TreeLocatorMCPBridgeClient | null {
|
|
666
|
+
if (config?.enabled === false) return null;
|
|
667
|
+
if (typeof window === "undefined") return null;
|
|
668
|
+
|
|
669
|
+
installConsoleCapture();
|
|
670
|
+
|
|
671
|
+
const client = new TreeLocatorMCPBridgeClient(
|
|
672
|
+
() => (window as Window & { __treelocator__?: LocatorJSAPI }).__treelocator__,
|
|
673
|
+
config
|
|
674
|
+
);
|
|
675
|
+
client.start();
|
|
676
|
+
return client;
|
|
677
|
+
}
|