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.
@@ -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-test, id, semantic tag + specific class, or concise parent path.
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
- // 3. Check for aria-label or name on buttons/inputs
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 < 30) {
40
- return `${element.tagName.toLowerCase()}[aria-label="${ariaLabel}"]`;
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 `${element.tagName.toLowerCase()}[name="${nameAttr}"]`;
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 tag = element.tagName.toLowerCase();
50
- const classList = Array.from(element.classList || [])
51
- .filter((c) => !c.startsWith('css-') && !c.startsWith('_') && !/^[a-z0-9]{5,}$/i.test(c)) // filter hashed utility classes
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
- if (element.parentElement && element.parentElement !== document.body && element.parentElement !== document.documentElement) {
61
- const parentSelector = getSemanticSelector(element.parentElement, { maxClasses: 1 });
62
- if (parentSelector && parentSelector !== 'unknown' && !parentSelector.includes('>')) {
63
- return `${parentSelector} > ${tag}`;
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
- const result = await handleToolCall(name, args || {}, ctx);
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
- await ensureDaemon();
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
+ });