/**
* Selector Generator - 选择器生成器
* 为 DOM 元素生成多个候选选择器
*/
import type {
NonEmptyArray,
NormalizedSelectorGenerationOptions,
SelectorCandidate,
SelectorGenerationOptions,
SelectorStrategy,
SelectorStrategyContext,
SelectorTarget,
ExtendedSelectorTarget,
} from './types';
import { compareSelectorCandidates, withStability } from './stability';
import { DEFAULT_SELECTOR_STRATEGIES } from './strategies';
import { computeDomPath } from './dom-path';
import { computeFingerprint } from './fingerprint';
const DEFAULT_MAX_CANDIDATES = 8;
const DEFAULT_TEXT_MAX_LENGTH = 64;
const DEFAULT_TEXT_TAGS = ['button', 'a', 'summary'] as const;
const DEFAULT_TESTID_ATTRS = [
'data-testid',
'data-test-id',
'data-testId',
'data-test',
'data-qa',
'data-cy',
'name',
'title',
'alt',
] as const;
function clampInt(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, Math.floor(value)));
}
/**
* 标准化选择器生成选项
*/
export function normalizeSelectorGenerationOptions(
options: SelectorGenerationOptions | undefined,
): NormalizedSelectorGenerationOptions {
return {
maxCandidates: clampInt(options?.maxCandidates ?? DEFAULT_MAX_CANDIDATES, 1, 50),
includeText: options?.includeText ?? true,
includeAria: options?.includeAria ?? true,
includeCssUnique: options?.includeCssUnique ?? true,
includeCssPath: options?.includeCssPath ?? true,
testIdAttributes: options?.testIdAttributes ?? DEFAULT_TESTID_ATTRS,
textMaxLength: clampInt(options?.textMaxLength ?? DEFAULT_TEXT_MAX_LENGTH, 1, 256),
textTags: options?.textTags ?? DEFAULT_TEXT_TAGS,
};
}
/**
* CSS 字符串转义
* Uses native CSS.escape when available; otherwise falls back to a spec-inspired polyfill.
*/
export function cssEscape(value: string): string {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(value);
const str = String(value);
const len = str.length;
if (len === 0) return '';
let result = '';
const firstCodeUnit = str.charCodeAt(0);
for (let i = 0; i < len; i++) {
const codeUnit = str.charCodeAt(i);
if (codeUnit === 0x0000) {
result += '\uFFFD';
continue;
}
if (
(codeUnit >= 0x0001 && codeUnit <= 0x001f) ||
codeUnit === 0x007f ||
(i === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
(i === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit === 0x002d)
) {
result += `\\${codeUnit.toString(16)} `;
continue;
}
if (i === 0 && len === 1 && codeUnit === 0x002d) {
result += `\\${str.charAt(i)}`;
continue;
}
const isAsciiAlnum =
(codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
(codeUnit >= 0x0041 && codeUnit <= 0x005a) ||
(codeUnit >= 0x0061 && codeUnit <= 0x007a);
const isSafe = isAsciiAlnum || codeUnit === 0x002d || codeUnit === 0x005f;
if (isSafe) result += str.charAt(i);
else result += `\\${str.charAt(i)}`;
}
return result;
}
function getQueryRoot(element: Element): ParentNode {
const root = element.getRootNode?.();
if (root instanceof ShadowRoot) return root;
if (typeof document !== 'undefined') return document;
throw new Error('Selector generator requires a DOM-like environment');
}
function safeQueryAll(root: ParentNode, selector: string): ReadonlyArray<Element> {
try {
return Array.from(root.querySelectorAll(selector));
} catch {
return [];
}
}
function isUnique(root: ParentNode, selector: string): boolean {
try {
return root.querySelectorAll(selector).length === 1;
} catch {
return false;
}
}
function candidateKey(c: SelectorCandidate): string {
switch (c.type) {
case 'text':
return `text:${c.value}:${c.tagNameHint ?? ''}:${c.match ?? ''}`;
case 'aria':
return `aria:${c.role ?? ''}:${c.name ?? ''}:${c.value}`;
default:
return `${c.type}:${c.value}`;
}
}
export interface GenerateSelectorTargetOptions extends SelectorGenerationOptions {
root?: ParentNode;
strategies?: ReadonlyArray<SelectorStrategy>;
}
/**
* 为 DOM 元素生成选择器目标
*/
export function generateSelectorTarget(
element: Element,
options: GenerateSelectorTargetOptions = {},
): SelectorTarget {
const normalized = normalizeSelectorGenerationOptions(options);
const root = options.root ?? getQueryRoot(element);
const helpers = {
cssEscape,
isUnique: (selector: string) => isUnique(root, selector),
safeQueryAll: (selector: string) => safeQueryAll(root, selector),
};
const ctx: SelectorStrategyContext = {
element,
root,
options: normalized,
helpers,
};
const strategies = options.strategies ?? DEFAULT_SELECTOR_STRATEGIES;
const raw: SelectorCandidate[] = [];
for (const strategy of strategies) {
const produced = strategy.generate(ctx);
for (const c0 of produced) {
raw.push({
...c0,
source: c0.source ?? 'generated',
strategy: c0.strategy ?? strategy.id,
});
}
}
// Dedupe (keep first occurrence)
const seen = new Set<string>();
const deduped: SelectorCandidate[] = [];
for (const c of raw) {
const key = candidateKey(c);
if (seen.has(key)) continue;
seen.add(key);
deduped.push(withStability(c));
}
// If strategies produced nothing (shouldn't happen), create a minimal fallback.
if (deduped.length === 0) {
const fallback: SelectorCandidate = withStability({
type: 'css',
value: 'body',
source: 'generated',
strategy: 'fallback',
});
const candidates: NonEmptyArray<SelectorCandidate> = [fallback];
return {
selector: fallback.value,
candidates,
tagName: element.tagName?.toLowerCase?.() ?? undefined,
};
}
// Sort and truncate
const sorted = [...deduped].sort(compareSelectorCandidates).slice(0, normalized.maxCandidates);
// Primary selector should be directly usable by locator (prefer CSS/attr)
const primary = sorted.find((c) => c.type === 'css' || c.type === 'attr') ?? sorted[0];
const reordered = (() => {
const idx = sorted.indexOf(primary);
if (idx <= 0) return sorted;
return [primary, ...sorted.slice(0, idx), ...sorted.slice(idx + 1)];
})();
const tagName = element.tagName?.toLowerCase?.() ?? undefined;
return {
selector: primary.value,
candidates: reordered as NonEmptyArray<SelectorCandidate>,
tagName,
};
}
// =============================================================================
// Extended Selector Target (Phase 1.2)
// =============================================================================
function safeMatches(element: Element, selector: string): boolean {
try {
return element.matches(selector);
} catch {
return false;
}
}
/**
* Pick the best selector for a shadow host element.
* Prefers unique CSS/attr selectors from the generated candidates.
*/
function pickShadowHostSelector(
host: Element,
hostRoot: ParentNode,
options: GenerateSelectorTargetOptions,
): string | null {
const hostTarget = generateSelectorTarget(host, { ...options, root: hostRoot });
let fallback: string | null = null;
// Try to find a unique selector from candidates
for (const candidate of hostTarget.candidates) {
if (candidate.type !== 'css' && candidate.type !== 'attr') continue;
const selector = String(candidate.value || '').trim();
if (!selector) continue;
// Verify the selector actually matches the host
if (!safeMatches(host, selector)) continue;
// Check uniqueness in the host's root
if (isUnique(hostRoot, selector)) {
return selector;
}
// Keep first matching selector as fallback
if (!fallback) {
fallback = selector;
}
}
// Try the primary selector
const primary = typeof hostTarget.selector === 'string' ? hostTarget.selector.trim() : '';
if (primary && safeMatches(host, primary)) {
return primary;
}
return fallback;
}
/**
* Compute shadow host selector chain (outer -> inner).
*
* Returns an empty array when:
* - Element is not inside Shadow DOM
* - A host selector cannot be generated for any boundary
*/
function computeShadowHostChain(
element: Element,
options: GenerateSelectorTargetOptions,
): string[] {
const chain: string[] = [];
let current: Element = element;
while (true) {
const rootNode = current.getRootNode?.();
if (!(rootNode instanceof ShadowRoot)) {
break;
}
const host = rootNode.host;
if (!(host instanceof Element)) {
break;
}
const hostRoot = getQueryRoot(host);
const hostSelector = pickShadowHostSelector(host, hostRoot, options);
if (!hostSelector) {
// Cannot generate selector for this host, return empty chain
return [];
}
chain.unshift(hostSelector);
current = host;
}
return chain;
}
/**
* Generate selector target with additional metadata (Phase 1.2).
*
* This function generates a complete ElementLocator-like structure including:
* - fingerprint: for fuzzy element matching
* - domPath: for fast element recovery
* - shadowHostChain: for Shadow DOM traversal
*
* @example
* ```ts
* const target = generateExtendedSelectorTarget(buttonElement);
* // target.fingerprint = "button|id=submit|class=btn.primary"
* // target.domPath = [0, 2, 1]
* // target.shadowHostChain = ["my-component"] or []
* ```
*/
export function generateExtendedSelectorTarget(
element: Element,
options: GenerateSelectorTargetOptions = {},
): ExtendedSelectorTarget {
const base = generateSelectorTarget(element, options);
return {
...base,
fingerprint: computeFingerprint(element),
domPath: computeDomPath(element),
shadowHostChain: computeShadowHostChain(element, options),
};
}