fast-replay 0.1.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/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/api.d.ts +65 -0
- package/dist/api.js +78 -0
- package/dist/api.js.map +1 -0
- package/dist/browser.d.ts +67 -0
- package/dist/browser.js +77 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli/format.d.ts +12 -0
- package/dist/cli/format.js +55 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +199 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/compiler/compile.d.ts +18 -0
- package/dist/compiler/compile.js +226 -0
- package/dist/compiler/compile.js.map +1 -0
- package/dist/compiler/normalize.d.ts +16 -0
- package/dist/compiler/normalize.js +75 -0
- package/dist/compiler/normalize.js.map +1 -0
- package/dist/compiler/waits.d.ts +31 -0
- package/dist/compiler/waits.js +69 -0
- package/dist/compiler/waits.js.map +1 -0
- package/dist/ir/io.d.ts +47 -0
- package/dist/ir/io.js +122 -0
- package/dist/ir/io.js.map +1 -0
- package/dist/ir/schema.d.ts +236 -0
- package/dist/ir/schema.js +153 -0
- package/dist/ir/schema.js.map +1 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +22 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/server.d.ts +8 -0
- package/dist/mcp/server.js +268 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/noise.d.ts +20 -0
- package/dist/noise.js +70 -0
- package/dist/noise.js.map +1 -0
- package/dist/recorder/agent/capture.d.ts +15 -0
- package/dist/recorder/agent/capture.js +310 -0
- package/dist/recorder/agent/capture.js.map +1 -0
- package/dist/recorder/agent/config.d.ts +25 -0
- package/dist/recorder/agent/config.js +23 -0
- package/dist/recorder/agent/config.js.map +1 -0
- package/dist/recorder/agent/dom-reaction.d.ts +13 -0
- package/dist/recorder/agent/dom-reaction.js +166 -0
- package/dist/recorder/agent/dom-reaction.js.map +1 -0
- package/dist/recorder/agent/main.d.ts +1 -0
- package/dist/recorder/agent/main.js +14 -0
- package/dist/recorder/agent/main.js.map +1 -0
- package/dist/recorder/agent/page-agent.d.ts +12 -0
- package/dist/recorder/agent/page-agent.js +40 -0
- package/dist/recorder/agent/page-agent.js.map +1 -0
- package/dist/recorder/agent/reveal-tracker.d.ts +26 -0
- package/dist/recorder/agent/reveal-tracker.js +41 -0
- package/dist/recorder/agent/reveal-tracker.js.map +1 -0
- package/dist/recorder/agent/roles.d.ts +11 -0
- package/dist/recorder/agent/roles.js +148 -0
- package/dist/recorder/agent/roles.js.map +1 -0
- package/dist/recorder/agent/selectors.d.ts +26 -0
- package/dist/recorder/agent/selectors.js +256 -0
- package/dist/recorder/agent/selectors.js.map +1 -0
- package/dist/recorder/agent/text.d.ts +19 -0
- package/dist/recorder/agent/text.js +82 -0
- package/dist/recorder/agent/text.js.map +1 -0
- package/dist/recorder/agent/transport.d.ts +14 -0
- package/dist/recorder/agent/transport.js +43 -0
- package/dist/recorder/agent/transport.js.map +1 -0
- package/dist/recorder/agent/visibility.d.ts +8 -0
- package/dist/recorder/agent/visibility.js +20 -0
- package/dist/recorder/agent/visibility.js.map +1 -0
- package/dist/recorder/agent-bundle.generated.d.ts +2 -0
- package/dist/recorder/agent-bundle.generated.js +6 -0
- package/dist/recorder/agent-bundle.generated.js.map +1 -0
- package/dist/recorder/attach.d.ts +52 -0
- package/dist/recorder/attach.js +159 -0
- package/dist/recorder/attach.js.map +1 -0
- package/dist/recorder/instrument.d.ts +17 -0
- package/dist/recorder/instrument.js +24 -0
- package/dist/recorder/instrument.js.map +1 -0
- package/dist/recorder/launch.d.ts +38 -0
- package/dist/recorder/launch.js +72 -0
- package/dist/recorder/launch.js.map +1 -0
- package/dist/recorder/reaction.d.ts +13 -0
- package/dist/recorder/reaction.js +65 -0
- package/dist/recorder/reaction.js.map +1 -0
- package/dist/recorder/types.d.ts +62 -0
- package/dist/recorder/types.js +2 -0
- package/dist/recorder/types.js.map +1 -0
- package/dist/replayer/artifacts.d.ts +25 -0
- package/dist/replayer/artifacts.js +58 -0
- package/dist/replayer/artifacts.js.map +1 -0
- package/dist/replayer/invariants.d.ts +26 -0
- package/dist/replayer/invariants.js +109 -0
- package/dist/replayer/invariants.js.map +1 -0
- package/dist/replayer/resolve.d.ts +35 -0
- package/dist/replayer/resolve.js +50 -0
- package/dist/replayer/resolve.js.map +1 -0
- package/dist/replayer/run.d.ts +104 -0
- package/dist/replayer/run.js +467 -0
- package/dist/replayer/run.js.map +1 -0
- package/dist/replayer/values.d.ts +13 -0
- package/dist/replayer/values.js +76 -0
- package/dist/replayer/values.js.map +1 -0
- package/dist/replayer/waits.d.ts +25 -0
- package/dist/replayer/waits.js +90 -0
- package/dist/replayer/waits.js.map +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +10 -0
- package/dist/version.js.map +1 -0
- package/package.json +77 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const DRAIN_INTERVAL_MS = 25;
|
|
2
|
+
/**
|
|
3
|
+
* Carries events from the page to Node over a Playwright exposed binding.
|
|
4
|
+
*
|
|
5
|
+
* Bindings are not guaranteed to exist when the first events fire, so events
|
|
6
|
+
* queue until one appears rather than being dropped on the floor.
|
|
7
|
+
*/
|
|
8
|
+
export function createTransport(config) {
|
|
9
|
+
const globals = window;
|
|
10
|
+
const pending = [];
|
|
11
|
+
let drainTimer = null;
|
|
12
|
+
function binding(name) {
|
|
13
|
+
const fn = globals[name];
|
|
14
|
+
return typeof fn === 'function' ? fn : null;
|
|
15
|
+
}
|
|
16
|
+
function drain() {
|
|
17
|
+
const fn = binding(config.emitBinding);
|
|
18
|
+
if (!fn)
|
|
19
|
+
return;
|
|
20
|
+
while (pending.length) {
|
|
21
|
+
const ev = pending.shift();
|
|
22
|
+
try {
|
|
23
|
+
fn(ev);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* page torn down mid-emit */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (drainTimer) {
|
|
30
|
+
clearInterval(drainTimer);
|
|
31
|
+
drainTimer = null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function emit(ev) {
|
|
35
|
+
pending.push(ev);
|
|
36
|
+
if (binding(config.emitBinding))
|
|
37
|
+
drain();
|
|
38
|
+
else if (!drainTimer)
|
|
39
|
+
drainTimer = setInterval(drain, DRAIN_INTERVAL_MS);
|
|
40
|
+
}
|
|
41
|
+
return { emit, binding };
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=transport.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transport.js","sourceRoot":"","sources":["../../../src/recorder/agent/transport.ts"],"names":[],"mappings":"AAGA,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAQ7B;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,MAAmB;IACjD,MAAM,OAAO,GAAG,MAA4C,CAAC;IAC7D,MAAM,OAAO,GAAgB,EAAE,CAAC;IAChC,IAAI,UAAU,GAA0C,IAAI,CAAC;IAE7D,SAAS,OAAO,CAAC,IAAY;QAC3B,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QACzB,OAAO,OAAO,EAAE,KAAK,UAAU,CAAC,CAAC,CAAE,EAAgC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC7E,CAAC;IAED,SAAS,KAAK;QACZ,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;QACvC,IAAI,CAAC,EAAE;YAAE,OAAO;QAChB,OAAO,OAAO,CAAC,MAAM,EAAE,CAAC;YACtB,MAAM,EAAE,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;YAC3B,IAAI,CAAC;gBACH,EAAE,CAAC,EAAE,CAAC,CAAC;YACT,CAAC;YAAC,MAAM,CAAC;gBACP,6BAA6B;YAC/B,CAAC;QACH,CAAC;QACD,IAAI,UAAU,EAAE,CAAC;YACf,aAAa,CAAC,UAAU,CAAC,CAAC;YAC1B,UAAU,GAAG,IAAI,CAAC;QACpB,CAAC;IACH,CAAC;IAED,SAAS,IAAI,CAAC,EAAa;QACzB,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACjB,IAAI,OAAO,CAAC,MAAM,CAAC,WAAW,CAAC;YAAE,KAAK,EAAE,CAAC;aACpC,IAAI,CAAC,UAAU;YAAE,UAAU,GAAG,WAAW,CAAC,KAAK,EAAE,iBAAiB,CAAC,CAAC;IAC3E,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;AAC3B,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the element actually rendered?
|
|
3
|
+
*
|
|
4
|
+
* Used to keep unwaitable signals out of the IR. An `<option>` inside a
|
|
5
|
+
* `<select>` is in the DOM but has no layout box, so a replay waiting for it to
|
|
6
|
+
* become visible would hang until the step timed out.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isVisible(el: Element): boolean;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the element actually rendered?
|
|
3
|
+
*
|
|
4
|
+
* Used to keep unwaitable signals out of the IR. An `<option>` inside a
|
|
5
|
+
* `<select>` is in the DOM but has no layout box, so a replay waiting for it to
|
|
6
|
+
* become visible would hang until the step timed out.
|
|
7
|
+
*/
|
|
8
|
+
export function isVisible(el) {
|
|
9
|
+
if (!el.isConnected)
|
|
10
|
+
return false;
|
|
11
|
+
const he = el;
|
|
12
|
+
if (he.hidden || el.getAttribute('aria-hidden') === 'true')
|
|
13
|
+
return false;
|
|
14
|
+
const rect = he.getBoundingClientRect();
|
|
15
|
+
if (rect.width === 0 && rect.height === 0)
|
|
16
|
+
return false;
|
|
17
|
+
const cs = window.getComputedStyle(he);
|
|
18
|
+
return cs.visibility !== 'hidden' && cs.display !== 'none' && cs.opacity !== '0';
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=visibility.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"visibility.js","sourceRoot":"","sources":["../../../src/recorder/agent/visibility.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,UAAU,SAAS,CAAC,EAAW;IACnC,IAAI,CAAC,EAAE,CAAC,WAAW;QAAE,OAAO,KAAK,CAAC;IAClC,MAAM,EAAE,GAAG,EAAiB,CAAC;IAC7B,IAAI,EAAE,CAAC,MAAM,IAAI,EAAE,CAAC,YAAY,CAAC,aAAa,CAAC,KAAK,MAAM;QAAE,OAAO,KAAK,CAAC;IACzE,MAAM,IAAI,GAAG,EAAE,CAAC,qBAAqB,EAAE,CAAC;IACxC,IAAI,IAAI,CAAC,KAAK,KAAK,CAAC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACxD,MAAM,EAAE,GAAG,MAAM,CAAC,gBAAgB,CAAC,EAAE,CAAC,CAAC;IACvC,OAAO,EAAE,CAAC,UAAU,KAAK,QAAQ,IAAI,EAAE,CAAC,OAAO,KAAK,MAAM,IAAI,EAAE,CAAC,OAAO,KAAK,GAAG,CAAC;AACnF,CAAC"}
|
|
@@ -0,0 +1,2 @@
|
|
|
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 = 5;\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 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 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 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 < 3; 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 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 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 });\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";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// GENERATED by scripts/build-agent.ts — do not edit.
|
|
2
|
+
// Source: src/recorder/agent/main.ts. Regenerate with `npm run build:agent`.
|
|
3
|
+
/* eslint-disable */
|
|
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 = 5;\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 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 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 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 < 3; 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 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 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 });\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
|
+
//# sourceMappingURL=agent-bundle.generated.js.map
|
|
@@ -0,0 +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,yryBAAyryB,CAAC"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { BrowserContext, Page } from 'playwright';
|
|
2
|
+
import { type AgentConfig } from './instrument.js';
|
|
3
|
+
import type { RecordingTrace } from './types.js';
|
|
4
|
+
export type StopReason = 'hotkey' | 'browser-closed' | 'signal' | 'programmatic'
|
|
5
|
+
/** The programmatic driver threw; the trace up to that point is kept. */
|
|
6
|
+
| 'drive-failed';
|
|
7
|
+
export interface AttachOptions {
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
agentConfig?: Partial<AgentConfig>;
|
|
10
|
+
}
|
|
11
|
+
export interface RecordingSession {
|
|
12
|
+
/** Filled in live as events arrive; safe to read after `stopped` resolves. */
|
|
13
|
+
trace: RecordingTrace;
|
|
14
|
+
stopped: Promise<StopReason>;
|
|
15
|
+
stop(reason: StopReason): void;
|
|
16
|
+
detach(): void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Attaches the capture pipeline to a context this function did NOT create.
|
|
20
|
+
*
|
|
21
|
+
* Keeping attach separate from launch is what makes Phase 1's hybrid takeover
|
|
22
|
+
* possible: an agent-driven session can be instrumented mid-flight, and human
|
|
23
|
+
* actions land in the same trace as agent actions.
|
|
24
|
+
*/
|
|
25
|
+
export declare function attachRecorder(context: BrowserContext, options: AttachOptions): Promise<RecordingSession>;
|
|
26
|
+
/**
|
|
27
|
+
* Assert the page agent actually installed.
|
|
28
|
+
*
|
|
29
|
+
* The failure this guards against is silent and total: if the injected source
|
|
30
|
+
* throws on its first line — a build helper leaking into the serialized body is
|
|
31
|
+
* the classic cause — every listener is missing and the recording completes
|
|
32
|
+
* happily with zero steps. Better to refuse to record than to hand someone an
|
|
33
|
+
* empty repro they will trust.
|
|
34
|
+
*/
|
|
35
|
+
export declare function verifyInstrumentation(page: Page): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Settle the final action's reaction before teardown.
|
|
38
|
+
*
|
|
39
|
+
* The last action has no successor to close its reaction window, so without
|
|
40
|
+
* this its DOM signals are lost to a confirmation timer that fires after the
|
|
41
|
+
* recorder has already detached — which silently empties the assertion.
|
|
42
|
+
*/
|
|
43
|
+
export declare function flushPageReactions(page: Page): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Path, query and fragment relative to baseUrl.
|
|
46
|
+
*
|
|
47
|
+
* The fragment is part of the address, not decoration: a hash-routed app keeps
|
|
48
|
+
* its entire route there. Dropping it recorded `/app.html` for
|
|
49
|
+
* `/app.html#/sensors`, so every replay opened the default screen and failed on
|
|
50
|
+
* step one for a reason that had nothing to do with the bug.
|
|
51
|
+
*/
|
|
52
|
+
export declare function pathOf(url: string, baseUrl: string): string;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { AGENT_READY_FLAG, agentSource, DEFAULT_AGENT_CONFIG, FLUSH_GLOBAL, } from './instrument.js';
|
|
2
|
+
import { collectReactions } from './reaction.js';
|
|
3
|
+
/**
|
|
4
|
+
* Attaches the capture pipeline to a context this function did NOT create.
|
|
5
|
+
*
|
|
6
|
+
* Keeping attach separate from launch is what makes Phase 1's hybrid takeover
|
|
7
|
+
* possible: an agent-driven session can be instrumented mid-flight, and human
|
|
8
|
+
* actions land in the same trace as agent actions.
|
|
9
|
+
*/
|
|
10
|
+
export async function attachRecorder(context, options) {
|
|
11
|
+
const config = { ...DEFAULT_AGENT_CONFIG, ...options.agentConfig };
|
|
12
|
+
const actions = [];
|
|
13
|
+
const dom = [];
|
|
14
|
+
const navigations = [];
|
|
15
|
+
const reactions = collectReactions(context);
|
|
16
|
+
const trace = {
|
|
17
|
+
actions,
|
|
18
|
+
dom,
|
|
19
|
+
navigations,
|
|
20
|
+
network: reactions.network,
|
|
21
|
+
console: reactions.console,
|
|
22
|
+
startedAt: Date.now(),
|
|
23
|
+
endedAt: 0,
|
|
24
|
+
baseUrl: options.baseUrl,
|
|
25
|
+
startPath: '/',
|
|
26
|
+
viewport: { width: 1440, height: 900 },
|
|
27
|
+
};
|
|
28
|
+
let resolveStopped = () => { };
|
|
29
|
+
let settled = false;
|
|
30
|
+
let detached = false;
|
|
31
|
+
const stopped = new Promise((resolve) => {
|
|
32
|
+
resolveStopped = resolve;
|
|
33
|
+
});
|
|
34
|
+
const stop = (reason) => {
|
|
35
|
+
if (settled)
|
|
36
|
+
return;
|
|
37
|
+
settled = true;
|
|
38
|
+
resolveStopped(reason);
|
|
39
|
+
};
|
|
40
|
+
// Guarded on `detached`, not `stop`: the caller deliberately leaves a grace
|
|
41
|
+
// period after stopping so the final action's reaction is not truncated.
|
|
42
|
+
await context.exposeBinding(config.emitBinding, (_source, raw) => {
|
|
43
|
+
if (detached)
|
|
44
|
+
return;
|
|
45
|
+
const ev = raw;
|
|
46
|
+
if (!ev || typeof ev !== 'object')
|
|
47
|
+
return;
|
|
48
|
+
if (ev.kind === 'action')
|
|
49
|
+
actions.push(ev);
|
|
50
|
+
else if (ev.kind === 'dom')
|
|
51
|
+
dom.push(ev);
|
|
52
|
+
});
|
|
53
|
+
await context.exposeBinding(config.stopBinding, () => {
|
|
54
|
+
stop('hotkey');
|
|
55
|
+
});
|
|
56
|
+
const script = agentSource(config);
|
|
57
|
+
await context.addInitScript({ content: script });
|
|
58
|
+
const watchPage = (page) => {
|
|
59
|
+
page.on('framenavigated', (frame) => {
|
|
60
|
+
if (frame !== page.mainFrame())
|
|
61
|
+
return;
|
|
62
|
+
navigations.push({ kind: 'navigation', url: frame.url(), t: Date.now() });
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
context.on('page', watchPage);
|
|
66
|
+
context.on('close', () => stop('browser-closed'));
|
|
67
|
+
// Pages that already exist never ran the init script, so inject directly.
|
|
68
|
+
for (const page of context.pages()) {
|
|
69
|
+
watchPage(page);
|
|
70
|
+
try {
|
|
71
|
+
await page.evaluate(script);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// about:blank and cross-origin pages can refuse evaluation; the init
|
|
75
|
+
// script will cover them on their next navigation.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const first = context.pages()[0];
|
|
79
|
+
if (first) {
|
|
80
|
+
const size = first.viewportSize();
|
|
81
|
+
if (size)
|
|
82
|
+
trace.viewport = size;
|
|
83
|
+
try {
|
|
84
|
+
trace.startPath = pathOf(first.url(), options.baseUrl);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* about:blank before the first navigation */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
trace,
|
|
92
|
+
stopped,
|
|
93
|
+
stop,
|
|
94
|
+
detach() {
|
|
95
|
+
if (detached)
|
|
96
|
+
return;
|
|
97
|
+
detached = true;
|
|
98
|
+
trace.endedAt = Date.now();
|
|
99
|
+
reactions.detach();
|
|
100
|
+
context.off('page', watchPage);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Assert the page agent actually installed.
|
|
106
|
+
*
|
|
107
|
+
* The failure this guards against is silent and total: if the injected source
|
|
108
|
+
* throws on its first line — a build helper leaking into the serialized body is
|
|
109
|
+
* the classic cause — every listener is missing and the recording completes
|
|
110
|
+
* happily with zero steps. Better to refuse to record than to hand someone an
|
|
111
|
+
* empty repro they will trust.
|
|
112
|
+
*/
|
|
113
|
+
export async function verifyInstrumentation(page) {
|
|
114
|
+
const ready = await page.evaluate((flag) => Boolean(window[flag]), AGENT_READY_FLAG);
|
|
115
|
+
if (ready)
|
|
116
|
+
return;
|
|
117
|
+
throw new Error('Recorder instrumentation failed to install in the page. No actions would be captured.\n' +
|
|
118
|
+
'This usually means the injected agent threw — check the browser console for the first error.');
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Settle the final action's reaction before teardown.
|
|
122
|
+
*
|
|
123
|
+
* The last action has no successor to close its reaction window, so without
|
|
124
|
+
* this its DOM signals are lost to a confirmation timer that fires after the
|
|
125
|
+
* recorder has already detached — which silently empties the assertion.
|
|
126
|
+
*/
|
|
127
|
+
export async function flushPageReactions(page) {
|
|
128
|
+
try {
|
|
129
|
+
await page.evaluate((flag) => {
|
|
130
|
+
const fn = window[flag];
|
|
131
|
+
if (typeof fn === 'function')
|
|
132
|
+
fn();
|
|
133
|
+
}, FLUSH_GLOBAL);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Page already gone (browser closed to stop recording); nothing to settle.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Path, query and fragment relative to baseUrl.
|
|
141
|
+
*
|
|
142
|
+
* The fragment is part of the address, not decoration: a hash-routed app keeps
|
|
143
|
+
* its entire route there. Dropping it recorded `/app.html` for
|
|
144
|
+
* `/app.html#/sensors`, so every replay opened the default screen and failed on
|
|
145
|
+
* step one for a reason that had nothing to do with the bug.
|
|
146
|
+
*/
|
|
147
|
+
export function pathOf(url, baseUrl) {
|
|
148
|
+
try {
|
|
149
|
+
const u = new URL(url);
|
|
150
|
+
const base = new URL(baseUrl);
|
|
151
|
+
if (u.origin !== base.origin)
|
|
152
|
+
return url;
|
|
153
|
+
return `${u.pathname}${u.search}${u.hash}` || '/';
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return '/';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=attach.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attach.js","sourceRoot":"","sources":["../../src/recorder/attach.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type AgentConfig } from './agent/config.js';
|
|
2
|
+
export { AGENT_READY_FLAG, DEFAULT_AGENT_CONFIG, FLUSH_GLOBAL, INSTALL_GLOBAL, type AgentConfig, } from './agent/config.js';
|
|
3
|
+
/**
|
|
4
|
+
* The exact source injected into the page: the prebuilt agent bundle, then a
|
|
5
|
+
* call applying this session's config.
|
|
6
|
+
*
|
|
7
|
+
* Wrapped as one expression so it is valid both as an init-script body and as
|
|
8
|
+
* an `evaluate` argument.
|
|
9
|
+
*
|
|
10
|
+
* Note what this deliberately is *not*: it does not serialize a live function
|
|
11
|
+
* with `Function.prototype.toString()`. That coupled what ran in the browser to
|
|
12
|
+
* whichever transform compiled the host, and esbuild's `keepNames` silently
|
|
13
|
+
* broke it — the agent threw immediately and every recording came back empty
|
|
14
|
+
* while the tests passed. The bundle is built once, by one toolchain, and
|
|
15
|
+
* verified at build time.
|
|
16
|
+
*/
|
|
17
|
+
export declare function agentSource(config: AgentConfig): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { AGENT_BUNDLE } from './agent-bundle.generated.js';
|
|
2
|
+
import { INSTALL_GLOBAL } from './agent/config.js';
|
|
3
|
+
export { AGENT_READY_FLAG, DEFAULT_AGENT_CONFIG, FLUSH_GLOBAL, INSTALL_GLOBAL, } from './agent/config.js';
|
|
4
|
+
/**
|
|
5
|
+
* The exact source injected into the page: the prebuilt agent bundle, then a
|
|
6
|
+
* call applying this session's config.
|
|
7
|
+
*
|
|
8
|
+
* Wrapped as one expression so it is valid both as an init-script body and as
|
|
9
|
+
* an `evaluate` argument.
|
|
10
|
+
*
|
|
11
|
+
* Note what this deliberately is *not*: it does not serialize a live function
|
|
12
|
+
* with `Function.prototype.toString()`. That coupled what ran in the browser to
|
|
13
|
+
* whichever transform compiled the host, and esbuild's `keepNames` silently
|
|
14
|
+
* broke it — the agent threw immediately and every recording came back empty
|
|
15
|
+
* while the tests passed. The bundle is built once, by one toolchain, and
|
|
16
|
+
* verified at build time.
|
|
17
|
+
*/
|
|
18
|
+
export function agentSource(config) {
|
|
19
|
+
return `(function () {
|
|
20
|
+
${AGENT_BUNDLE}
|
|
21
|
+
window[${JSON.stringify(INSTALL_GLOBAL)}](${JSON.stringify(config)});
|
|
22
|
+
})()`;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=instrument.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"instrument.js","sourceRoot":"","sources":["../../src/recorder/instrument.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAoB,MAAM,mBAAmB,CAAC;AAErE,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,cAAc,GAEf,MAAM,mBAAmB,CAAC;AAE3B;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,WAAW,CAAC,MAAmB;IAC7C,OAAO;EACP,YAAY;SACL,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,KAAK,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC;KAC7D,CAAC;AACN,CAAC"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
import { type StopReason } from './attach.js';
|
|
3
|
+
import type { RecordingTrace } from './types.js';
|
|
4
|
+
export interface LaunchRecordingOptions {
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
startPath?: string;
|
|
7
|
+
viewport?: {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
};
|
|
11
|
+
/** Seed cookies/localStorage/IndexedDB from an existing Playwright state file. */
|
|
12
|
+
storageStatePath?: string | null;
|
|
13
|
+
/** Record against a persistent Chromium profile instead of a fresh context. */
|
|
14
|
+
profileDir?: string | null;
|
|
15
|
+
/** Called once the browser is up and instrumented, so the CLI can print help. */
|
|
16
|
+
onReady?: () => void;
|
|
17
|
+
/** Recording is a human activity by default; only a driver makes it headless-able. */
|
|
18
|
+
headless?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Drive the session programmatically instead of waiting for a human. The
|
|
21
|
+
* recording stops when this resolves.
|
|
22
|
+
*
|
|
23
|
+
* This is how Phase 1's `repro auto` will work: an LLM browser agent takes
|
|
24
|
+
* the page and produces the exact same IR a human recording produces, because
|
|
25
|
+
* capture happens below whoever is doing the driving.
|
|
26
|
+
*/
|
|
27
|
+
drive?: (page: Page) => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export interface RecordingResult {
|
|
30
|
+
trace: RecordingTrace;
|
|
31
|
+
/** Serialized storageState captured at the START of the recording. */
|
|
32
|
+
storageState: string;
|
|
33
|
+
stopReason: StopReason;
|
|
34
|
+
/** Set when `drive` threw. The trace up to that point is still usable. */
|
|
35
|
+
driveError: Error | null;
|
|
36
|
+
}
|
|
37
|
+
export declare const STOP_HOTKEY = "Ctrl/Cmd + Shift + X";
|
|
38
|
+
export declare function launchRecording(options: LaunchRecordingOptions): Promise<RecordingResult>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { captureStorageState, openBrowser } from '../browser.js';
|
|
2
|
+
import { attachRecorder, flushPageReactions, pathOf, verifyInstrumentation, } from './attach.js';
|
|
3
|
+
export const STOP_HOTKEY = 'Ctrl/Cmd + Shift + X';
|
|
4
|
+
export async function launchRecording(options) {
|
|
5
|
+
const viewport = options.viewport ?? { width: 1440, height: 900 };
|
|
6
|
+
const startPath = options.startPath ?? '/';
|
|
7
|
+
const opened = await openBrowser({
|
|
8
|
+
headless: options.headless ?? false,
|
|
9
|
+
viewport,
|
|
10
|
+
storageStatePath: options.storageStatePath ?? null,
|
|
11
|
+
profileDir: options.profileDir ?? null,
|
|
12
|
+
});
|
|
13
|
+
try {
|
|
14
|
+
const { context, page } = opened;
|
|
15
|
+
const session = await attachRecorder(context, { baseUrl: options.baseUrl });
|
|
16
|
+
const startUrl = new URL(startPath, options.baseUrl).toString();
|
|
17
|
+
await page.goto(startUrl, { waitUntil: 'domcontentloaded' });
|
|
18
|
+
await verifyInstrumentation(page);
|
|
19
|
+
// Snapshot session state before the dev touches anything, so replay starts
|
|
20
|
+
// from exactly the auth/session the recording started from.
|
|
21
|
+
const storageState = await captureStorageState(context);
|
|
22
|
+
session.trace.startPath = pathOf(page.url(), options.baseUrl);
|
|
23
|
+
session.trace.viewport = viewport;
|
|
24
|
+
// The first navigation is the starting point, not a step.
|
|
25
|
+
session.trace.navigations.length = 0;
|
|
26
|
+
// Neither is anything the page did to itself while booting. Layout probes
|
|
27
|
+
// and autofocus fire real events before the user touches anything, and a
|
|
28
|
+
// step recorded there is ordered ahead of the navigation that created the
|
|
29
|
+
// page — so its selector can never resolve and step one always fails.
|
|
30
|
+
session.trace.actions.length = 0;
|
|
31
|
+
session.trace.dom.length = 0;
|
|
32
|
+
options.onReady?.();
|
|
33
|
+
const onSigint = () => session.stop('signal');
|
|
34
|
+
process.once('SIGINT', onSigint);
|
|
35
|
+
let driveError = null;
|
|
36
|
+
let stopReason;
|
|
37
|
+
try {
|
|
38
|
+
if (options.drive) {
|
|
39
|
+
try {
|
|
40
|
+
// A driven session ends when the driver is done — but an early browser
|
|
41
|
+
// close or hotkey still wins, so takeover behaves the same either way.
|
|
42
|
+
await Promise.race([
|
|
43
|
+
options.drive(page).then(() => session.stop('programmatic')),
|
|
44
|
+
session.stopped,
|
|
45
|
+
]);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
// A driver that throws halfway through still produced real steps.
|
|
49
|
+
// Discarding them would throw away everything up to the failure —
|
|
50
|
+
// which on a slow app can be several minutes of work.
|
|
51
|
+
driveError = err instanceof Error ? err : new Error(String(err));
|
|
52
|
+
session.stop('drive-failed');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
stopReason = await session.stopped;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
process.off('SIGINT', onSigint);
|
|
59
|
+
}
|
|
60
|
+
// Settle the last action's reaction, then give in-flight bindings and any
|
|
61
|
+
// trailing network a moment to land, so the final step is not truncated by
|
|
62
|
+
// the shutdown itself.
|
|
63
|
+
await flushPageReactions(page);
|
|
64
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
65
|
+
session.detach();
|
|
66
|
+
return { trace: session.trace, storageState, stopReason, driveError };
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await opened.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=launch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"launch.js","sourceRoot":"","sources":["../../src/recorder/launch.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { BrowserContext } from 'playwright';
|
|
2
|
+
import type { RawConsoleEvent, RawNetworkEvent } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Buffers the environment's reaction — network traffic and console errors —
|
|
5
|
+
* for the lifetime of a recording. Attribution to a specific action happens
|
|
6
|
+
* later in the compiler, which is why everything here is just timestamped.
|
|
7
|
+
*/
|
|
8
|
+
export interface ReactionCollector {
|
|
9
|
+
network: RawNetworkEvent[];
|
|
10
|
+
console: RawConsoleEvent[];
|
|
11
|
+
detach(): void;
|
|
12
|
+
}
|
|
13
|
+
export declare function collectReactions(context: BrowserContext): ReactionCollector;
|