src/lib/e2e-export/smart-selector.ts
File Relationships
Symbols by Kind
function
14
All Symbols
| Name | Kind | Visibility | Status | Lines | Signature |
|---|---|---|---|---|---|
| escapeCSS | function | - | 19-21 | escapeCSS(value: string): : string |
|
| tryDataTestId | function | - | 23-36 | tryDataTestId(el: Element): : SmartSelector | null |
|
| tryAriaLabel | function | - | 38-60 | tryAriaLabel(el: Element): : SmartSelector | null |
|
| tryRole | function | - | 62-80 | tryRole(el: Element): : SmartSelector | null |
|
| tryName | function | - | 82-92 | tryName(el: Element): : SmartSelector | null |
|
| tryId | function | - | 94-105 | tryId(el: Element): : SmartSelector | null |
|
| tryPlaceholder | function | - | 107-117 | tryPlaceholder(el: Element): : SmartSelector | null |
|
| isStableClass | function | - | 123-134 | isStableClass(cls: string): : boolean |
|
| getStableClasses | function | exported- | 140-142 | getStableClasses(el: Element): : string[] |
|
| tryClasses | function | - | 144-155 | tryClasses(el: Element): : SmartSelector | null |
|
| buildCSSPath | function | exported- | 161-163 | buildCSSPath(el: Element): : string |
|
| buildFallbackCSS | function | - | 165-199 | buildFallbackCSS(el: Element): : SmartSelector |
|
| extractSmartSelectors | function | exported- | 205-233 | extractSmartSelectors(el: Element): : SmartSelector[] |
|
| pickBestSelector | function | exported- | 239-245 | pickBestSelector(
selectors: SmartSelector[] | undefined,
fallbackCSS: string,
): : string |
Full Source
/**
* Smart Selector Extractor
*
* Extracts multiple selector candidates for an element, ordered by
* resilience (most stable first):
* 1. [data-testid] / [data-test-id] / [data-cy]
* 2. aria-label / aria-labelledby
* 3. role + accessible name
* 4. [name] attribute
* 5. #id
* 6. [placeholder]
* 7. Fallback CSS (tag + nth-of-type chain)
*
* Runs in content-script context (DOM access required).
*/
import type { SmartSelector, SelectorStrategy } from "./e2e-export.types";
function escapeCSS(value: string): string {
return CSS.escape(value);
}
function tryDataTestId(el: Element): SmartSelector | null {
const attrs = ["data-testid", "data-test-id", "data-cy", "data-test"];
for (const attr of attrs) {
const val = el.getAttribute(attr);
if (val) {
return {
value: `[${attr}="${escapeCSS(val)}"]`,
strategy: "data-testid",
description: `${attr}="${val}"`,
};
}
}
return null;
}
function tryAriaLabel(el: Element): SmartSelector | null {
const label = el.getAttribute("aria-label");
if (label) {
return {
value: `[aria-label="${escapeCSS(label)}"]`,
strategy: "aria-label",
description: `aria-label="${label}"`,
};
}
const labelledBy = el.getAttribute("aria-labelledby");
if (labelledBy) {
const labelEl = el.ownerDocument.getElementById(labelledBy);
if (labelEl?.textContent?.trim()) {
return {
value: `[aria-labelledby="${escapeCSS(labelledBy)}"]`,
strategy: "aria-label",
description: `aria-labelledby → "${labelEl.textContent.trim()}"`,
};
}
}
return null;
}
function tryRole(el: Element): SmartSelector | null {
const role = el.getAttribute("role");
if (!role) return null;
const name = el.getAttribute("aria-label") ?? el.getAttribute("name") ?? "";
if (name) {
return {
value: `[role="${escapeCSS(role)}"][aria-label="${escapeCSS(name)}"]`,
strategy: "role",
description: `role="${role}" name="${name}"`,
};
}
return {
value: `[role="${escapeCSS(role)}"]`,
strategy: "role",
description: `role="${role}"`,
};
}
function tryName(el: Element): SmartSelector | null {
const name = el.getAttribute("name");
if (!name) return null;
const tag = el.tagName.toLowerCase();
return {
value: `${tag}[name="${escapeCSS(name)}"]`,
strategy: "name",
description: `name="${name}"`,
};
}
function tryId(el: Element): SmartSelector | null {
if (!el.id) return null;
// Skip auto-generated IDs (common patterns: :r0:, react-xxx, ember123, etc.)
if (/^:r\d|^(react|ember|ng-|js-)[\w-]*\d/i.test(el.id)) return null;
return {
value: `#${escapeCSS(el.id)}`,
strategy: "id",
description: `id="${el.id}"`,
};
}
function tryPlaceholder(el: Element): SmartSelector | null {
const placeholder = el.getAttribute("placeholder");
if (!placeholder) return null;
const tag = el.tagName.toLowerCase();
return {
value: `${tag}[placeholder="${escapeCSS(placeholder)}"]`,
strategy: "placeholder",
description: `placeholder="${placeholder}"`,
};
}
/**
* Returns true if a CSS class name looks like a stable, semantic class
* (not generated by CSS Modules, styled-components, emotion, etc.).
*/
function isStableClass(cls: string): boolean {
if (cls.length < 2 || cls.length > 50) return false;
// Skip CSS-in-JS and module-generated prefixes
if (/^(_|css-|sc-|emotion-|jss-|makeStyles)/i.test(cls)) return false;
// Skip all-digit strings
if (/^\d+$/.test(cls)) return false;
// Skip hex-like hashes (e.g. a3b2c1f0)
if (/^[a-f0-9]{6,}$/i.test(cls)) return false;
// Skip long alphanumeric blobs without separators (likely generated)
if (/^[a-z0-9]{20,}$/i.test(cls)) return false;
return true;
}
/**
* Returns up to 3 stable, semantic class names for an element.
* Exported so action-recorder can reuse the same logic.
*/
export function getStableClasses(el: Element): string[] {
return Array.from(el.classList).filter(isStableClass).slice(0, 3);
}
function tryClasses(el: Element): SmartSelector | null {
const stable = getStableClasses(el);
if (stable.length === 0) return null;
const tag = el.tagName.toLowerCase();
const selector = `${tag}.${stable.map((c) => CSS.escape(c)).join(".")}`;
return {
value: selector,
strategy: "classes",
description: `classes: ${stable.join(", ")}`,
};
}
/**
* Returns the CSS path string for an element (the fallback CSS strategy value).
* Exported so other modules (e.g. action-recorder) can use the same path logic.
*/
export function buildCSSPath(el: Element): string {
return buildFallbackCSS(el).value;
}
function buildFallbackCSS(el: Element): SmartSelector {
if (el.id) return { value: `#${escapeCSS(el.id)}`, strategy: "css" };
const parts: string[] = [];
let current: Element | null = el;
while (current && current !== document.body) {
let selector = current.tagName.toLowerCase();
if (current.id) {
parts.unshift(`#${escapeCSS(current.id)}`);
break;
}
const parent: Element | null = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter(
(c: Element) => c.tagName === current!.tagName,
);
if (siblings.length > 1) {
const index = siblings.indexOf(current) + 1;
selector += `:nth-of-type(${index})`;
}
}
parts.unshift(selector);
current = parent;
}
return {
value: parts.join(" > "),
strategy: "selector-path",
description: "DOM selector path",
};
}
/**
* Extracts smart selectors for an element, ordered by priority.
* Returns at least one selector (CSS fallback is always present).
*/
export function extractSmartSelectors(el: Element): SmartSelector[] {
const strategies: Array<(el: Element) => SmartSelector | null> = [
tryDataTestId,
tryAriaLabel,
tryRole,
tryName,
tryId,
tryClasses,
tryPlaceholder,
];
const selectors: SmartSelector[] = [];
const seen = new Set<string>();
for (const strategy of strategies) {
const result = strategy(el);
if (result && !seen.has(result.value)) {
seen.add(result.value);
selectors.push(result);
}
}
const fallback = buildFallbackCSS(el);
if (!seen.has(fallback.value)) {
selectors.push(fallback);
}
return selectors;
}
/**
* Picks the best selector from a SmartSelector array.
* Returns the first one (highest priority) or the raw CSS fallback.
*/
export function pickBestSelector(
selectors: SmartSelector[] | undefined,
fallbackCSS: string,
): string {
if (!selectors || selectors.length === 0) return fallbackCSS;
return selectors[0].value;
}