/**
* dyad-recorder-client.js
*
* Injected into the preview iframe by the proxy server (see proxy_server.js).
* Observes user interactions while recording is active and reports a normalized
* action stream to the renderer via postMessage. OBSERVE-ONLY: it never calls
* preventDefault, so the app behaves exactly as it would without the recorder.
*
* Capture semantics are a small port of Playwright's in-page RecordActionTool:
* text entry becomes a single `fill` with the full value, form-control toggles
* come from `change`, and selectors prefer stable, human-readable locators.
* Clicks are reported immediately — a click that navigates would otherwise be
* lost with the unloading document — and only the first click of a multi-click
* gesture is reported, so the renderer's `collapseActions` folds exactly one
* click into the `dblclick` that follows it.
*
* Plain, dependency-free IIFE JS: it is read verbatim and injected into every
* previewed HTML document. See `src/lib/test_recorder/types.ts` for the schemas
* of the actions and locator descriptors it emits.
*
* Protocol:
* down (from parent): { type: "activate-dyad-recorder" | "deactivate-dyad-recorder", token }
* { type: "flush-dyad-recorder", token, requestId }
* up (to parent): { type: "dyad-recorder-initialized" }
* { type: "dyad-recorder-action", action: RecordedAction }
* { type: "dyad-recorder-flushed", requestId }
*
* Known limitation — the top-level preview document only. This script is
* injected into nested frames too, but Dyad activates only the frame it embeds,
* and `window.parent` from a nested document is the app, not the renderer. So
* interactions inside an app's own iframes are absent from the recording rather
* than misrecorded. Covering them needs the activation relayed down, the actions
* relayed back up, and a frame reference carried on each action so replay can
* address it via `page.frameLocator` — without that last part a relayed action
* would replay against the main frame and silently assert the wrong document.
* Tracked as follow-up work; see the PR discussion.
*/
(() => {
// Read once, here, and then take the global away.
//
// The vm/happy-dom unit test sets this before the script runs so it can drive
// the recorder with synthetic events. Nothing else legitimately sets it, and
// the proxy splices this script in immediately after `
` — so every one
// of the app's own scripts, inline or bundled or from a CDN, runs strictly
// after this line. Snapshotting is therefore enough to stop a page script
// flipping the recorder into accepting untrusted events mid-recording and
// fabricating clicks and fills that land in a spec the user commits and runs.
const allowUntrustedEvents =
window.__DYAD_RECORDER_ALLOW_UNTRUSTED__ === true;
// Minted per proxy and known only to the trusted renderer. Without this, an
// unrelated page can frame the localhost preview, arm the recorder, and
// receive every captured fill value through the parent message channel.
//
// Removed from the DOM once read, for the same reason the global below is
// deleted: the app's own scripts all run after this line, but the attribute
// would otherwise stay in the document for any of them — including CDN code
// in an AI-generated app — to lift with one `querySelector`.
const recorderToken = document.currentScript?.dataset.dyadRecorderToken;
try {
document.currentScript?.removeAttribute("data-dyad-recorder-token");
} catch {
// Best effort: the value is already snapshotted above either way.
}
try {
delete window.__DYAD_RECORDER_ALLOW_UNTRUSTED__;
} catch {
// Non-configurable, so someone is holding it deliberately. The snapshot
// above already decided this session's answer either way.
}
const OVERLAY_CLASS = "__dyad_recorder_overlay__";
// Absorbs the synthetic duplicate the browser dispatches when a
// activates its control, without swallowing deliberate repeat interactions.
const DEDUPE_MS = 50;
// A typed password must never land in the generated spec as a plaintext
// secret. The `fill` step is still recorded so the flow/locator is preserved.
const PASSWORD_PLACEHOLDER = "REPLACE_WITH_PASSWORD";
// Fields whose name says "secret" even though the type doesn't. Deliberately
// conservative — a false positive costs one placeholder to replace, a false
// negative writes a live credential into the user's repo.
const SECRET_NAME_RE =
/password|passwd|passphrase|secret|token|api[-_ ]?key|private[-_ ]?key|credential|\botp|\btotp|\bmfa|\b2fa/i;
const SECRET_ATTRS = ["name", "id", "data-testid", "aria-label"];
// Controls seen as type="password" at any point this session. A show/hide
// toggle flips the input to type="text", so without this every keystroke after
// the reveal would be captured in plaintext.
//
// Known limitation: heuristic. A secret typed into a field that was never
// type="password" and whose attributes don't read as secret-bearing is still
// recorded verbatim, so a generated spec is worth a glance before committing.
const seenAsPassword = new WeakSet();
// Native elements that are interactive whatever their role says.
const INTERACTIVE_TAGS = "button, a, input, select, textarea, summary";
// Roles that make an element the thing a click is really aimed at. Matched
// against `computeRole`'s answer rather than the `role` attribute's text: only
// the first supported token of a fallback list is live, so
// `role="presentation button"` is a presentation element that merely contains
// the word "button". Retargeting to it would record the click against a
// wrapper and leave the real handler on the descendant unexercised on replay.
const INTERACTIVE_ROLES = new Set([
"button",
"link",
"checkbox",
"radio",
"tab",
"menuitem",
"switch",
"option",
]);
// WAI-ARIA 1.2 roles, which is what Playwright's `getByRole` accepts. Used to
// pick the winning token out of a fallback role list — see `computeRole`.
const KNOWN_ROLES = new Set([
"alert",
"alertdialog",
"application",
"article",
"banner",
"blockquote",
"button",
"caption",
"cell",
"checkbox",
"code",
"columnheader",
"combobox",
"complementary",
"contentinfo",
"definition",
"deletion",
"dialog",
"directory",
"document",
"emphasis",
"feed",
"figure",
"form",
"generic",
"grid",
"gridcell",
"group",
"heading",
"img",
"insertion",
"link",
"list",
"listbox",
"listitem",
"log",
"main",
"marquee",
"math",
"menu",
"menubar",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"meter",
"navigation",
"none",
"note",
"option",
"paragraph",
"presentation",
"progressbar",
"radio",
"radiogroup",
"region",
"row",
"rowgroup",
"rowheader",
"scrollbar",
"search",
"searchbox",
"separator",
"slider",
"spinbutton",
"status",
"strong",
"subscript",
"superscript",
"switch",
"tab",
"table",
"tablist",
"tabpanel",
"term",
"textbox",
"time",
"timer",
"toolbar",
"tooltip",
"tree",
"treegrid",
"treeitem",
]);
const NAV_KEYS = new Set([
"Enter",
"Escape",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
]);
let active = false;
let lastEmit = { key: "", at: 0 };
/**
* The last Enter press this recorder actually recorded, and where.
*
* Enter in a text field inside a form submits it implicitly, and the browser
* delivers that submission as a trusted `click` on the form's default submit
* button. Recording both replays the submission twice — and since the press
* navigates first, the duplicate click usually lands on the destination
* document and fails. `onClick` uses this to recognise that click.
*/
let lastEnterPress = { target: null, at: 0 };
/** How long after an Enter press a click may still be its implicit submit. */
const IMPLICIT_SUBMIT_MS = 100;
let hoverBox = null;
let passwordObserver = null;
/* ---------- small helpers -------------------------------------------- */
const css = (el, obj) => Object.assign(el.style, obj);
function normalize(text) {
return (text || "").replace(/\s+/g, " ").trim();
}
function cssEscape(value) {
if (typeof window !== "undefined" && window.CSS && window.CSS.escape) {
return window.CSS.escape(value);
}
return String(value).replace(/["\\\]]/g, "\\$&");
}
function trustedOk(e) {
return e.isTrusted || allowUntrustedEvents;
}
function deepTarget(e) {
const path = e.composedPath && e.composedPath();
if (path && path.length) {
for (const node of path) {
if (node && node.nodeType === 1) return node;
}
}
return e.target;
}
function isOverlayEvent(e) {
const path = (e.composedPath && e.composedPath()) || [];
return path.some(
(node) =>
node && node.classList && node.classList.contains(OVERLAY_CLASS),
);
}
function isCheckboxOrRadio(el) {
if (!el || el.tagName !== "INPUT") return false;
const type = (el.getAttribute("type") || "text").toLowerCase();
return type === "checkbox" || type === "radio";
}
/**
* Controls whose value is chosen rather than typed — a colour picker, a
* slider. `isEditable` excludes them because there is no text to type, but
* the value is still the user's choice, and Playwright's `fill` sets these
* directly instead of typing (its `kInputTypesToSetValue`), so `fill("42")`
* replays exactly what was picked.
*
* Captured from `change` (the commit) rather than `input`, which fires for
* every frame of a drag.
*/
const VALUE_PICKER_TYPES = ["color", "range"];
function isValuePicker(el) {
if (!el || el.tagName !== "INPUT") return false;
return VALUE_PICKER_TYPES.includes(
(el.getAttribute("type") || "").toLowerCase(),
);
}
/**
* A file picker. Nothing here can be replayed: the value is a file the user
* chose from their own machine, which the page cannot read and this recorder
* has no business capturing. A recorded `click()` is worse than no step —
* replay opens the OS file chooser and the test hangs there.
*/
function isFileInput(el) {
if (!el || el.tagName !== "INPUT") return false;
return (el.getAttribute("type") || "").toLowerCase() === "file";
}
function isPasswordField(el) {
if (!el || el.tagName !== "INPUT") return false;
return (el.getAttribute("type") || "text").toLowerCase() === "password";
}
/**
* Remember a control that is currently a password field, so a later reveal
* toggle (type="text") can't launder its value into the recording.
*/
function notePasswordField(el) {
if (isPasswordField(el)) seenAsPassword.add(el);
}
function noteRevealedPasswords(records) {
for (const record of records) {
if (
record.attributeName === "type" &&
(record.oldValue || "").toLowerCase() === "password"
) {
seenAsPassword.add(record.target);
}
}
}
/**
* MutationObserver callbacks are microtasks, so a reveal toggle and a keystroke
* landing in the same task would otherwise be evaluated before the flip is
* known — and that one keystroke captured in plaintext.
*/
function drainPasswordMutations() {
if (passwordObserver) noteRevealedPasswords(passwordObserver.takeRecords());
}
/**
* Catch a reveal toggle even for a field never seen as a password: the
* password→text flip is itself the signal, so a field that was autofilled and
* then unmasked is still redacted rather than captured in plaintext.
*/
function watchPasswordReveals() {
const Observer =
typeof MutationObserver !== "undefined"
? MutationObserver
: window.MutationObserver;
if (!Observer || passwordObserver) return;
passwordObserver = new Observer(noteRevealedPasswords);
passwordObserver.observe(document.documentElement || document, {
subtree: true,
attributes: true,
attributeFilter: ["type"],
attributeOldValue: true,
});
// Anything already masked when recording starts is a password field even if
// the user only ever touches its reveal toggle.
Array.prototype.forEach.call(
document.querySelectorAll('input[type="password"]'),
(el) => seenAsPassword.add(el),
);
}
function stopWatchingPasswordReveals() {
if (!passwordObserver) return;
passwordObserver.disconnect();
passwordObserver = null;
}
function looksSecretByName(el) {
if (!el || !el.getAttribute) return false;
const autocomplete = (el.getAttribute("autocomplete") || "").toLowerCase();
if (
autocomplete.includes("password") ||
autocomplete === "one-time-code" ||
autocomplete === "cc-number" ||
autocomplete === "cc-csc"
) {
return true;
}
return SECRET_ATTRS.some((attr) =>
SECRET_NAME_RE.test(el.getAttribute(attr) || ""),
);
}
/** Whether this control's value must be redacted rather than recorded. */
function isSecretField(el) {
if (isPasswordField(el)) {
seenAsPassword.add(el);
return true;
}
drainPasswordMutations();
return seenAsPassword.has(el) || looksSecretByName(el);
}
function isEditable(el) {
if (!el) return false;
if (el.tagName === "TEXTAREA") return true;
if (el.tagName === "INPUT") {
const type = (el.getAttribute("type") || "text").toLowerCase();
return ![
"checkbox",
"radio",
"button",
"submit",
"reset",
"file",
"image",
"range",
"color",
"hidden",
].includes(type);
}
return el.isContentEditable === true;
}
/**
* Resolve the control a pointer interaction concerns. Clicking a
* activates its associated control, so map to that and let the control's own
* `change`/`click` drive recording. Null for non-form-controls.
*/
function resolveControl(el) {
if (!el) return null;
const label = el.closest && el.closest("label");
if (label) {
if (label.control) return label.control;
const forId = label.getAttribute("for");
if (forId) {
const control = document.getElementById(forId);
if (control) return control;
}
}
if (
el.tagName === "INPUT" ||
el.tagName === "SELECT" ||
el.tagName === "TEXTAREA"
) {
return el;
}
return null;
}
/**
* Controls that arrow keys DRIVE rather than merely move a caret within, so
* the keypress produces a `change` this recorder already captures as the
* value action.
*
* A `` walks its options, range and number inputs step, and arrows
* inside a radio group both move focus and check the radio they land on.
* Recording the press as well as the resulting `select`/`fill`/`check` makes
* replay perform the value change twice — once by keystroke, once by the
* value action — and land somewhere the user never went. Text fields are not
* here: arrows only move the caret, and no `change` follows.
*
* Checkboxes are deliberately absent even though radios are here. Arrows do
* nothing to a checkbox — there is no group to walk and no `change` to take
* the press's place — so suppressing it would erase the only record of a
* keyboard-only interaction with the app's own key handler.
*
* A number input is the one control where WHICH arrow matters: only up and
* down step it. Left and right move the caret exactly as in a text field, so
* no `change` follows to stand in for the press and dropping it would erase
* caret navigation — and any app key handler behind it — from the recording.
*/
function changesValueOnArrowKeys(control, key) {
if (!control || control.nodeType !== 1) return false;
if (control.tagName === "SELECT") return true;
if (control.tagName !== "INPUT") return false;
const type = (control.getAttribute("type") || "text").toLowerCase();
if (type === "radio" || type === "range") return true;
return type === "number" && (key === "ArrowUp" || key === "ArrowDown");
}
/** Controls whose meaningful action is captured by input/change, not click. */
function recordsWithoutClick(control) {
return (
isCheckboxOrRadio(control) ||
isValuePicker(control) ||
isFileInput(control) ||
control.tagName === "SELECT" ||
isEditable(control)
);
}
function retarget(el) {
if (!el || el.nodeType !== 1) return el;
// Nearest-first, the way `closest` walked it — but asking `computeRole`
// per node instead of letting a CSS attribute match stand in for the role.
for (
let node = el;
node && node.nodeType === 1;
node = node.parentElement
) {
if (node.matches && node.matches(INTERACTIVE_TAGS)) return node;
if (INTERACTIVE_ROLES.has(computeRole(node))) return node;
}
return el;
}
/* ---------- role / accessible-name (minimal port) -------------------- */
function computeRole(el) {
if (!el || el.nodeType !== 1) return null;
const explicit = el.getAttribute && el.getAttribute("role");
if (explicit && explicit.trim()) {
// `role` is a space-separated fallback list, and the browser takes the
// first token it recognises — `role="switch checkbox"` is a switch. Handing
// the whole attribute over as one role emits
// `getByRole("switch checkbox")`, which matches nothing on replay.
for (const token of explicit.trim().toLowerCase().split(/\s+/)) {
if (KNOWN_ROLES.has(token)) return token;
}
// Every token was junk. Browsers ignore an unrecognised `role` and fall
// back to the implicit one, so drop through rather than returning null.
}
const tag = el.tagName ? el.tagName.toLowerCase() : "";
switch (tag) {
case "button":
return "button";
case "a":
return el.hasAttribute("href") ? "link" : null;
case "select":
// A select is a combobox only while it presents one row at a time.
// `multiple`, or a `size` above 1, makes it a list — and Playwright
// matches it as `listbox`, so calling it a combobox generates a locator
// that never resolves.
return el.hasAttribute("multiple") ||
Number(el.getAttribute("size")) > 1
? "listbox"
: "combobox";
case "textarea":
return "textbox";
case "nav":
return "navigation";
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6":
return "heading";
case "img":
return "img";
case "input": {
const type = (el.getAttribute("type") || "text").toLowerCase();
if (type === "checkbox") return "checkbox";
if (type === "radio") return "radio";
if (["button", "submit", "reset", "image"].includes(type))
return "button";
if (type === "range") return "slider";
if (type === "search") return "searchbox";
if (type === "password") return null; // no matching ARIA role
// Playwright exposes number inputs as `spinbutton`; calling them
// textboxes would generate a locator that matches nothing at replay.
if (type === "number") return "spinbutton";
// No ARIA role Playwright can match. Falling through to `textbox` would
// let a unique-looking `getByRole("textbox", { name })` win over
// `getByLabel` and generate a locator that resolves to nothing every
// time the test runs.
if (
[
"date",
"datetime-local",
"month",
"week",
"time",
"color",
"file",
"hidden",
].includes(type)
) {
return null;
}
// An unknown or missing type behaves as `text`, so textbox is right for
// everything left.
return "textbox";
}
default:
return null;
}
}
function associatedLabelText(el) {
if (!el || el.nodeType !== 1) return null;
// Native labelable controls expose every associated label in document
// order through `.labels`, including both `for=` and wrapping labels.
// Playwright concatenates those labels for the accessible name, so using
// only the first one can produce a unique-looking locator that never
// resolves on replay.
let labels = el.labels ? Array.from(el.labels) : [];
if (!labels.length) {
// Fallback for DOM implementations without `HTMLInputElement.labels`.
if (el.id) {
labels = Array.from(
document.querySelectorAll(`label[for="${cssEscape(el.id)}"]`),
);
}
const wrap = el.closest && el.closest("label");
if (wrap && !labels.includes(wrap)) labels.push(wrap);
}
const parts = labels
.map((label) => normalize(accessibleTextContent(label)))
.filter(Boolean);
return parts.length ? normalize(parts.join(" ")) : null;
}
/**
* Text as an accessible-name computation reads it: `aria-hidden` and `hidden`
* subtrees contribute nothing.
*
* Raw `textContent` would name `× Close
* ` "×Close", and the `getByRole("button", { name: "×Close" })` that
* produced would match nothing when the test replays — Playwright computes the
* name the same way this does, and gets "Close".
*/
function accessibleTextContent(el, includeHidden = false) {
let out = "";
const children = el.childNodes || [];
for (let i = 0; i < children.length; i++) {
const node = children[i];
if (node.nodeType === 3) {
out += node.nodeValue || "";
continue;
}
if (node.nodeType !== 1) continue;
if (
!includeHidden &&
node.getAttribute &&
node.getAttribute("aria-hidden") === "true"
)
continue;
// The same visibility rule `getByRole` matching uses, so a name built
// here is one Playwright can actually match. `display: none` and
// `visibility: hidden` descendants contribute nothing to a name — a
// screen-reader-only label or a collapsed tooltip would otherwise be
// baked into a locator that matches nothing at replay.
if (!includeHidden && !isAriaVisible(node)) continue;
out += accessibleTextContent(node, includeHidden);
}
return out;
}
/**
* The text `aria-labelledby` resolves to, or null when it names nothing.
*
* Ahead of `aria-label` wherever both are consulted: the accessible-name
* algorithm gives `aria-labelledby` precedence (accname step 2B before 2C),
* and Playwright implements it faithfully. Reading `aria-label` first on an
* element carrying both makes the uniqueness scan call
* `getByRole(..., { name: , exact: true })` unique while
* Playwright computes the referenced text as the name — so the locator
* matches nothing at replay. A labelledby that resolves to nothing falls
* through to `aria-label`, as the algorithm's own fallthrough does.
*/
function labelledByText(el) {
const labelledby = el.getAttribute && el.getAttribute("aria-labelledby");
if (!labelledby) return null;
const parts = labelledby
.split(/\s+/)
.map((id) => {
const ref = document.getElementById(id);
// Explicit aria-labelledby references contribute even when the
// referenced node is visually hidden. Ordinary descendants and native
// labels still use the visibility filter in accessibleTextContent.
return ref ? normalize(accessibleTextContent(ref, true)) : "";
})
.filter(Boolean);
return parts.length ? normalize(parts.join(" ")) : null;
}
function computeAccName(el) {
if (!el || el.nodeType !== 1) return null;
const labelled = labelledByText(el);
if (labelled) return labelled;
const aria = el.getAttribute && el.getAttribute("aria-label");
if (aria && aria.trim()) return normalize(aria);
const fromLabel = associatedLabelText(el);
if (fromLabel) return fromLabel;
if (el.tagName === "IMG") {
const alt = el.getAttribute("alt");
if (alt && alt.trim()) return normalize(alt);
}
const role = computeRole(el);
if (
["button", "link", "heading", "tab", "menuitem", "option"].includes(role)
) {
const text = normalize(accessibleTextContent(el));
if (text) return text;
}
const title = el.getAttribute && el.getAttribute("title");
if (title && title.trim()) return normalize(title);
return null;
}
function labelForGetByLabel(el) {
// Same precedence as computeAccName, for the same reason: Playwright's
// `getByLabel` reads aria-labelledby before aria-label too, so the other
// order produces a locator it will never match.
const labelled = labelledByText(el);
if (labelled) return labelled;
const aria = el.getAttribute && el.getAttribute("aria-label");
if (aria && aria.trim()) return normalize(aria);
return associatedLabelText(el);
}
/* ---------- selector generation -------------------------------------- */
// Per-`selectorFor`-call scratch space. Each candidate kind would otherwise
// re-walk the document recomputing role + accessible name — several O(N)
// sweeps per event, in the capture phase. Memoizing collapses that to one.
let scan = null;
function beginScan() {
scan = { all: null, roles: new Map(), names: new Map() };
}
function endScan() {
scan = null;
}
/**
* Every element the locator has to be unique among — open shadow roots
* included.
*
* Playwright's locators pierce open shadow roots, so uniqueness judged from
* `document.querySelectorAll` alone can call a locator unique while replay
* finds a second match inside a web component and fails on strict mode.
*/
function collectElements(root, into) {
const found = root.querySelectorAll("*");
for (let i = 0; i < found.length; i++) {
const el = found[i];
into.push(el);
if (el.shadowRoot) collectElements(el.shadowRoot, into);
}
return into;
}
function allElements() {
if (scan) {
if (!scan.all) scan.all = collectElements(document, []);
return scan.all;
}
return collectElements(document, []);
}
function roleOf(el) {
if (!scan) return computeRole(el);
if (!scan.roles.has(el)) scan.roles.set(el, computeRole(el));
return scan.roles.get(el);
}
function accNameOf(el) {
if (!scan) return computeAccName(el);
if (!scan.names.has(el)) scan.names.set(el, computeAccName(el));
return scan.names.get(el);
}
/**
* The next element up, crossing out of a shadow root to its host.
*
* `parentElement` returns null at a shadow boundary, so without this a
* component whose host is `aria-hidden` or `display: none` would have its
* shadow content counted as visible — and now that uniqueness scans include
* shadow descendants, that is a locator claiming to be unique against
* elements Playwright can't see.
*/
function ariaParent(el) {
if (el.parentElement) return el.parentElement;
const root = el.getRootNode && el.getRootNode();
return root && root.host ? root.host : null;
}
/**
* Whether an element is exposed to the a11y tree the way Playwright's
* `getByRole` sees it (`includeHidden: false`). Uniqueness and `nth` are
* computed from these matches, so counting a hidden duplicate — a closed
* dialog's buttons, a `display: none` mobile nav — would hand replay an
* `.nth(1)` pointing at an element Playwright never sees.
*/
function isAriaVisible(el) {
if (!el || el.nodeType !== 1) return false;
for (let node = el; node && node.nodeType === 1; node = ariaParent(node)) {
if (node.getAttribute && node.getAttribute("aria-hidden") === "true") {
return false;
}
if (node.hasAttribute && node.hasAttribute("hidden")) return false;
}
// `checkVisibility` accounts for an ancestor's `display: none`,
// `content-visibility`, and detached subtrees. Walking computed styles
// cannot: `display` doesn't inherit, so a child of a hidden container
// computes to `block` and reads as visible.
if (typeof el.checkVisibility === "function") {
try {
// Playwright treats `visibility: hidden` as hidden but NOT `opacity: 0`,
// so `visibilityProperty` is on and `opacityProperty` stays off.
return el.checkVisibility({
visibilityProperty: true,
contentVisibilityAuto: true,
});
} catch {
// fall through to the manual check
}
}
// happy-dom and other minimal DOMs don't implement layout; treat "can't
// tell" as visible so selector quality degrades rather than breaking.
const view =
(el.ownerDocument && el.ownerDocument.defaultView) ||
(typeof window !== "undefined" ? window : null);
if (!view || typeof view.getComputedStyle !== "function") return true;
for (let node = el; node && node.nodeType === 1; node = ariaParent(node)) {
let style;
try {
style = view.getComputedStyle(node);
} catch {
return true;
}
if (!style) return true;
if (style.display === "none") return false;
// `visibility` inherits, so only the element's own computed value matters.
if (node === el && style.visibility === "hidden") return false;
}
return true;
}
function hasDescendantWithText(el, value) {
return Array.prototype.some.call(
el.querySelectorAll("*"),
(child) => normalize(child.textContent) === value,
);
}
function queryAll(descriptor) {
switch (descriptor.kind) {
case "testid":
return allElements().filter(
(e) => e.getAttribute("data-testid") === descriptor.value,
);
case "role":
return allElements().filter(
(e) =>
roleOf(e) === descriptor.value &&
accNameOf(e) === descriptor.name &&
isAriaVisible(e),
);
case "placeholder":
return allElements().filter(
(e) => e.getAttribute("placeholder") === descriptor.value,
);
case "label":
return allElements().filter(
(e) => labelForGetByLabel(e) === descriptor.value,
);
case "text":
return allElements().filter(
(e) =>
normalize(e.textContent) === descriptor.value &&
!hasDescendantWithText(e, descriptor.value),
);
case "css":
try {
return Array.prototype.slice.call(
document.querySelectorAll(descriptor.value),
);
} catch {
return [];
}
default:
return [];
}
}
/**
* Candidate descriptors for `el`, highest priority first.
*
* Every name/text-based candidate carries `exact: true`: Playwright matches
* names as case-insensitive substrings by default, but the uniqueness check
* below compares with `===`. Without it the recorder would call a "Save" button
* unique and omit `.nth()`, while replay also matched "Save draft".
*/
function buildCandidates(el) {
const candidates = [];
const testid = el.getAttribute && el.getAttribute("data-testid");
if (testid) candidates.push({ kind: "testid", value: testid });
const role = roleOf(el);
const name = accNameOf(el);
if (role && name)
candidates.push({ kind: "role", value: role, name, exact: true });
const placeholder = el.getAttribute && el.getAttribute("placeholder");
if (placeholder)
candidates.push({ kind: "placeholder", value: placeholder, exact: true });
const label = labelForGetByLabel(el);
if (label) candidates.push({ kind: "label", value: label, exact: true });
const text = normalize(el.textContent);
if (text && text.length <= 40 && !isEditable(el)) {
candidates.push({ kind: "text", value: text, exact: true });
}
// `data-dyad-id` is deliberately NOT a candidate: it encodes a source
// location, so it moves whenever the file it points into is edited, and the
// Dyad plugin that injects it doesn't run in the build the test replays
// against. An element with no better locator falls through to the CSS path.
return candidates;
}
function cssPathDescriptor(el) {
if (el.id) {
const idSel = `#${cssEscape(el.id)}`;
try {
if (document.querySelectorAll(idSel).length === 1) {
return { kind: "css", value: idSel };
}
} catch {
/* fall through to path building */
}
}
const parts = [];
let node = el;
while (node && node.nodeType === 1 && node !== document.body) {
let part = node.tagName.toLowerCase();
if (node.id) {
parts.unshift(`#${cssEscape(node.id)}`);
break;
}
// The nodes `node` is indexed among. Inside a shadow root that's the root
// itself, not an element — `parentElement` is null there.
const siblingRoot = node.parentElement ?? node.getRootNode();
const sameTag = Array.prototype.filter.call(
siblingRoot?.children ?? [],
(c) => c.tagName === node.tagName,
);
if (sameTag.length > 1) {
part += `:nth-of-type(${sameTag.indexOf(node) + 1})`;
}
parts.unshift(part);
// `parentElement` is null at a shadow boundary, which would end the walk
// there and emit a path rooted inside the component — `div`, say, which
// Playwright's shadow-piercing CSS then matches all over the page.
// Continuing through the host keeps the path anchored in the light DOM.
node = ariaParent(node);
}
// The walk stops before , so an element that *is* body produces no
// segments — and `locator("")` throws at replay. Name the element instead.
if (parts.length === 0) {
return {
kind: "css",
value: el === document.documentElement ? "html" : "body",
};
}
// Anchored at unless the walk already terminated on an id, or on
// itself — `body > html` is not a thing, and neither is
// `body > html > head`. A bare `div:nth-of-type(1) > div` is a pattern, not
// a path: it matches anywhere that shape recurs, so Playwright's strict mode
// fails the step or picks the wrong element. `>` from body keeps it a single
// rooted chain.
if (parts[0] !== "html" && !parts[0].startsWith("#")) {
parts.unshift("body");
}
return { kind: "css", value: parts.join(" > ") };
}
/**
* Find the source location Dyad's development tagger attached to this DOM
* node, or to its nearest tagged ancestor when a component rendered the
* concrete control internally. This is agent navigation metadata only — the
* generated Playwright test never locates by `data-dyad-id`.
*/
function sourceHintFor(el) {
// Keep these bounds aligned with LocatorSourceHintSchema. The suffix can
// contain two MAX_SAFE_INTEGER values in addition to the 2,048-char path.
const MAX_SOURCE_HINT_PATH_LENGTH = 2048;
const MAX_SOURCE_HINT_ATTRIBUTE_LENGTH =
MAX_SOURCE_HINT_PATH_LENGTH +
2 * String(Number.MAX_SAFE_INTEGER).length +
2;
const unsafeSourceHintText = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
const isRelativeInAppSourcePath = (value) =>
!/^[\\/]/.test(value) &&
!/^[a-z][a-z0-9+.-]*:/i.test(value) &&
!/(^|[\\/])\.\.([\\/]|$)/.test(value);
let source = el;
while (source && source.nodeType === 1) {
const raw = source.getAttribute && source.getAttribute("data-dyad-id");
if (
raw &&
raw.length <= MAX_SOURCE_HINT_ATTRIBUTE_LENGTH &&
!unsafeSourceHintText.test(raw)
) {
// Paths may themselves contain colons (notably a Windows drive), so
// take the final two colon-delimited integers as line and column.
const match = /^(.*):(\d+):(\d+)$/.exec(raw);
const line = match ? Number(match[2]) : NaN;
const column = match ? Number(match[3]) : NaN;
if (
match &&
match[1] &&
match[1].length <= MAX_SOURCE_HINT_PATH_LENGTH &&
isRelativeInAppSourcePath(match[1]) &&
Number.isSafeInteger(line) &&
line > 0 &&
Number.isSafeInteger(column) &&
column >= 0
) {
const tagName = String(el.tagName || "").toLowerCase();
if (/^[a-z][a-z0-9-]*$/i.test(tagName)) {
const hint = {
relativePath: match[1],
line,
column,
tagName,
exact: source === el,
};
const inputType =
tagName === "input" && el.getAttribute
? el.getAttribute("type")
: null;
if (
inputType &&
inputType.length <= 128 &&
!unsafeSourceHintText.test(inputType)
) {
hint.inputType = inputType;
}
return hint;
}
}
}
source = ariaParent(source);
}
return null;
}
/**
* The highest-priority candidate that uniquely matches `el`; else the
* highest-priority one disambiguated by an nth index; else a CSS-path fallback.
*
* Candidates are resolved lazily and the loop returns on the first unique
* match, so an element carrying a `data-testid` never pays for the
* whole-document role scan behind it.
*/
function selectorFor(el) {
beginScan();
try {
const candidates = buildCandidates(el);
const resolved = [];
for (const descriptor of candidates) {
const matches = queryAll(descriptor);
if (!matches.includes(el)) continue;
if (matches.length === 1) return descriptor;
resolved.push({ descriptor, matches });
}
for (const { descriptor, matches } of resolved) {
if (matches.length <= 20) {
return { ...descriptor, nth: matches.indexOf(el) };
}
}
const descriptor = cssPathDescriptor(el);
const sourceHint = sourceHintFor(el);
return sourceHint ? { ...descriptor, sourceHint } : descriptor;
} finally {
endScan();
}
}
// Typing is the only genuinely hot path: `input` fires per keystroke and the
// element doesn't move between them, so reuse its descriptor instead of
// re-sweeping the document each time. Only an uninterrupted run of `input` on
// the same element reuses it — any other interaction can have changed the DOM
// enough to make the cached locator ambiguous, so those clear it.
let lastFill = { el: null, locator: null };
function clearFillLocator() {
lastFill = { el: null, locator: null };
}
function fillLocatorFor(el) {
if (lastFill.el === el && el.isConnected) return lastFill.locator;
const locator = selectorFor(el);
lastFill = { el, locator };
return locator;
}
/* ---------- emit / dedupe -------------------------------------------- */
function emit(action) {
const key = JSON.stringify(action);
const now = Date.now();
if (key === lastEmit.key && now - lastEmit.at < DEDUPE_MS) return;
lastEmit = { key, at: now };
window.parent.postMessage({ type: "dyad-recorder-action", action }, "*");
}
/* ---------- key handling --------------------------------------------- */
function keyCombo(e) {
const mods = [];
if (e.ctrlKey) mods.push("Control");
if (e.metaKey) mods.push("Meta");
if (e.altKey) mods.push("Alt");
if (e.shiftKey) mods.push("Shift");
let key = e.key;
if (key && key.length === 1) key = key.toUpperCase();
return [...mods, key].join("+");
}
/**
* Controls the browser activates by dispatching its own trusted `click` when
* Enter is pressed on them. `onClick` records that click, so recording the
* press as well replays the action twice — and for a link or a submit button
* the press navigates first, leaving the duplicate click to run against the
* destination document, where it usually fails outright.
*
* NATIVE elements only. `role="button"` is a promise about semantics, not
* behaviour: nothing dispatches a click for it, so the page's own keydown
* handler is the whole interaction. Suppressing the press there would drop
* the action from the recording entirely rather than deduplicate it.
*/
function activatesOnEnter(el) {
if (!el || el.nodeType !== 1) return false;
if (el.tagName === "BUTTON" || el.tagName === "SUMMARY") return true;
if (el.tagName === "A") return el.hasAttribute("href");
if (el.tagName === "INPUT") {
const type = (el.getAttribute("type") || "text").toLowerCase();
return ["submit", "button", "reset", "image"].includes(type);
}
return false;
}
/**
* Elements the browser itself acts on when Space is pressed, producing
* something the recorder already captures — a synthesized click, a toggle, a
* typed character, a native picker.
*
* The mirror of `activatesOnEnter`, and deliberately not the same list: an
* anchor activates on Enter but NOT on Space. So `` is a custom control as far as Space is concerned, however
* native its tag — the required Space behaviour is entirely the page's own
* keydown handler, and a press left unrecorded there vanishes from replay
* with nothing else standing in for it.
*/
function activatesOnSpace(el) {
if (!el || el.nodeType !== 1) return false;
// Space types a character, which the resulting `fill` carries.
if (el.isContentEditable || el.tagName === "TEXTAREA") return true;
// BUTTON/SUMMARY activate; SELECT opens its picker, and whatever is chosen
// arrives as a `select`. Every INPUT type either acts on Space (checkbox,
// radio, the button types, file) or types into it.
return ["BUTTON", "SUMMARY", "SELECT", "INPUT"].includes(el.tagName);
}
function shouldRecordPress(e) {
if (["Control", "Meta", "Alt", "Shift"].includes(e.key)) return false;
const hasNonShiftModifier = e.ctrlKey || e.metaKey || e.altKey;
if (hasNonShiftModifier) return true;
if (e.key === " " || e.key === "Spacebar") {
const t = deepTarget(e);
// Browsers synthesize the useful click/change for the controls above, but
// custom ARIA controls rely on the app's key handler. Record that Space
// press or keyboard-only interactions disappear from replay entirely.
if (!t || activatesOnSpace(t)) return false;
return ["button", "checkbox", "radio", "switch", "menuitem"].includes(
computeRole(t),
);
}
if (NAV_KEYS.has(e.key)) {
if (e.key === "Enter") {
const t = deepTarget(e);
// Enter inside multi-line editors inserts a newline — that is captured
// by the resulting `fill`, so don't also record it as a press.
if (t && (t.tagName === "TEXTAREA" || t.isContentEditable))
return false;
if (activatesOnEnter(t)) return false;
return true;
}
// Arrows that drive a native control are already recorded as the value
// action the resulting `change` produces. Keeping the press too would
// replay the change twice.
//
// Arrow keys only. Escape is in NAV_KEYS as well, and it changes nothing
// on these controls — so no `change` follows to stand in for the press,
// and suppressing it would drop the interaction from the recording
// entirely.
if (e.key.startsWith("Arrow")) {
const raw = deepTarget(e);
const control = resolveControl(raw) || raw;
if (changesValueOnArrowKeys(control, e.key)) return false;
}
return true;
}
return false;
}
/* ---------- event handlers ------------------------------------------- */
function onClick(e) {
if (!active || !trustedOk(e) || isOverlayEvent(e)) return;
// `detail` is the browser's own click counter for the gesture in progress.
// The 2nd (and later) click of a double-click is reported by the `dblclick`
// that follows, so dropping them here leaves exactly one click ahead of it
// — which is what lets `collapseActions` merge without guessing from
// timestamps it only sees after a postMessage hop.
if (typeof e.detail === "number" && e.detail >= 2) return;
const raw = deepTarget(e);
// The implicit submit an Enter press just triggered. A click the browser
// synthesises carries `detail === 0`, and this one lands somewhere other
// than where Enter was pressed — that pair is what tells it apart from
// Enter pressed directly on a button, whose press is suppressed instead so
// that click is the only record of the action.
if (
e.detail === 0 &&
lastEnterPress.target &&
lastEnterPress.target !== raw &&
Date.now() - lastEnterPress.at < IMPLICIT_SUBMIT_MS
) {
return;
}
clearFillLocator();
const control = resolveControl(raw);
if (control) {
// Seen before any reveal toggle can flip it to type="text".
notePasswordField(control);
// Form controls are recorded from `change` (toggles, selects) or `input`
// (text); skip the click so we don't double-record.
if (recordsWithoutClick(control)) {
return;
}
}
// Emitted now, not after a debounce: a click on a link unloads this document
// within milliseconds and a stalled click would go with it. The click
// preceding a double-click is folded in by the renderer's `collapseActions`.
// A label click dispatches a second trusted click on its associated
// click-driven control. Targeting the control here makes both emissions
// identical, so `emit` deduplicates the browser-generated second click.
const target = control || retarget(raw);
emit({ kind: "click", locator: selectorFor(target) });
}
function onDblClick(e) {
if (!active || !trustedOk(e) || isOverlayEvent(e)) return;
const raw = deepTarget(e);
const control = resolveControl(raw);
// Each click composing the gesture already fired `change` on these, so both
// are recorded (a double-clicked checkbox emits `check` then `uncheck`) and
// there is no leading `click` for `collapseActions` to absorb. Emitting the
// double-click as well would replay four activations for the user's two.
if (control && recordsWithoutClick(control)) return;
// Match the leading-click target for label-activated click controls so the
// renderer can collapse click + dblclick into one double-click action.
const target = control || retarget(raw);
emit({ kind: "dblclick", locator: selectorFor(target) });
}
function onInput(e) {
if (!active || !trustedOk(e)) return;
const t = deepTarget(e);
if (!t || t.nodeType !== 1) return;
if (t.tagName === "SELECT") return; // handled by `change`
if (!isEditable(t)) return;
if (isSecretField(t)) {
// Redact at capture time so the plaintext never leaves the iframe.
emit({
kind: "fill",
locator: fillLocatorFor(t),
value: PASSWORD_PLACEHOLDER,
});
return;
}
const value = t.isContentEditable ? t.innerText : t.value;
emit({
kind: "fill",
locator: fillLocatorFor(t),
value: value == null ? "" : value,
});
}
function onChange(e) {
if (!active || !trustedOk(e)) return;
clearFillLocator();
const t = deepTarget(e);
if (!t || t.nodeType !== 1) return;
if (t.tagName === "SELECT") {
const values = [];
const options = t.options || [];
for (let i = 0; i < options.length; i++) {
if (options[i].selected) values.push(options[i].value);
}
emit({ kind: "select", locator: selectorFor(t), values });
return;
}
if (isValuePicker(t)) {
emit({
kind: "fill",
locator: fillLocatorFor(t),
value: t.value == null ? "" : t.value,
});
return;
}
if (isCheckboxOrRadio(t)) {
// `change` fires after the toggle, so `checked` is the final state.
const checked = t.tagName === "INPUT" ? t.checked : false;
emit({ kind: checked ? "check" : "uncheck", locator: selectorFor(t) });
}
}
function onKeyDown(e) {
if (!active || !trustedOk(e)) return;
if (isOverlayEvent(e)) return;
if (!shouldRecordPress(e)) return;
clearFillLocator();
const raw = deepTarget(e);
// Noted before emitting, so the implicit-submit click this may be about to
// trigger can recognise itself as already recorded. Only Enter submits a
// form implicitly.
if (e.key === "Enter") lastEnterPress = { target: raw, at: Date.now() };
const target = retarget(raw);
// Keys fired with nothing focused target , which has no meaningful
// locator; record those as a page-level `page.keyboard.press`.
if (
!target ||
target === document.body ||
target === document.documentElement
) {
emit({ kind: "press", key: keyCombo(e) });
return;
}
emit({ kind: "press", locator: selectorFor(target), key: keyCombo(e) });
}
/* ---------- hover highlight ------------------------------------------ */
function ensureHoverBox() {
if (hoverBox && hoverBox.isConnected) return hoverBox;
hoverBox = document.createElement("div");
hoverBox.className = OVERLAY_CLASS;
css(hoverBox, {
position: "fixed",
pointerEvents: "none",
zIndex: "2147483647",
border: "2px solid #a855f7",
background: "rgba(168,85,247,0.08)",
borderRadius: "3px",
display: "none",
});
(document.body || document.documentElement).appendChild(hoverBox);
return hoverBox;
}
function hideHover() {
if (hoverBox) hoverBox.style.display = "none";
}
/**
* Take the highlight out of the document entirely, rather than just hiding
* it. Hiding is right between pointer moves; this runs when recording stops,
* and this script's whole contract is that the app is left exactly as it
* would have been without it.
*/
function removeHoverBox() {
if (hoverBox && hoverBox.parentNode) {
hoverBox.parentNode.removeChild(hoverBox);
}
hoverBox = null;
}
function onMouseMove(e) {
if (!active || isOverlayEvent(e)) return;
const raw = deepTarget(e);
const target = resolveControl(raw) || retarget(raw);
notePasswordField(target);
if (!target || target.nodeType !== 1 || !target.getBoundingClientRect) {
hideHover();
return;
}
const rect = target.getBoundingClientRect();
const box = ensureHoverBox();
css(box, {
display: "block",
top: `${rect.top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
});
}
/* ---------- activation ----------------------------------------------- */
function activate() {
if (active) return;
active = true;
watchPasswordReveals();
document.addEventListener("click", onClick, true);
document.addEventListener("dblclick", onDblClick, true);
document.addEventListener("input", onInput, true);
document.addEventListener("change", onChange, true);
document.addEventListener("keydown", onKeyDown, true);
document.addEventListener("mousemove", onMouseMove, true);
}
function deactivate() {
if (!active) return;
active = false;
stopWatchingPasswordReveals();
clearFillLocator();
document.removeEventListener("click", onClick, true);
document.removeEventListener("dblclick", onDblClick, true);
document.removeEventListener("input", onInput, true);
document.removeEventListener("change", onChange, true);
document.removeEventListener("keydown", onKeyDown, true);
document.removeEventListener("mousemove", onMouseMove, true);
removeHoverBox();
}
/* ---------- message bridge ------------------------------------------- */
// Same reasoning as the auth bootstrap's guard: `e.source === window.parent`
// is trivially true for a page's own scripts when this document is top-level
// (opening the preview URL in a real browser), and arming the recorder there
// would stream everything the user types — `fill` values included — to
// whoever asked. This script only has a job inside Dyad's preview frame.
const isFramed = window.parent !== window;
window.addEventListener("message", (e) => {
if (!isFramed) return;
if (e.source !== window.parent) return;
if (!recorderToken || e.data?.token !== recorderToken) return;
const type = e.data.type;
if (type === "activate-dyad-recorder") activate();
else if (type === "deactivate-dyad-recorder") deactivate();
else if (
type === "flush-dyad-recorder" &&
typeof e.data.requestId === "string"
) {
window.parent.postMessage(
{ type: "dyad-recorder-flushed", requestId: e.data.requestId },
"*",
);
}
});
function init() {
window.parent.postMessage({ type: "dyad-recorder-initialized" }, "*");
console.debug("Dyad recorder client initialized");
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();