From 02bf98d57e833d0b0492d3f9cb9fb148f196292b Mon Sep 17 00:00:00 2001 From: Xia Dong Date: Fri, 29 May 2026 20:21:57 -0700 Subject: [PATCH] generate multiple selectors in record actions --- recorder.js | 94 ++++++++++++++++++++++++++++++++++----------- script_generator.js | 62 ++++++++++++++++++++++++------ 2 files changed, 123 insertions(+), 33 deletions(-) diff --git a/recorder.js b/recorder.js index af3ac40..9b82cb3 100644 --- a/recorder.js +++ b/recorder.js @@ -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) { @@ -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(), @@ -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(), }); @@ -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(), @@ -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(), diff --git a/script_generator.js b/script_generator.js index 83a2f70..48854e7 100644 --- a/script_generator.js +++ b/script_generator.js @@ -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: @@ -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 !/^(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; @@ -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)) + : 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)) + : 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)) + : 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}));}})();`,