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