libretto 0.5.1 → 0.5.2

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.
@@ -1,4 +1,12 @@
1
1
  import type { BrowserContext, Page } from "playwright";
2
+ import {
3
+ filterSemanticClasses,
4
+ INTERACTIVE_ROLE_NAMES,
5
+ INTERACTIVE_TAG_NAMES,
6
+ isObfuscatedClass,
7
+ TEST_ATTRIBUTE_NAMES,
8
+ TRUSTED_ATTRIBUTE_NAMES,
9
+ } from "../../shared/dom-semantics.js";
2
10
 
3
11
  type TelemetryEntry = Record<string, unknown>;
4
12
 
@@ -186,6 +194,428 @@ export async function installSessionTelemetry(
186
194
  return locator;
187
195
  };
188
196
 
197
+ const createUserDomTrackingInitScript = (): string => {
198
+ const selectorAttributeNames = [
199
+ "id",
200
+ ...TEST_ATTRIBUTE_NAMES,
201
+ "name",
202
+ "for",
203
+ "role",
204
+ "aria-label",
205
+ "title",
206
+ "placeholder",
207
+ "alt",
208
+ "href",
209
+ "type",
210
+ ];
211
+
212
+ return `
213
+ (() => {
214
+ if (window.__btDomListenersInstalled) return;
215
+ window.__btDomListenersInstalled = true;
216
+
217
+ const TEST_ATTRS = new Set(${JSON.stringify([...TEST_ATTRIBUTE_NAMES])});
218
+ const TRUSTED_ATTRS = new Set(${JSON.stringify([...TRUSTED_ATTRIBUTE_NAMES])});
219
+ const INTERACTIVE_TAGS = new Set(${JSON.stringify([...INTERACTIVE_TAG_NAMES])});
220
+ const INTERACTIVE_ROLES = new Set(${JSON.stringify([...INTERACTIVE_ROLE_NAMES])});
221
+ const SELECTOR_ATTRS = ${JSON.stringify(selectorAttributeNames)};
222
+
223
+ ${filterSemanticClasses.toString()}
224
+ ${isObfuscatedClass.toString()}
225
+
226
+ const normalizeWhitespace = (value) =>
227
+ String(value || "").replace(/\\s+/g, " ").trim();
228
+
229
+ const clipText = (value, limit = 120) => {
230
+ const normalized = normalizeWhitespace(value);
231
+ if (!normalized) return "";
232
+ return normalized.length > limit
233
+ ? \`\${normalized.slice(0, limit - 1)}…\`
234
+ : normalized;
235
+ };
236
+
237
+ const cssEscape = (value) => {
238
+ if (globalThis.CSS && typeof globalThis.CSS.escape === "function") {
239
+ return globalThis.CSS.escape(String(value));
240
+ }
241
+ return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => {
242
+ const hex = char.charCodeAt(0).toString(16);
243
+ return \`\\\\\${hex} \`;
244
+ });
245
+ };
246
+
247
+ const quoteAttrValue = (value) =>
248
+ String(value).replace(/\\\\/g, "\\\\\\\\").replace(/"/g, '\\\\"');
249
+
250
+ const isElementNode = (value) => value instanceof Element;
251
+
252
+ const isInteractiveElement = (el) => {
253
+ if (!el || !el.tagName) return false;
254
+ const tagName = el.tagName.toLowerCase();
255
+ if (INTERACTIVE_TAGS.has(tagName)) return true;
256
+ if (el.hasAttribute("tabindex") || el.hasAttribute("contenteditable")) {
257
+ return true;
258
+ }
259
+ const role = normalizeWhitespace(el.getAttribute("role")).toLowerCase();
260
+ return Boolean(role) && INTERACTIVE_ROLES.has(role);
261
+ };
262
+
263
+ const getFilteredClassSelector = (el) => {
264
+ const className =
265
+ typeof el.className === "string"
266
+ ? filterSemanticClasses(el.className)
267
+ : "";
268
+ const classes = className
269
+ .split(/\\s+/)
270
+ .filter(Boolean)
271
+ .slice(0, 2);
272
+ if (classes.length === 0) return "";
273
+ return classes.map((cls) => \`.\${cssEscape(cls)}\`).join("");
274
+ };
275
+
276
+ const getMeaningfulAttrValue = (el, attrName) => {
277
+ if (!el.hasAttribute(attrName)) return "";
278
+ const value = clipText(el.getAttribute(attrName) || "", 80);
279
+ if (!value) return "";
280
+ if (attrName === "href" && value.startsWith("javascript:")) return "";
281
+ return value;
282
+ };
283
+
284
+ const buildSelectorForElement = (el) => {
285
+ if (!isElementNode(el)) return "";
286
+ const tagName = el.tagName.toLowerCase();
287
+ const id = clipText(el.id || "", 80);
288
+ if (id) {
289
+ return \`\${tagName}#\${cssEscape(id)}\`;
290
+ }
291
+
292
+ for (const attrName of SELECTOR_ATTRS) {
293
+ if (attrName === "id") continue;
294
+ const value = getMeaningfulAttrValue(el, attrName);
295
+ if (!value) continue;
296
+ if (attrName.startsWith("data-")) {
297
+ return \`\${tagName}[\${attrName}="\${quoteAttrValue(value)}"]\`;
298
+ }
299
+ return \`\${tagName}[\${attrName}="\${quoteAttrValue(value)}"]\`;
300
+ }
301
+
302
+ const classSelector = getFilteredClassSelector(el);
303
+ if (classSelector) {
304
+ return \`\${tagName}\${classSelector}\`;
305
+ }
306
+
307
+ return tagName;
308
+ };
309
+
310
+ const getElementSummary = (el) => {
311
+ if (!isElementNode(el)) return "";
312
+ const selector = buildSelectorForElement(el);
313
+ const role = clipText(el.getAttribute("role") || "", 40);
314
+ const text = getElementText(el);
315
+ const suffix = [];
316
+ if (role) suffix.push(\`role=\${role}\`);
317
+ if (text) suffix.push(\`text="\${text}"\`);
318
+ return suffix.length > 0 ? \`\${selector} [\${suffix.join(", ")}]\` : selector;
319
+ };
320
+
321
+ const getElementText = (el) => {
322
+ if (!isElementNode(el)) return "";
323
+ const tagName = el.tagName.toLowerCase();
324
+ if (tagName === "input") {
325
+ const inputType = normalizeWhitespace(el.getAttribute("type")).toLowerCase();
326
+ if (inputType === "password") return "";
327
+ const value = clipText(el.value || el.getAttribute("value") || "", 80);
328
+ if (value) return value;
329
+ }
330
+
331
+ const attrCandidates = [
332
+ el.getAttribute("aria-label"),
333
+ el.getAttribute("title"),
334
+ el.getAttribute("placeholder"),
335
+ el.getAttribute("alt"),
336
+ el.textContent,
337
+ ];
338
+
339
+ for (const candidate of attrCandidates) {
340
+ const text = clipText(candidate || "", 120);
341
+ if (text.length >= 2) return text;
342
+ }
343
+
344
+ return "";
345
+ };
346
+
347
+ const isMeaningfulAncestor = (el) => {
348
+ if (!isElementNode(el)) return false;
349
+ const tagName = el.tagName.toLowerCase();
350
+ if (isInteractiveElement(el)) return true;
351
+ if (
352
+ [
353
+ "tr",
354
+ "li",
355
+ "td",
356
+ "th",
357
+ "label",
358
+ "section",
359
+ "article",
360
+ "dialog",
361
+ "fieldset",
362
+ "summary",
363
+ ].includes(tagName)
364
+ ) {
365
+ return true;
366
+ }
367
+ if (el.id) return true;
368
+ for (const attrName of TEST_ATTRS) {
369
+ if (el.hasAttribute(attrName)) return true;
370
+ }
371
+ const role = normalizeWhitespace(el.getAttribute("role")).toLowerCase();
372
+ return Boolean(role);
373
+ };
374
+
375
+ const hasStrongSelectorSignal = (el, selector) => {
376
+ if (!isElementNode(el)) return false;
377
+ if (!selector) return false;
378
+ const tagName = el.tagName.toLowerCase();
379
+ if (el.id) return true;
380
+ if (isInteractiveElement(el)) return true;
381
+ if (getFilteredClassSelector(el)) return true;
382
+ for (const attrName of SELECTOR_ATTRS) {
383
+ if (attrName === "id") continue;
384
+ if (getMeaningfulAttrValue(el, attrName)) return true;
385
+ }
386
+ return selector !== tagName;
387
+ };
388
+
389
+ const getAncestorSelectors = (target) => {
390
+ const selectors = [];
391
+ let current = isElementNode(target) ? target : null;
392
+ let depth = 0;
393
+ while (current && depth < 7) {
394
+ const selector = buildSelectorForElement(current);
395
+ if (
396
+ selector &&
397
+ !selectors.includes(selector) &&
398
+ (
399
+ (depth === 0 && hasStrongSelectorSignal(current, selector)) ||
400
+ isMeaningfulAncestor(current)
401
+ )
402
+ ) {
403
+ selectors.push(selector);
404
+ }
405
+ current = current.parentElement;
406
+ depth += 1;
407
+ }
408
+ if (selectors.length === 0 && isElementNode(target)) {
409
+ selectors.push(buildSelectorForElement(target));
410
+ }
411
+ return selectors;
412
+ };
413
+
414
+ const getNearbyText = (target) => {
415
+ let current = isElementNode(target) ? target : null;
416
+ let depth = 0;
417
+ while (current && depth < 6) {
418
+ const text = getElementText(current);
419
+ if (text.length >= 2) return text;
420
+ current = current.parentElement;
421
+ depth += 1;
422
+ }
423
+ return "";
424
+ };
425
+
426
+ const getComposedPathSummary = (event) => {
427
+ const path = typeof event.composedPath === "function" ? event.composedPath() : [];
428
+ return path
429
+ .filter((entry) => isElementNode(entry))
430
+ .slice(0, 6)
431
+ .map((entry) => getElementSummary(entry));
432
+ };
433
+
434
+ const getCoordinates = (event) => {
435
+ if (!(event instanceof MouseEvent)) return undefined;
436
+ return {
437
+ x: Math.round(event.clientX),
438
+ y: Math.round(event.clientY),
439
+ };
440
+ };
441
+
442
+ const buildActionPayload = (event, action, extra = {}) => {
443
+ const target = isElementNode(event.target) ? event.target : null;
444
+ const ancestorSelectors = getAncestorSelectors(target);
445
+ const bestSemanticSelector = ancestorSelectors[0] || "";
446
+ const targetSelector = target ? buildSelectorForElement(target) : "";
447
+ return {
448
+ action,
449
+ bestSemanticSelector: bestSemanticSelector || undefined,
450
+ targetSelector: targetSelector || undefined,
451
+ ancestorSelectors: ancestorSelectors.length > 0 ? ancestorSelectors : undefined,
452
+ nearbyText: getNearbyText(target) || undefined,
453
+ composedPath: getComposedPathSummary(event),
454
+ coordinates: getCoordinates(event),
455
+ success: true,
456
+ ...extra,
457
+ };
458
+ };
459
+
460
+ let clickTimer = null;
461
+ let pendingClick = null;
462
+
463
+ document.addEventListener(
464
+ "click",
465
+ (event) => {
466
+ if (window.__btApiActionInProgress) return;
467
+ const target = isElementNode(event.target) ? event.target : null;
468
+ if (target?.type === "checkbox") {
469
+ window.__btActionLog(
470
+ JSON.stringify(
471
+ buildActionPayload(event, target.checked ? "check" : "uncheck"),
472
+ ),
473
+ );
474
+ return;
475
+ }
476
+
477
+ pendingClick = buildActionPayload(event, "click");
478
+ if (clickTimer) clearTimeout(clickTimer);
479
+ clickTimer = setTimeout(() => {
480
+ if (pendingClick) {
481
+ window.__btActionLog(JSON.stringify(pendingClick));
482
+ }
483
+ pendingClick = null;
484
+ clickTimer = null;
485
+ }, 200);
486
+ },
487
+ true,
488
+ );
489
+
490
+ document.addEventListener(
491
+ "dblclick",
492
+ (event) => {
493
+ if (window.__btApiActionInProgress) return;
494
+ if (clickTimer) {
495
+ clearTimeout(clickTimer);
496
+ clickTimer = null;
497
+ pendingClick = null;
498
+ }
499
+ window.__btActionLog(
500
+ JSON.stringify(buildActionPayload(event, "dblclick")),
501
+ );
502
+ },
503
+ true,
504
+ );
505
+
506
+ const inputTimers = new WeakMap();
507
+ document.addEventListener(
508
+ "input",
509
+ (event) => {
510
+ if (window.__btApiActionInProgress) return;
511
+ const target = isElementNode(event.target) ? event.target : null;
512
+ if (!target) return;
513
+
514
+ if (target.tagName === "SELECT") {
515
+ window.__btActionLog(
516
+ JSON.stringify(
517
+ buildActionPayload(event, "selectOption", {
518
+ value: target.value,
519
+ }),
520
+ ),
521
+ );
522
+ return;
523
+ }
524
+
525
+ const existing = inputTimers.get(target);
526
+ if (existing) clearTimeout(existing);
527
+ inputTimers.set(
528
+ target,
529
+ setTimeout(() => {
530
+ inputTimers.delete(target);
531
+ window.__btActionLog(
532
+ JSON.stringify(
533
+ buildActionPayload(event, "fill", {
534
+ value: String(target.value || "").slice(0, 100),
535
+ }),
536
+ ),
537
+ );
538
+ }, 500),
539
+ );
540
+ },
541
+ true,
542
+ );
543
+
544
+ const specialKeys = new Set([
545
+ "Enter",
546
+ "Escape",
547
+ "Tab",
548
+ "Backspace",
549
+ "Delete",
550
+ "ArrowUp",
551
+ "ArrowDown",
552
+ "ArrowLeft",
553
+ "ArrowRight",
554
+ "Home",
555
+ "End",
556
+ "PageUp",
557
+ "PageDown",
558
+ "F1",
559
+ "F2",
560
+ "F3",
561
+ "F4",
562
+ "F5",
563
+ "F6",
564
+ "F7",
565
+ "F8",
566
+ "F9",
567
+ "F10",
568
+ "F11",
569
+ "F12",
570
+ ]);
571
+
572
+ document.addEventListener(
573
+ "keydown",
574
+ (event) => {
575
+ if (window.__btApiActionInProgress) return;
576
+ const isShortcut = event.ctrlKey || event.metaKey || event.altKey;
577
+ if (!isShortcut && !specialKeys.has(event.key)) return;
578
+ const keyDesc =
579
+ (event.ctrlKey ? "Ctrl+" : "") +
580
+ (event.metaKey ? "Meta+" : "") +
581
+ (event.altKey ? "Alt+" : "") +
582
+ (event.shiftKey ? "Shift+" : "") +
583
+ event.key;
584
+ window.__btActionLog(
585
+ JSON.stringify(
586
+ buildActionPayload(event, "press", {
587
+ value: keyDesc,
588
+ }),
589
+ ),
590
+ );
591
+ },
592
+ true,
593
+ );
594
+
595
+ let scrollTimer = null;
596
+ document.addEventListener(
597
+ "scroll",
598
+ () => {
599
+ if (window.__btApiActionInProgress) return;
600
+ if (scrollTimer) clearTimeout(scrollTimer);
601
+ scrollTimer = setTimeout(() => {
602
+ scrollTimer = null;
603
+ window.__btActionLog(
604
+ JSON.stringify({
605
+ action: "scroll",
606
+ bestSemanticSelector: "document",
607
+ success: true,
608
+ value: \`y=\${window.scrollY}\`,
609
+ }),
610
+ );
611
+ }, 300);
612
+ },
613
+ true,
614
+ );
615
+ })();
616
+ `;
617
+ };
618
+
189
619
  const installUserDomTracking = async (
190
620
  page: Page,
191
621
  pageId: string,
@@ -202,196 +632,7 @@ export async function installSessionTelemetry(
202
632
  });
203
633
  });
204
634
 
205
- await page.addInitScript(() => {
206
- if ((window as any).__btDomListenersInstalled) return;
207
- (window as any).__btDomListenersInstalled = true;
208
-
209
- const identify = (el: any): string => {
210
- if (!el || !el.tagName) return "";
211
- const testId = el.getAttribute("data-testid");
212
- if (testId) return `[data-testid="${testId}"]`;
213
- const role = el.getAttribute("role") || "";
214
- const id = el.id;
215
- if (role && id) return `${role}#${id}`;
216
- const label =
217
- el.getAttribute("aria-label") ||
218
- (el.textContent || "").trim().slice(0, 30) ||
219
- "";
220
- if (role && label) return `${role} "${label}"`;
221
- const tag = el.tagName.toLowerCase();
222
- const cls =
223
- el.className && typeof el.className === "string"
224
- ? "." + el.className.trim().split(/\s+/).slice(0, 2).join(".")
225
- : "";
226
- return `${tag}${cls}`;
227
- };
228
-
229
- let clickTimer: ReturnType<typeof setTimeout> | null = null;
230
- let pendingClick: { selector: string } | null = null;
231
-
232
- document.addEventListener(
233
- "click",
234
- (event) => {
235
- if ((window as any).__btApiActionInProgress) return;
236
- const target = event.target as any;
237
- const selector = identify(target);
238
- if (target?.type === "checkbox") {
239
- (window as any).__btActionLog(
240
- JSON.stringify({
241
- action: target.checked ? "check" : "uncheck",
242
- selector,
243
- success: true,
244
- }),
245
- );
246
- return;
247
- }
248
- pendingClick = { selector };
249
- if (clickTimer) clearTimeout(clickTimer);
250
- clickTimer = setTimeout(() => {
251
- if (pendingClick) {
252
- (window as any).__btActionLog(
253
- JSON.stringify({
254
- action: "click",
255
- selector: pendingClick.selector,
256
- success: true,
257
- }),
258
- );
259
- }
260
- pendingClick = null;
261
- clickTimer = null;
262
- }, 200);
263
- },
264
- true,
265
- );
266
-
267
- document.addEventListener(
268
- "dblclick",
269
- (event) => {
270
- if ((window as any).__btApiActionInProgress) return;
271
- if (clickTimer) {
272
- clearTimeout(clickTimer);
273
- clickTimer = null;
274
- pendingClick = null;
275
- }
276
- const selector = identify(event.target);
277
- (window as any).__btActionLog(
278
- JSON.stringify({ action: "dblclick", selector, success: true }),
279
- );
280
- },
281
- true,
282
- );
283
-
284
- const inputTimers = new WeakMap<any, ReturnType<typeof setTimeout>>();
285
- document.addEventListener(
286
- "input",
287
- (event) => {
288
- if ((window as any).__btApiActionInProgress) return;
289
- const target = event.target as any;
290
- const selector = identify(target);
291
- if (target.tagName === "SELECT") {
292
- (window as any).__btActionLog(
293
- JSON.stringify({
294
- action: "selectOption",
295
- selector,
296
- value: target.value,
297
- success: true,
298
- }),
299
- );
300
- return;
301
- }
302
- const existing = inputTimers.get(target);
303
- if (existing) clearTimeout(existing);
304
- inputTimers.set(
305
- target,
306
- setTimeout(() => {
307
- inputTimers.delete(target);
308
- (window as any).__btActionLog(
309
- JSON.stringify({
310
- action: "fill",
311
- selector,
312
- value: (target.value || "").slice(0, 100),
313
- success: true,
314
- }),
315
- );
316
- }, 500),
317
- );
318
- },
319
- true,
320
- );
321
-
322
- const specialKeys = new Set([
323
- "Enter",
324
- "Escape",
325
- "Tab",
326
- "Backspace",
327
- "Delete",
328
- "ArrowUp",
329
- "ArrowDown",
330
- "ArrowLeft",
331
- "ArrowRight",
332
- "Home",
333
- "End",
334
- "PageUp",
335
- "PageDown",
336
- "F1",
337
- "F2",
338
- "F3",
339
- "F4",
340
- "F5",
341
- "F6",
342
- "F7",
343
- "F8",
344
- "F9",
345
- "F10",
346
- "F11",
347
- "F12",
348
- ]);
349
- document.addEventListener(
350
- "keydown",
351
- (event) => {
352
- if ((window as any).__btApiActionInProgress) return;
353
- const isShortcut = event.ctrlKey || event.metaKey || event.altKey;
354
- if (!isShortcut && !specialKeys.has(event.key)) return;
355
- const selector = identify(event.target);
356
- const keyDesc =
357
- (event.ctrlKey ? "Ctrl+" : "") +
358
- (event.metaKey ? "Meta+" : "") +
359
- (event.altKey ? "Alt+" : "") +
360
- (event.shiftKey ? "Shift+" : "") +
361
- event.key;
362
- (window as any).__btActionLog(
363
- JSON.stringify({
364
- action: "press",
365
- selector,
366
- value: keyDesc,
367
- success: true,
368
- }),
369
- );
370
- },
371
- true,
372
- );
373
-
374
- let scrollTimer: ReturnType<typeof setTimeout> | null = null;
375
- document.addEventListener(
376
- "scroll",
377
- () => {
378
- if ((window as any).__btApiActionInProgress) return;
379
- if (scrollTimer) clearTimeout(scrollTimer);
380
- scrollTimer = setTimeout(() => {
381
- scrollTimer = null;
382
- (window as any).__btActionLog(
383
- JSON.stringify({
384
- action: "scroll",
385
- selector: "document",
386
- value: `y=${window.scrollY}`,
387
- success: true,
388
- }),
389
- );
390
- }, 300);
391
- },
392
- true,
393
- );
394
- });
635
+ await page.addInitScript({ content: createUserDomTrackingInitScript() });
395
636
  };
396
637
 
397
638
  const wrapPageActions = (page: Page, pageId: string): void => {
@@ -96,6 +96,15 @@ export type ActionLogEntry = {
96
96
  action: string;
97
97
  source: "user" | "agent";
98
98
  selector?: string;
99
+ bestSemanticSelector?: string;
100
+ targetSelector?: string;
101
+ ancestorSelectors?: string[];
102
+ nearbyText?: string;
103
+ composedPath?: string[];
104
+ coordinates?: {
105
+ x: number;
106
+ y: number;
107
+ };
99
108
  value?: string;
100
109
  url?: string;
101
110
  duration?: number;
@@ -152,6 +161,11 @@ export function readActionLog(
152
161
  (e) =>
153
162
  re.test(e.action) ||
154
163
  re.test(e.selector || "") ||
164
+ re.test(e.bestSemanticSelector || "") ||
165
+ re.test(e.targetSelector || "") ||
166
+ re.test((e.ancestorSelectors || []).join(" ")) ||
167
+ re.test(e.nearbyText || "") ||
168
+ re.test((e.composedPath || []).join(" ")) ||
155
169
  re.test(e.value || "") ||
156
170
  re.test(e.url || ""),
157
171
  );
@@ -171,8 +185,16 @@ export function readActionLog(
171
185
  export function formatActionEntry(e: ActionLogEntry): string {
172
186
  const time = e.ts.replace(/.*T/, "").replace(/\.\d+Z$/, "");
173
187
  const src = e.source.toUpperCase().padEnd(5);
188
+ const displaySelector = e.bestSemanticSelector || e.selector;
174
189
  const parts = [`[${time}]`, `[${src}]`, e.action];
175
- if (e.selector) parts.push(e.selector);
190
+ if (displaySelector) parts.push(displaySelector);
191
+ if (e.targetSelector && e.targetSelector !== displaySelector) {
192
+ parts.push(`target=${e.targetSelector}`);
193
+ }
194
+ if (e.nearbyText) parts.push(`text="${e.nearbyText}"`);
195
+ if (e.coordinates) {
196
+ parts.push(`@(${e.coordinates.x},${e.coordinates.y})`);
197
+ }
176
198
  if (e.value) parts.push(`"${e.value}"`);
177
199
  if (e.url) parts.push(e.url);
178
200
  if (e.duration != null) parts.push(`${e.duration}ms`);