Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 72 additions & 22 deletions recorder.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,34 +44,82 @@ if (!window.__vpRecorderInstalled) {
return selector;
}

function pickSelector(el) {
function nthTagSelector(el) {
const tag = el.tagName.toLowerCase();
if (tag !== "select" && tag !== "input" && tag !== "textarea") return null;
const all = [...document.querySelectorAll(tag)];
const n = all.indexOf(el);
if (n < 0) return null;
return `(//${tag})[${n + 1}]`;
}

function xpathSelector(el) {
const tag = el.tagName.toLowerCase();
const text = el.textContent?.trim();
if (text && text.length <= 80 && !text.includes('"')) {
if (tag === "button" || tag === "a" || tag === "label") {
return `xpath/.//${tag}[normalize-space(.)="${text}"]`;
}
}
const ariaLabel = el.getAttribute("aria-label");
if (ariaLabel && !ariaLabel.includes('"')) {
return `xpath/.//${tag}[@aria-label="${ariaLabel}"]`;
}
return null;
}

function pickSelectors(el) {
if (!el || !(el instanceof Element)) return null;

if (el.id) return `#${CSS.escape(el.id)}`;
const candidates = [];

const stableAttrs = ["data-testid", "data-test", "data-qa", "data-id", "name", "aria-label"];
const add = (sel) => {
if (sel && !candidates.includes(sel)) candidates.push(sel);
};

if (el.id) add(`#${CSS.escape(el.id)}`);

const stableAttrs = ["data-testid", "data-test", "data-qa", "data-id"];
for (const attr of stableAttrs) {
const value = el.getAttribute(attr);
if (value) {
const sel = `${el.tagName.toLowerCase()}[${attr}="${value.replaceAll('"', '\\"')}"]`;
return getUniqueSelector(sel, el);
add(getUniqueSelector(`${el.tagName.toLowerCase()}[${attr}="${value.replaceAll('"', '\\"')}"]`, el));
}
}

const ariaLabel = el.getAttribute("aria-label");
if (ariaLabel) {
add(getUniqueSelector(`${el.tagName.toLowerCase()}[aria-label="${ariaLabel.replaceAll('"', '\\"')}"]`, el));
}

const name = el.getAttribute("name");
if (name) {
add(getUniqueSelector(`${el.tagName.toLowerCase()}[name="${name.replaceAll('"', '\\"')}"]`, el));
}

const type = el.getAttribute("type");
if (type && el.tagName === "INPUT") {
const name = el.getAttribute("name");
if (name) return `input[type="${type}"][name="${name.replaceAll('"', '\\"')}"]`;
return getUniqueSelector(`input[type="${type}"]`, el);
if (name) {
add(`input[type="${type}"][name="${name.replaceAll('"', '\\"')}"]`);
} else {
add(getUniqueSelector(`input[type="${type}"]`, el));
}
}

const xpath = xpathSelector(el);
if (xpath) add(xpath);

const nthTag = nthTagSelector(el);
if (nthTag) add(nthTag);

const classes = [...el.classList].slice(0, 2);
if (classes.length > 0) {
const sel = `${el.tagName.toLowerCase()}${classes.map((c) => `.${CSS.escape(c)}`).join("")}`;
return getUniqueSelector(sel, el);
add(getUniqueSelector(`${el.tagName.toLowerCase()}${classes.map((c) => `.${CSS.escape(c)}`).join("")}`, el));
}

return getUniqueSelector(el.tagName.toLowerCase(), el);
add(getUniqueSelector(el.tagName.toLowerCase(), el));

return candidates.length > 0 ? candidates : null;
}

function getLabelText(el) {
Expand Down Expand Up @@ -112,9 +160,11 @@ if (!window.__vpRecorderInstalled) {

if (tag === "input" || tag === "textarea" || tag === "select") {
if (inputType === "checkbox" || inputType === "radio") {
const selectors = pickSelectors(el);
if (!selectors) return;
sendAction({
type: "setChecked",
selector: pickSelector(el),
selectors,
checked: el.checked,
label: getLabelText(el),
timestamp: Date.now(),
Expand All @@ -123,12 +173,12 @@ if (!window.__vpRecorderInstalled) {
return;
}

const selector = pickSelector(el);
if (!selector) return;
const selectors = pickSelectors(el);
if (!selectors) return;

sendAction({
type: "click",
selector,
selectors,
label: getLabelText(el),
timestamp: Date.now(),
});
Expand Down Expand Up @@ -167,12 +217,12 @@ if (!window.__vpRecorderInstalled) {
const oldValue = preFocusValues.get(el);
if (newValue === oldValue) return;

const selector = pickSelector(el);
if (!selector) return;
const selectors = pickSelectors(el);
if (!selectors) return;

sendAction({
type: "setValue",
selector,
selectors,
value: newValue,
label: getLabelText(el),
timestamp: Date.now(),
Expand All @@ -189,14 +239,14 @@ if (!window.__vpRecorderInstalled) {
if (!el || !(el instanceof Element)) return;
if (el.tagName.toLowerCase() !== "select") return;

const selector = pickSelector(el);
if (!selector) return;
const selectors = pickSelectors(el);
if (!selectors) return;

const selectedOption = el.options[el.selectedIndex];

sendAction({
type: "setValue",
selector,
type: "selectValue",
selectors,
value: el.value,
label: selectedOption?.text?.trim().slice(0, 80) || getLabelText(el),
timestamp: Date.now(),
Expand Down
62 changes: 51 additions & 11 deletions script_generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -1708,14 +1708,22 @@ function refreshRecordingButtonState() {
elements.useRecordingActionsDiv.hidden = state.isRecording || state.recordedActions.length === 0;
}

function primarySelector(action) {
if (Array.isArray(action.selectors)) return action.selectors[0] ?? "";
return action.selector ?? "";
}

function actionLabel(action) {
const sel = primarySelector(action);
switch (action.type) {
case "click":
return `Click${action.label ? ` "${action.label}"` : ""} (${action.selector})`;
return `Click${action.label ? ` "${action.label}"` : ""} (${sel})`;
case "setValue":
return `Type "${action.value}"${action.label ? ` into "${action.label}"` : ""} (${action.selector})`;
return `Type "${action.value}"${action.label ? ` into "${action.label}"` : ""} (${sel})`;
case "selectValue":
return `Select "${action.value}"${action.label ? ` ("${action.label}")` : ""} (${sel})`;
case "setChecked":
return `${action.checked ? "Check" : "Uncheck"}${action.label ? ` "${action.label}"` : ""} (${action.selector})`;
return `${action.checked ? "Check" : "Uncheck"}${action.label ? ` "${action.label}"` : ""} (${sel})`;
case "navigate":
return `Navigate to ${action.url}`;
default:
Expand All @@ -1742,6 +1750,11 @@ function renderRecordedActions() {
list.scrollTop = list.scrollHeight;
}

function resolveSelectorsSnippet(selectors) {
const list = JSON.stringify(selectors);
return `(function(){var ss=${list};for(var i=0;i<ss.length;i++){try{var e=document.querySelector(ss[i]);if(e)return e;}catch(e){}}return null;})()`;
}

function convertRecordingToScript(actions) {
if (!actions.length) return "";

Expand All @@ -1758,26 +1771,37 @@ function convertRecordingToScript(actions) {
continue;
}

const selectors = Array.isArray(action.selectors)
? action.selectors.filter((s) => !/^(xpath\/|aria\/|pierce\/|text\/|\(\/\/)/.test(s))
: [action.selector].filter(Boolean);

if (action.type === "click") {
const comment = action.label ? ` // ${action.label.replace(/[\r\n]+/g, " ")}` : "";
lines.push(`document.querySelector(${JSON.stringify(action.selector)})?.click();${comment}`);
lines.push(`${resolveSelectorsSnippet(selectors)}?.click();${comment}`);
continue;
}

if (action.type === "setValue") {
const sel = JSON.stringify(action.selector);
const val = JSON.stringify(action.value);
const comment = action.label ? ` // ${action.label.replace(/[\r\n]+/g, " ")}` : "";
lines.push(`(function() { var el = document.querySelector(${sel});${comment}`);
lines.push(`(function() { var el = ${resolveSelectorsSnippet(selectors)};${comment}`);
lines.push(`if (el) { el.value = ${val}; el.dispatchEvent(new Event('input', {bubbles:true})); el.dispatchEvent(new Event('change', {bubbles:true})); }`);
lines.push(`})();`);
continue;
}

if (action.type === "selectValue") {
const val = JSON.stringify(action.value);
const comment = action.label ? ` // ${action.label.replace(/[\r\n]+/g, " ")}` : "";
lines.push(`(function() { var el = ${resolveSelectorsSnippet(selectors)};${comment}`);
lines.push(`if (el) { el.value = ${val}; el.dispatchEvent(new Event('change', {bubbles:true})); }`);
lines.push(`})();`);
continue;
}

if (action.type === "setChecked") {
const sel = JSON.stringify(action.selector);
const comment = action.label ? ` // ${action.label.replace(/[\r\n]+/g, " ")}` : "";
lines.push(`(function() { var el = document.querySelector(${sel});${comment}`);
lines.push(`(function() { var el = ${resolveSelectorsSnippet(selectors)};${comment}`);
lines.push(`if (el) { el.checked = ${Boolean(action.checked)}; el.dispatchEvent(new Event('change', {bubbles:true})); }`);
lines.push(`})();`);
continue;
Expand All @@ -1798,17 +1822,33 @@ function convertRecordingToPreactions(actions) {
}

if (action.type === "click") {
preactions.push({ click: action.selector });
const cssSelectors = Array.isArray(action.selectors)
? action.selectors.filter((s) => !/^(xpath|aria|pierce|text)\//.test(s))
Comment thread
xiadongdev marked this conversation as resolved.
: null;
preactions.push({ click: cssSelectors && cssSelectors.length > 1 ? cssSelectors : primarySelector(action) });
continue;
}

if (action.type === "setValue") {
preactions.push({ type: { field: action.selector, value: action.value } });
const cssSelectors = Array.isArray(action.selectors)
? action.selectors.filter((s) => !/^(xpath|aria|pierce|text)\//.test(s))
Comment thread
xiadongdev marked this conversation as resolved.
: null;
const field = cssSelectors && cssSelectors.length > 1 ? cssSelectors : primarySelector(action);
preactions.push({ type: { field, value: action.value } });
continue;
}

if (action.type === "selectValue") {
const cssSelectors = Array.isArray(action.selectors)
? action.selectors.filter((s) => !/^(xpath|aria|pierce|text)\//.test(s))
Comment thread
xiadongdev marked this conversation as resolved.
: null;
const field = cssSelectors && cssSelectors.length > 1 ? cssSelectors : primarySelector(action);
preactions.push({ select: { field, value: action.value } });
continue;
}

if (action.type === "setChecked") {
const sel = action.selector.replace(/'/g, "\\'");
const sel = primarySelector(action).replace(/'/g, "\\'");
const checked = Boolean(action.checked);
preactions.push({
script: `(function(){var el=document.querySelector('${sel}');if(el&&el.checked!==${checked}){el.checked=${checked};el.dispatchEvent(new Event('change',{bubbles:true}));}})();`,
Expand Down