fast-replay 0.4.2 → 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/README.md +34 -3
- package/dist/api.d.ts +21 -0
- package/dist/api.js +24 -0
- package/dist/api.js.map +1 -1
- package/dist/cli/index.js +36 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/ir/schema.d.ts +6 -3
- package/dist/ir/schema.js +12 -0
- package/dist/ir/schema.js.map +1 -1
- package/dist/recorder/agent/selectors.d.ts +9 -0
- package/dist/recorder/agent/selectors.js +53 -8
- package/dist/recorder/agent/selectors.js.map +1 -1
- package/dist/recorder/agent/text.js +4 -0
- package/dist/recorder/agent/text.js.map +1 -1
- package/dist/recorder/agent-bundle.generated.d.ts +1 -1
- package/dist/recorder/agent-bundle.generated.js +1 -1
- package/dist/recorder/agent-bundle.generated.js.map +1 -1
- package/dist/recorder/types.d.ts +1 -0
- package/dist/replayer/resolve.d.ts +28 -0
- package/dist/replayer/resolve.js +49 -0
- package/dist/replayer/resolve.js.map +1 -1
- package/dist/replayer/run.d.ts +15 -1
- package/dist/replayer/run.js +59 -15
- package/dist/replayer/run.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The in-page capture agent, bundled as a standalone IIFE. */
|
|
2
|
-
export declare const AGENT_BUNDLE = "\"use strict\";\n(() => {\n // src/recorder/agent/config.ts\n var INSTALL_GLOBAL = \"__replayInstall\";\n var AGENT_READY_FLAG = \"__replayAgentReady\";\n var FLUSH_GLOBAL = \"__replayFlush\";\n\n // src/recorder/agent/text.ts\n function clean(s, max = 80) {\n if (!s) return \"\";\n const t = s.replace(/\\s+/g, \" \").trim();\n return t.length > max ? `${t.slice(0, max - 1)}\\u2026` : t;\n }\n function escAttr(v) {\n return v.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n }\n function escId(v) {\n return typeof window.CSS?.escape === \"function\" ? window.CSS.escape(v) : v.replace(/([^\\w-])/g, \"\\\\$1\");\n }\n function isStableToken(t) {\n if (!t || t.length > 40) return false;\n if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(t)) return false;\n if (/^(css|sc|emotion|styled|jsx|glamor|makeStyles)-/i.test(t)) return false;\n if (/[0-9a-f]{6,}/i.test(t)) return false;\n if ((t.match(/\\d/g) || []).length >= 3) return false;\n if (/[A-Z]/.test(t) && /\\d/.test(t)) return false;\n if (/^[a-z]{1,2}-[a-z0-9]{5,}$/i.test(t) && /\\d/.test(t)) return false;\n if (t.split(/[-_]/).some(looksGenerated)) return false;\n if (/-(aria|diff|live|announcer|portal)$/i.test(t)) return false;\n return true;\n }\n function looksGenerated(segment) {\n if (segment.length < 6) return false;\n if (!/^[a-z0-9]+$/i.test(segment)) return false;\n const vowels = (segment.match(/[aeiouy]/gi) || []).length;\n return /\\d/.test(segment) ? vowels <= 1 : vowels === 0;\n }\n function isStableClass(c) {\n return isStableToken(c) && !/^_/.test(c);\n }\n function renderedText(el, max = 80) {\n const inner = el.innerText;\n return clean(typeof inner === \"string\" ? inner : el.textContent, max);\n }\n\n // src/recorder/agent/roles.ts\n var TAG_ROLES = {\n button: \"button\",\n select: \"combobox\",\n textarea: \"textbox\",\n nav: \"navigation\",\n main: \"main\",\n header: \"banner\",\n footer: \"contentinfo\",\n aside: \"complementary\",\n form: \"form\",\n table: \"table\",\n tr: \"row\",\n td: \"cell\",\n th: \"columnheader\",\n ul: \"list\",\n ol: \"list\",\n li: \"listitem\",\n dialog: \"dialog\",\n option: \"option\",\n h1: \"heading\",\n h2: \"heading\",\n h3: \"heading\",\n h4: \"heading\",\n h5: \"heading\",\n h6: \"heading\"\n };\n var INPUT_ROLES = {\n button: \"button\",\n submit: \"button\",\n reset: \"button\",\n image: \"button\",\n checkbox: \"checkbox\",\n radio: \"radio\",\n range: \"slider\",\n number: \"spinbutton\",\n search: \"searchbox\",\n email: \"textbox\",\n tel: \"textbox\",\n text: \"textbox\",\n url: \"textbox\"\n };\n var NAME_FROM_CONTENT = [\n \"button\",\n \"link\",\n \"heading\",\n \"cell\",\n \"columnheader\",\n \"rowheader\",\n \"listitem\",\n \"option\",\n \"tab\",\n \"menuitem\",\n \"menuitemcheckbox\",\n \"menuitemradio\",\n \"treeitem\",\n \"switch\",\n \"checkbox\",\n \"radio\"\n ];\n var NON_EDITABLE_INPUTS = [\"checkbox\", \"radio\", \"button\", \"submit\", \"reset\", \"file\", \"image\"];\n function getRole(el) {\n const explicit = el.getAttribute(\"role\");\n if (explicit) return explicit.trim().split(/\\s+/)[0] || null;\n const tag = el.tagName.toLowerCase();\n if (tag === \"a\") return el.hasAttribute(\"href\") ? \"link\" : null;\n if (tag === \"img\") return el.getAttribute(\"alt\") === \"\" ? null : \"img\";\n if (tag === \"input\") {\n const type = (el.getAttribute(\"type\") || \"text\").toLowerCase();\n return INPUT_ROLES[type] ?? null;\n }\n return TAG_ROLES[tag] ?? null;\n }\n function isEditable(el) {\n if (!el || el.nodeType !== 1) return false;\n const tag = el.tagName.toLowerCase();\n if (tag === \"textarea\") return true;\n if (el.isContentEditable) return true;\n if (tag !== \"input\") return false;\n const type = (el.getAttribute(\"type\") || \"text\").toLowerCase();\n return !NON_EDITABLE_INPUTS.includes(type);\n }\n function accessibleName(el) {\n const aria = el.getAttribute(\"aria-label\");\n if (aria && aria.trim()) return clean(aria);\n const labelledby = el.getAttribute(\"aria-labelledby\");\n if (labelledby) {\n const parts = labelledby.split(/\\s+/).map((id) => document.getElementById(id)).filter((n) => !!n).map((n) => clean(n.textContent));\n const joined = clean(parts.filter(Boolean).join(\" \"));\n if (joined) return joined;\n }\n const tag = el.tagName.toLowerCase();\n if ([\"input\", \"select\", \"textarea\"].includes(tag)) {\n const id = el.getAttribute(\"id\");\n if (id) {\n const forLabel = document.querySelector(`label[for=\"${escAttr(id)}\"]`);\n if (forLabel) {\n const t = clean(forLabel.textContent);\n if (t) return t;\n }\n }\n const wrapping = el.closest(\"label\");\n if (wrapping) {\n const t = clean(wrapping.textContent);\n if (t) return t;\n }\n }\n for (const attr of [\"alt\", \"title\", \"placeholder\"]) {\n const v = el.getAttribute(attr);\n if (v && v.trim()) return clean(v);\n }\n const role = getRole(el);\n if (role && NAME_FROM_CONTENT.includes(role)) {\n const t = clean(el.textContent);\n if (t) return t;\n }\n return \"\";\n }\n function ownText(el) {\n return clean(el.textContent, 60);\n }\n\n // src/recorder/agent/selectors.ts\n var TEST_ID_ATTRS = [\n \"data-testid\",\n \"data-test\",\n \"data-test-id\",\n \"data-cy\",\n \"data-sentry-label\",\n \"data-testid-label\"\n ];\n var MAX_CANDIDATES = 6;\n var INTERACTIVE = 'button, a[href], [role=\"button\"], [role=\"link\"], [role=\"menuitem\"], [role=\"menuitemradio\"], [role=\"option\"], [role=\"tab\"], [role=\"checkbox\"], [role=\"radio\"], [role=\"switch\"], [tabindex], [data-focusable=\"true\"], [onclick]';\n var MAX_CSS_PATH_DEPTH = 6;\n function testIdSelector(el) {\n for (const attr of TEST_ID_ATTRS) {\n const v = el.getAttribute(attr);\n if (v) return `[${attr}=\"${escAttr(v)}\"]`;\n }\n return null;\n }\n function idSelector(el) {\n const id = el.getAttribute(\"id\");\n return id && isStableToken(id) ? `#${escId(id)}` : null;\n }\n function roleNameSelector(el) {\n const role = getRole(el);\n if (!role) return null;\n const name = accessibleName(el);\n if (!name) return null;\n return `role=${role}[name=\"${escAttr(name)}\"]`;\n }\n function appearedSelector(el) {\n return testIdSelector(el) ?? idSelector(el) ?? roleNameSelector(el);\n }\n function goneSelector(el) {\n return testIdSelector(el) ?? idSelector(el);\n }\n function cssSelectorFor(el) {\n return testIdSelector(el) ?? idSelector(el) ?? buildCssPath(el);\n }\n function allElements() {\n return Array.from(document.querySelectorAll(\"*\"));\n }\n function withNth(base, el, matches) {\n if (matches.length <= 1) return base;\n const idx = matches.indexOf(el);\n return idx < 0 ? base : `${base} >> nth=${idx}`;\n }\n function buildCssPath(el) {\n const segs = [];\n let cur = el;\n let depth = 0;\n while (cur && cur.nodeType === 1 && cur !== document.documentElement && depth < MAX_CSS_PATH_DEPTH) {\n const node = cur;\n if (depth > 0) {\n const anchor = testIdSelector(node) ?? idSelector(node);\n if (anchor) {\n segs.unshift(anchor);\n break;\n }\n }\n let seg = node.tagName.toLowerCase();\n const classes = Array.from(node.classList).filter(isStableClass).slice(0, 2);\n if (classes.length) seg += `.${classes.map(escId).join(\".\")}`;\n const parent = node.parentElement;\n if (parent) {\n const sameTag = Array.from(parent.children).filter((c) => c.tagName === node.tagName);\n if (sameTag.length > 1) seg += `:nth-of-type(${sameTag.indexOf(node) + 1})`;\n }\n segs.unshift(seg);\n const partial = segs.join(\" > \");\n try {\n if (document.querySelectorAll(partial).length === 1) return partial;\n } catch {\n return null;\n }\n cur = parent;\n depth++;\n }\n if (!segs.length) return null;\n const sel = segs.join(\" > \");\n try {\n return document.querySelectorAll(sel).length ? sel : null;\n } catch {\n return null;\n }\n }\n function buildCandidates(el) {\n const out = [];\n const push = (s) => {\n if (s && out.indexOf(s) === -1) out.push(s);\n };\n push(testIdSelector(el));\n push(idSelector(el));\n const nameAttr = el.getAttribute(\"name\");\n if (nameAttr && isStableToken(nameAttr)) {\n push(`${el.tagName.toLowerCase()}[name=\"${escAttr(nameAttr)}\"]`);\n }\n const roleName = roleNameSelector(el);\n if (roleName) {\n const role = getRole(el);\n const name = accessibleName(el);\n const matches = allElements().filter((c) => getRole(c) === role && accessibleName(c) === name);\n push(withNth(roleName, el, matches));\n }\n for (const selector of interactiveHostSelectors(el)) push(selector);\n push(labelledAncestorSelector(el));\n const cssPath = buildCssPath(el);\n const text = ownText(el);\n const textSelector = text && isSmallestWithText(el, text) ? withNth(`text=\"${escAttr(text)}\"`, el, allElements().filter((c) => isSmallestWithText(c, text))) : null;\n if (isPositionalOnly(cssPath)) {\n push(textSelector);\n push(cssPath);\n } else {\n push(cssPath);\n push(textSelector);\n }\n return out.slice(0, MAX_CANDIDATES);\n }\n function interactiveHostSelectors(el) {\n let host = null;\n try {\n const found = el.closest(INTERACTIVE);\n host = found && found !== el ? found : null;\n } catch {\n host = null;\n }\n if (!host) return [];\n const out = [];\n const push = (s) => {\n if (s && out.indexOf(s) === -1) out.push(s);\n };\n push(testIdSelector(host));\n push(idSelector(host));\n push(roleNameSelector(host));\n const label = renderedText(host, 60);\n if (label) {\n const tag = host.tagName.toLowerCase();\n const role = host.getAttribute(\"role\");\n const qualifier = host.getAttribute(\"data-focusable\") ? '[data-focusable=\"true\"]' : role ? `[role=\"${escAttr(role)}\"]` : host.hasAttribute(\"tabindex\") ? `[tabindex=\"${escAttr(host.getAttribute(\"tabindex\") ?? \"0\")}\"]` : \"\";\n if (qualifier) push(`${tag}${qualifier}:has-text(\"${escAttr(label)}\")`);\n }\n return out;\n }\n function isPositionalOnly(selector) {\n if (!selector) return false;\n if (!selector.includes(\":nth-of-type(\")) return false;\n return !selector.includes(\"[\") && !selector.includes(\".\");\n }\n function labelledAncestorSelector(el) {\n if (testIdSelector(el) ?? idSelector(el)) return null;\n let cur = el.parentElement;\n for (let depth = 0; cur && depth < MAX_CSS_PATH_DEPTH; depth++) {\n const anchor = testIdSelector(cur);\n if (anchor) {\n try {\n return document.querySelectorAll(anchor).length === 1 ? anchor : null;\n } catch {\n return null;\n }\n }\n cur = cur.parentElement;\n }\n return null;\n }\n function isSmallestWithText(el, text) {\n if (ownText(el) !== text) return false;\n return !Array.from(el.children).some((child) => ownText(child) === text);\n }\n function contextLabel(el) {\n const row = el.closest('tr, [role=\"row\"], li, [role=\"listitem\"], [data-testid*=\"row\"]');\n if (row && row !== el) {\n const own = renderedText(el, 60);\n const full = renderedText(row, 120);\n const t = clean(own ? full.split(own).join(\" \") : full, 60);\n if (t) return ` in the row containing \"${t}\"`;\n }\n const section = el.closest('form, section, dialog, [role=\"dialog\"], nav, main, aside');\n if (section && section !== el) {\n const label = section.getAttribute(\"aria-label\") || clean(section.querySelector(\"h1, h2, h3, h4, legend\")?.textContent) || \"\";\n if (label) return ` in the ${section.tagName.toLowerCase()} labelled \"${clean(label, 40)}\"`;\n }\n return \"\";\n }\n function semanticOf(el) {\n const role = getRole(el) || el.tagName.toLowerCase();\n const name = accessibleName(el) || ownText(el);\n const head = name ? `${name} ${role}` : role;\n return head + contextLabel(el);\n }\n function describe(el) {\n const candidates = buildCandidates(el);\n if (!candidates.length) return null;\n return { candidates, semantic: semanticOf(el) };\n }\n\n // src/recorder/agent/capture.ts\n var ECHO_WINDOW_MS = 30;\n var GESTURE_WINDOW_MS = 1e3;\n var FOCUS_SETTLE_MS = 120;\n var MEANINGFUL_KEYS = [\n \"Enter\",\n \"Escape\",\n \"Tab\",\n \"ArrowUp\",\n \"ArrowDown\",\n \"ArrowLeft\",\n \"ArrowRight\",\n \"Backspace\",\n \"Delete\",\n \"Home\",\n \"End\",\n \"PageUp\",\n \"PageDown\",\n \" \"\n ];\n function targetOf(e) {\n const t = e.target;\n return t && t.nodeType === 1 ? t : null;\n }\n function playwrightKey(e) {\n const mods = [];\n if (e.ctrlKey) mods.push(\"Control\");\n if (e.altKey) mods.push(\"Alt\");\n if (e.metaKey) mods.push(\"Meta\");\n if (e.shiftKey && e.key.length > 1) mods.push(\"Shift\");\n const key = e.key === \" \" ? \"Space\" : e.key;\n return [...mods, key].join(\"+\");\n }\n function isStopHotkey(e) {\n return (e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === \"x\";\n }\n function installCapture(ctx) {\n const { config, transport, reveals } = ctx;\n const focusValue = /* @__PURE__ */ new WeakMap();\n const committed = /* @__PURE__ */ new WeakMap();\n let lastRecorded = null;\n let lastPointerDown = null;\n function resolveGestureTarget(clicked) {\n const pd = lastPointerDown;\n if (!pd || pd.el === clicked) return clicked;\n if (Date.now() - pd.t > GESTURE_WINDOW_MS) return clicked;\n const retargeted = clicked === document.documentElement || clicked === document.body;\n if (retargeted && pd.el.isConnected && describe(pd.el)) return pd.el;\n if (!describe(clicked) && pd.el.isConnected && describe(pd.el)) return pd.el;\n return clicked;\n }\n function isEchoOfRecordedClick(el) {\n const prev = lastRecorded;\n if (!prev) return false;\n if (Date.now() - prev.t > ECHO_WINDOW_MS) return false;\n if (prev.el === el) return true;\n if (prev.el.contains(el) || el.contains(prev.el)) return true;\n const label = el.closest(\"label\");\n return Boolean(label && label === prev.el.closest(\"label\"));\n }\n function emitAction(action, el, value) {\n ctx.beforeAction?.();\n const target = el ? describe(el) : null;\n if (el && !target) return false;\n const t = Date.now();\n reveals.noteAction(t);\n const ev = { kind: \"action\", action, value, target, t, author: \"human\" };\n transport.emit(ev);\n return true;\n }\n let focusTimer = null;\n let lastFocus = null;\n function noteFocusChange() {\n if (focusTimer) clearTimeout(focusTimer);\n focusTimer = setTimeout(() => {\n const el = document.activeElement;\n const selector = el ? cssSelectorFor(el) : null;\n if (!selector || selector === lastFocus) return;\n lastFocus = selector;\n transport.emit({ kind: \"focus\", selector, t: Date.now() });\n }, FOCUS_SETTLE_MS);\n }\n function flushRevealingHover(actionTarget) {\n const hovered = reveals.takeLoadBearingHover(actionTarget);\n if (hovered) emitAction(\"hover\", hovered, null);\n }\n function valueOf(el) {\n if (el.isContentEditable) return clean(el.innerText, 500);\n return el.value ?? \"\";\n }\n function commitFill(el) {\n if (!isEditable(el)) return;\n const value = valueOf(el);\n if (committed.get(el) === value) return;\n if (focusValue.get(el) === value && committed.get(el) === void 0) return;\n committed.set(el, value);\n flushRevealingHover(el);\n emitAction(\"fill\", el, value);\n }\n function flushPendingFill() {\n const active = document.activeElement;\n if (active && isEditable(active)) commitFill(active);\n }\n const on = (type, handler) => {\n document.addEventListener(type, handler, true);\n };\n on(\"focusin\", (e) => {\n const el = targetOf(e);\n if (el && isEditable(el)) focusValue.set(el, valueOf(el));\n noteFocusChange();\n });\n on(\"focusout\", () => noteFocusChange());\n on(\"mouseover\", (e) => {\n const el = targetOf(e);\n if (el) reveals.noteHover(el);\n });\n on(\"pointerdown\", (e) => {\n const el = targetOf(e);\n if (el) lastPointerDown = { el, t: Date.now() };\n });\n on(\"click\", (e) => {\n const clicked = targetOf(e);\n if (!clicked) return;\n const el = resolveGestureTarget(clicked);\n if (isEchoOfRecordedClick(el)) return;\n flushPendingFill();\n flushRevealingHover(el);\n if (emitAction(\"click\", el, null)) lastRecorded = { el, t: Date.now() };\n });\n on(\"dblclick\", (e) => {\n const el = targetOf(e);\n if (!el) return;\n flushPendingFill();\n emitAction(\"dblclick\", el, null);\n });\n on(\"change\", (e) => {\n const el = targetOf(e);\n if (!el) return;\n if (el.tagName.toLowerCase() === \"select\") {\n const sel = el;\n const opt = sel.selectedOptions[0];\n flushPendingFill();\n flushRevealingHover(el);\n emitAction(\"select\", el, opt ? opt.value || clean(opt.textContent) : sel.value);\n return;\n }\n if (isEditable(el)) commitFill(el);\n });\n on(\"blur\", (e) => {\n const el = targetOf(e);\n if (el && isEditable(el)) commitFill(el);\n });\n on(\"keydown\", (e) => {\n if (isStopHotkey(e)) {\n e.preventDefault();\n e.stopPropagation();\n transport.binding(config.stopBinding)?.({ reason: \"hotkey\" });\n return;\n }\n const el = targetOf(e);\n if (isEditable(el)) {\n if (e.key !== \"Enter\" && e.key !== \"Escape\") return;\n commitFill(el);\n } else if (!MEANINGFUL_KEYS.includes(e.key) && !(e.ctrlKey || e.metaKey || e.altKey)) {\n return;\n } else {\n flushPendingFill();\n }\n emitAction(\"press\", el, playwrightKey(e));\n });\n installScrollCapture(ctx, emitAction);\n }\n var RESIZE_PROBE = [\n \".erd_scroll_detection_container\",\n \".resize-sensor\",\n \".resize-triggers\",\n \"[data-resize-sensor]\"\n ];\n function isResizeProbe(el) {\n if (!el) return false;\n return RESIZE_PROBE.some((sel) => {\n try {\n return el.matches(sel) || el.closest(sel) !== null;\n } catch {\n return false;\n }\n });\n }\n function installScrollCapture(ctx, emitAction) {\n const { config } = ctx;\n const lastPosition = /* @__PURE__ */ new WeakMap();\n const timers = /* @__PURE__ */ new WeakMap();\n document.addEventListener(\n \"scroll\",\n (e) => {\n const raw = e.target;\n if (raw && raw.nodeType === 1 && isResizeProbe(raw)) return;\n const isDocument = !raw || raw === document || raw === document.documentElement || raw === document.body;\n const key = isDocument ? document : raw;\n const el = isDocument ? null : raw;\n const x = el ? el.scrollLeft : window.scrollX;\n const y = el ? el.scrollTop : window.scrollY;\n const existing = timers.get(key);\n if (existing) clearTimeout(existing);\n timers.set(\n key,\n setTimeout(() => {\n const last = lastPosition.get(key) ?? { x: 0, y: 0 };\n const moved = Math.abs(x - last.x) >= config.scrollMinDeltaPx || Math.abs(y - last.y) >= config.scrollMinDeltaPx;\n if (!moved) return;\n lastPosition.set(key, { x, y });\n emitAction(\"scroll\", el, JSON.stringify({ x, y }));\n }, config.scrollDebounceMs)\n );\n },\n true\n );\n }\n\n // src/noise.ts\n var TRANSIENT_NAME = /\\b(loading|spinner|progress|skeleton|please wait|submitting|saving)\\b/i;\n function isTransientIndicator(role, name) {\n if (role === \"progressbar\") return true;\n return TRANSIENT_NAME.test(name);\n }\n\n // src/recorder/agent/visibility.ts\n function isVisible(el) {\n if (!el.isConnected) return false;\n const he = el;\n if (he.hidden || el.getAttribute(\"aria-hidden\") === \"true\") return false;\n const rect = he.getBoundingClientRect();\n if (rect.width === 0 && rect.height === 0) return false;\n const cs = window.getComputedStyle(he);\n return cs.visibility !== \"hidden\" && cs.display !== \"none\" && cs.opacity !== \"0\";\n }\n\n // src/recorder/agent/dom-reaction.ts\n var INTERESTING = \"[data-testid], [data-test], [data-test-id], [data-cy], [id], [role], button, a[href]\";\n var OBSERVED_ATTRS = [\"class\", \"style\", \"hidden\", \"aria-hidden\"];\n var APPEAR_CONFIRM_MS = 600;\n function toCssSelector(selector) {\n if (selector.startsWith(\"role=\") || selector.startsWith(\"text=\")) {\n throw new Error(\"not a CSS selector\");\n }\n return selector;\n }\n function harvest(nodes, into, pick, revealed, max) {\n for (let i = 0; i < nodes.length; i++) {\n const node = nodes[i];\n if (!node || node.nodeType !== 1) continue;\n const el = node;\n revealed.push(el);\n const own = pick(el);\n if (own && into.size < max) into.add(own);\n const descendants = el.querySelectorAll(INTERESTING);\n for (let j = 0; j < descendants.length && into.size < max; j++) {\n const d = descendants[j];\n if (!d) continue;\n const sel = pick(d);\n if (sel) into.add(sel);\n }\n }\n }\n function observeDomReactions(ctx) {\n const { config, transport, reveals } = ctx;\n const visibility = /* @__PURE__ */ new WeakMap();\n let pending = [];\n const stillVisible = (sel) => {\n try {\n const el = document.querySelector(toCssSelector(sel));\n return el ? isVisible(el) : false;\n } catch {\n return true;\n }\n };\n const confirm = (entry) => {\n const survived = entry.selectors.filter(stillVisible);\n if (survived.length) {\n transport.emit({ kind: \"dom\", appeared: survived, gone: [], t: entry.at });\n }\n };\n const flushAppearances = () => {\n const due = pending;\n pending = [];\n for (const entry of due) {\n clearTimeout(entry.timer);\n confirm(entry);\n }\n };\n const visibleAppearedSelector = (el) => {\n if (!isVisible(el)) return null;\n if (isTransientIndicator(getRole(el), accessibleName(el))) return null;\n return appearedSelector(el);\n };\n const start = () => {\n const observer = new MutationObserver((records) => {\n const appeared = /* @__PURE__ */ new Set();\n const gone = /* @__PURE__ */ new Set();\n const revealed = [];\n for (const rec of records) {\n if (rec.type === \"childList\") {\n harvest(rec.addedNodes, appeared, visibleAppearedSelector, revealed, config.maxSelectorsPerMutation);\n harvest(rec.removedNodes, gone, goneSelector, [], config.maxSelectorsPerMutation);\n continue;\n }\n const el = rec.target;\n const sel = appearedSelector(el);\n if (!sel) continue;\n const now = isVisible(el);\n const was = visibility.get(el);\n visibility.set(el, now);\n if (was === void 0 || was === now) continue;\n if (now) {\n appeared.add(sel);\n revealed.push(el);\n } else {\n const g = goneSelector(el);\n if (g) gone.add(g);\n }\n }\n const at = Date.now();\n reveals.noteRevealed(revealed, at);\n if (gone.size) {\n transport.emit({ kind: \"dom\", appeared: [], gone: Array.from(gone), t: at });\n }\n if (appeared.size) {\n const entry = {\n selectors: Array.from(appeared),\n at,\n timer: setTimeout(() => {\n pending = pending.filter((e) => e !== entry);\n confirm(entry);\n }, APPEAR_CONFIRM_MS)\n };\n pending.push(entry);\n }\n });\n observer.observe(document.documentElement, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: OBSERVED_ATTRS\n });\n };\n if (document.documentElement) start();\n else document.addEventListener(\"DOMContentLoaded\", start, { once: true });\n return { flushAppearances };\n }\n\n // src/recorder/agent/reveal-tracker.ts\n var ACTION_COOLDOWN_MS = 600;\n function createRevealTracker(config) {\n let pending = null;\n let lastHovered = null;\n let lastActionAt = 0;\n return {\n noteHover(el) {\n lastHovered = el;\n },\n noteAction(at) {\n lastActionAt = at;\n },\n noteRevealed(nodes, at) {\n if (!nodes.length || !lastHovered) return;\n if (at - lastActionAt <= ACTION_COOLDOWN_MS) return;\n pending = { el: lastHovered, nodes, at };\n },\n takeLoadBearingHover(actionTarget) {\n const reveal = pending;\n pending = null;\n if (!reveal || !actionTarget) return null;\n if (Date.now() - reveal.at > config.hoverRevealWindowMs) return null;\n if (!reveal.el.isConnected) return null;\n const inside = reveal.nodes.some((n) => n === actionTarget || n.contains(actionTarget));\n return inside ? reveal.el : null;\n }\n };\n }\n\n // src/recorder/agent/transport.ts\n var DRAIN_INTERVAL_MS = 25;\n function createTransport(config) {\n const globals = window;\n const pending = [];\n let drainTimer = null;\n function binding(name) {\n const fn = globals[name];\n return typeof fn === \"function\" ? fn : null;\n }\n function drain() {\n const fn = binding(config.emitBinding);\n if (!fn) return;\n while (pending.length) {\n const ev = pending.shift();\n try {\n fn(ev);\n } catch {\n }\n }\n if (drainTimer) {\n clearInterval(drainTimer);\n drainTimer = null;\n }\n }\n function emit(ev) {\n pending.push(ev);\n if (binding(config.emitBinding)) drain();\n else if (!drainTimer) drainTimer = setInterval(drain, DRAIN_INTERVAL_MS);\n }\n return { emit, binding };\n }\n\n // src/recorder/agent/page-agent.ts\n var INSTALLED_FLAG = \"__replayAgentInstalled\";\n function pageAgent(config) {\n const globals = window;\n if (globals[INSTALLED_FLAG]) return;\n if (window.top !== window) return;\n globals[INSTALLED_FLAG] = true;\n const transport = createTransport(config);\n const reveals = createRevealTracker(config);\n const dom = observeDomReactions({ config, transport, reveals });\n installCapture({ config, transport, reveals, beforeAction: () => dom.flushAppearances() });\n globals[FLUSH_GLOBAL] = () => dom.flushAppearances();\n globals[AGENT_READY_FLAG] = true;\n }\n\n // src/recorder/agent/main.ts\n window[INSTALL_GLOBAL] = (config) => {\n pageAgent(config);\n };\n})();\n";
|
|
2
|
+
export declare const AGENT_BUNDLE = "\"use strict\";\n(() => {\n // src/recorder/agent/config.ts\n var INSTALL_GLOBAL = \"__replayInstall\";\n var AGENT_READY_FLAG = \"__replayAgentReady\";\n var FLUSH_GLOBAL = \"__replayFlush\";\n\n // src/recorder/agent/text.ts\n function clean(s, max = 80) {\n if (!s) return \"\";\n const t = s.replace(/\\s+/g, \" \").trim();\n return t.length > max ? `${t.slice(0, max - 1)}\\u2026` : t;\n }\n function escAttr(v) {\n return v.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n }\n function escId(v) {\n return typeof window.CSS?.escape === \"function\" ? window.CSS.escape(v) : v.replace(/([^\\w-])/g, \"\\\\$1\");\n }\n function isStableToken(t) {\n if (!t || t.length > 40) return false;\n if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(t)) return false;\n if (/^(css|sc|emotion|styled|jsx|glamor|makeStyles)-/i.test(t)) return false;\n if (/^r-/.test(t)) return false;\n if (/[0-9a-f]{6,}/i.test(t)) return false;\n if ((t.match(/\\d/g) || []).length >= 3) return false;\n if (/[A-Z]/.test(t) && /\\d/.test(t)) return false;\n if (/^[a-z]{1,2}-[a-z0-9]{5,}$/i.test(t) && /\\d/.test(t)) return false;\n if (t.split(/[-_]/).some(looksGenerated)) return false;\n if (/-(aria|diff|live|announcer|portal)$/i.test(t)) return false;\n return true;\n }\n function looksGenerated(segment) {\n if (segment.length < 6) return false;\n if (!/^[a-z0-9]+$/i.test(segment)) return false;\n const vowels = (segment.match(/[aeiouy]/gi) || []).length;\n return /\\d/.test(segment) ? vowels <= 1 : vowels === 0;\n }\n function isStableClass(c) {\n return isStableToken(c) && !/^_/.test(c);\n }\n function renderedText(el, max = 80) {\n const inner = el.innerText;\n return clean(typeof inner === \"string\" ? inner : el.textContent, max);\n }\n\n // src/recorder/agent/roles.ts\n var TAG_ROLES = {\n button: \"button\",\n select: \"combobox\",\n textarea: \"textbox\",\n nav: \"navigation\",\n main: \"main\",\n header: \"banner\",\n footer: \"contentinfo\",\n aside: \"complementary\",\n form: \"form\",\n table: \"table\",\n tr: \"row\",\n td: \"cell\",\n th: \"columnheader\",\n ul: \"list\",\n ol: \"list\",\n li: \"listitem\",\n dialog: \"dialog\",\n option: \"option\",\n h1: \"heading\",\n h2: \"heading\",\n h3: \"heading\",\n h4: \"heading\",\n h5: \"heading\",\n h6: \"heading\"\n };\n var INPUT_ROLES = {\n button: \"button\",\n submit: \"button\",\n reset: \"button\",\n image: \"button\",\n checkbox: \"checkbox\",\n radio: \"radio\",\n range: \"slider\",\n number: \"spinbutton\",\n search: \"searchbox\",\n email: \"textbox\",\n tel: \"textbox\",\n text: \"textbox\",\n url: \"textbox\"\n };\n var NAME_FROM_CONTENT = [\n \"button\",\n \"link\",\n \"heading\",\n \"cell\",\n \"columnheader\",\n \"rowheader\",\n \"listitem\",\n \"option\",\n \"tab\",\n \"menuitem\",\n \"menuitemcheckbox\",\n \"menuitemradio\",\n \"treeitem\",\n \"switch\",\n \"checkbox\",\n \"radio\"\n ];\n var NON_EDITABLE_INPUTS = [\"checkbox\", \"radio\", \"button\", \"submit\", \"reset\", \"file\", \"image\"];\n function getRole(el) {\n const explicit = el.getAttribute(\"role\");\n if (explicit) return explicit.trim().split(/\\s+/)[0] || null;\n const tag = el.tagName.toLowerCase();\n if (tag === \"a\") return el.hasAttribute(\"href\") ? \"link\" : null;\n if (tag === \"img\") return el.getAttribute(\"alt\") === \"\" ? null : \"img\";\n if (tag === \"input\") {\n const type = (el.getAttribute(\"type\") || \"text\").toLowerCase();\n return INPUT_ROLES[type] ?? null;\n }\n return TAG_ROLES[tag] ?? null;\n }\n function isEditable(el) {\n if (!el || el.nodeType !== 1) return false;\n const tag = el.tagName.toLowerCase();\n if (tag === \"textarea\") return true;\n if (el.isContentEditable) return true;\n if (tag !== \"input\") return false;\n const type = (el.getAttribute(\"type\") || \"text\").toLowerCase();\n return !NON_EDITABLE_INPUTS.includes(type);\n }\n function accessibleName(el) {\n const aria = el.getAttribute(\"aria-label\");\n if (aria && aria.trim()) return clean(aria);\n const labelledby = el.getAttribute(\"aria-labelledby\");\n if (labelledby) {\n const parts = labelledby.split(/\\s+/).map((id) => document.getElementById(id)).filter((n) => !!n).map((n) => clean(n.textContent));\n const joined = clean(parts.filter(Boolean).join(\" \"));\n if (joined) return joined;\n }\n const tag = el.tagName.toLowerCase();\n if ([\"input\", \"select\", \"textarea\"].includes(tag)) {\n const id = el.getAttribute(\"id\");\n if (id) {\n const forLabel = document.querySelector(`label[for=\"${escAttr(id)}\"]`);\n if (forLabel) {\n const t = clean(forLabel.textContent);\n if (t) return t;\n }\n }\n const wrapping = el.closest(\"label\");\n if (wrapping) {\n const t = clean(wrapping.textContent);\n if (t) return t;\n }\n }\n for (const attr of [\"alt\", \"title\", \"placeholder\"]) {\n const v = el.getAttribute(attr);\n if (v && v.trim()) return clean(v);\n }\n const role = getRole(el);\n if (role && NAME_FROM_CONTENT.includes(role)) {\n const t = clean(el.textContent);\n if (t) return t;\n }\n return \"\";\n }\n function ownText(el) {\n return clean(el.textContent, 60);\n }\n\n // src/recorder/agent/selectors.ts\n var TEST_ID_ATTRS = [\n \"data-testid\",\n \"data-test\",\n \"data-test-id\",\n \"data-cy\",\n \"data-sentry-label\",\n \"data-testid-label\"\n ];\n var MAX_CANDIDATES = 6;\n var INTERACTIVE = 'button, a[href], [role=\"button\"], [role=\"link\"], [role=\"menuitem\"], [role=\"menuitemradio\"], [role=\"option\"], [role=\"tab\"], [role=\"checkbox\"], [role=\"radio\"], [role=\"switch\"], [tabindex], [data-focusable=\"true\"], [onclick]';\n var MAX_CSS_PATH_DEPTH = 6;\n function testIdSelector(el) {\n for (const attr of TEST_ID_ATTRS) {\n const v = el.getAttribute(attr);\n if (v) return `[${attr}=\"${escAttr(v)}\"]`;\n }\n return null;\n }\n function idSelector(el) {\n const id = el.getAttribute(\"id\");\n return id && isStableToken(id) ? `#${escId(id)}` : null;\n }\n function roleNameSelector(el) {\n const role = getRole(el);\n if (!role) return null;\n const name = accessibleName(el);\n if (!name) return null;\n return `role=${role}[name=\"${escAttr(name)}\"]`;\n }\n function appearedSelector(el) {\n return testIdSelector(el) ?? idSelector(el) ?? roleNameSelector(el);\n }\n function goneSelector(el) {\n return testIdSelector(el) ?? idSelector(el);\n }\n function cssSelectorFor(el) {\n return testIdSelector(el) ?? idSelector(el) ?? buildCssPath(el);\n }\n function allElements() {\n return Array.from(document.querySelectorAll(\"*\"));\n }\n function withNth(base, el, matches) {\n if (matches.length <= 1) return base;\n const idx = matches.indexOf(el);\n return idx < 0 ? base : `${base} >> nth=${idx}`;\n }\n function buildCssPath(el) {\n const segs = [];\n let cur = el;\n let depth = 0;\n while (cur && cur.nodeType === 1 && cur !== document.documentElement && depth < MAX_CSS_PATH_DEPTH) {\n const node = cur;\n if (depth > 0) {\n const anchor = testIdSelector(node) ?? idSelector(node);\n if (anchor) {\n segs.unshift(anchor);\n break;\n }\n }\n let seg = node.tagName.toLowerCase();\n const classes = Array.from(node.classList).filter(isStableClass).slice(0, 2);\n if (classes.length) seg += `.${classes.map(escId).join(\".\")}`;\n const parent = node.parentElement;\n if (parent) {\n const sameTag = Array.from(parent.children).filter((c) => c.tagName === node.tagName);\n if (sameTag.length > 1) seg += `:nth-of-type(${sameTag.indexOf(node) + 1})`;\n }\n segs.unshift(seg);\n const partial = segs.join(\" > \");\n try {\n if (document.querySelectorAll(partial).length === 1) return partial;\n } catch {\n return null;\n }\n cur = parent;\n depth++;\n }\n if (!segs.length) return null;\n const sel = segs.join(\" > \");\n try {\n return document.querySelectorAll(sel).length ? sel : null;\n } catch {\n return null;\n }\n }\n function buildCandidates(el) {\n const out = [];\n const push = (s) => {\n if (s && out.indexOf(s) === -1) out.push(s);\n };\n push(testIdSelector(el));\n push(idSelector(el));\n const nameAttr = el.getAttribute(\"name\");\n if (nameAttr && isStableToken(nameAttr)) {\n push(`${el.tagName.toLowerCase()}[name=\"${escAttr(nameAttr)}\"]`);\n }\n const roleName = roleNameSelector(el);\n if (roleName) {\n const role = getRole(el);\n const name = accessibleName(el);\n const matches = allElements().filter((c) => getRole(c) === role && accessibleName(c) === name);\n push(withNth(roleName, el, matches));\n }\n for (const selector of interactiveHostSelectors(el)) push(selector);\n push(labelledAncestorSelector(el));\n const cssPath = buildCssPath(el);\n const text = ownText(el);\n const textSelector = text && isSmallestWithText(el, text) ? withNth(`text=\"${escAttr(text)}\"`, el, allElements().filter((c) => isSmallestWithText(c, text))) : null;\n if (isPositionalOnly(cssPath)) {\n push(textSelector);\n push(cssPath);\n } else {\n push(cssPath);\n push(textSelector);\n }\n return out.slice(0, MAX_CANDIDATES);\n }\n function interactiveHostSelectors(el) {\n let host = null;\n try {\n const found = el.closest(INTERACTIVE);\n host = found && found !== el ? found : null;\n } catch {\n host = null;\n }\n if (!host) return [];\n const out = [];\n const push = (s) => {\n if (s && out.indexOf(s) === -1) out.push(s);\n };\n push(testIdSelector(host));\n push(idSelector(host));\n push(roleNameSelector(host));\n const label = renderedText(host, 60);\n if (label) {\n const tag = host.tagName.toLowerCase();\n const role = host.getAttribute(\"role\");\n const qualifier = host.getAttribute(\"data-focusable\") ? '[data-focusable=\"true\"]' : role ? `[role=\"${escAttr(role)}\"]` : host.hasAttribute(\"tabindex\") ? `[tabindex=\"${escAttr(host.getAttribute(\"tabindex\") ?? \"0\")}\"]` : \"\";\n if (qualifier) push(`${tag}${qualifier}:has-text(\"${escAttr(label)}\")`);\n }\n return out;\n }\n function isPositionalOnly(selector) {\n return Boolean(selector?.includes(\":nth-of-type(\"));\n }\n function labelledAncestorSelector(el) {\n if (testIdSelector(el) ?? idSelector(el)) return null;\n let cur = el.parentElement;\n for (let depth = 0; cur && depth < MAX_CSS_PATH_DEPTH; depth++) {\n const anchor = testIdSelector(cur);\n if (anchor) {\n try {\n return document.querySelectorAll(anchor).length === 1 ? anchor : null;\n } catch {\n return null;\n }\n }\n cur = cur.parentElement;\n }\n return null;\n }\n function isSmallestWithText(el, text) {\n if (ownText(el) !== text) return false;\n return !Array.from(el.children).some((child) => ownText(child) === text);\n }\n function contextLabel(el) {\n const row = el.closest('tr, [role=\"row\"], li, [role=\"listitem\"], [data-testid*=\"row\"]');\n if (row && row !== el) {\n const own = renderedText(el, 60);\n const full = renderedText(row, 120);\n const t = clean(own ? full.split(own).join(\" \") : full, 60);\n if (t) return ` in the row containing \"${t}\"`;\n }\n const section = el.closest('form, section, dialog, [role=\"dialog\"], nav, main, aside');\n if (section && section !== el) {\n const label = section.getAttribute(\"aria-label\") || clean(section.querySelector(\"h1, h2, h3, h4, legend\")?.textContent) || \"\";\n if (label) return ` in the ${section.tagName.toLowerCase()} labelled \"${clean(label, 40)}\"`;\n }\n return \"\";\n }\n function semanticOf(el) {\n const role = getRole(el) || el.tagName.toLowerCase();\n const name = accessibleName(el) || ownText(el);\n const head = name ? `${name} ${role}` : role;\n return head + contextLabel(el);\n }\n function identityOf(el) {\n const own = accessibleName(el) || renderedText(el, 80);\n if (own && countSharingLabel(own) <= 1) return own;\n const row = el.closest(\n 'tr, [role=\"row\"], li, [role=\"listitem\"], [data-testid*=\"row\"], [data-testid*=\"Row\"]'\n );\n if (row && row !== el) {\n const rowText = renderedText(row, 80);\n if (rowText) return rowText;\n }\n return own || void 0;\n }\n function countSharingLabel(label) {\n const wanted = label.replace(/\\s+/g, \" \").trim();\n let count = 0;\n for (const el of allElements()) {\n const text = (el.textContent ?? \"\").replace(/\\s+/g, \" \").trim();\n if (text === wanted && ++count > 1) return count;\n if (el.getAttribute(\"aria-label\")?.trim() === wanted && ++count > 1) return count;\n }\n return count;\n }\n function describe(el) {\n const candidates = buildCandidates(el);\n if (!candidates.length) return null;\n const identity = identityOf(el);\n return { candidates, semantic: semanticOf(el), ...identity ? { identity } : {} };\n }\n\n // src/recorder/agent/capture.ts\n var ECHO_WINDOW_MS = 30;\n var GESTURE_WINDOW_MS = 1e3;\n var FOCUS_SETTLE_MS = 120;\n var MEANINGFUL_KEYS = [\n \"Enter\",\n \"Escape\",\n \"Tab\",\n \"ArrowUp\",\n \"ArrowDown\",\n \"ArrowLeft\",\n \"ArrowRight\",\n \"Backspace\",\n \"Delete\",\n \"Home\",\n \"End\",\n \"PageUp\",\n \"PageDown\",\n \" \"\n ];\n function targetOf(e) {\n const t = e.target;\n return t && t.nodeType === 1 ? t : null;\n }\n function playwrightKey(e) {\n const mods = [];\n if (e.ctrlKey) mods.push(\"Control\");\n if (e.altKey) mods.push(\"Alt\");\n if (e.metaKey) mods.push(\"Meta\");\n if (e.shiftKey && e.key.length > 1) mods.push(\"Shift\");\n const key = e.key === \" \" ? \"Space\" : e.key;\n return [...mods, key].join(\"+\");\n }\n function isStopHotkey(e) {\n return (e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === \"x\";\n }\n function installCapture(ctx) {\n const { config, transport, reveals } = ctx;\n const focusValue = /* @__PURE__ */ new WeakMap();\n const committed = /* @__PURE__ */ new WeakMap();\n let lastRecorded = null;\n let lastPointerDown = null;\n function resolveGestureTarget(clicked) {\n const pd = lastPointerDown;\n if (!pd || pd.el === clicked) return clicked;\n if (Date.now() - pd.t > GESTURE_WINDOW_MS) return clicked;\n const retargeted = clicked === document.documentElement || clicked === document.body;\n if (retargeted && pd.el.isConnected && describe(pd.el)) return pd.el;\n if (!describe(clicked) && pd.el.isConnected && describe(pd.el)) return pd.el;\n return clicked;\n }\n function isEchoOfRecordedClick(el) {\n const prev = lastRecorded;\n if (!prev) return false;\n if (Date.now() - prev.t > ECHO_WINDOW_MS) return false;\n if (prev.el === el) return true;\n if (prev.el.contains(el) || el.contains(prev.el)) return true;\n const label = el.closest(\"label\");\n return Boolean(label && label === prev.el.closest(\"label\"));\n }\n function emitAction(action, el, value) {\n ctx.beforeAction?.();\n const target = el ? describe(el) : null;\n if (el && !target) return false;\n const t = Date.now();\n reveals.noteAction(t);\n const ev = { kind: \"action\", action, value, target, t, author: \"human\" };\n transport.emit(ev);\n return true;\n }\n let focusTimer = null;\n let lastFocus = null;\n function noteFocusChange() {\n if (focusTimer) clearTimeout(focusTimer);\n focusTimer = setTimeout(() => {\n const el = document.activeElement;\n const selector = el ? cssSelectorFor(el) : null;\n if (!selector || selector === lastFocus) return;\n lastFocus = selector;\n transport.emit({ kind: \"focus\", selector, t: Date.now() });\n }, FOCUS_SETTLE_MS);\n }\n function flushRevealingHover(actionTarget) {\n const hovered = reveals.takeLoadBearingHover(actionTarget);\n if (hovered) emitAction(\"hover\", hovered, null);\n }\n function valueOf(el) {\n if (el.isContentEditable) return clean(el.innerText, 500);\n return el.value ?? \"\";\n }\n function commitFill(el) {\n if (!isEditable(el)) return;\n const value = valueOf(el);\n if (committed.get(el) === value) return;\n if (focusValue.get(el) === value && committed.get(el) === void 0) return;\n committed.set(el, value);\n flushRevealingHover(el);\n emitAction(\"fill\", el, value);\n }\n function flushPendingFill() {\n const active = document.activeElement;\n if (active && isEditable(active)) commitFill(active);\n }\n const on = (type, handler) => {\n document.addEventListener(type, handler, true);\n };\n on(\"focusin\", (e) => {\n const el = targetOf(e);\n if (el && isEditable(el)) focusValue.set(el, valueOf(el));\n noteFocusChange();\n });\n on(\"focusout\", () => noteFocusChange());\n on(\"mouseover\", (e) => {\n const el = targetOf(e);\n if (el) reveals.noteHover(el);\n });\n on(\"pointerdown\", (e) => {\n const el = targetOf(e);\n if (el) lastPointerDown = { el, t: Date.now() };\n });\n on(\"click\", (e) => {\n const clicked = targetOf(e);\n if (!clicked) return;\n const el = resolveGestureTarget(clicked);\n if (isEchoOfRecordedClick(el)) return;\n flushPendingFill();\n flushRevealingHover(el);\n if (emitAction(\"click\", el, null)) lastRecorded = { el, t: Date.now() };\n });\n on(\"dblclick\", (e) => {\n const el = targetOf(e);\n if (!el) return;\n flushPendingFill();\n emitAction(\"dblclick\", el, null);\n });\n on(\"change\", (e) => {\n const el = targetOf(e);\n if (!el) return;\n if (el.tagName.toLowerCase() === \"select\") {\n const sel = el;\n const opt = sel.selectedOptions[0];\n flushPendingFill();\n flushRevealingHover(el);\n emitAction(\"select\", el, opt ? opt.value || clean(opt.textContent) : sel.value);\n return;\n }\n if (isEditable(el)) commitFill(el);\n });\n on(\"blur\", (e) => {\n const el = targetOf(e);\n if (el && isEditable(el)) commitFill(el);\n });\n on(\"keydown\", (e) => {\n if (isStopHotkey(e)) {\n e.preventDefault();\n e.stopPropagation();\n transport.binding(config.stopBinding)?.({ reason: \"hotkey\" });\n return;\n }\n const el = targetOf(e);\n if (isEditable(el)) {\n if (e.key !== \"Enter\" && e.key !== \"Escape\") return;\n commitFill(el);\n } else if (!MEANINGFUL_KEYS.includes(e.key) && !(e.ctrlKey || e.metaKey || e.altKey)) {\n return;\n } else {\n flushPendingFill();\n }\n emitAction(\"press\", el, playwrightKey(e));\n });\n installScrollCapture(ctx, emitAction);\n }\n var RESIZE_PROBE = [\n \".erd_scroll_detection_container\",\n \".resize-sensor\",\n \".resize-triggers\",\n \"[data-resize-sensor]\"\n ];\n function isResizeProbe(el) {\n if (!el) return false;\n return RESIZE_PROBE.some((sel) => {\n try {\n return el.matches(sel) || el.closest(sel) !== null;\n } catch {\n return false;\n }\n });\n }\n function installScrollCapture(ctx, emitAction) {\n const { config } = ctx;\n const lastPosition = /* @__PURE__ */ new WeakMap();\n const timers = /* @__PURE__ */ new WeakMap();\n document.addEventListener(\n \"scroll\",\n (e) => {\n const raw = e.target;\n if (raw && raw.nodeType === 1 && isResizeProbe(raw)) return;\n const isDocument = !raw || raw === document || raw === document.documentElement || raw === document.body;\n const key = isDocument ? document : raw;\n const el = isDocument ? null : raw;\n const x = el ? el.scrollLeft : window.scrollX;\n const y = el ? el.scrollTop : window.scrollY;\n const existing = timers.get(key);\n if (existing) clearTimeout(existing);\n timers.set(\n key,\n setTimeout(() => {\n const last = lastPosition.get(key) ?? { x: 0, y: 0 };\n const moved = Math.abs(x - last.x) >= config.scrollMinDeltaPx || Math.abs(y - last.y) >= config.scrollMinDeltaPx;\n if (!moved) return;\n lastPosition.set(key, { x, y });\n emitAction(\"scroll\", el, JSON.stringify({ x, y }));\n }, config.scrollDebounceMs)\n );\n },\n true\n );\n }\n\n // src/noise.ts\n var TRANSIENT_NAME = /\\b(loading|spinner|progress|skeleton|please wait|submitting|saving)\\b/i;\n function isTransientIndicator(role, name) {\n if (role === \"progressbar\") return true;\n return TRANSIENT_NAME.test(name);\n }\n\n // src/recorder/agent/visibility.ts\n function isVisible(el) {\n if (!el.isConnected) return false;\n const he = el;\n if (he.hidden || el.getAttribute(\"aria-hidden\") === \"true\") return false;\n const rect = he.getBoundingClientRect();\n if (rect.width === 0 && rect.height === 0) return false;\n const cs = window.getComputedStyle(he);\n return cs.visibility !== \"hidden\" && cs.display !== \"none\" && cs.opacity !== \"0\";\n }\n\n // src/recorder/agent/dom-reaction.ts\n var INTERESTING = \"[data-testid], [data-test], [data-test-id], [data-cy], [id], [role], button, a[href]\";\n var OBSERVED_ATTRS = [\"class\", \"style\", \"hidden\", \"aria-hidden\"];\n var APPEAR_CONFIRM_MS = 600;\n function toCssSelector(selector) {\n if (selector.startsWith(\"role=\") || selector.startsWith(\"text=\")) {\n throw new Error(\"not a CSS selector\");\n }\n return selector;\n }\n function harvest(nodes, into, pick, revealed, max) {\n for (let i = 0; i < nodes.length; i++) {\n const node = nodes[i];\n if (!node || node.nodeType !== 1) continue;\n const el = node;\n revealed.push(el);\n const own = pick(el);\n if (own && into.size < max) into.add(own);\n const descendants = el.querySelectorAll(INTERESTING);\n for (let j = 0; j < descendants.length && into.size < max; j++) {\n const d = descendants[j];\n if (!d) continue;\n const sel = pick(d);\n if (sel) into.add(sel);\n }\n }\n }\n function observeDomReactions(ctx) {\n const { config, transport, reveals } = ctx;\n const visibility = /* @__PURE__ */ new WeakMap();\n let pending = [];\n const stillVisible = (sel) => {\n try {\n const el = document.querySelector(toCssSelector(sel));\n return el ? isVisible(el) : false;\n } catch {\n return true;\n }\n };\n const confirm = (entry) => {\n const survived = entry.selectors.filter(stillVisible);\n if (survived.length) {\n transport.emit({ kind: \"dom\", appeared: survived, gone: [], t: entry.at });\n }\n };\n const flushAppearances = () => {\n const due = pending;\n pending = [];\n for (const entry of due) {\n clearTimeout(entry.timer);\n confirm(entry);\n }\n };\n const visibleAppearedSelector = (el) => {\n if (!isVisible(el)) return null;\n if (isTransientIndicator(getRole(el), accessibleName(el))) return null;\n return appearedSelector(el);\n };\n const start = () => {\n const observer = new MutationObserver((records) => {\n const appeared = /* @__PURE__ */ new Set();\n const gone = /* @__PURE__ */ new Set();\n const revealed = [];\n for (const rec of records) {\n if (rec.type === \"childList\") {\n harvest(rec.addedNodes, appeared, visibleAppearedSelector, revealed, config.maxSelectorsPerMutation);\n harvest(rec.removedNodes, gone, goneSelector, [], config.maxSelectorsPerMutation);\n continue;\n }\n const el = rec.target;\n const sel = appearedSelector(el);\n if (!sel) continue;\n const now = isVisible(el);\n const was = visibility.get(el);\n visibility.set(el, now);\n if (was === void 0 || was === now) continue;\n if (now) {\n appeared.add(sel);\n revealed.push(el);\n } else {\n const g = goneSelector(el);\n if (g) gone.add(g);\n }\n }\n const at = Date.now();\n reveals.noteRevealed(revealed, at);\n if (gone.size) {\n transport.emit({ kind: \"dom\", appeared: [], gone: Array.from(gone), t: at });\n }\n if (appeared.size) {\n const entry = {\n selectors: Array.from(appeared),\n at,\n timer: setTimeout(() => {\n pending = pending.filter((e) => e !== entry);\n confirm(entry);\n }, APPEAR_CONFIRM_MS)\n };\n pending.push(entry);\n }\n });\n observer.observe(document.documentElement, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: OBSERVED_ATTRS\n });\n };\n if (document.documentElement) start();\n else document.addEventListener(\"DOMContentLoaded\", start, { once: true });\n return { flushAppearances };\n }\n\n // src/recorder/agent/reveal-tracker.ts\n var ACTION_COOLDOWN_MS = 600;\n function createRevealTracker(config) {\n let pending = null;\n let lastHovered = null;\n let lastActionAt = 0;\n return {\n noteHover(el) {\n lastHovered = el;\n },\n noteAction(at) {\n lastActionAt = at;\n },\n noteRevealed(nodes, at) {\n if (!nodes.length || !lastHovered) return;\n if (at - lastActionAt <= ACTION_COOLDOWN_MS) return;\n pending = { el: lastHovered, nodes, at };\n },\n takeLoadBearingHover(actionTarget) {\n const reveal = pending;\n pending = null;\n if (!reveal || !actionTarget) return null;\n if (Date.now() - reveal.at > config.hoverRevealWindowMs) return null;\n if (!reveal.el.isConnected) return null;\n const inside = reveal.nodes.some((n) => n === actionTarget || n.contains(actionTarget));\n return inside ? reveal.el : null;\n }\n };\n }\n\n // src/recorder/agent/transport.ts\n var DRAIN_INTERVAL_MS = 25;\n function createTransport(config) {\n const globals = window;\n const pending = [];\n let drainTimer = null;\n function binding(name) {\n const fn = globals[name];\n return typeof fn === \"function\" ? fn : null;\n }\n function drain() {\n const fn = binding(config.emitBinding);\n if (!fn) return;\n while (pending.length) {\n const ev = pending.shift();\n try {\n fn(ev);\n } catch {\n }\n }\n if (drainTimer) {\n clearInterval(drainTimer);\n drainTimer = null;\n }\n }\n function emit(ev) {\n pending.push(ev);\n if (binding(config.emitBinding)) drain();\n else if (!drainTimer) drainTimer = setInterval(drain, DRAIN_INTERVAL_MS);\n }\n return { emit, binding };\n }\n\n // src/recorder/agent/page-agent.ts\n var INSTALLED_FLAG = \"__replayAgentInstalled\";\n function pageAgent(config) {\n const globals = window;\n if (globals[INSTALLED_FLAG]) return;\n if (window.top !== window) return;\n globals[INSTALLED_FLAG] = true;\n const transport = createTransport(config);\n const reveals = createRevealTracker(config);\n const dom = observeDomReactions({ config, transport, reveals });\n installCapture({ config, transport, reveals, beforeAction: () => dom.flushAppearances() });\n globals[FLUSH_GLOBAL] = () => dom.flushAppearances();\n globals[AGENT_READY_FLAG] = true;\n }\n\n // src/recorder/agent/main.ts\n window[INSTALL_GLOBAL] = (config) => {\n pageAgent(config);\n };\n})();\n";
|
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
// Source: src/recorder/agent/main.ts. Regenerate with `npm run build:agent`.
|
|
3
3
|
/* eslint-disable */
|
|
4
4
|
/** The in-page capture agent, bundled as a standalone IIFE. */
|
|
5
|
-
export const AGENT_BUNDLE = "\"use strict\";\n(() => {\n // src/recorder/agent/config.ts\n var INSTALL_GLOBAL = \"__replayInstall\";\n var AGENT_READY_FLAG = \"__replayAgentReady\";\n var FLUSH_GLOBAL = \"__replayFlush\";\n\n // src/recorder/agent/text.ts\n function clean(s, max = 80) {\n if (!s) return \"\";\n const t = s.replace(/\\s+/g, \" \").trim();\n return t.length > max ? `${t.slice(0, max - 1)}\\u2026` : t;\n }\n function escAttr(v) {\n return v.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n }\n function escId(v) {\n return typeof window.CSS?.escape === \"function\" ? window.CSS.escape(v) : v.replace(/([^\\w-])/g, \"\\\\$1\");\n }\n function isStableToken(t) {\n if (!t || t.length > 40) return false;\n if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(t)) return false;\n if (/^(css|sc|emotion|styled|jsx|glamor|makeStyles)-/i.test(t)) return false;\n if (/[0-9a-f]{6,}/i.test(t)) return false;\n if ((t.match(/\\d/g) || []).length >= 3) return false;\n if (/[A-Z]/.test(t) && /\\d/.test(t)) return false;\n if (/^[a-z]{1,2}-[a-z0-9]{5,}$/i.test(t) && /\\d/.test(t)) return false;\n if (t.split(/[-_]/).some(looksGenerated)) return false;\n if (/-(aria|diff|live|announcer|portal)$/i.test(t)) return false;\n return true;\n }\n function looksGenerated(segment) {\n if (segment.length < 6) return false;\n if (!/^[a-z0-9]+$/i.test(segment)) return false;\n const vowels = (segment.match(/[aeiouy]/gi) || []).length;\n return /\\d/.test(segment) ? vowels <= 1 : vowels === 0;\n }\n function isStableClass(c) {\n return isStableToken(c) && !/^_/.test(c);\n }\n function renderedText(el, max = 80) {\n const inner = el.innerText;\n return clean(typeof inner === \"string\" ? inner : el.textContent, max);\n }\n\n // src/recorder/agent/roles.ts\n var TAG_ROLES = {\n button: \"button\",\n select: \"combobox\",\n textarea: \"textbox\",\n nav: \"navigation\",\n main: \"main\",\n header: \"banner\",\n footer: \"contentinfo\",\n aside: \"complementary\",\n form: \"form\",\n table: \"table\",\n tr: \"row\",\n td: \"cell\",\n th: \"columnheader\",\n ul: \"list\",\n ol: \"list\",\n li: \"listitem\",\n dialog: \"dialog\",\n option: \"option\",\n h1: \"heading\",\n h2: \"heading\",\n h3: \"heading\",\n h4: \"heading\",\n h5: \"heading\",\n h6: \"heading\"\n };\n var INPUT_ROLES = {\n button: \"button\",\n submit: \"button\",\n reset: \"button\",\n image: \"button\",\n checkbox: \"checkbox\",\n radio: \"radio\",\n range: \"slider\",\n number: \"spinbutton\",\n search: \"searchbox\",\n email: \"textbox\",\n tel: \"textbox\",\n text: \"textbox\",\n url: \"textbox\"\n };\n var NAME_FROM_CONTENT = [\n \"button\",\n \"link\",\n \"heading\",\n \"cell\",\n \"columnheader\",\n \"rowheader\",\n \"listitem\",\n \"option\",\n \"tab\",\n \"menuitem\",\n \"menuitemcheckbox\",\n \"menuitemradio\",\n \"treeitem\",\n \"switch\",\n \"checkbox\",\n \"radio\"\n ];\n var NON_EDITABLE_INPUTS = [\"checkbox\", \"radio\", \"button\", \"submit\", \"reset\", \"file\", \"image\"];\n function getRole(el) {\n const explicit = el.getAttribute(\"role\");\n if (explicit) return explicit.trim().split(/\\s+/)[0] || null;\n const tag = el.tagName.toLowerCase();\n if (tag === \"a\") return el.hasAttribute(\"href\") ? \"link\" : null;\n if (tag === \"img\") return el.getAttribute(\"alt\") === \"\" ? null : \"img\";\n if (tag === \"input\") {\n const type = (el.getAttribute(\"type\") || \"text\").toLowerCase();\n return INPUT_ROLES[type] ?? null;\n }\n return TAG_ROLES[tag] ?? null;\n }\n function isEditable(el) {\n if (!el || el.nodeType !== 1) return false;\n const tag = el.tagName.toLowerCase();\n if (tag === \"textarea\") return true;\n if (el.isContentEditable) return true;\n if (tag !== \"input\") return false;\n const type = (el.getAttribute(\"type\") || \"text\").toLowerCase();\n return !NON_EDITABLE_INPUTS.includes(type);\n }\n function accessibleName(el) {\n const aria = el.getAttribute(\"aria-label\");\n if (aria && aria.trim()) return clean(aria);\n const labelledby = el.getAttribute(\"aria-labelledby\");\n if (labelledby) {\n const parts = labelledby.split(/\\s+/).map((id) => document.getElementById(id)).filter((n) => !!n).map((n) => clean(n.textContent));\n const joined = clean(parts.filter(Boolean).join(\" \"));\n if (joined) return joined;\n }\n const tag = el.tagName.toLowerCase();\n if ([\"input\", \"select\", \"textarea\"].includes(tag)) {\n const id = el.getAttribute(\"id\");\n if (id) {\n const forLabel = document.querySelector(`label[for=\"${escAttr(id)}\"]`);\n if (forLabel) {\n const t = clean(forLabel.textContent);\n if (t) return t;\n }\n }\n const wrapping = el.closest(\"label\");\n if (wrapping) {\n const t = clean(wrapping.textContent);\n if (t) return t;\n }\n }\n for (const attr of [\"alt\", \"title\", \"placeholder\"]) {\n const v = el.getAttribute(attr);\n if (v && v.trim()) return clean(v);\n }\n const role = getRole(el);\n if (role && NAME_FROM_CONTENT.includes(role)) {\n const t = clean(el.textContent);\n if (t) return t;\n }\n return \"\";\n }\n function ownText(el) {\n return clean(el.textContent, 60);\n }\n\n // src/recorder/agent/selectors.ts\n var TEST_ID_ATTRS = [\n \"data-testid\",\n \"data-test\",\n \"data-test-id\",\n \"data-cy\",\n \"data-sentry-label\",\n \"data-testid-label\"\n ];\n var MAX_CANDIDATES = 6;\n var INTERACTIVE = 'button, a[href], [role=\"button\"], [role=\"link\"], [role=\"menuitem\"], [role=\"menuitemradio\"], [role=\"option\"], [role=\"tab\"], [role=\"checkbox\"], [role=\"radio\"], [role=\"switch\"], [tabindex], [data-focusable=\"true\"], [onclick]';\n var MAX_CSS_PATH_DEPTH = 6;\n function testIdSelector(el) {\n for (const attr of TEST_ID_ATTRS) {\n const v = el.getAttribute(attr);\n if (v) return `[${attr}=\"${escAttr(v)}\"]`;\n }\n return null;\n }\n function idSelector(el) {\n const id = el.getAttribute(\"id\");\n return id && isStableToken(id) ? `#${escId(id)}` : null;\n }\n function roleNameSelector(el) {\n const role = getRole(el);\n if (!role) return null;\n const name = accessibleName(el);\n if (!name) return null;\n return `role=${role}[name=\"${escAttr(name)}\"]`;\n }\n function appearedSelector(el) {\n return testIdSelector(el) ?? idSelector(el) ?? roleNameSelector(el);\n }\n function goneSelector(el) {\n return testIdSelector(el) ?? idSelector(el);\n }\n function cssSelectorFor(el) {\n return testIdSelector(el) ?? idSelector(el) ?? buildCssPath(el);\n }\n function allElements() {\n return Array.from(document.querySelectorAll(\"*\"));\n }\n function withNth(base, el, matches) {\n if (matches.length <= 1) return base;\n const idx = matches.indexOf(el);\n return idx < 0 ? base : `${base} >> nth=${idx}`;\n }\n function buildCssPath(el) {\n const segs = [];\n let cur = el;\n let depth = 0;\n while (cur && cur.nodeType === 1 && cur !== document.documentElement && depth < MAX_CSS_PATH_DEPTH) {\n const node = cur;\n if (depth > 0) {\n const anchor = testIdSelector(node) ?? idSelector(node);\n if (anchor) {\n segs.unshift(anchor);\n break;\n }\n }\n let seg = node.tagName.toLowerCase();\n const classes = Array.from(node.classList).filter(isStableClass).slice(0, 2);\n if (classes.length) seg += `.${classes.map(escId).join(\".\")}`;\n const parent = node.parentElement;\n if (parent) {\n const sameTag = Array.from(parent.children).filter((c) => c.tagName === node.tagName);\n if (sameTag.length > 1) seg += `:nth-of-type(${sameTag.indexOf(node) + 1})`;\n }\n segs.unshift(seg);\n const partial = segs.join(\" > \");\n try {\n if (document.querySelectorAll(partial).length === 1) return partial;\n } catch {\n return null;\n }\n cur = parent;\n depth++;\n }\n if (!segs.length) return null;\n const sel = segs.join(\" > \");\n try {\n return document.querySelectorAll(sel).length ? sel : null;\n } catch {\n return null;\n }\n }\n function buildCandidates(el) {\n const out = [];\n const push = (s) => {\n if (s && out.indexOf(s) === -1) out.push(s);\n };\n push(testIdSelector(el));\n push(idSelector(el));\n const nameAttr = el.getAttribute(\"name\");\n if (nameAttr && isStableToken(nameAttr)) {\n push(`${el.tagName.toLowerCase()}[name=\"${escAttr(nameAttr)}\"]`);\n }\n const roleName = roleNameSelector(el);\n if (roleName) {\n const role = getRole(el);\n const name = accessibleName(el);\n const matches = allElements().filter((c) => getRole(c) === role && accessibleName(c) === name);\n push(withNth(roleName, el, matches));\n }\n for (const selector of interactiveHostSelectors(el)) push(selector);\n push(labelledAncestorSelector(el));\n const cssPath = buildCssPath(el);\n const text = ownText(el);\n const textSelector = text && isSmallestWithText(el, text) ? withNth(`text=\"${escAttr(text)}\"`, el, allElements().filter((c) => isSmallestWithText(c, text))) : null;\n if (isPositionalOnly(cssPath)) {\n push(textSelector);\n push(cssPath);\n } else {\n push(cssPath);\n push(textSelector);\n }\n return out.slice(0, MAX_CANDIDATES);\n }\n function interactiveHostSelectors(el) {\n let host = null;\n try {\n const found = el.closest(INTERACTIVE);\n host = found && found !== el ? found : null;\n } catch {\n host = null;\n }\n if (!host) return [];\n const out = [];\n const push = (s) => {\n if (s && out.indexOf(s) === -1) out.push(s);\n };\n push(testIdSelector(host));\n push(idSelector(host));\n push(roleNameSelector(host));\n const label = renderedText(host, 60);\n if (label) {\n const tag = host.tagName.toLowerCase();\n const role = host.getAttribute(\"role\");\n const qualifier = host.getAttribute(\"data-focusable\") ? '[data-focusable=\"true\"]' : role ? `[role=\"${escAttr(role)}\"]` : host.hasAttribute(\"tabindex\") ? `[tabindex=\"${escAttr(host.getAttribute(\"tabindex\") ?? \"0\")}\"]` : \"\";\n if (qualifier) push(`${tag}${qualifier}:has-text(\"${escAttr(label)}\")`);\n }\n return out;\n }\n function isPositionalOnly(selector) {\n if (!selector) return false;\n if (!selector.includes(\":nth-of-type(\")) return false;\n return !selector.includes(\"[\") && !selector.includes(\".\");\n }\n function labelledAncestorSelector(el) {\n if (testIdSelector(el) ?? idSelector(el)) return null;\n let cur = el.parentElement;\n for (let depth = 0; cur && depth < MAX_CSS_PATH_DEPTH; depth++) {\n const anchor = testIdSelector(cur);\n if (anchor) {\n try {\n return document.querySelectorAll(anchor).length === 1 ? anchor : null;\n } catch {\n return null;\n }\n }\n cur = cur.parentElement;\n }\n return null;\n }\n function isSmallestWithText(el, text) {\n if (ownText(el) !== text) return false;\n return !Array.from(el.children).some((child) => ownText(child) === text);\n }\n function contextLabel(el) {\n const row = el.closest('tr, [role=\"row\"], li, [role=\"listitem\"], [data-testid*=\"row\"]');\n if (row && row !== el) {\n const own = renderedText(el, 60);\n const full = renderedText(row, 120);\n const t = clean(own ? full.split(own).join(\" \") : full, 60);\n if (t) return ` in the row containing \"${t}\"`;\n }\n const section = el.closest('form, section, dialog, [role=\"dialog\"], nav, main, aside');\n if (section && section !== el) {\n const label = section.getAttribute(\"aria-label\") || clean(section.querySelector(\"h1, h2, h3, h4, legend\")?.textContent) || \"\";\n if (label) return ` in the ${section.tagName.toLowerCase()} labelled \"${clean(label, 40)}\"`;\n }\n return \"\";\n }\n function semanticOf(el) {\n const role = getRole(el) || el.tagName.toLowerCase();\n const name = accessibleName(el) || ownText(el);\n const head = name ? `${name} ${role}` : role;\n return head + contextLabel(el);\n }\n function describe(el) {\n const candidates = buildCandidates(el);\n if (!candidates.length) return null;\n return { candidates, semantic: semanticOf(el) };\n }\n\n // src/recorder/agent/capture.ts\n var ECHO_WINDOW_MS = 30;\n var GESTURE_WINDOW_MS = 1e3;\n var FOCUS_SETTLE_MS = 120;\n var MEANINGFUL_KEYS = [\n \"Enter\",\n \"Escape\",\n \"Tab\",\n \"ArrowUp\",\n \"ArrowDown\",\n \"ArrowLeft\",\n \"ArrowRight\",\n \"Backspace\",\n \"Delete\",\n \"Home\",\n \"End\",\n \"PageUp\",\n \"PageDown\",\n \" \"\n ];\n function targetOf(e) {\n const t = e.target;\n return t && t.nodeType === 1 ? t : null;\n }\n function playwrightKey(e) {\n const mods = [];\n if (e.ctrlKey) mods.push(\"Control\");\n if (e.altKey) mods.push(\"Alt\");\n if (e.metaKey) mods.push(\"Meta\");\n if (e.shiftKey && e.key.length > 1) mods.push(\"Shift\");\n const key = e.key === \" \" ? \"Space\" : e.key;\n return [...mods, key].join(\"+\");\n }\n function isStopHotkey(e) {\n return (e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === \"x\";\n }\n function installCapture(ctx) {\n const { config, transport, reveals } = ctx;\n const focusValue = /* @__PURE__ */ new WeakMap();\n const committed = /* @__PURE__ */ new WeakMap();\n let lastRecorded = null;\n let lastPointerDown = null;\n function resolveGestureTarget(clicked) {\n const pd = lastPointerDown;\n if (!pd || pd.el === clicked) return clicked;\n if (Date.now() - pd.t > GESTURE_WINDOW_MS) return clicked;\n const retargeted = clicked === document.documentElement || clicked === document.body;\n if (retargeted && pd.el.isConnected && describe(pd.el)) return pd.el;\n if (!describe(clicked) && pd.el.isConnected && describe(pd.el)) return pd.el;\n return clicked;\n }\n function isEchoOfRecordedClick(el) {\n const prev = lastRecorded;\n if (!prev) return false;\n if (Date.now() - prev.t > ECHO_WINDOW_MS) return false;\n if (prev.el === el) return true;\n if (prev.el.contains(el) || el.contains(prev.el)) return true;\n const label = el.closest(\"label\");\n return Boolean(label && label === prev.el.closest(\"label\"));\n }\n function emitAction(action, el, value) {\n ctx.beforeAction?.();\n const target = el ? describe(el) : null;\n if (el && !target) return false;\n const t = Date.now();\n reveals.noteAction(t);\n const ev = { kind: \"action\", action, value, target, t, author: \"human\" };\n transport.emit(ev);\n return true;\n }\n let focusTimer = null;\n let lastFocus = null;\n function noteFocusChange() {\n if (focusTimer) clearTimeout(focusTimer);\n focusTimer = setTimeout(() => {\n const el = document.activeElement;\n const selector = el ? cssSelectorFor(el) : null;\n if (!selector || selector === lastFocus) return;\n lastFocus = selector;\n transport.emit({ kind: \"focus\", selector, t: Date.now() });\n }, FOCUS_SETTLE_MS);\n }\n function flushRevealingHover(actionTarget) {\n const hovered = reveals.takeLoadBearingHover(actionTarget);\n if (hovered) emitAction(\"hover\", hovered, null);\n }\n function valueOf(el) {\n if (el.isContentEditable) return clean(el.innerText, 500);\n return el.value ?? \"\";\n }\n function commitFill(el) {\n if (!isEditable(el)) return;\n const value = valueOf(el);\n if (committed.get(el) === value) return;\n if (focusValue.get(el) === value && committed.get(el) === void 0) return;\n committed.set(el, value);\n flushRevealingHover(el);\n emitAction(\"fill\", el, value);\n }\n function flushPendingFill() {\n const active = document.activeElement;\n if (active && isEditable(active)) commitFill(active);\n }\n const on = (type, handler) => {\n document.addEventListener(type, handler, true);\n };\n on(\"focusin\", (e) => {\n const el = targetOf(e);\n if (el && isEditable(el)) focusValue.set(el, valueOf(el));\n noteFocusChange();\n });\n on(\"focusout\", () => noteFocusChange());\n on(\"mouseover\", (e) => {\n const el = targetOf(e);\n if (el) reveals.noteHover(el);\n });\n on(\"pointerdown\", (e) => {\n const el = targetOf(e);\n if (el) lastPointerDown = { el, t: Date.now() };\n });\n on(\"click\", (e) => {\n const clicked = targetOf(e);\n if (!clicked) return;\n const el = resolveGestureTarget(clicked);\n if (isEchoOfRecordedClick(el)) return;\n flushPendingFill();\n flushRevealingHover(el);\n if (emitAction(\"click\", el, null)) lastRecorded = { el, t: Date.now() };\n });\n on(\"dblclick\", (e) => {\n const el = targetOf(e);\n if (!el) return;\n flushPendingFill();\n emitAction(\"dblclick\", el, null);\n });\n on(\"change\", (e) => {\n const el = targetOf(e);\n if (!el) return;\n if (el.tagName.toLowerCase() === \"select\") {\n const sel = el;\n const opt = sel.selectedOptions[0];\n flushPendingFill();\n flushRevealingHover(el);\n emitAction(\"select\", el, opt ? opt.value || clean(opt.textContent) : sel.value);\n return;\n }\n if (isEditable(el)) commitFill(el);\n });\n on(\"blur\", (e) => {\n const el = targetOf(e);\n if (el && isEditable(el)) commitFill(el);\n });\n on(\"keydown\", (e) => {\n if (isStopHotkey(e)) {\n e.preventDefault();\n e.stopPropagation();\n transport.binding(config.stopBinding)?.({ reason: \"hotkey\" });\n return;\n }\n const el = targetOf(e);\n if (isEditable(el)) {\n if (e.key !== \"Enter\" && e.key !== \"Escape\") return;\n commitFill(el);\n } else if (!MEANINGFUL_KEYS.includes(e.key) && !(e.ctrlKey || e.metaKey || e.altKey)) {\n return;\n } else {\n flushPendingFill();\n }\n emitAction(\"press\", el, playwrightKey(e));\n });\n installScrollCapture(ctx, emitAction);\n }\n var RESIZE_PROBE = [\n \".erd_scroll_detection_container\",\n \".resize-sensor\",\n \".resize-triggers\",\n \"[data-resize-sensor]\"\n ];\n function isResizeProbe(el) {\n if (!el) return false;\n return RESIZE_PROBE.some((sel) => {\n try {\n return el.matches(sel) || el.closest(sel) !== null;\n } catch {\n return false;\n }\n });\n }\n function installScrollCapture(ctx, emitAction) {\n const { config } = ctx;\n const lastPosition = /* @__PURE__ */ new WeakMap();\n const timers = /* @__PURE__ */ new WeakMap();\n document.addEventListener(\n \"scroll\",\n (e) => {\n const raw = e.target;\n if (raw && raw.nodeType === 1 && isResizeProbe(raw)) return;\n const isDocument = !raw || raw === document || raw === document.documentElement || raw === document.body;\n const key = isDocument ? document : raw;\n const el = isDocument ? null : raw;\n const x = el ? el.scrollLeft : window.scrollX;\n const y = el ? el.scrollTop : window.scrollY;\n const existing = timers.get(key);\n if (existing) clearTimeout(existing);\n timers.set(\n key,\n setTimeout(() => {\n const last = lastPosition.get(key) ?? { x: 0, y: 0 };\n const moved = Math.abs(x - last.x) >= config.scrollMinDeltaPx || Math.abs(y - last.y) >= config.scrollMinDeltaPx;\n if (!moved) return;\n lastPosition.set(key, { x, y });\n emitAction(\"scroll\", el, JSON.stringify({ x, y }));\n }, config.scrollDebounceMs)\n );\n },\n true\n );\n }\n\n // src/noise.ts\n var TRANSIENT_NAME = /\\b(loading|spinner|progress|skeleton|please wait|submitting|saving)\\b/i;\n function isTransientIndicator(role, name) {\n if (role === \"progressbar\") return true;\n return TRANSIENT_NAME.test(name);\n }\n\n // src/recorder/agent/visibility.ts\n function isVisible(el) {\n if (!el.isConnected) return false;\n const he = el;\n if (he.hidden || el.getAttribute(\"aria-hidden\") === \"true\") return false;\n const rect = he.getBoundingClientRect();\n if (rect.width === 0 && rect.height === 0) return false;\n const cs = window.getComputedStyle(he);\n return cs.visibility !== \"hidden\" && cs.display !== \"none\" && cs.opacity !== \"0\";\n }\n\n // src/recorder/agent/dom-reaction.ts\n var INTERESTING = \"[data-testid], [data-test], [data-test-id], [data-cy], [id], [role], button, a[href]\";\n var OBSERVED_ATTRS = [\"class\", \"style\", \"hidden\", \"aria-hidden\"];\n var APPEAR_CONFIRM_MS = 600;\n function toCssSelector(selector) {\n if (selector.startsWith(\"role=\") || selector.startsWith(\"text=\")) {\n throw new Error(\"not a CSS selector\");\n }\n return selector;\n }\n function harvest(nodes, into, pick, revealed, max) {\n for (let i = 0; i < nodes.length; i++) {\n const node = nodes[i];\n if (!node || node.nodeType !== 1) continue;\n const el = node;\n revealed.push(el);\n const own = pick(el);\n if (own && into.size < max) into.add(own);\n const descendants = el.querySelectorAll(INTERESTING);\n for (let j = 0; j < descendants.length && into.size < max; j++) {\n const d = descendants[j];\n if (!d) continue;\n const sel = pick(d);\n if (sel) into.add(sel);\n }\n }\n }\n function observeDomReactions(ctx) {\n const { config, transport, reveals } = ctx;\n const visibility = /* @__PURE__ */ new WeakMap();\n let pending = [];\n const stillVisible = (sel) => {\n try {\n const el = document.querySelector(toCssSelector(sel));\n return el ? isVisible(el) : false;\n } catch {\n return true;\n }\n };\n const confirm = (entry) => {\n const survived = entry.selectors.filter(stillVisible);\n if (survived.length) {\n transport.emit({ kind: \"dom\", appeared: survived, gone: [], t: entry.at });\n }\n };\n const flushAppearances = () => {\n const due = pending;\n pending = [];\n for (const entry of due) {\n clearTimeout(entry.timer);\n confirm(entry);\n }\n };\n const visibleAppearedSelector = (el) => {\n if (!isVisible(el)) return null;\n if (isTransientIndicator(getRole(el), accessibleName(el))) return null;\n return appearedSelector(el);\n };\n const start = () => {\n const observer = new MutationObserver((records) => {\n const appeared = /* @__PURE__ */ new Set();\n const gone = /* @__PURE__ */ new Set();\n const revealed = [];\n for (const rec of records) {\n if (rec.type === \"childList\") {\n harvest(rec.addedNodes, appeared, visibleAppearedSelector, revealed, config.maxSelectorsPerMutation);\n harvest(rec.removedNodes, gone, goneSelector, [], config.maxSelectorsPerMutation);\n continue;\n }\n const el = rec.target;\n const sel = appearedSelector(el);\n if (!sel) continue;\n const now = isVisible(el);\n const was = visibility.get(el);\n visibility.set(el, now);\n if (was === void 0 || was === now) continue;\n if (now) {\n appeared.add(sel);\n revealed.push(el);\n } else {\n const g = goneSelector(el);\n if (g) gone.add(g);\n }\n }\n const at = Date.now();\n reveals.noteRevealed(revealed, at);\n if (gone.size) {\n transport.emit({ kind: \"dom\", appeared: [], gone: Array.from(gone), t: at });\n }\n if (appeared.size) {\n const entry = {\n selectors: Array.from(appeared),\n at,\n timer: setTimeout(() => {\n pending = pending.filter((e) => e !== entry);\n confirm(entry);\n }, APPEAR_CONFIRM_MS)\n };\n pending.push(entry);\n }\n });\n observer.observe(document.documentElement, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: OBSERVED_ATTRS\n });\n };\n if (document.documentElement) start();\n else document.addEventListener(\"DOMContentLoaded\", start, { once: true });\n return { flushAppearances };\n }\n\n // src/recorder/agent/reveal-tracker.ts\n var ACTION_COOLDOWN_MS = 600;\n function createRevealTracker(config) {\n let pending = null;\n let lastHovered = null;\n let lastActionAt = 0;\n return {\n noteHover(el) {\n lastHovered = el;\n },\n noteAction(at) {\n lastActionAt = at;\n },\n noteRevealed(nodes, at) {\n if (!nodes.length || !lastHovered) return;\n if (at - lastActionAt <= ACTION_COOLDOWN_MS) return;\n pending = { el: lastHovered, nodes, at };\n },\n takeLoadBearingHover(actionTarget) {\n const reveal = pending;\n pending = null;\n if (!reveal || !actionTarget) return null;\n if (Date.now() - reveal.at > config.hoverRevealWindowMs) return null;\n if (!reveal.el.isConnected) return null;\n const inside = reveal.nodes.some((n) => n === actionTarget || n.contains(actionTarget));\n return inside ? reveal.el : null;\n }\n };\n }\n\n // src/recorder/agent/transport.ts\n var DRAIN_INTERVAL_MS = 25;\n function createTransport(config) {\n const globals = window;\n const pending = [];\n let drainTimer = null;\n function binding(name) {\n const fn = globals[name];\n return typeof fn === \"function\" ? fn : null;\n }\n function drain() {\n const fn = binding(config.emitBinding);\n if (!fn) return;\n while (pending.length) {\n const ev = pending.shift();\n try {\n fn(ev);\n } catch {\n }\n }\n if (drainTimer) {\n clearInterval(drainTimer);\n drainTimer = null;\n }\n }\n function emit(ev) {\n pending.push(ev);\n if (binding(config.emitBinding)) drain();\n else if (!drainTimer) drainTimer = setInterval(drain, DRAIN_INTERVAL_MS);\n }\n return { emit, binding };\n }\n\n // src/recorder/agent/page-agent.ts\n var INSTALLED_FLAG = \"__replayAgentInstalled\";\n function pageAgent(config) {\n const globals = window;\n if (globals[INSTALLED_FLAG]) return;\n if (window.top !== window) return;\n globals[INSTALLED_FLAG] = true;\n const transport = createTransport(config);\n const reveals = createRevealTracker(config);\n const dom = observeDomReactions({ config, transport, reveals });\n installCapture({ config, transport, reveals, beforeAction: () => dom.flushAppearances() });\n globals[FLUSH_GLOBAL] = () => dom.flushAppearances();\n globals[AGENT_READY_FLAG] = true;\n }\n\n // src/recorder/agent/main.ts\n window[INSTALL_GLOBAL] = (config) => {\n pageAgent(config);\n };\n})();\n";
|
|
5
|
+
export const AGENT_BUNDLE = "\"use strict\";\n(() => {\n // src/recorder/agent/config.ts\n var INSTALL_GLOBAL = \"__replayInstall\";\n var AGENT_READY_FLAG = \"__replayAgentReady\";\n var FLUSH_GLOBAL = \"__replayFlush\";\n\n // src/recorder/agent/text.ts\n function clean(s, max = 80) {\n if (!s) return \"\";\n const t = s.replace(/\\s+/g, \" \").trim();\n return t.length > max ? `${t.slice(0, max - 1)}\\u2026` : t;\n }\n function escAttr(v) {\n return v.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n }\n function escId(v) {\n return typeof window.CSS?.escape === \"function\" ? window.CSS.escape(v) : v.replace(/([^\\w-])/g, \"\\\\$1\");\n }\n function isStableToken(t) {\n if (!t || t.length > 40) return false;\n if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(t)) return false;\n if (/^(css|sc|emotion|styled|jsx|glamor|makeStyles)-/i.test(t)) return false;\n if (/^r-/.test(t)) return false;\n if (/[0-9a-f]{6,}/i.test(t)) return false;\n if ((t.match(/\\d/g) || []).length >= 3) return false;\n if (/[A-Z]/.test(t) && /\\d/.test(t)) return false;\n if (/^[a-z]{1,2}-[a-z0-9]{5,}$/i.test(t) && /\\d/.test(t)) return false;\n if (t.split(/[-_]/).some(looksGenerated)) return false;\n if (/-(aria|diff|live|announcer|portal)$/i.test(t)) return false;\n return true;\n }\n function looksGenerated(segment) {\n if (segment.length < 6) return false;\n if (!/^[a-z0-9]+$/i.test(segment)) return false;\n const vowels = (segment.match(/[aeiouy]/gi) || []).length;\n return /\\d/.test(segment) ? vowels <= 1 : vowels === 0;\n }\n function isStableClass(c) {\n return isStableToken(c) && !/^_/.test(c);\n }\n function renderedText(el, max = 80) {\n const inner = el.innerText;\n return clean(typeof inner === \"string\" ? inner : el.textContent, max);\n }\n\n // src/recorder/agent/roles.ts\n var TAG_ROLES = {\n button: \"button\",\n select: \"combobox\",\n textarea: \"textbox\",\n nav: \"navigation\",\n main: \"main\",\n header: \"banner\",\n footer: \"contentinfo\",\n aside: \"complementary\",\n form: \"form\",\n table: \"table\",\n tr: \"row\",\n td: \"cell\",\n th: \"columnheader\",\n ul: \"list\",\n ol: \"list\",\n li: \"listitem\",\n dialog: \"dialog\",\n option: \"option\",\n h1: \"heading\",\n h2: \"heading\",\n h3: \"heading\",\n h4: \"heading\",\n h5: \"heading\",\n h6: \"heading\"\n };\n var INPUT_ROLES = {\n button: \"button\",\n submit: \"button\",\n reset: \"button\",\n image: \"button\",\n checkbox: \"checkbox\",\n radio: \"radio\",\n range: \"slider\",\n number: \"spinbutton\",\n search: \"searchbox\",\n email: \"textbox\",\n tel: \"textbox\",\n text: \"textbox\",\n url: \"textbox\"\n };\n var NAME_FROM_CONTENT = [\n \"button\",\n \"link\",\n \"heading\",\n \"cell\",\n \"columnheader\",\n \"rowheader\",\n \"listitem\",\n \"option\",\n \"tab\",\n \"menuitem\",\n \"menuitemcheckbox\",\n \"menuitemradio\",\n \"treeitem\",\n \"switch\",\n \"checkbox\",\n \"radio\"\n ];\n var NON_EDITABLE_INPUTS = [\"checkbox\", \"radio\", \"button\", \"submit\", \"reset\", \"file\", \"image\"];\n function getRole(el) {\n const explicit = el.getAttribute(\"role\");\n if (explicit) return explicit.trim().split(/\\s+/)[0] || null;\n const tag = el.tagName.toLowerCase();\n if (tag === \"a\") return el.hasAttribute(\"href\") ? \"link\" : null;\n if (tag === \"img\") return el.getAttribute(\"alt\") === \"\" ? null : \"img\";\n if (tag === \"input\") {\n const type = (el.getAttribute(\"type\") || \"text\").toLowerCase();\n return INPUT_ROLES[type] ?? null;\n }\n return TAG_ROLES[tag] ?? null;\n }\n function isEditable(el) {\n if (!el || el.nodeType !== 1) return false;\n const tag = el.tagName.toLowerCase();\n if (tag === \"textarea\") return true;\n if (el.isContentEditable) return true;\n if (tag !== \"input\") return false;\n const type = (el.getAttribute(\"type\") || \"text\").toLowerCase();\n return !NON_EDITABLE_INPUTS.includes(type);\n }\n function accessibleName(el) {\n const aria = el.getAttribute(\"aria-label\");\n if (aria && aria.trim()) return clean(aria);\n const labelledby = el.getAttribute(\"aria-labelledby\");\n if (labelledby) {\n const parts = labelledby.split(/\\s+/).map((id) => document.getElementById(id)).filter((n) => !!n).map((n) => clean(n.textContent));\n const joined = clean(parts.filter(Boolean).join(\" \"));\n if (joined) return joined;\n }\n const tag = el.tagName.toLowerCase();\n if ([\"input\", \"select\", \"textarea\"].includes(tag)) {\n const id = el.getAttribute(\"id\");\n if (id) {\n const forLabel = document.querySelector(`label[for=\"${escAttr(id)}\"]`);\n if (forLabel) {\n const t = clean(forLabel.textContent);\n if (t) return t;\n }\n }\n const wrapping = el.closest(\"label\");\n if (wrapping) {\n const t = clean(wrapping.textContent);\n if (t) return t;\n }\n }\n for (const attr of [\"alt\", \"title\", \"placeholder\"]) {\n const v = el.getAttribute(attr);\n if (v && v.trim()) return clean(v);\n }\n const role = getRole(el);\n if (role && NAME_FROM_CONTENT.includes(role)) {\n const t = clean(el.textContent);\n if (t) return t;\n }\n return \"\";\n }\n function ownText(el) {\n return clean(el.textContent, 60);\n }\n\n // src/recorder/agent/selectors.ts\n var TEST_ID_ATTRS = [\n \"data-testid\",\n \"data-test\",\n \"data-test-id\",\n \"data-cy\",\n \"data-sentry-label\",\n \"data-testid-label\"\n ];\n var MAX_CANDIDATES = 6;\n var INTERACTIVE = 'button, a[href], [role=\"button\"], [role=\"link\"], [role=\"menuitem\"], [role=\"menuitemradio\"], [role=\"option\"], [role=\"tab\"], [role=\"checkbox\"], [role=\"radio\"], [role=\"switch\"], [tabindex], [data-focusable=\"true\"], [onclick]';\n var MAX_CSS_PATH_DEPTH = 6;\n function testIdSelector(el) {\n for (const attr of TEST_ID_ATTRS) {\n const v = el.getAttribute(attr);\n if (v) return `[${attr}=\"${escAttr(v)}\"]`;\n }\n return null;\n }\n function idSelector(el) {\n const id = el.getAttribute(\"id\");\n return id && isStableToken(id) ? `#${escId(id)}` : null;\n }\n function roleNameSelector(el) {\n const role = getRole(el);\n if (!role) return null;\n const name = accessibleName(el);\n if (!name) return null;\n return `role=${role}[name=\"${escAttr(name)}\"]`;\n }\n function appearedSelector(el) {\n return testIdSelector(el) ?? idSelector(el) ?? roleNameSelector(el);\n }\n function goneSelector(el) {\n return testIdSelector(el) ?? idSelector(el);\n }\n function cssSelectorFor(el) {\n return testIdSelector(el) ?? idSelector(el) ?? buildCssPath(el);\n }\n function allElements() {\n return Array.from(document.querySelectorAll(\"*\"));\n }\n function withNth(base, el, matches) {\n if (matches.length <= 1) return base;\n const idx = matches.indexOf(el);\n return idx < 0 ? base : `${base} >> nth=${idx}`;\n }\n function buildCssPath(el) {\n const segs = [];\n let cur = el;\n let depth = 0;\n while (cur && cur.nodeType === 1 && cur !== document.documentElement && depth < MAX_CSS_PATH_DEPTH) {\n const node = cur;\n if (depth > 0) {\n const anchor = testIdSelector(node) ?? idSelector(node);\n if (anchor) {\n segs.unshift(anchor);\n break;\n }\n }\n let seg = node.tagName.toLowerCase();\n const classes = Array.from(node.classList).filter(isStableClass).slice(0, 2);\n if (classes.length) seg += `.${classes.map(escId).join(\".\")}`;\n const parent = node.parentElement;\n if (parent) {\n const sameTag = Array.from(parent.children).filter((c) => c.tagName === node.tagName);\n if (sameTag.length > 1) seg += `:nth-of-type(${sameTag.indexOf(node) + 1})`;\n }\n segs.unshift(seg);\n const partial = segs.join(\" > \");\n try {\n if (document.querySelectorAll(partial).length === 1) return partial;\n } catch {\n return null;\n }\n cur = parent;\n depth++;\n }\n if (!segs.length) return null;\n const sel = segs.join(\" > \");\n try {\n return document.querySelectorAll(sel).length ? sel : null;\n } catch {\n return null;\n }\n }\n function buildCandidates(el) {\n const out = [];\n const push = (s) => {\n if (s && out.indexOf(s) === -1) out.push(s);\n };\n push(testIdSelector(el));\n push(idSelector(el));\n const nameAttr = el.getAttribute(\"name\");\n if (nameAttr && isStableToken(nameAttr)) {\n push(`${el.tagName.toLowerCase()}[name=\"${escAttr(nameAttr)}\"]`);\n }\n const roleName = roleNameSelector(el);\n if (roleName) {\n const role = getRole(el);\n const name = accessibleName(el);\n const matches = allElements().filter((c) => getRole(c) === role && accessibleName(c) === name);\n push(withNth(roleName, el, matches));\n }\n for (const selector of interactiveHostSelectors(el)) push(selector);\n push(labelledAncestorSelector(el));\n const cssPath = buildCssPath(el);\n const text = ownText(el);\n const textSelector = text && isSmallestWithText(el, text) ? withNth(`text=\"${escAttr(text)}\"`, el, allElements().filter((c) => isSmallestWithText(c, text))) : null;\n if (isPositionalOnly(cssPath)) {\n push(textSelector);\n push(cssPath);\n } else {\n push(cssPath);\n push(textSelector);\n }\n return out.slice(0, MAX_CANDIDATES);\n }\n function interactiveHostSelectors(el) {\n let host = null;\n try {\n const found = el.closest(INTERACTIVE);\n host = found && found !== el ? found : null;\n } catch {\n host = null;\n }\n if (!host) return [];\n const out = [];\n const push = (s) => {\n if (s && out.indexOf(s) === -1) out.push(s);\n };\n push(testIdSelector(host));\n push(idSelector(host));\n push(roleNameSelector(host));\n const label = renderedText(host, 60);\n if (label) {\n const tag = host.tagName.toLowerCase();\n const role = host.getAttribute(\"role\");\n const qualifier = host.getAttribute(\"data-focusable\") ? '[data-focusable=\"true\"]' : role ? `[role=\"${escAttr(role)}\"]` : host.hasAttribute(\"tabindex\") ? `[tabindex=\"${escAttr(host.getAttribute(\"tabindex\") ?? \"0\")}\"]` : \"\";\n if (qualifier) push(`${tag}${qualifier}:has-text(\"${escAttr(label)}\")`);\n }\n return out;\n }\n function isPositionalOnly(selector) {\n return Boolean(selector?.includes(\":nth-of-type(\"));\n }\n function labelledAncestorSelector(el) {\n if (testIdSelector(el) ?? idSelector(el)) return null;\n let cur = el.parentElement;\n for (let depth = 0; cur && depth < MAX_CSS_PATH_DEPTH; depth++) {\n const anchor = testIdSelector(cur);\n if (anchor) {\n try {\n return document.querySelectorAll(anchor).length === 1 ? anchor : null;\n } catch {\n return null;\n }\n }\n cur = cur.parentElement;\n }\n return null;\n }\n function isSmallestWithText(el, text) {\n if (ownText(el) !== text) return false;\n return !Array.from(el.children).some((child) => ownText(child) === text);\n }\n function contextLabel(el) {\n const row = el.closest('tr, [role=\"row\"], li, [role=\"listitem\"], [data-testid*=\"row\"]');\n if (row && row !== el) {\n const own = renderedText(el, 60);\n const full = renderedText(row, 120);\n const t = clean(own ? full.split(own).join(\" \") : full, 60);\n if (t) return ` in the row containing \"${t}\"`;\n }\n const section = el.closest('form, section, dialog, [role=\"dialog\"], nav, main, aside');\n if (section && section !== el) {\n const label = section.getAttribute(\"aria-label\") || clean(section.querySelector(\"h1, h2, h3, h4, legend\")?.textContent) || \"\";\n if (label) return ` in the ${section.tagName.toLowerCase()} labelled \"${clean(label, 40)}\"`;\n }\n return \"\";\n }\n function semanticOf(el) {\n const role = getRole(el) || el.tagName.toLowerCase();\n const name = accessibleName(el) || ownText(el);\n const head = name ? `${name} ${role}` : role;\n return head + contextLabel(el);\n }\n function identityOf(el) {\n const own = accessibleName(el) || renderedText(el, 80);\n if (own && countSharingLabel(own) <= 1) return own;\n const row = el.closest(\n 'tr, [role=\"row\"], li, [role=\"listitem\"], [data-testid*=\"row\"], [data-testid*=\"Row\"]'\n );\n if (row && row !== el) {\n const rowText = renderedText(row, 80);\n if (rowText) return rowText;\n }\n return own || void 0;\n }\n function countSharingLabel(label) {\n const wanted = label.replace(/\\s+/g, \" \").trim();\n let count = 0;\n for (const el of allElements()) {\n const text = (el.textContent ?? \"\").replace(/\\s+/g, \" \").trim();\n if (text === wanted && ++count > 1) return count;\n if (el.getAttribute(\"aria-label\")?.trim() === wanted && ++count > 1) return count;\n }\n return count;\n }\n function describe(el) {\n const candidates = buildCandidates(el);\n if (!candidates.length) return null;\n const identity = identityOf(el);\n return { candidates, semantic: semanticOf(el), ...identity ? { identity } : {} };\n }\n\n // src/recorder/agent/capture.ts\n var ECHO_WINDOW_MS = 30;\n var GESTURE_WINDOW_MS = 1e3;\n var FOCUS_SETTLE_MS = 120;\n var MEANINGFUL_KEYS = [\n \"Enter\",\n \"Escape\",\n \"Tab\",\n \"ArrowUp\",\n \"ArrowDown\",\n \"ArrowLeft\",\n \"ArrowRight\",\n \"Backspace\",\n \"Delete\",\n \"Home\",\n \"End\",\n \"PageUp\",\n \"PageDown\",\n \" \"\n ];\n function targetOf(e) {\n const t = e.target;\n return t && t.nodeType === 1 ? t : null;\n }\n function playwrightKey(e) {\n const mods = [];\n if (e.ctrlKey) mods.push(\"Control\");\n if (e.altKey) mods.push(\"Alt\");\n if (e.metaKey) mods.push(\"Meta\");\n if (e.shiftKey && e.key.length > 1) mods.push(\"Shift\");\n const key = e.key === \" \" ? \"Space\" : e.key;\n return [...mods, key].join(\"+\");\n }\n function isStopHotkey(e) {\n return (e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === \"x\";\n }\n function installCapture(ctx) {\n const { config, transport, reveals } = ctx;\n const focusValue = /* @__PURE__ */ new WeakMap();\n const committed = /* @__PURE__ */ new WeakMap();\n let lastRecorded = null;\n let lastPointerDown = null;\n function resolveGestureTarget(clicked) {\n const pd = lastPointerDown;\n if (!pd || pd.el === clicked) return clicked;\n if (Date.now() - pd.t > GESTURE_WINDOW_MS) return clicked;\n const retargeted = clicked === document.documentElement || clicked === document.body;\n if (retargeted && pd.el.isConnected && describe(pd.el)) return pd.el;\n if (!describe(clicked) && pd.el.isConnected && describe(pd.el)) return pd.el;\n return clicked;\n }\n function isEchoOfRecordedClick(el) {\n const prev = lastRecorded;\n if (!prev) return false;\n if (Date.now() - prev.t > ECHO_WINDOW_MS) return false;\n if (prev.el === el) return true;\n if (prev.el.contains(el) || el.contains(prev.el)) return true;\n const label = el.closest(\"label\");\n return Boolean(label && label === prev.el.closest(\"label\"));\n }\n function emitAction(action, el, value) {\n ctx.beforeAction?.();\n const target = el ? describe(el) : null;\n if (el && !target) return false;\n const t = Date.now();\n reveals.noteAction(t);\n const ev = { kind: \"action\", action, value, target, t, author: \"human\" };\n transport.emit(ev);\n return true;\n }\n let focusTimer = null;\n let lastFocus = null;\n function noteFocusChange() {\n if (focusTimer) clearTimeout(focusTimer);\n focusTimer = setTimeout(() => {\n const el = document.activeElement;\n const selector = el ? cssSelectorFor(el) : null;\n if (!selector || selector === lastFocus) return;\n lastFocus = selector;\n transport.emit({ kind: \"focus\", selector, t: Date.now() });\n }, FOCUS_SETTLE_MS);\n }\n function flushRevealingHover(actionTarget) {\n const hovered = reveals.takeLoadBearingHover(actionTarget);\n if (hovered) emitAction(\"hover\", hovered, null);\n }\n function valueOf(el) {\n if (el.isContentEditable) return clean(el.innerText, 500);\n return el.value ?? \"\";\n }\n function commitFill(el) {\n if (!isEditable(el)) return;\n const value = valueOf(el);\n if (committed.get(el) === value) return;\n if (focusValue.get(el) === value && committed.get(el) === void 0) return;\n committed.set(el, value);\n flushRevealingHover(el);\n emitAction(\"fill\", el, value);\n }\n function flushPendingFill() {\n const active = document.activeElement;\n if (active && isEditable(active)) commitFill(active);\n }\n const on = (type, handler) => {\n document.addEventListener(type, handler, true);\n };\n on(\"focusin\", (e) => {\n const el = targetOf(e);\n if (el && isEditable(el)) focusValue.set(el, valueOf(el));\n noteFocusChange();\n });\n on(\"focusout\", () => noteFocusChange());\n on(\"mouseover\", (e) => {\n const el = targetOf(e);\n if (el) reveals.noteHover(el);\n });\n on(\"pointerdown\", (e) => {\n const el = targetOf(e);\n if (el) lastPointerDown = { el, t: Date.now() };\n });\n on(\"click\", (e) => {\n const clicked = targetOf(e);\n if (!clicked) return;\n const el = resolveGestureTarget(clicked);\n if (isEchoOfRecordedClick(el)) return;\n flushPendingFill();\n flushRevealingHover(el);\n if (emitAction(\"click\", el, null)) lastRecorded = { el, t: Date.now() };\n });\n on(\"dblclick\", (e) => {\n const el = targetOf(e);\n if (!el) return;\n flushPendingFill();\n emitAction(\"dblclick\", el, null);\n });\n on(\"change\", (e) => {\n const el = targetOf(e);\n if (!el) return;\n if (el.tagName.toLowerCase() === \"select\") {\n const sel = el;\n const opt = sel.selectedOptions[0];\n flushPendingFill();\n flushRevealingHover(el);\n emitAction(\"select\", el, opt ? opt.value || clean(opt.textContent) : sel.value);\n return;\n }\n if (isEditable(el)) commitFill(el);\n });\n on(\"blur\", (e) => {\n const el = targetOf(e);\n if (el && isEditable(el)) commitFill(el);\n });\n on(\"keydown\", (e) => {\n if (isStopHotkey(e)) {\n e.preventDefault();\n e.stopPropagation();\n transport.binding(config.stopBinding)?.({ reason: \"hotkey\" });\n return;\n }\n const el = targetOf(e);\n if (isEditable(el)) {\n if (e.key !== \"Enter\" && e.key !== \"Escape\") return;\n commitFill(el);\n } else if (!MEANINGFUL_KEYS.includes(e.key) && !(e.ctrlKey || e.metaKey || e.altKey)) {\n return;\n } else {\n flushPendingFill();\n }\n emitAction(\"press\", el, playwrightKey(e));\n });\n installScrollCapture(ctx, emitAction);\n }\n var RESIZE_PROBE = [\n \".erd_scroll_detection_container\",\n \".resize-sensor\",\n \".resize-triggers\",\n \"[data-resize-sensor]\"\n ];\n function isResizeProbe(el) {\n if (!el) return false;\n return RESIZE_PROBE.some((sel) => {\n try {\n return el.matches(sel) || el.closest(sel) !== null;\n } catch {\n return false;\n }\n });\n }\n function installScrollCapture(ctx, emitAction) {\n const { config } = ctx;\n const lastPosition = /* @__PURE__ */ new WeakMap();\n const timers = /* @__PURE__ */ new WeakMap();\n document.addEventListener(\n \"scroll\",\n (e) => {\n const raw = e.target;\n if (raw && raw.nodeType === 1 && isResizeProbe(raw)) return;\n const isDocument = !raw || raw === document || raw === document.documentElement || raw === document.body;\n const key = isDocument ? document : raw;\n const el = isDocument ? null : raw;\n const x = el ? el.scrollLeft : window.scrollX;\n const y = el ? el.scrollTop : window.scrollY;\n const existing = timers.get(key);\n if (existing) clearTimeout(existing);\n timers.set(\n key,\n setTimeout(() => {\n const last = lastPosition.get(key) ?? { x: 0, y: 0 };\n const moved = Math.abs(x - last.x) >= config.scrollMinDeltaPx || Math.abs(y - last.y) >= config.scrollMinDeltaPx;\n if (!moved) return;\n lastPosition.set(key, { x, y });\n emitAction(\"scroll\", el, JSON.stringify({ x, y }));\n }, config.scrollDebounceMs)\n );\n },\n true\n );\n }\n\n // src/noise.ts\n var TRANSIENT_NAME = /\\b(loading|spinner|progress|skeleton|please wait|submitting|saving)\\b/i;\n function isTransientIndicator(role, name) {\n if (role === \"progressbar\") return true;\n return TRANSIENT_NAME.test(name);\n }\n\n // src/recorder/agent/visibility.ts\n function isVisible(el) {\n if (!el.isConnected) return false;\n const he = el;\n if (he.hidden || el.getAttribute(\"aria-hidden\") === \"true\") return false;\n const rect = he.getBoundingClientRect();\n if (rect.width === 0 && rect.height === 0) return false;\n const cs = window.getComputedStyle(he);\n return cs.visibility !== \"hidden\" && cs.display !== \"none\" && cs.opacity !== \"0\";\n }\n\n // src/recorder/agent/dom-reaction.ts\n var INTERESTING = \"[data-testid], [data-test], [data-test-id], [data-cy], [id], [role], button, a[href]\";\n var OBSERVED_ATTRS = [\"class\", \"style\", \"hidden\", \"aria-hidden\"];\n var APPEAR_CONFIRM_MS = 600;\n function toCssSelector(selector) {\n if (selector.startsWith(\"role=\") || selector.startsWith(\"text=\")) {\n throw new Error(\"not a CSS selector\");\n }\n return selector;\n }\n function harvest(nodes, into, pick, revealed, max) {\n for (let i = 0; i < nodes.length; i++) {\n const node = nodes[i];\n if (!node || node.nodeType !== 1) continue;\n const el = node;\n revealed.push(el);\n const own = pick(el);\n if (own && into.size < max) into.add(own);\n const descendants = el.querySelectorAll(INTERESTING);\n for (let j = 0; j < descendants.length && into.size < max; j++) {\n const d = descendants[j];\n if (!d) continue;\n const sel = pick(d);\n if (sel) into.add(sel);\n }\n }\n }\n function observeDomReactions(ctx) {\n const { config, transport, reveals } = ctx;\n const visibility = /* @__PURE__ */ new WeakMap();\n let pending = [];\n const stillVisible = (sel) => {\n try {\n const el = document.querySelector(toCssSelector(sel));\n return el ? isVisible(el) : false;\n } catch {\n return true;\n }\n };\n const confirm = (entry) => {\n const survived = entry.selectors.filter(stillVisible);\n if (survived.length) {\n transport.emit({ kind: \"dom\", appeared: survived, gone: [], t: entry.at });\n }\n };\n const flushAppearances = () => {\n const due = pending;\n pending = [];\n for (const entry of due) {\n clearTimeout(entry.timer);\n confirm(entry);\n }\n };\n const visibleAppearedSelector = (el) => {\n if (!isVisible(el)) return null;\n if (isTransientIndicator(getRole(el), accessibleName(el))) return null;\n return appearedSelector(el);\n };\n const start = () => {\n const observer = new MutationObserver((records) => {\n const appeared = /* @__PURE__ */ new Set();\n const gone = /* @__PURE__ */ new Set();\n const revealed = [];\n for (const rec of records) {\n if (rec.type === \"childList\") {\n harvest(rec.addedNodes, appeared, visibleAppearedSelector, revealed, config.maxSelectorsPerMutation);\n harvest(rec.removedNodes, gone, goneSelector, [], config.maxSelectorsPerMutation);\n continue;\n }\n const el = rec.target;\n const sel = appearedSelector(el);\n if (!sel) continue;\n const now = isVisible(el);\n const was = visibility.get(el);\n visibility.set(el, now);\n if (was === void 0 || was === now) continue;\n if (now) {\n appeared.add(sel);\n revealed.push(el);\n } else {\n const g = goneSelector(el);\n if (g) gone.add(g);\n }\n }\n const at = Date.now();\n reveals.noteRevealed(revealed, at);\n if (gone.size) {\n transport.emit({ kind: \"dom\", appeared: [], gone: Array.from(gone), t: at });\n }\n if (appeared.size) {\n const entry = {\n selectors: Array.from(appeared),\n at,\n timer: setTimeout(() => {\n pending = pending.filter((e) => e !== entry);\n confirm(entry);\n }, APPEAR_CONFIRM_MS)\n };\n pending.push(entry);\n }\n });\n observer.observe(document.documentElement, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: OBSERVED_ATTRS\n });\n };\n if (document.documentElement) start();\n else document.addEventListener(\"DOMContentLoaded\", start, { once: true });\n return { flushAppearances };\n }\n\n // src/recorder/agent/reveal-tracker.ts\n var ACTION_COOLDOWN_MS = 600;\n function createRevealTracker(config) {\n let pending = null;\n let lastHovered = null;\n let lastActionAt = 0;\n return {\n noteHover(el) {\n lastHovered = el;\n },\n noteAction(at) {\n lastActionAt = at;\n },\n noteRevealed(nodes, at) {\n if (!nodes.length || !lastHovered) return;\n if (at - lastActionAt <= ACTION_COOLDOWN_MS) return;\n pending = { el: lastHovered, nodes, at };\n },\n takeLoadBearingHover(actionTarget) {\n const reveal = pending;\n pending = null;\n if (!reveal || !actionTarget) return null;\n if (Date.now() - reveal.at > config.hoverRevealWindowMs) return null;\n if (!reveal.el.isConnected) return null;\n const inside = reveal.nodes.some((n) => n === actionTarget || n.contains(actionTarget));\n return inside ? reveal.el : null;\n }\n };\n }\n\n // src/recorder/agent/transport.ts\n var DRAIN_INTERVAL_MS = 25;\n function createTransport(config) {\n const globals = window;\n const pending = [];\n let drainTimer = null;\n function binding(name) {\n const fn = globals[name];\n return typeof fn === \"function\" ? fn : null;\n }\n function drain() {\n const fn = binding(config.emitBinding);\n if (!fn) return;\n while (pending.length) {\n const ev = pending.shift();\n try {\n fn(ev);\n } catch {\n }\n }\n if (drainTimer) {\n clearInterval(drainTimer);\n drainTimer = null;\n }\n }\n function emit(ev) {\n pending.push(ev);\n if (binding(config.emitBinding)) drain();\n else if (!drainTimer) drainTimer = setInterval(drain, DRAIN_INTERVAL_MS);\n }\n return { emit, binding };\n }\n\n // src/recorder/agent/page-agent.ts\n var INSTALLED_FLAG = \"__replayAgentInstalled\";\n function pageAgent(config) {\n const globals = window;\n if (globals[INSTALLED_FLAG]) return;\n if (window.top !== window) return;\n globals[INSTALLED_FLAG] = true;\n const transport = createTransport(config);\n const reveals = createRevealTracker(config);\n const dom = observeDomReactions({ config, transport, reveals });\n installCapture({ config, transport, reveals, beforeAction: () => dom.flushAppearances() });\n globals[FLUSH_GLOBAL] = () => dom.flushAppearances();\n globals[AGENT_READY_FLAG] = true;\n }\n\n // src/recorder/agent/main.ts\n window[INSTALL_GLOBAL] = (config) => {\n pageAgent(config);\n };\n})();\n";
|
|
6
6
|
//# sourceMappingURL=agent-bundle.generated.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-bundle.generated.js","sourceRoot":"","sources":["../../src/recorder/agent-bundle.generated.ts"],"names":[],"mappings":"AAAA,qDAAqD;AACrD,6EAA6E;AAC7E,oBAAoB;AAEpB,+DAA+D;AAC/D,MAAM,CAAC,MAAM,YAAY,GAAG,
|
|
1
|
+
{"version":3,"file":"agent-bundle.generated.js","sourceRoot":"","sources":["../../src/recorder/agent-bundle.generated.ts"],"names":[],"mappings":"AAAA,qDAAqD;AACrD,6EAA6E;AAC7E,oBAAoB;AAEpB,+DAA+D;AAC/D,MAAM,CAAC,MAAM,YAAY,GAAG,o+3BAAo+3B,CAAC"}
|
package/dist/recorder/types.d.ts
CHANGED
|
@@ -13,6 +13,34 @@ export interface Resolved {
|
|
|
13
13
|
/** Index into target.candidates, so callers can report degraded resolution. */
|
|
14
14
|
candidateIndex: number;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* The selector resolved, but to something that is not what was recorded.
|
|
18
|
+
*
|
|
19
|
+
* Distinct from "nothing matched": the app is reachable and the flow could
|
|
20
|
+
* continue, but continuing would act on the wrong record and produce a verdict
|
|
21
|
+
* about a bug that was never exercised.
|
|
22
|
+
*/
|
|
23
|
+
export declare class IdentityMismatchError extends Error {
|
|
24
|
+
readonly target: Target;
|
|
25
|
+
readonly selector: string;
|
|
26
|
+
readonly found: string;
|
|
27
|
+
constructor(target: Target, selector: string, found: string);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Normalised, forgiving comparison.
|
|
31
|
+
*
|
|
32
|
+
* Either side containing the other counts: a row's text includes the name plus
|
|
33
|
+
* whatever else the row renders, and an accessible name is often a fragment of
|
|
34
|
+
* the visible label. Requiring equality would refuse constantly.
|
|
35
|
+
*/
|
|
36
|
+
export declare function identityMatches(found: string, identity: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Identities too weak to judge by.
|
|
39
|
+
*
|
|
40
|
+
* A bare number or a one-word label appears all over a page, so a mismatch
|
|
41
|
+
* would say nothing — and a check that refuses on noise gets switched off.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isCheckableIdentity(identity: string | undefined): identity is string;
|
|
16
44
|
export declare class TargetResolutionError extends Error {
|
|
17
45
|
readonly target: Target;
|
|
18
46
|
readonly attempts: {
|
package/dist/replayer/resolve.js
CHANGED
|
@@ -1,4 +1,53 @@
|
|
|
1
1
|
export const DEFAULT_RESOLVE_TIMEOUTS = { first: 800, subsequent: 400 };
|
|
2
|
+
/**
|
|
3
|
+
* The selector resolved, but to something that is not what was recorded.
|
|
4
|
+
*
|
|
5
|
+
* Distinct from "nothing matched": the app is reachable and the flow could
|
|
6
|
+
* continue, but continuing would act on the wrong record and produce a verdict
|
|
7
|
+
* about a bug that was never exercised.
|
|
8
|
+
*/
|
|
9
|
+
export class IdentityMismatchError extends Error {
|
|
10
|
+
target;
|
|
11
|
+
selector;
|
|
12
|
+
found;
|
|
13
|
+
constructor(target, selector, found) {
|
|
14
|
+
super(`Selector ${selector} matched an element that is not "${target.identity}".\n` +
|
|
15
|
+
` Expected to act on: ${target.semantic}\n` +
|
|
16
|
+
` Actually found: ${found || '(no text)'}\n` +
|
|
17
|
+
` Acting on it would produce a verdict about the wrong record.`);
|
|
18
|
+
this.target = target;
|
|
19
|
+
this.selector = selector;
|
|
20
|
+
this.found = found;
|
|
21
|
+
this.name = 'IdentityMismatchError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Normalised, forgiving comparison.
|
|
26
|
+
*
|
|
27
|
+
* Either side containing the other counts: a row's text includes the name plus
|
|
28
|
+
* whatever else the row renders, and an accessible name is often a fragment of
|
|
29
|
+
* the visible label. Requiring equality would refuse constantly.
|
|
30
|
+
*/
|
|
31
|
+
export function identityMatches(found, identity) {
|
|
32
|
+
const norm = (v) => v.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
33
|
+
const a = norm(found);
|
|
34
|
+
const b = norm(identity);
|
|
35
|
+
if (!a || !b)
|
|
36
|
+
return true;
|
|
37
|
+
return a.includes(b) || b.includes(a);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Identities too weak to judge by.
|
|
41
|
+
*
|
|
42
|
+
* A bare number or a one-word label appears all over a page, so a mismatch
|
|
43
|
+
* would say nothing — and a check that refuses on noise gets switched off.
|
|
44
|
+
*/
|
|
45
|
+
export function isCheckableIdentity(identity) {
|
|
46
|
+
if (!identity)
|
|
47
|
+
return false;
|
|
48
|
+
const trimmed = identity.trim();
|
|
49
|
+
return trimmed.length >= 4 && /[a-z]/i.test(trimmed);
|
|
50
|
+
}
|
|
2
51
|
export class TargetResolutionError extends Error {
|
|
3
52
|
target;
|
|
4
53
|
attempts;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolve.js","sourceRoot":"","sources":["../../src/replayer/resolve.ts"],"names":[],"mappings":"AAUA,MAAM,CAAC,MAAM,wBAAwB,GAAoB,EAAE,KAAK,EAAE,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,CAAC;AASzF,MAAM,OAAO,qBAAsB,SAAQ,KAAK;IAEnC;IACA;IAFX,YACW,MAAc,EACd,QAA+C;QAExD,KAAK,CAAC,kBAAkB,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC;QAHlC,WAAM,GAAN,MAAM,CAAQ;QACd,aAAQ,GAAR,QAAQ,CAAuC;QAGxD,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;IACtC,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,IAAU,EACV,MAAc,EACd,WAA4B,wBAAwB,EACpD,WAAoE;IAEpE,MAAM,QAAQ,GAA0C,EAAE,CAAC;IAE3D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAClD,MAAM,QAAQ,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QACtC,IAAI,CAAC,QAAQ;YAAE,SAAS;QACxB,MAAM,OAAO,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC;QAC/D,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,KAAK,EAAE,CAAC;YAC/C,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,CAAC;YACrD,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,CAAC,EAAE,CAAC;QAClD,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACrD,CAAC;IACH,CAAC;IAED,IAAI,WAAW,EAAE,CAAC;QAChB,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QAC/C,IAAI,MAAM,EAAE,CAAC;YACX,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,CAAC;YAC7C,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC;YACrE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC,CAAC,EAAE,CAAC;QAC3D,CAAC;IACH,CAAC;IAED,MAAM,IAAI,qBAAqB,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;AACpD,CAAC;AAED,SAAS,SAAS,CAAC,GAAY;IAC7B,MAAM,GAAG,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC7D,OAAO,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC;AACnC,CAAC"}
|
|
1
|
+
{"version":3,"file":"resolve.js","sourceRoot":"","sources":["../../src/replayer/resolve.ts"],"names":[],"mappings":"AAUA,MAAM,CAAC,MAAM,wBAAwB,GAAoB,EAAE,KAAK,EAAE,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,CAAC;AASzF;;;;;;GAMG;AACH,MAAM,OAAO,qBAAsB,SAAQ,KAAK;IAEnC;IACA;IACA;IAHX,YACW,MAAc,EACd,QAAgB,EAChB,KAAa;QAEtB,KAAK,CACH,YAAY,QAAQ,oCAAoC,MAAM,CAAC,QAAQ,MAAM;YAC3E,6BAA6B,MAAM,CAAC,QAAQ,IAAI;YAChD,6BAA6B,KAAK,IAAI,WAAW,IAAI;YACrD,oEAAoE,CACvE,CAAC;QATO,WAAM,GAAN,MAAM,CAAQ;QACd,aAAQ,GAAR,QAAQ,CAAQ;QAChB,UAAK,GAAL,KAAK,CAAQ;QAQtB,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;IACtC,CAAC;CACF;AAED;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,KAAa,EAAE,QAAgB;IAC7D,MAAM,IAAI,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IACxE,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC;IACtB,MAAM,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAC;IACzB,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1B,OAAO,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;AACxC,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CAAC,QAA4B;IAC9D,IAAI,CAAC,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5B,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC;IAChC,OAAO,OAAO,CAAC,MAAM,IAAI,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AACvD,CAAC;AAED,MAAM,OAAO,qBAAsB,SAAQ,KAAK;IAEnC;IACA;IAFX,YACW,MAAc,EACd,QAA+C;QAExD,KAAK,CAAC,kBAAkB,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC;QAHlC,WAAM,GAAN,MAAM,CAAQ;QACd,aAAQ,GAAR,QAAQ,CAAuC;QAGxD,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;IACtC,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,IAAU,EACV,MAAc,EACd,WAA4B,wBAAwB,EACpD,WAAoE;IAEpE,MAAM,QAAQ,GAA0C,EAAE,CAAC;IAE3D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAClD,MAAM,QAAQ,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QACtC,IAAI,CAAC,QAAQ;YAAE,SAAS;QACxB,MAAM,OAAO,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC;QAC/D,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,KAAK,EAAE,CAAC;YAC/C,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,CAAC;YACrD,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,CAAC,EAAE,CAAC;QAClD,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACrD,CAAC;IACH,CAAC;IAED,IAAI,WAAW,EAAE,CAAC;QAChB,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QAC/C,IAAI,MAAM,EAAE,CAAC;YACX,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,CAAC;YAC7C,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC;YACrE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC,CAAC,EAAE,CAAC;QAC3D,CAAC;IACH,CAAC;IAED,MAAM,IAAI,qBAAqB,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;AACpD,CAAC;AAED,SAAS,SAAS,CAAC,GAAY;IAC7B,MAAM,GAAG,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC7D,OAAO,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC;AACnC,CAAC"}
|
package/dist/replayer/run.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Browser, Page } from 'playwright';
|
|
1
|
+
import type { Browser, BrowserContext, Page } from 'playwright';
|
|
2
2
|
import type { Repro, Target } from '../ir/schema.js';
|
|
3
3
|
import { type WrittenArtifacts } from './artifacts.js';
|
|
4
4
|
import { type InvariantViolation } from './invariants.js';
|
|
@@ -102,6 +102,20 @@ export interface RunOptions {
|
|
|
102
102
|
* hand-edit and share, and one that can execute commands is a liability.
|
|
103
103
|
*/
|
|
104
104
|
setupCommand?: string | null;
|
|
105
|
+
/**
|
|
106
|
+
* Reuse an already-open page instead of creating one.
|
|
107
|
+
*
|
|
108
|
+
* A fresh context boots the app from a cold cache every run, which on a heavy
|
|
109
|
+
* single-page app costs far more than the replay itself. Reusing the page
|
|
110
|
+
* keeps the HTTP and V8 caches warm. The cost is isolation: state carries
|
|
111
|
+
* over between runs, so a flow that mutates anything needs `--setup`. Meant
|
|
112
|
+
* for a human sitting in a fix-verify loop, not for a verification that has
|
|
113
|
+
* to stand on its own.
|
|
114
|
+
*/
|
|
115
|
+
session?: {
|
|
116
|
+
context: BrowserContext;
|
|
117
|
+
page: Page;
|
|
118
|
+
} | null;
|
|
105
119
|
/**
|
|
106
120
|
* Replay this repro against a different deployment of the same app.
|
|
107
121
|
*
|
package/dist/replayer/run.js
CHANGED
|
@@ -8,7 +8,7 @@ import { reproPaths } from '../ir/io.js';
|
|
|
8
8
|
import { collectReactions } from '../recorder/reaction.js';
|
|
9
9
|
import { writeFailureArtifacts } from './artifacts.js';
|
|
10
10
|
import { checkBugRecurred, checkInvariants, hasBugSignature, hasFixCriterion, } from './invariants.js';
|
|
11
|
-
import { DEFAULT_RESOLVE_TIMEOUTS, resolveTarget, TargetResolutionError, } from './resolve.js';
|
|
11
|
+
import { DEFAULT_RESOLVE_TIMEOUTS, identityMatches, IdentityMismatchError, isCheckableIdentity, resolveTarget, TargetResolutionError, } from './resolve.js';
|
|
12
12
|
import { retargetRepro, retargetStorageState } from './retarget.js';
|
|
13
13
|
import { createExpander } from './values.js';
|
|
14
14
|
import { waitForReaction } from './waits.js';
|
|
@@ -29,22 +29,27 @@ export async function runRepro(input, options = {}) {
|
|
|
29
29
|
await execAsync(options.setupCommand);
|
|
30
30
|
}
|
|
31
31
|
const sessionPath = options.profileDir ? null : storageStatePath(repro, root);
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
32
|
+
const reused = options.session ?? null;
|
|
33
|
+
const opened = reused
|
|
34
|
+
? { context: reused.context, page: reused.page, persistent: false, close: async () => { } }
|
|
35
|
+
: await openBrowser({
|
|
36
|
+
headless: !options.headed,
|
|
37
|
+
viewport: repro.viewport,
|
|
38
|
+
storageStatePath: options.envUrl ? null : sessionPath,
|
|
39
|
+
// A session is origin-keyed, so restoring it unchanged would authenticate
|
|
40
|
+
// the environment it was recorded against and leave the target signed out.
|
|
41
|
+
storageState: options.envUrl && sessionPath
|
|
42
|
+
? retargetStorageState(JSON.parse(readFileSync(sessionPath, 'utf8')), recordedBaseUrl, options.envUrl)
|
|
43
|
+
: null,
|
|
44
|
+
profileDir: options.profileDir ?? null,
|
|
45
|
+
// A persistent profile owns its own process, so it cannot share one.
|
|
46
|
+
browser: options.profileDir ? null : (options.browser ?? null),
|
|
47
|
+
});
|
|
48
|
+
let reactionsRef = null;
|
|
45
49
|
try {
|
|
46
50
|
const { context, page } = opened;
|
|
47
51
|
const reactions = collectReactions(context);
|
|
52
|
+
reactionsRef = reactions;
|
|
48
53
|
await page.goto(new URL(repro.startPath, baseUrl).toString(), {
|
|
49
54
|
waitUntil: 'domcontentloaded',
|
|
50
55
|
});
|
|
@@ -324,6 +329,9 @@ export async function runRepro(input, options = {}) {
|
|
|
324
329
|
};
|
|
325
330
|
}
|
|
326
331
|
finally {
|
|
332
|
+
// Listeners are per-run. A fresh context discards them with itself, but a
|
|
333
|
+
// reused one would accumulate a set on every replay.
|
|
334
|
+
reactionsRef?.detach();
|
|
327
335
|
await opened.close();
|
|
328
336
|
}
|
|
329
337
|
}
|
|
@@ -458,7 +466,10 @@ function expectationOf(step) {
|
|
|
458
466
|
}
|
|
459
467
|
/** Performs one step; returns which candidate selector worked (-1 when targetless). */
|
|
460
468
|
async function performStep(page, step, baseUrl, options, expand) {
|
|
461
|
-
|
|
469
|
+
// Derived from what this step actually measured, not from a constant. A flat
|
|
470
|
+
// 800ms is wrong by more than an order of magnitude on a heavy app, and
|
|
471
|
+
// guessing the first budget contradicts the rule every other wait follows.
|
|
472
|
+
const timeouts = options.resolveTimeouts ?? deriveResolveTimeouts(step);
|
|
462
473
|
if (step.action === 'goto') {
|
|
463
474
|
await page.goto(rebase(step.value, baseUrl), { waitUntil: 'domcontentloaded' });
|
|
464
475
|
return -1;
|
|
@@ -480,6 +491,28 @@ async function performStep(page, step, baseUrl, options, expand) {
|
|
|
480
491
|
};
|
|
481
492
|
const resolved = await resolveTarget(page, target, timeouts, options.onStepFailure);
|
|
482
493
|
const { locator } = resolved;
|
|
494
|
+
// Confirm the element we found is the one that was recorded, before doing
|
|
495
|
+
// anything to it. A selector that drifts onto a neighbouring row still
|
|
496
|
+
// resolves, still clicks, and still produces a well-formed verdict — about
|
|
497
|
+
// the wrong record.
|
|
498
|
+
const identity = expand.expand(target.identity);
|
|
499
|
+
if (isCheckableIdentity(identity)) {
|
|
500
|
+
// The control's own label and the row it sits in, together. A "Remove"
|
|
501
|
+
// button reads the same on every row, so its own text can neither confirm
|
|
502
|
+
// nor deny which record it belongs to — only the row can. Checking just one
|
|
503
|
+
// of the two would refuse on every correct list row.
|
|
504
|
+
const found = await locator
|
|
505
|
+
.evaluate((el) => {
|
|
506
|
+
const self = el.innerText || el.textContent || '';
|
|
507
|
+
const row = el.closest('tr, [role="row"], li, [role="listitem"], [data-testid*="row"], [data-testid*="Row"]');
|
|
508
|
+
const context = row && row !== el ? (row.innerText ?? '') : '';
|
|
509
|
+
return `${self} ${context}`;
|
|
510
|
+
})
|
|
511
|
+
.catch(() => '');
|
|
512
|
+
if (!identityMatches(found, identity)) {
|
|
513
|
+
throw new IdentityMismatchError(target, resolved.selector, found.replace(/\s+/g, ' ').trim());
|
|
514
|
+
}
|
|
515
|
+
}
|
|
483
516
|
switch (step.action) {
|
|
484
517
|
case 'click':
|
|
485
518
|
await locator.click();
|
|
@@ -512,6 +545,17 @@ async function performStep(page, step, baseUrl, options, expand) {
|
|
|
512
545
|
}
|
|
513
546
|
return resolved.candidateIndex;
|
|
514
547
|
}
|
|
548
|
+
/**
|
|
549
|
+
* A selector budget proportional to how slowly this app was observed to react.
|
|
550
|
+
*
|
|
551
|
+
* A quarter of the step's own wait: long enough for a heavy app to render the
|
|
552
|
+
* control, short enough that a genuinely missing element fails fast instead of
|
|
553
|
+
* burning the whole budget on the first of five candidates.
|
|
554
|
+
*/
|
|
555
|
+
function deriveResolveTimeouts(step) {
|
|
556
|
+
const first = Math.min(15_000, Math.max(DEFAULT_RESOLVE_TIMEOUTS.first, Math.round(step.waitAfter.timeoutMs / 4)));
|
|
557
|
+
return { first, subsequent: Math.max(DEFAULT_RESOLVE_TIMEOUTS.subsequent, Math.round(first / 2)) };
|
|
558
|
+
}
|
|
515
559
|
/** Re-point a recorded absolute URL at the base URL replay is actually using. */
|
|
516
560
|
function rebase(recorded, baseUrl) {
|
|
517
561
|
if (!recorded)
|