src/lib/e2e-export/smart-selector.ts

Total Symbols
14
Lines of Code
246
Avg Complexity
3.3
Avg Coverage
100.0%

File Relationships

graph LR tryDataTestId["tryDataTestId"] escapeCSS["escapeCSS"] tryAriaLabel["tryAriaLabel"] tryRole["tryRole"] tryName["tryName"] tryId["tryId"] tryPlaceholder["tryPlaceholder"] tryClasses["tryClasses"] getStableClasses["getStableClasses"] buildCSSPath["buildCSSPath"] buildFallbackCSS["buildFallbackCSS"] extractSmartSelectors["extractSmartSelectors"] tryDataTestId -->|calls| escapeCSS tryAriaLabel -->|calls| escapeCSS tryRole -->|calls| escapeCSS tryName -->|calls| escapeCSS tryId -->|calls| escapeCSS tryPlaceholder -->|calls| escapeCSS tryClasses -->|calls| getStableClasses buildCSSPath -->|calls| buildFallbackCSS buildFallbackCSS -->|calls| escapeCSS extractSmartSelectors -->|calls| buildFallbackCSS click tryDataTestId "../symbols/3b7a431d1d766645.html" click escapeCSS "../symbols/44646aa96f30257e.html" click tryAriaLabel "../symbols/7a8f51fc769eb549.html" click tryRole "../symbols/9ee401b748dfa32d.html" click tryName "../symbols/e2e47aab259a336f.html" click tryId "../symbols/b3cdceee83420d12.html" click tryPlaceholder "../symbols/1ef71348c5527bf9.html" click tryClasses "../symbols/c3222d6f83c1e82f.html" click getStableClasses "../symbols/ea120245e0b8c28c.html" click buildCSSPath "../symbols/2f2f3878eed47f3f.html" click buildFallbackCSS "../symbols/9e270d9c7136a3da.html" click extractSmartSelectors "../symbols/6f96c46b2e3fc740.html"

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;
}