browsertrack 0.2.2 → 0.2.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/{chunk-TWEYRBDU.js → chunk-5NR5K3ER.js} +24 -5
- package/dist/chunk-5NR5K3ER.js.map +1 -0
- package/dist/{chunk-AYSVE6NG.js → chunk-G5CIZSQM.js} +2 -2
- package/dist/{chunk-QRZ57ME3.js → chunk-ONPW7AYL.js} +75 -11
- package/dist/chunk-ONPW7AYL.js.map +1 -0
- package/dist/{chunk-4HRLW6YF.js → chunk-PG4JJDCV.js} +196 -17
- package/dist/chunk-PG4JJDCV.js.map +1 -0
- package/dist/cli/index.js +22 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/client/index.cjs +267 -26
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +2 -2
- package/dist/client.iife.js +121 -15
- package/dist/core/index.d.ts +9 -2
- package/dist/core/index.js +3 -1
- package/dist/daemon/index.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6 -4
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +3 -3
- package/dist/{server-DjV7RWQM.d.ts → server-BRG-RQQP.d.ts} +1 -0
- package/package.json +1 -1
- package/packages/client/src/notes/inspector.ts +210 -19
- package/packages/core/src/selector.ts +110 -15
- package/packages/mcp/src/handlers.ts +7 -0
- package/packages/mcp/src/server.ts +21 -3
- package/test/core/selector.test.ts +133 -0
- package/dist/chunk-4HRLW6YF.js.map +0 -1
- package/dist/chunk-QRZ57ME3.js.map +0 -1
- package/dist/chunk-TWEYRBDU.js.map +0 -1
- /package/dist/{chunk-AYSVE6NG.js.map → chunk-G5CIZSQM.js.map} +0 -0
|
@@ -6,23 +6,26 @@ export interface SelectorOptions {
|
|
|
6
6
|
maxClasses?: number;
|
|
7
7
|
maxOuterHTMLLength?: number;
|
|
8
8
|
maxInnerTextLength?: number;
|
|
9
|
+
maxParentDepth?: number;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Builds a clean, human-readable semantic selector for a DOM element.
|
|
13
|
-
* Prioritizes data-testid/data-
|
|
14
|
+
* Prioritizes data-testid/data-component, id, semantic tag + curated class, aria/role, or concise unique parent path.
|
|
14
15
|
*/
|
|
15
16
|
export function getSemanticSelector(element: HTMLElement | Element, options: SelectorOptions = {}): string {
|
|
16
17
|
if (!element || !element.tagName) return 'unknown';
|
|
17
18
|
|
|
18
19
|
const maxClasses = options.maxClasses ?? 2;
|
|
20
|
+
const maxParentDepth = options.maxParentDepth ?? 3;
|
|
19
21
|
|
|
20
|
-
// 1. Check for standard test attributes
|
|
22
|
+
// 1. Check for standard test or component attributes
|
|
21
23
|
const testId =
|
|
22
24
|
element.getAttribute('data-testid') ||
|
|
23
25
|
element.getAttribute('data-test') ||
|
|
24
26
|
element.getAttribute('data-cy') ||
|
|
25
|
-
element.getAttribute('data-qa')
|
|
27
|
+
element.getAttribute('data-qa') ||
|
|
28
|
+
element.getAttribute('data-component');
|
|
26
29
|
|
|
27
30
|
if (testId) {
|
|
28
31
|
return `[data-testid="${testId}"]`;
|
|
@@ -34,21 +37,43 @@ export function getSemanticSelector(element: HTMLElement | Element, options: Sel
|
|
|
34
37
|
return `#${id}`;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
|
|
40
|
+
const tag = element.tagName.toLowerCase();
|
|
41
|
+
|
|
42
|
+
// 3. Check for aria-label, role, title or name on interactive elements
|
|
38
43
|
const ariaLabel = element.getAttribute('aria-label');
|
|
39
|
-
if (ariaLabel && ariaLabel.length <
|
|
40
|
-
return `${
|
|
44
|
+
if (ariaLabel && ariaLabel.length < 40) {
|
|
45
|
+
return `${tag}[aria-label="${ariaLabel}"]`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const role = element.getAttribute('role');
|
|
49
|
+
if (role && !['presentation', 'none'].includes(role)) {
|
|
50
|
+
const roleSelector = `${tag}[role="${role}"]`;
|
|
51
|
+
if (tag === 'button' || tag === 'a' || tag === 'nav' || tag === 'dialog') {
|
|
52
|
+
return roleSelector;
|
|
53
|
+
}
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
const nameAttr = element.getAttribute('name');
|
|
44
57
|
if (nameAttr) {
|
|
45
|
-
return `${
|
|
58
|
+
return `${tag}[name="${nameAttr}"]`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const titleAttr = element.getAttribute('title');
|
|
62
|
+
if (titleAttr && titleAttr.length < 30) {
|
|
63
|
+
return `${tag}[title="${titleAttr}"]`;
|
|
46
64
|
}
|
|
47
65
|
|
|
48
66
|
// 4. Tag + curated classes
|
|
49
|
-
const
|
|
50
|
-
const classList =
|
|
51
|
-
.filter(
|
|
67
|
+
const rawClassList = Array.from(element.classList || []);
|
|
68
|
+
const classList = rawClassList
|
|
69
|
+
.filter(
|
|
70
|
+
(c) =>
|
|
71
|
+
!c.startsWith('css-') &&
|
|
72
|
+
!c.startsWith('_') &&
|
|
73
|
+
!/^[a-z0-9]{5,}$/i.test(c) && // filter dynamic hashed utility classes
|
|
74
|
+
!c.includes(':') && // Tailwind pseudo-class prefixes (hover:, md:, etc.)
|
|
75
|
+
!c.includes('[') // Tailwind arbitrary value classes
|
|
76
|
+
)
|
|
52
77
|
.slice(0, maxClasses);
|
|
53
78
|
|
|
54
79
|
if (classList.length > 0) {
|
|
@@ -56,17 +81,87 @@ export function getSemanticSelector(element: HTMLElement | Element, options: Sel
|
|
|
56
81
|
return `${tag}${classStr}`;
|
|
57
82
|
}
|
|
58
83
|
|
|
59
|
-
// 5. If generic element (div, span, p) without attributes, try parent context
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
// 5. If generic element (div, span, p, li) without attributes, try parent context & nth-of-type
|
|
85
|
+
const hasParent =
|
|
86
|
+
element.parentElement &&
|
|
87
|
+
(typeof document === 'undefined' ||
|
|
88
|
+
(element.parentElement !== document.body && element.parentElement !== document.documentElement));
|
|
89
|
+
|
|
90
|
+
if (hasParent) {
|
|
91
|
+
let suffix = '';
|
|
92
|
+
// Calculate nth-of-type if siblings of same tag exist
|
|
93
|
+
if (element.parentElement.children && element.parentElement.children.length > 1) {
|
|
94
|
+
const sameTagSiblings = Array.from(element.parentElement.children).filter(
|
|
95
|
+
(child: any) => child.tagName && child.tagName.toLowerCase() === tag
|
|
96
|
+
);
|
|
97
|
+
if (sameTagSiblings.length > 1) {
|
|
98
|
+
const index = sameTagSiblings.indexOf(element) + 1;
|
|
99
|
+
if (index > 0) {
|
|
100
|
+
suffix = `:nth-of-type(${index})`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (maxParentDepth > 0) {
|
|
106
|
+
const parentSelector = getSemanticSelector(element.parentElement, {
|
|
107
|
+
maxClasses: 1,
|
|
108
|
+
maxParentDepth: maxParentDepth - 1,
|
|
109
|
+
});
|
|
110
|
+
if (parentSelector && parentSelector !== 'unknown' && parentSelector !== 'body' && parentSelector !== 'html') {
|
|
111
|
+
return `${parentSelector} > ${tag}${suffix}`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (suffix) {
|
|
116
|
+
return `${tag}${suffix}`;
|
|
64
117
|
}
|
|
65
118
|
}
|
|
66
119
|
|
|
67
120
|
return tag;
|
|
68
121
|
}
|
|
69
122
|
|
|
123
|
+
/**
|
|
124
|
+
* If an element under the cursor is an inline or leaf formatting element
|
|
125
|
+
* (e.g. svg, path, i, b, strong, or an inner span without attributes)
|
|
126
|
+
* inside an interactive container or component, resolves to the parent container.
|
|
127
|
+
*/
|
|
128
|
+
export function resolveMeaningfulTarget(element: HTMLElement | Element): HTMLElement {
|
|
129
|
+
if (!element || typeof element !== 'object') {
|
|
130
|
+
return element as HTMLElement;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const el = element as HTMLElement;
|
|
134
|
+
|
|
135
|
+
// 1. If element is inside an SVG or is an SVG child (path, circle, rect, g, svg)
|
|
136
|
+
const isSvg = el.tagName.toLowerCase() === 'svg' || el.namespaceURI?.includes('svg');
|
|
137
|
+
if (isSvg || el.closest?.('svg')) {
|
|
138
|
+
const interactiveParent = el.closest?.(
|
|
139
|
+
'button, a, [role="button"], [role="link"], [role="tab"], summary, label, [data-testid], [data-component]'
|
|
140
|
+
) as HTMLElement | null;
|
|
141
|
+
if (interactiveParent) return interactiveParent;
|
|
142
|
+
const svgEl = isSvg ? el : (el.closest?.('svg') as HTMLElement | null);
|
|
143
|
+
if (svgEl) return svgEl;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 2. Minor leaf tags (span, i, em, b, strong, small) with no distinct attributes inside interactive element
|
|
147
|
+
const leafTags = ['span', 'i', 'em', 'b', 'strong', 'small', 'mark', 'code'];
|
|
148
|
+
const tag = el.tagName.toLowerCase();
|
|
149
|
+
if (
|
|
150
|
+
leafTags.includes(tag) &&
|
|
151
|
+
!el.id &&
|
|
152
|
+
!el.getAttribute('data-testid') &&
|
|
153
|
+
!el.getAttribute('data-component') &&
|
|
154
|
+
!el.getAttribute('aria-label')
|
|
155
|
+
) {
|
|
156
|
+
const interactiveParent = el.closest?.(
|
|
157
|
+
'button, a, [role="button"], [role="link"], [role="tab"], [role="menuitem"], summary, label, [data-testid], [data-component]'
|
|
158
|
+
) as HTMLElement | null;
|
|
159
|
+
if (interactiveParent) return interactiveParent;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return el;
|
|
163
|
+
}
|
|
164
|
+
|
|
70
165
|
/**
|
|
71
166
|
* Truncates string to specified max length with ellipsis
|
|
72
167
|
*/
|
|
@@ -9,9 +9,16 @@ export interface McpContext {
|
|
|
9
9
|
verificationEngine?: VerificationEngine;
|
|
10
10
|
noteVerificationEngine?: NoteVerificationEngine;
|
|
11
11
|
daemonUrl?: string;
|
|
12
|
+
daemonInitPromise?: Promise<void>;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
async function sendSessionCommand(ctx: McpContext, sessionId: string | undefined, command: any, timeoutMs = 5000): Promise<any> {
|
|
16
|
+
if (ctx.daemonInitPromise) {
|
|
17
|
+
try {
|
|
18
|
+
await Promise.race([ctx.daemonInitPromise, new Promise((r) => setTimeout(r, 2000))]);
|
|
19
|
+
} catch {}
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
// 1. In-process session manager (if active sockets exist)
|
|
16
23
|
if (ctx.sessionManager && ctx.sessionManager.getActiveCount() > 0) {
|
|
17
24
|
const targetSession = sessionId ? ctx.db.getSession(sessionId) : ctx.sessionManager.getAnyActiveSession();
|
|
@@ -159,13 +159,21 @@ export function createMcpServer(options: McpServerOptions = {}) {
|
|
|
159
159
|
const cliPath = resolveCliPath();
|
|
160
160
|
if (cliPath && fs.existsSync(cliPath)) {
|
|
161
161
|
try {
|
|
162
|
+
const nodePaths = [
|
|
163
|
+
'/usr/local/bin',
|
|
164
|
+
'/opt/homebrew/bin',
|
|
165
|
+
process.env.HOME ? `${process.env.HOME}/.nvm/versions/node/v20.19.5/bin` : '',
|
|
166
|
+
process.env.HOME ? `${process.env.HOME}/.nvm/versions/node/v23.1.0/bin` : '',
|
|
167
|
+
].filter(Boolean);
|
|
168
|
+
const extendedPath = `${process.env.PATH || ''}:${nodePaths.join(':')}`;
|
|
169
|
+
|
|
162
170
|
const child = spawn(
|
|
163
171
|
process.execPath,
|
|
164
172
|
[cliPath, 'start', '--port', String(config.port), '--host', config.host],
|
|
165
173
|
{
|
|
166
174
|
detached: true,
|
|
167
175
|
stdio: 'ignore',
|
|
168
|
-
env: { ...process.env, BROWSERTRACK_DAEMON_DETACHED: '1' },
|
|
176
|
+
env: { ...process.env, PATH: extendedPath, BROWSERTRACK_DAEMON_DETACHED: '1' },
|
|
169
177
|
}
|
|
170
178
|
);
|
|
171
179
|
child.unref();
|
|
@@ -245,7 +253,11 @@ export function createMcpServer(options: McpServerOptions = {}) {
|
|
|
245
253
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
246
254
|
const { name, arguments: args } = request.params;
|
|
247
255
|
try {
|
|
248
|
-
|
|
256
|
+
// 20-second safety timeout so tool execution never hangs indefinitely
|
|
257
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
258
|
+
setTimeout(() => reject(new Error(`Tool execution timed out after 20s: ${name}`)), 20000)
|
|
259
|
+
);
|
|
260
|
+
const result = await Promise.race([handleToolCall(name, args || {}, ctx), timeoutPromise]);
|
|
249
261
|
return {
|
|
250
262
|
content: [
|
|
251
263
|
{
|
|
@@ -278,9 +290,15 @@ export function createMcpServer(options: McpServerOptions = {}) {
|
|
|
278
290
|
}
|
|
279
291
|
},
|
|
280
292
|
async startStdio() {
|
|
281
|
-
|
|
293
|
+
// Connect stdio transport immediately so client handshakes are answered instantly
|
|
282
294
|
const transport = new StdioServerTransport();
|
|
283
295
|
await server.connect(transport);
|
|
296
|
+
|
|
297
|
+
// Start daemon verification / auto-boot asynchronously in background so client handshakes never block
|
|
298
|
+
const daemonInitPromise = ensureDaemon().catch((err) => {
|
|
299
|
+
console.error(`[BrowserTrack MCP] Daemon background init note: ${err?.message || err}`);
|
|
300
|
+
});
|
|
301
|
+
ctx.daemonInitPromise = daemonInitPromise;
|
|
284
302
|
},
|
|
285
303
|
};
|
|
286
304
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getSemanticSelector, resolveMeaningfulTarget } from '../../packages/core/src/index.js';
|
|
3
|
+
|
|
4
|
+
function createMockElement(options: {
|
|
5
|
+
tagName: string;
|
|
6
|
+
id?: string;
|
|
7
|
+
classList?: string[];
|
|
8
|
+
attributes?: Record<string, string>;
|
|
9
|
+
parentElement?: any;
|
|
10
|
+
children?: any[];
|
|
11
|
+
closest?: (sel: string) => any;
|
|
12
|
+
}): any {
|
|
13
|
+
const attrs = options.attributes || {};
|
|
14
|
+
const el: any = {
|
|
15
|
+
tagName: options.tagName.toUpperCase(),
|
|
16
|
+
id: options.id || '',
|
|
17
|
+
classList: options.classList || [],
|
|
18
|
+
parentElement: options.parentElement || null,
|
|
19
|
+
children: options.children || [],
|
|
20
|
+
getAttribute: (name: string) => attrs[name] || null,
|
|
21
|
+
closest: options.closest || (() => null),
|
|
22
|
+
};
|
|
23
|
+
return el;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('Selector & Target Resolution', () => {
|
|
27
|
+
it('prioritizes data-testid and data-component attributes', () => {
|
|
28
|
+
const el = createMockElement({
|
|
29
|
+
tagName: 'div',
|
|
30
|
+
attributes: { 'data-testid': 'submit-btn' },
|
|
31
|
+
});
|
|
32
|
+
expect(getSemanticSelector(el)).toBe('[data-testid="submit-btn"]');
|
|
33
|
+
|
|
34
|
+
const elComp = createMockElement({
|
|
35
|
+
tagName: 'div',
|
|
36
|
+
attributes: { 'data-component': 'UserProfile' },
|
|
37
|
+
});
|
|
38
|
+
expect(getSemanticSelector(elComp)).toBe('[data-testid="UserProfile"]');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('uses clean IDs and skips uuid/numeric hashes', () => {
|
|
42
|
+
const el = createMockElement({
|
|
43
|
+
tagName: 'div',
|
|
44
|
+
id: 'navbar-header',
|
|
45
|
+
});
|
|
46
|
+
expect(getSemanticSelector(el)).toBe('#navbar-header');
|
|
47
|
+
|
|
48
|
+
const numericEl = createMockElement({
|
|
49
|
+
tagName: 'div',
|
|
50
|
+
id: '123456',
|
|
51
|
+
});
|
|
52
|
+
expect(getSemanticSelector(numericEl)).toBe('div');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('handles aria-label, role, and title on elements', () => {
|
|
56
|
+
const btn = createMockElement({
|
|
57
|
+
tagName: 'button',
|
|
58
|
+
attributes: { 'aria-label': 'Close dialog' },
|
|
59
|
+
});
|
|
60
|
+
expect(getSemanticSelector(btn)).toBe('button[aria-label="Close dialog"]');
|
|
61
|
+
|
|
62
|
+
const nav = createMockElement({
|
|
63
|
+
tagName: 'nav',
|
|
64
|
+
attributes: { role: 'navigation' },
|
|
65
|
+
});
|
|
66
|
+
expect(getSemanticSelector(nav)).toBe('nav[role="navigation"]');
|
|
67
|
+
|
|
68
|
+
const iconBtn = createMockElement({
|
|
69
|
+
tagName: 'button',
|
|
70
|
+
attributes: { title: 'Refresh page' },
|
|
71
|
+
});
|
|
72
|
+
expect(getSemanticSelector(iconBtn)).toBe('button[title="Refresh page"]');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('filters out dynamic hashed utility classes and retains semantic classes', () => {
|
|
76
|
+
const el = createMockElement({
|
|
77
|
+
tagName: 'div',
|
|
78
|
+
classList: ['css-98741', 'card-item', '_scoped123', 'md:flex', 'p-[12px]', 'primary-box'],
|
|
79
|
+
});
|
|
80
|
+
expect(getSemanticSelector(el)).toBe('div.card-item.primary-box');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('generates nth-of-type for identical siblings', () => {
|
|
84
|
+
const parent = createMockElement({
|
|
85
|
+
tagName: 'div',
|
|
86
|
+
id: 'sidebar',
|
|
87
|
+
});
|
|
88
|
+
const child1 = createMockElement({
|
|
89
|
+
tagName: 'div',
|
|
90
|
+
parentElement: parent,
|
|
91
|
+
});
|
|
92
|
+
const child2 = createMockElement({
|
|
93
|
+
tagName: 'div',
|
|
94
|
+
parentElement: parent,
|
|
95
|
+
});
|
|
96
|
+
parent.children = [child1, child2];
|
|
97
|
+
|
|
98
|
+
expect(getSemanticSelector(child2)).toBe('#sidebar > div:nth-of-type(2)');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('resolves meaningful targets from leaf SVG or formatting tags', () => {
|
|
102
|
+
const button = createMockElement({
|
|
103
|
+
tagName: 'button',
|
|
104
|
+
id: 'login-button',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const span = createMockElement({
|
|
108
|
+
tagName: 'span',
|
|
109
|
+
closest: (sel: string) => (sel.includes('button') ? button : null),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(resolveMeaningfulTarget(span)).toBe(button);
|
|
113
|
+
|
|
114
|
+
const link = createMockElement({
|
|
115
|
+
tagName: 'a',
|
|
116
|
+
id: 'home-link',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const svg = createMockElement({
|
|
120
|
+
tagName: 'svg',
|
|
121
|
+
closest: (sel: string) => (sel.includes('a') ? link : null),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(resolveMeaningfulTarget(svg)).toBe(link);
|
|
125
|
+
|
|
126
|
+
// Standalone div returns itself
|
|
127
|
+
const card = createMockElement({
|
|
128
|
+
tagName: 'div',
|
|
129
|
+
id: 'info-card',
|
|
130
|
+
});
|
|
131
|
+
expect(resolveMeaningfulTarget(card)).toBe(card);
|
|
132
|
+
});
|
|
133
|
+
});
|