/** * Selector Stability - 选择器稳定性评估 */ import type { SelectorCandidate, SelectorStability, SelectorStabilitySignals, SelectorType, } from './types'; import { splitCompositeSelector } from './types'; const TESTID_ATTR_NAMES = [ 'data-testid', 'data-test-id', 'data-test', 'data-qa', 'data-cy', ] as const; function clamp01(n: number): number { if (!Number.isFinite(n)) return 0; return Math.min(1, Math.max(0, n)); } function mergeSignals( a: SelectorStabilitySignals, b: SelectorStabilitySignals, ): SelectorStabilitySignals { return { usesId: a.usesId || b.usesId || undefined, usesTestId: a.usesTestId || b.usesTestId || undefined, usesAria: a.usesAria || b.usesAria || undefined, usesText: a.usesText || b.usesText || undefined, usesNthOfType: a.usesNthOfType || b.usesNthOfType || undefined, usesAttributes: a.usesAttributes || b.usesAttributes || undefined, usesClass: a.usesClass || b.usesClass || undefined, }; } function analyzeCssLike(selector: string): SelectorStabilitySignals { const s = String(selector || ''); const usesNthOfType = /:nth-of-type\(/i.test(s); const usesAttributes = /\[[^\]]+\]/.test(s); const usesAria = /\[\s*aria-[^=]+\s*=|\[\s*role\s*=|\brole\s*=\s*/i.test(s); // Avoid counting `#` inside attribute values (e.g. href="#...") by requiring a token-ish pattern. const usesId = /(^|[\s>+~])#[^\s>+~.:#[]+/.test(s); const usesClass = /(^|[\s>+~])\.[^\s>+~.:#[]+/.test(s); const lower = s.toLowerCase(); const usesTestId = TESTID_ATTR_NAMES.some((a) => lower.includes(`[${a}`)); return { usesId: usesId || undefined, usesTestId: usesTestId || undefined, usesAria: usesAria || undefined, usesNthOfType: usesNthOfType || undefined, usesAttributes: usesAttributes || undefined, usesClass: usesClass || undefined, }; } function baseScoreForCssSignals(signals: SelectorStabilitySignals): number { if (signals.usesTestId) return 0.95; if (signals.usesId) return 0.9; if (signals.usesAria) return 0.8; if (signals.usesAttributes) return 0.75; if (signals.usesClass) return 0.65; return 0.5; } function lengthPenalty(value: string): number { const len = value.length; if (len <= 60) return 0; if (len <= 120) return 0.05; if (len <= 200) return 0.1; return 0.18; } /** * 计算选择器稳定性评分 */ export function computeSelectorStability(candidate: SelectorCandidate): SelectorStability { if (candidate.type === 'css' || candidate.type === 'attr') { const composite = splitCompositeSelector(candidate.value); if (composite) { const a = analyzeCssLike(composite.frameSelector); const b = analyzeCssLike(composite.innerSelector); const merged = mergeSignals(a, b); let score = baseScoreForCssSignals(merged); score -= 0.05; // iframe coupling penalty if (merged.usesNthOfType) score -= 0.2; score -= lengthPenalty(candidate.value); return { score: clamp01(score), signals: merged, note: 'composite' }; } const signals = analyzeCssLike(candidate.value); let score = baseScoreForCssSignals(signals); if (signals.usesNthOfType) score -= 0.2; score -= lengthPenalty(candidate.value); return { score: clamp01(score), signals }; } if (candidate.type === 'xpath') { const s = String(candidate.value || ''); const signals: SelectorStabilitySignals = { usesAttributes: /@[\w-]+\s*=/.test(s) || undefined, usesId: /@id\s*=/.test(s) || undefined, usesTestId: /@data-testid\s*=/.test(s) || undefined, }; let score = 0.42; if (signals.usesTestId) score = 0.85; else if (signals.usesId) score = 0.75; else if (signals.usesAttributes) score = 0.55; score -= lengthPenalty(s); return { score: clamp01(score), signals }; } if (candidate.type === 'aria') { const hasName = typeof candidate.name === 'string' && candidate.name.trim().length > 0; const hasRole = typeof candidate.role === 'string' && candidate.role.trim().length > 0; const signals: SelectorStabilitySignals = { usesAria: true }; let score = hasName && hasRole ? 0.8 : hasName ? 0.72 : 0.6; score -= lengthPenalty(candidate.value); return { score: clamp01(score), signals }; } // text const text = String(candidate.value || '').trim(); const signals: SelectorStabilitySignals = { usesText: true }; let score = 0.35; // Very short texts tend to be ambiguous; very long texts are unstable. if (text.length >= 6 && text.length <= 48) score = 0.45; if (text.length > 80) score = 0.3; return { score: clamp01(score), signals }; } /** * 为选择器候选添加稳定性评分 */ export function withStability(candidate: SelectorCandidate): SelectorCandidate { if (candidate.stability) return candidate; return { ...candidate, stability: computeSelectorStability(candidate) }; } function typePriority(type: SelectorType): number { switch (type) { case 'attr': return 5; case 'css': return 4; case 'aria': return 3; case 'xpath': return 2; case 'text': return 1; default: return 0; } } /** * 比较两个选择器候选的优先级 * 返回负数表示 a 优先,正数表示 b 优先 */ export function compareSelectorCandidates(a: SelectorCandidate, b: SelectorCandidate): number { // 1. 用户指定的权重优先 const aw = a.weight ?? 0; const bw = b.weight ?? 0; if (aw !== bw) return bw - aw; // 2. 稳定性评分 const as = a.stability?.score ?? computeSelectorStability(a).score; const bs = b.stability?.score ?? computeSelectorStability(b).score; if (as !== bs) return bs - as; // 3. 类型优先级 const ap = typePriority(a.type); const bp = typePriority(b.type); if (ap !== bp) return bp - ap; // 4. 长度(越短越好) const alen = String(a.value || '').length; const blen = String(b.value || '').length; return alen - blen; }