UNPKG

claude-playwright

Version:

Seamless integration between Claude Code and Playwright MCP for efficient browser automation and testing

1 lines 542 kB
{"version":3,"sources":["../src/utils/project-paths.ts","../src/core/context-aware-similarity.ts","../src/core/smart-normalizer.ts","../src/utils/dom-signature.ts","../src/core/enhanced-cache-key.ts","../src/core/cache-migration.ts","../src/core/bidirectional-cache.ts","../src/core/test-scenario-cache.ts","../src/index.ts","../src/core/session-manager.ts","../src/core/browser-profile.ts","../src/utils/session-helper.ts","../src/utils/mcp-integration.ts","../src/core/protocol-validator.ts","../src/core/protocol-error-recovery.ts","../src/core/protocol-validation-layer.ts","../src/core/tool-naming-strategy.ts","../src/core/progressive-tool-loader.ts","../src/core/performance-monitor.ts","../src/core/feature-flag-manager.ts","../src/core/test-orchestrator.ts","../src/core/validation-reporter.ts"],"sourcesContent":["import path from 'path';\nimport * as fs from 'fs-extra';\n\n/**\n * Centralized project path management\n * Uses project-local .claude-playwright directory instead of user home directory\n */\nexport class ProjectPaths {\n private static projectRoot: string | null = null;\n private static baseDir: string | null = null;\n\n /**\n * Find the project root by looking for package.json, .git, or other markers\n */\n static findProjectRoot(startDir: string = process.cwd()): string {\n if (this.projectRoot) {\n return this.projectRoot;\n }\n\n let currentDir = path.resolve(startDir);\n const root = path.parse(currentDir).root;\n\n while (currentDir !== root) {\n // Look for project markers\n const markers = ['package.json', '.git', '.mcp.json', 'tsconfig.json', 'composer.json'];\n \n for (const marker of markers) {\n try {\n if (fs.pathExistsSync(path.join(currentDir, marker))) {\n this.projectRoot = currentDir;\n return currentDir;\n }\n } catch (error) {\n // Ignore errors and continue searching\n }\n }\n\n currentDir = path.dirname(currentDir);\n }\n\n // Fallback to current working directory if no project root found\n this.projectRoot = process.cwd();\n return this.projectRoot;\n }\n\n /**\n * Get the base .claude-playwright directory (project-local)\n */\n static getBaseDir(): string {\n if (this.baseDir) {\n return this.baseDir;\n }\n\n const projectRoot = this.findProjectRoot();\n this.baseDir = path.join(projectRoot, '.claude-playwright');\n return this.baseDir;\n }\n\n /**\n * Get the cache directory\n */\n static getCacheDir(): string {\n return path.join(this.getBaseDir(), 'cache');\n }\n\n /**\n * Get the sessions directory\n */\n static getSessionsDir(): string {\n return path.join(this.getBaseDir(), 'sessions');\n }\n\n /**\n * Get the profiles directory\n */\n static getProfilesDir(): string {\n return path.join(this.getBaseDir(), 'profiles');\n }\n\n /**\n * Get the logs directory\n */\n static getLogsDir(): string {\n return path.join(this.getBaseDir(), 'logs');\n }\n\n /**\n * Ensure all required directories exist\n */\n static async ensureDirectories(): Promise<void> {\n const dirs = [\n this.getBaseDir(),\n this.getCacheDir(),\n this.getSessionsDir(),\n this.getProfilesDir(),\n this.getLogsDir()\n ];\n\n for (const dir of dirs) {\n await fs.ensureDir(dir);\n }\n }\n\n /**\n * Reset cached paths (for testing)\n */\n static reset(): void {\n this.projectRoot = null;\n this.baseDir = null;\n }\n\n /**\n * Get project-relative path for display purposes\n */\n static getRelativePath(fullPath: string): string {\n const projectRoot = this.findProjectRoot();\n return path.relative(projectRoot, fullPath);\n }\n\n /**\n * Check if we're in a valid project (has project markers)\n */\n static isValidProject(): boolean {\n const projectRoot = this.findProjectRoot();\n const markers = ['package.json', '.git', '.mcp.json', 'tsconfig.json'];\n \n return markers.some(marker => {\n try {\n return fs.pathExistsSync(path.join(projectRoot, marker));\n } catch (error) {\n return false;\n }\n });\n }\n}","import { SmartNormalizer, NormalizationResult } from './smart-normalizer.js';\n\n/**\n * Context information for similarity calculations\n */\nexport interface SimilarityContext {\n /** Current URL for context */\n currentUrl: string;\n \n /** DOM signature for page state */\n domSignature?: string;\n \n /** Browser profile being used */\n profile: string;\n \n /** Whether domains match between contexts */\n domainMatch: boolean;\n \n /** Operation type context */\n operationType?: 'test_search' | 'cache_lookup' | 'pattern_match' | 'cross_env' | 'default';\n \n /** Additional context metadata */\n metadata?: {\n /** Environment type (local, staging, prod) */\n environment?: string;\n \n /** Page type (login, dashboard, settings) */\n pageType?: string;\n \n /** User intent confidence */\n intentConfidence?: number;\n };\n}\n\n/**\n * Context-aware similarity threshold configuration\n * Different use cases require different sensitivity levels\n */\nexport const SIMILARITY_THRESHOLDS = {\n /** Stricter for test matching to prevent false positives */\n test_search: 0.35,\n \n /** Permissive for selector variation tolerance */\n cache_lookup: 0.15,\n \n /** Moderate for pattern recognition */\n pattern_match: 0.25,\n \n /** Very strict for cross-environment matching */\n cross_env: 0.40,\n \n /** Default fallback threshold */\n default: 0.20\n} as const;\n\n/**\n * Action conflict pairs - actions that should never match\n */\nconst ACTION_CONFLICTS: Record<string, string[]> = {\n 'login': ['logout', 'signout', 'disconnect'],\n 'logout': ['login', 'signin', 'connect'],\n 'create': ['delete', 'remove', 'destroy'],\n 'delete': ['create', 'add', 'new'],\n 'open': ['close', 'minimize', 'hide'],\n 'close': ['open', 'maximize', 'show'],\n 'start': ['stop', 'end', 'finish'],\n 'stop': ['start', 'begin', 'resume'],\n 'enable': ['disable', 'deactivate'],\n 'disable': ['enable', 'activate'],\n 'save': ['discard', 'cancel', 'reset'],\n 'cancel': ['save', 'submit', 'confirm']\n};\n\n/**\n * Domain extraction for cross-environment matching\n */\nfunction extractDomain(url: string): string {\n try {\n const urlObj = new URL(url);\n return urlObj.hostname;\n } catch {\n return url.split('/')[0] || url;\n }\n}\n\n/**\n * Enhanced context-aware similarity calculation system\n * Provides intelligent matching with action conflict detection and contextual thresholds\n */\nexport class ContextAwareSimilarity {\n private normalizer: SmartNormalizer;\n\n constructor() {\n this.normalizer = new SmartNormalizer();\n }\n\n /**\n * Calculate context-aware similarity between two texts\n * Returns enhanced similarity score with context considerations\n */\n calculateSimilarity(\n query: string, \n candidate: string, \n context: SimilarityContext\n ): number {\n if (!query || !candidate) return 0;\n\n // Get normalized results for both inputs\n const queryNorm = this.normalizer.normalize(query);\n const candidateNorm = this.normalizer.normalize(candidate);\n\n // Base Jaccard similarity\n let similarity = this.calculateBaseSimilarity(queryNorm, candidateNorm);\n\n // Apply context-aware enhancements\n similarity = this.applyContextEnhancements(similarity, query, candidate, context);\n\n // Apply action-specific logic\n similarity = this.applyActionLogic(similarity, queryNorm, candidateNorm);\n\n // Apply domain matching bonuses\n similarity = this.applyDomainMatching(similarity, context);\n\n // Ensure bounds\n return Math.max(0, Math.min(1, similarity));\n }\n\n /**\n * Check if query has exact action match with candidate\n */\n hasExactActionMatch(query: string, candidate: string): boolean {\n const queryActions = this.extractActions(query);\n const candidateActions = this.extractActions(candidate);\n\n return queryActions.some(qAction => \n candidateActions.some(cAction => qAction === cAction)\n );\n }\n\n /**\n * Check if query has conflicting actions with candidate\n * Returns true if actions should prevent matching\n */\n hasConflictingActions(query: string, candidate: string): boolean {\n const queryActions = this.extractActions(query);\n const candidateActions = this.extractActions(candidate);\n\n for (const qAction of queryActions) {\n const conflicts = ACTION_CONFLICTS[qAction] || [];\n for (const cAction of candidateActions) {\n if (conflicts.includes(cAction)) {\n return true;\n }\n }\n }\n\n return false;\n }\n\n /**\n * Get appropriate threshold for given context\n */\n getThresholdForContext(context: SimilarityContext): number {\n if (context.operationType && context.operationType in SIMILARITY_THRESHOLDS) {\n return SIMILARITY_THRESHOLDS[context.operationType];\n }\n return SIMILARITY_THRESHOLDS.default;\n }\n\n /**\n * Check if similarity meets threshold for context\n */\n meetsThreshold(similarity: number, context: SimilarityContext): boolean {\n const threshold = this.getThresholdForContext(context);\n return similarity >= threshold;\n }\n\n /**\n * Calculate base Jaccard similarity using normalized results\n */\n private calculateBaseSimilarity(\n queryNorm: NormalizationResult, \n candidateNorm: NormalizationResult\n ): number {\n return this.normalizer.calculateSimilarity(queryNorm, candidateNorm);\n }\n\n /**\n * Apply context-aware enhancements to similarity score\n */\n private applyContextEnhancements(\n baseSimilarity: number, \n query: string, \n candidate: string,\n context: SimilarityContext\n ): number {\n let enhanced = baseSimilarity;\n\n // Environment context boost\n if (context.metadata?.environment) {\n if (query.toLowerCase().includes(context.metadata.environment) ||\n candidate.toLowerCase().includes(context.metadata.environment)) {\n enhanced += 0.1;\n }\n }\n\n // Page type context boost\n if (context.metadata?.pageType) {\n if (query.toLowerCase().includes(context.metadata.pageType) ||\n candidate.toLowerCase().includes(context.metadata.pageType)) {\n enhanced += 0.1;\n }\n }\n\n // Intent confidence scaling\n if (context.metadata?.intentConfidence !== undefined) {\n enhanced *= context.metadata.intentConfidence;\n }\n\n // Profile-specific considerations\n if (context.profile !== 'default') {\n // Mobile profile might need different matching\n if (context.profile.includes('mobile') && \n (query.includes('tap') || candidate.includes('tap'))) {\n enhanced += 0.05;\n }\n }\n\n return enhanced;\n }\n\n /**\n * Apply action-specific logic (boosts and conflicts)\n */\n private applyActionLogic(\n similarity: number,\n queryNorm: NormalizationResult,\n candidateNorm: NormalizationResult\n ): number {\n const queryText = queryNorm.normalized;\n const candidateText = candidateNorm.normalized;\n\n // Exact action match boost\n if (this.hasExactActionMatch(queryText, candidateText)) {\n similarity += 0.2;\n }\n\n // Conflicting action penalty\n if (this.hasConflictingActions(queryText, candidateText)) {\n // Severe penalty for conflicting actions\n similarity -= 0.5;\n }\n\n // Semantic action matching (synonyms)\n const actionBoost = this.calculateActionSynonymBoost(queryText, candidateText);\n similarity += actionBoost;\n\n return similarity;\n }\n\n /**\n * Apply domain matching logic for cross-environment scenarios\n */\n private applyDomainMatching(similarity: number, context: SimilarityContext): number {\n if (context.domainMatch) {\n // Same domain - slight boost for consistency\n return similarity + 0.05;\n } else {\n // Different domains - apply cross-environment logic\n const domains = this.extractDomainPatterns(context.currentUrl);\n \n // Common domain patterns get smaller penalty\n if (domains.some(domain => \n ['localhost', 'staging', 'dev', 'test'].some(pattern => \n domain.includes(pattern)))) {\n return similarity - 0.05; // Small penalty for dev environments\n }\n \n // Completely different domains get larger penalty\n return similarity - 0.1;\n }\n }\n\n /**\n * Extract actions from normalized text\n */\n private extractActions(text: string): string[] {\n const actions: string[] = [];\n const normalized = text.toLowerCase();\n \n // Common action patterns\n const actionPatterns = [\n /\\b(click|tap|press|select|choose)\\b/g,\n /\\b(type|enter|input|fill|write)\\b/g,\n /\\b(navigate|go|open|visit|load)\\b/g,\n /\\b(login|signin|authenticate)\\b/g,\n /\\b(logout|signout|disconnect)\\b/g,\n /\\b(create|add|new|make)\\b/g,\n /\\b(delete|remove|destroy|clear)\\b/g,\n /\\b(save|submit|confirm)\\b/g,\n /\\b(cancel|discard|reset)\\b/g,\n /\\b(start|begin|initiate)\\b/g,\n /\\b(stop|end|finish|close)\\b/g,\n /\\b(enable|activate|turn\\s+on)\\b/g,\n /\\b(disable|deactivate|turn\\s+off)\\b/g\n ];\n\n for (const pattern of actionPatterns) {\n const matches = normalized.match(pattern);\n if (matches) {\n actions.push(...matches);\n }\n }\n\n return [...new Set(actions)]; // Remove duplicates\n }\n\n /**\n * Calculate boost for action synonyms\n */\n private calculateActionSynonymBoost(query: string, candidate: string): number {\n const queryActions = this.extractActions(query);\n const candidateActions = this.extractActions(candidate);\n\n let boost = 0;\n\n // Check for synonym matches\n const synonymGroups = [\n ['click', 'tap', 'press', 'select'],\n ['type', 'enter', 'input', 'fill'],\n ['navigate', 'go', 'open', 'visit'],\n ['login', 'signin', 'authenticate'],\n ['logout', 'signout', 'disconnect'],\n ['create', 'add', 'new'],\n ['delete', 'remove', 'destroy'],\n ['save', 'submit', 'confirm'],\n ['cancel', 'discard', 'reset']\n ];\n\n for (const group of synonymGroups) {\n const queryHasSynonym = queryActions.some(action => \n group.some(synonym => action.includes(synonym))\n );\n const candidateHasSynonym = candidateActions.some(action =>\n group.some(synonym => action.includes(synonym))\n );\n\n if (queryHasSynonym && candidateHasSynonym) {\n boost += 0.1;\n }\n }\n\n return Math.min(boost, 0.3); // Cap the synonym boost\n }\n\n /**\n * Extract domain patterns for analysis\n */\n private extractDomainPatterns(url: string): string[] {\n const domain = extractDomain(url);\n const parts = domain.split('.');\n \n return [\n domain,\n ...parts,\n parts.slice(-2).join('.') // TLD + domain\n ];\n }\n\n /**\n * Create similarity context from available information\n */\n static createContext(\n currentUrl: string,\n operationType: 'test_search' | 'cache_lookup' | 'pattern_match' | 'cross_env' | 'default' = 'default',\n options: Partial<SimilarityContext> = {}\n ): SimilarityContext {\n const context: SimilarityContext = {\n currentUrl,\n operationType: operationType as any,\n profile: options.profile || 'default',\n domainMatch: false,\n ...options\n };\n\n // Determine domain match if comparing URLs\n if (options.domSignature && context.currentUrl) {\n const currentDomain = extractDomain(context.currentUrl);\n const contextDomain = extractDomain(options.domSignature);\n context.domainMatch = currentDomain === contextDomain;\n }\n\n return context;\n }\n\n /**\n * Enhanced similarity with automatic context detection\n */\n calculateSimilarityWithAutoContext(\n query: string,\n candidate: string,\n currentUrl: string,\n operationType: 'test_search' | 'cache_lookup' | 'pattern_match' | 'cross_env' | 'default' = 'default'\n ): number {\n const context = ContextAwareSimilarity.createContext(currentUrl, operationType);\n return this.calculateSimilarity(query, candidate, context);\n }\n\n /**\n * Batch similarity calculation with context\n */\n calculateBatchSimilarity(\n query: string,\n candidates: string[],\n context: SimilarityContext\n ): Array<{ candidate: string; similarity: number; meetsThreshold: boolean }> {\n return candidates.map(candidate => {\n const similarity = this.calculateSimilarity(query, candidate, context);\n return {\n candidate,\n similarity,\n meetsThreshold: this.meetsThreshold(similarity, context)\n };\n });\n }\n\n /**\n * Find best matches with context awareness\n */\n findBestMatches(\n query: string,\n candidates: string[],\n context: SimilarityContext,\n maxResults: number = 5\n ): Array<{ candidate: string; similarity: number; rank: number }> {\n const results = this.calculateBatchSimilarity(query, candidates, context)\n .filter(result => result.meetsThreshold)\n .sort((a, b) => b.similarity - a.similarity)\n .slice(0, maxResults);\n\n return results.map((result, index) => ({\n candidate: result.candidate,\n similarity: result.similarity,\n rank: index + 1\n }));\n }\n}\n\n/**\n * Singleton instance for global usage\n */\nexport const contextAwareSimilarity = new ContextAwareSimilarity();","import crypto from 'crypto';\n\ninterface PositionalKeyword {\n word: string;\n position: number;\n context?: string;\n}\n\nexport interface InputFeatures {\n hasId: boolean;\n hasClass: boolean;\n hasQuoted: boolean;\n numbers: string[];\n positions: PositionalKeyword[];\n attributes: string[];\n wordCount: number;\n hasImperative: boolean;\n casePattern: 'lower' | 'upper' | 'mixed' | 'title';\n isNavigation: boolean;\n isFormAction: boolean;\n hasDataTestId: boolean;\n}\n\nexport interface NormalizationResult {\n normalized: string;\n tokens: string[];\n positions: PositionalKeyword[];\n features: InputFeatures;\n hash: string;\n}\n\nexport class SmartNormalizer {\n private readonly POSITION_KEYWORDS = [\n 'before', 'after', 'first', 'last', 'next', 'previous', \n 'above', 'below', 'top', 'bottom', 'left', 'right'\n ];\n \n private readonly RELATION_KEYWORDS = [\n 'in', 'of', 'from', 'to', 'with', 'by', 'for'\n ];\n \n private readonly STOP_WORDS = [\n 'the', 'a', 'an', 'and', 'or', 'but', 'at', 'on'\n ];\n \n private readonly ACTION_SYNONYMS = {\n 'click': ['click', 'press', 'tap', 'hit', 'select', 'choose'],\n 'type': ['type', 'enter', 'input', 'fill', 'write'],\n 'navigate': ['go', 'navigate', 'open', 'visit', 'load'],\n 'hover': ['hover', 'mouseover', 'move']\n };\n\n normalize(input: string): NormalizationResult {\n const original = input.trim();\n \n // STEP 0: Fix common Playwright syntax errors EARLY\n const syntaxFixed = this.fixPlaywrightSyntax(original);\n \n const features = this.extractFeatures(syntaxFixed);\n \n // Step 1: Basic cleanup\n let text = syntaxFixed.toLowerCase();\n \n // Step 2: Extract and preserve quoted strings\n const quotedStrings: string[] = [];\n text = text.replace(/([\"'])((?:(?!\\1)[^\\\\]|\\\\.)*)(\\1)/g, (match, quote, content) => {\n quotedStrings.push(content);\n return `QUOTED_${quotedStrings.length - 1}`;\n });\n \n // Step 3: Extract positional information\n const positions = this.extractPositions(text);\n \n // Step 4: Normalize actions to canonical forms\n text = this.normalizeActions(text);\n \n // Step 5: Remove common patterns\n text = this.removeCommonPatterns(text);\n \n // Step 6: Tokenize and filter\n const words = text.split(/\\s+/).filter(word => word.length > 0);\n const tokens = [];\n const preserved = [];\n \n for (let i = 0; i < words.length; i++) {\n const word = words[i];\n \n if (this.POSITION_KEYWORDS.includes(word)) {\n // Preserve positional keywords with context\n preserved.push({\n word,\n position: i,\n context: words[i + 1] || null\n });\n } else if (!this.STOP_WORDS.includes(word) && \n !this.RELATION_KEYWORDS.includes(word) &&\n !['button', 'field', 'element'].includes(word)) {\n tokens.push(word);\n }\n }\n \n // Step 7: Sort tokens for order-invariance (except preserved)\n tokens.sort();\n \n // Step 8: Build normalized string\n let normalized = tokens.join(' ');\n \n // Step 9: Add positional information\n if (preserved.length > 0) {\n const posInfo = preserved.map(p => \n `${p.word}${p.context ? '-' + p.context : ''}`\n ).join(',');\n normalized += ` _pos:${posInfo}`;\n }\n \n // Step 10: Add quoted content back\n if (quotedStrings.length > 0) {\n normalized += ` _quoted:${quotedStrings.join(',')}`;\n }\n \n const hash = crypto.createHash('md5').update(normalized).digest('hex');\n \n return {\n normalized,\n tokens,\n positions,\n features,\n hash\n };\n }\n\n extractFeatures(input: string): InputFeatures {\n const text = input.toLowerCase();\n \n return {\n hasId: /#[\\w-]+/.test(input),\n hasClass: /\\.[\\w-]+/.test(input),\n hasQuoted: /\"[^\"]+\"|'[^']+'/.test(input),\n numbers: (input.match(/\\d+/g) || []),\n positions: this.extractPositions(text),\n attributes: this.extractAttributes(input),\n wordCount: input.split(/\\s+/).length,\n hasImperative: /^(click|press|tap|select|enter|type|fill)/i.test(input),\n casePattern: this.detectCasePattern(input),\n isNavigation: /^(go|navigate|open|visit)/i.test(input),\n isFormAction: /(submit|enter|fill|type|input)/i.test(input),\n hasDataTestId: /data-test|testid|data-cy/i.test(input)\n };\n }\n\n private extractPositions(text: string): PositionalKeyword[] {\n const positions: PositionalKeyword[] = [];\n const words = text.split(/\\s+/);\n \n for (let i = 0; i < words.length; i++) {\n const word = words[i];\n if (this.POSITION_KEYWORDS.includes(word)) {\n positions.push({\n word,\n position: i,\n context: words[i + 1] || words[i - 1] || undefined\n });\n }\n }\n \n return positions;\n }\n\n private extractAttributes(input: string): string[] {\n const attributes = [];\n \n // Extract common attribute patterns\n const patterns = [\n /\\[([^\\]]+)\\]/g, // [attribute=value]\n /data-[\\w-]+/g, // data-testid\n /aria-[\\w-]+/g, // aria-label\n /role=\"[\\w-]+\"/g, // role=\"button\"\n /type=\"[\\w-]+\"/g, // type=\"submit\"\n /placeholder=\"[^\"]+\"/g // placeholder=\"text\"\n ];\n \n for (const pattern of patterns) {\n const matches = input.match(pattern);\n if (matches) {\n attributes.push(...matches);\n }\n }\n \n return attributes;\n }\n\n private detectCasePattern(input: string): 'lower' | 'upper' | 'mixed' | 'title' {\n const hasLower = /[a-z]/.test(input);\n const hasUpper = /[A-Z]/.test(input);\n \n if (!hasLower && hasUpper) return 'upper';\n if (hasLower && !hasUpper) return 'lower';\n \n // Check if it's title case\n const words = input.split(/\\s+/);\n const isTitleCase = words.every(word => \n /^[A-Z][a-z]*$/.test(word) || /^[a-z]+$/.test(word)\n );\n \n return isTitleCase ? 'title' : 'mixed';\n }\n\n /**\n * Fix common Playwright CSS selector syntax errors early in the process\n */\n private fixPlaywrightSyntax(input: string): string {\n let fixed = input.trim();\n \n // Common syntax fixes\n fixed = fixed\n .replace(/:text\\(/g, ':has-text(') // :text() → :has-text()\n .replace(/\\btext\\(/g, 'text=') // text() → text=\n .replace(/:first\\b/g, ':first-of-type') // :first → :first-of-type\n .replace(/:last\\b/g, ':last-of-type') // :last → :last-of-type\n .replace(/>>(\\s+)first(\\s|$)/g, '>> nth=0') // >> first → >> nth=0\n .replace(/>>(\\s+)last(\\s|$)/g, '>> nth=-1'); // >> last → >> nth=-1\n \n // Log syntax fixes for debugging\n if (fixed !== input) {\n console.error(`[SmartNormalizer] Syntax fixed: \"${input}\" → \"${fixed}\"`);\n }\n \n return fixed;\n }\n\n private normalizeActions(text: string): string {\n for (const [canonical, synonyms] of Object.entries(this.ACTION_SYNONYMS)) {\n for (const synonym of synonyms) {\n const regex = new RegExp(`\\\\b${synonym}\\\\b`, 'g');\n text = text.replace(regex, canonical);\n }\n }\n return text;\n }\n\n private removeCommonPatterns(text: string): string {\n // Remove common prefixes and suffixes\n text = text.replace(/^(click|press|tap)(\\s+on)?(\\s+the)?/i, 'click');\n text = text.replace(/\\s+(button|element|field)$/i, '');\n text = text.replace(/button\\s+/i, '');\n \n // Remove articles and common words\n text = text.replace(/\\b(the|a|an)\\b/g, '');\n \n // Clean up punctuation\n text = text.replace(/[^\\w\\s#._-]/g, ' ');\n \n // Normalize whitespace\n text = text.replace(/\\s+/g, ' ').trim();\n \n return text;\n }\n\n // Utility methods for similarity\n calculateSimilarity(result1: NormalizationResult, result2: NormalizationResult): number {\n // Token-based Jaccard similarity\n const set1 = new Set(result1.tokens);\n const set2 = new Set(result2.tokens);\n const intersection = new Set([...set1].filter(x => set2.has(x)));\n const union = new Set([...set1, ...set2]);\n \n let similarity = intersection.size / union.size;\n \n // Boost for matching quoted strings\n const quoted1 = result1.normalized.match(/_quoted:([^_]*)/)?.[1] || '';\n const quoted2 = result2.normalized.match(/_quoted:([^_]*)/)?.[1] || '';\n if (quoted1 === quoted2 && quoted1.length > 0) {\n similarity += 0.2;\n }\n \n // Penalty for mismatched positions\n const pos1 = result1.normalized.match(/_pos:([^_]*)/)?.[1] || '';\n const pos2 = result2.normalized.match(/_pos:([^_]*)/)?.[1] || '';\n if (pos1 !== pos2 && (pos1.length > 0 || pos2.length > 0)) {\n similarity -= 0.3;\n }\n \n return Math.max(0, Math.min(1, similarity));\n }\n\n // Fuzzy matching for typo tolerance\n damerauLevenshtein(a: string, b: string): number {\n const da: { [key: string]: number } = {};\n const maxdist = a.length + b.length;\n const H: number[][] = [];\n \n H[-1] = [];\n H[-1][-1] = maxdist;\n \n for (let i = 0; i <= a.length; i++) {\n H[i] = [];\n H[i][-1] = maxdist;\n H[i][0] = i;\n }\n \n for (let j = 0; j <= b.length; j++) {\n H[-1][j] = maxdist;\n H[0][j] = j;\n }\n \n for (let i = 1; i <= a.length; i++) {\n let db = 0;\n for (let j = 1; j <= b.length; j++) {\n const k = da[b[j - 1]] || 0;\n const l = db;\n let cost = 1;\n if (a[i - 1] === b[j - 1]) {\n cost = 0;\n db = j;\n }\n \n H[i][j] = Math.min(\n H[i - 1][j] + 1, // insertion\n H[i][j - 1] + 1, // deletion\n H[i - 1][j - 1] + cost, // substitution\n H[k - 1][l - 1] + (i - k - 1) + 1 + (j - l - 1) // transposition\n );\n }\n da[a[i - 1]] = i;\n }\n \n return H[a.length][b.length];\n }\n\n // Create fuzzy variations for learning\n generateVariations(input: string): string[] {\n const variations = [input];\n const normalized = this.normalize(input);\n \n // Generate common variations\n variations.push(input.toLowerCase());\n variations.push(input.replace(/\\s+/g, ' ').trim());\n variations.push(input.replace(/^(click|press)\\s+/i, 'tap '));\n variations.push(input.replace(/\\s+button$/i, ''));\n \n // Token permutations (limited)\n if (normalized.tokens.length <= 4) {\n const permutations = this.generateTokenPermutations(normalized.tokens);\n variations.push(...permutations.slice(0, 3)); // Limit to 3 permutations\n }\n \n return [...new Set(variations)];\n }\n\n private generateTokenPermutations(tokens: string[]): string[] {\n if (tokens.length <= 1) return tokens;\n if (tokens.length > 4) return []; // Too many combinations\n \n const result: string[] = [];\n const permute = (arr: string[], start = 0) => {\n if (start === arr.length) {\n result.push(arr.join(' '));\n return;\n }\n \n for (let i = start; i < arr.length; i++) {\n [arr[start], arr[i]] = [arr[i], arr[start]];\n permute(arr, start + 1);\n [arr[start], arr[i]] = [arr[i], arr[start]]; // backtrack\n }\n };\n \n permute([...tokens]);\n return result;\n }\n\n /**\n * Calculate Jaccard similarity between two texts\n * Returns similarity score between 0 (no similarity) and 1 (identical)\n */\n calculateJaccardSimilarity(text1: string, text2: string): number {\n if (!text1 || !text2) return 0;\n \n const tokens1 = new Set(text1.toLowerCase().split(/\\s+/).filter(t => t.length > 0));\n const tokens2 = new Set(text2.toLowerCase().split(/\\s+/).filter(t => t.length > 0));\n \n const intersection = new Set([...tokens1].filter(x => tokens2.has(x)));\n const union = new Set([...tokens1, ...tokens2]);\n \n return union.size > 0 ? intersection.size / union.size : 0;\n }\n\n /**\n * Context-aware similarity calculation\n * Integrates with ContextAwareSimilarity for enhanced matching\n */\n calculateContextAwareSimilarity(\n text1: string, \n text2: string, \n context?: {\n currentUrl?: string;\n operationType?: 'test_search' | 'cache_lookup' | 'pattern_match' | 'cross_env' | 'default';\n profile?: string;\n domainMatch?: boolean;\n }\n ): number {\n // Lazy load to avoid circular dependencies\n const { contextAwareSimilarity } = require('./context-aware-similarity.js');\n \n if (!context || !context.currentUrl) {\n // Fallback to basic Jaccard similarity\n return this.calculateJaccardSimilarity(text1, text2);\n }\n \n const similarityContext = {\n currentUrl: context.currentUrl,\n profile: context.profile || 'default',\n domainMatch: context.domainMatch || false,\n operationType: context.operationType || 'default'\n };\n \n return contextAwareSimilarity.calculateSimilarity(text1, text2, similarityContext);\n }\n\n /**\n * Enhanced similarity that automatically detects action conflicts\n * Returns -1 if actions conflict (should prevent matching)\n */\n calculateSimilarityWithActionDetection(text1: string, text2: string): number {\n // Lazy load to avoid circular dependencies\n const { contextAwareSimilarity } = require('./context-aware-similarity.js');\n \n // Check for conflicting actions first\n if (contextAwareSimilarity.hasConflictingActions(text1, text2)) {\n return -1; // Special value indicating conflict\n }\n \n // Check for exact action match boost\n const baseSimilarity = this.calculateJaccardSimilarity(text1, text2);\n if (contextAwareSimilarity.hasExactActionMatch(text1, text2)) {\n return Math.min(1, baseSimilarity + 0.2); // Boost exact matches\n }\n \n return baseSimilarity;\n }\n\n /**\n * Get context-appropriate threshold for similarity matching\n */\n getThresholdForOperation(operationType: 'test_search' | 'cache_lookup' | 'pattern_match' | 'cross_env' | 'default' = 'default'): number {\n // Lazy load to avoid circular dependencies\n const { SIMILARITY_THRESHOLDS } = require('./context-aware-similarity.js');\n \n return SIMILARITY_THRESHOLDS[operationType] || SIMILARITY_THRESHOLDS.default;\n }\n\n /**\n * Check if similarity meets context-appropriate threshold\n */\n meetsThresholdForOperation(\n similarity: number, \n operationType: 'test_search' | 'cache_lookup' | 'pattern_match' | 'cross_env' | 'default' = 'default'\n ): boolean {\n const threshold = this.getThresholdForOperation(operationType);\n return similarity >= threshold;\n }\n}","import crypto from 'crypto';\n\ninterface ElementSignature {\n tag: string;\n attributes: Record<string, string>;\n textContent?: string;\n position?: number;\n}\n\ninterface DOMSignatureResult {\n criticalHash: string;\n importantHash: string;\n contextHash: string;\n fullSignature: string;\n elementCounts: {\n critical: number;\n important: number;\n context: number;\n };\n}\n\ninterface CachedSignature {\n signature: DOMSignatureResult;\n timestamp: number;\n url: string;\n}\n\ninterface DOMSignatureOptions {\n cacheTTL?: number; // milliseconds\n includeTextContent?: boolean;\n includePositions?: boolean;\n maxElementsPerLevel?: number;\n}\n\n/**\n * DOMSignatureManager creates hierarchical page fingerprints for cache key generation.\n * \n * Generates 3-level DOM signatures:\n * - Level 1: Critical interactive elements (buttons, inputs, forms)\n * - Level 2: Important structural elements (links, navigation, containers) \n * - Level 3: Page context (headings, main content areas, sections)\n * \n * Final signature format: criticalHash:importantHash:contextHash\n */\nexport class DOMSignatureManager {\n private signatureCache = new Map<string, CachedSignature>();\n private cleanupTimer?: NodeJS.Timeout;\n private options: Required<DOMSignatureOptions>;\n\n constructor(options: DOMSignatureOptions = {}) {\n this.options = {\n cacheTTL: options.cacheTTL ?? 60000, // 1 minute default\n includeTextContent: options.includeTextContent ?? true,\n includePositions: options.includePositions ?? false,\n maxElementsPerLevel: options.maxElementsPerLevel ?? 50\n };\n\n this.startCleanupTimer();\n }\n\n /**\n * Generate hierarchical DOM signature for a page\n * @param page Playwright Page object or DOM content string\n * @param url Current page URL for caching\n * @returns Promise<DOMSignatureResult> with hierarchical hashes\n */\n async generateSignature(page: any, url: string): Promise<DOMSignatureResult> {\n // Check cache first\n const cached = this.getCachedSignature(url);\n if (cached) {\n return cached.signature;\n }\n\n try {\n // Extract DOM structure based on page type\n let domElements: ElementSignature[];\n \n if (typeof page === 'string') {\n // Handle raw DOM content\n domElements = this.extractElementsFromHTML(page);\n } else if (page.evaluate) {\n // Handle Playwright Page object\n domElements = await page.evaluate(DOMSignatureManager.getDOMExtractionScript());\n } else {\n throw new Error('Invalid page parameter: must be Playwright Page or HTML string');\n }\n\n // Generate hierarchical signatures\n const signature = this.createHierarchicalSignature(domElements);\n\n // Cache the result\n this.cacheSignature(url, signature);\n\n return signature;\n } catch (error) {\n console.error('[DOMSignature] Error generating signature:', error);\n \n // Return fallback signature\n return {\n criticalHash: 'fallback',\n importantHash: 'fallback', \n contextHash: 'fallback',\n fullSignature: 'fallback:fallback:fallback',\n elementCounts: { critical: 0, important: 0, context: 0 }\n };\n }\n }\n\n /**\n * Extract elements from raw HTML string\n */\n private extractElementsFromHTML(html: string): ElementSignature[] {\n // Simple HTML parsing for fallback - in production you'd use a proper HTML parser\n const elements: ElementSignature[] = [];\n \n // Extract button elements\n const buttonMatches = html.match(/<button[^>]*>.*?<\\/button>/gi) || [];\n buttonMatches.forEach((match, index) => {\n const attributes = this.extractAttributesFromHTML(match);\n const textContent = match.replace(/<[^>]*>/g, '').trim();\n elements.push({\n tag: 'button',\n attributes,\n textContent,\n position: index\n });\n });\n\n // Extract input elements\n const inputMatches = html.match(/<input[^>]*\\/?>/gi) || [];\n inputMatches.forEach((match, index) => {\n const attributes = this.extractAttributesFromHTML(match);\n elements.push({\n tag: 'input',\n attributes,\n position: index\n });\n });\n\n return elements;\n }\n\n /**\n * Extract attributes from HTML string\n */\n private extractAttributesFromHTML(html: string): Record<string, string> {\n const attributes: Record<string, string> = {};\n const attrMatches = html.match(/\\s+(\\w+)=[\"']([^\"']*)[\"']/g) || [];\n \n attrMatches.forEach(match => {\n const [, name, value] = match.match(/\\s+(\\w+)=[\"']([^\"']*)[\"']/) || [];\n if (name && value !== undefined) {\n attributes[name] = value;\n }\n });\n\n return attributes;\n }\n\n /**\n * Extract elements from live DOM (runs in browser context)\n * This method would be injected into page.evaluate()\n * Note: This is a placeholder - actual implementation should be converted to string \n * and executed in browser context via page.evaluate()\n */\n private extractElementsFromDOM(): ElementSignature[] {\n // This method is not meant to be called directly in Node.js context\n // It should be stringified and passed to page.evaluate()\n throw new Error('extractElementsFromDOM should only be used in browser context via page.evaluate()');\n }\n\n /**\n * Get the DOM extraction function as a string for page.evaluate()\n */\n static getDOMExtractionScript(): string {\n return `\n function extractElementsFromDOM() {\n const elements = [];\n\n // Critical interactive elements\n const criticalSelectors = [\n 'button', 'input', 'textarea', 'select', 'form',\n '[role=\"button\"]', '[onclick]', '[type=\"submit\"]',\n '.btn', '.button', '.form-control'\n ];\n\n // Important structural elements \n const importantSelectors = [\n 'a', 'nav', 'header', 'footer', 'aside', 'section',\n '[role=\"navigation\"]', '[role=\"main\"]', '.nav', '.navbar',\n '.menu', '.container', '.content'\n ];\n\n // Context elements\n const contextSelectors = [\n 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'main', 'article',\n '.title', '.heading', '.page-title', '[role=\"heading\"]'\n ];\n\n const allSelectors = [...criticalSelectors, ...importantSelectors, ...contextSelectors];\n \n allSelectors.forEach(selector => {\n try {\n const elements_found = document.querySelectorAll(selector);\n Array.from(elements_found).forEach((el, index) => {\n const attributes = {};\n \n // Extract key attributes\n ['id', 'class', 'type', 'name', 'role', 'data-testid'].forEach(attr => {\n const value = el.getAttribute(attr);\n if (value) attributes[attr] = value;\n });\n\n elements.push({\n tag: el.tagName.toLowerCase(),\n attributes,\n textContent: el.textContent?.trim().substring(0, 100), // Limit text length\n position: index\n });\n });\n } catch (error) {\n // Ignore selector errors\n }\n });\n\n return elements;\n }\n return extractElementsFromDOM();\n `;\n }\n\n /**\n * Create hierarchical signature from extracted elements\n */\n private createHierarchicalSignature(elements: ElementSignature[]): DOMSignatureResult {\n // Level 1: Critical interactive elements\n const critical = elements.filter(el => \n ['button', 'input', 'textarea', 'select', 'form'].includes(el.tag) ||\n el.attributes.role === 'button' ||\n el.attributes.onclick ||\n el.attributes.type === 'submit' ||\n el.attributes.class?.includes('btn') ||\n el.attributes.class?.includes('button')\n );\n\n // Level 2: Important structural elements\n const important = elements.filter(el =>\n ['a', 'nav', 'header', 'footer', 'aside', 'section'].includes(el.tag) ||\n el.attributes.role === 'navigation' ||\n el.attributes.role === 'main' ||\n ['nav', 'navbar', 'menu', 'container', 'content'].some(cls => \n el.attributes.class?.includes(cls)\n )\n );\n\n // Level 3: Context elements \n const context = elements.filter(el =>\n ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'main', 'article'].includes(el.tag) ||\n el.attributes.role === 'heading' ||\n ['title', 'heading', 'page-title'].some(cls =>\n el.attributes.class?.includes(cls)\n )\n );\n\n // Generate hashes for each level\n const criticalHash = this.hashElements(critical.slice(0, this.options.maxElementsPerLevel));\n const importantHash = this.hashElements(important.slice(0, this.options.maxElementsPerLevel));\n const contextHash = this.hashElements(context.slice(0, this.options.maxElementsPerLevel));\n\n // Create full signature\n const fullSignature = `${criticalHash}:${importantHash}:${contextHash}`;\n\n return {\n criticalHash,\n importantHash,\n contextHash,\n fullSignature,\n elementCounts: {\n critical: critical.length,\n important: important.length,\n context: context.length\n }\n };\n }\n\n /**\n * Generate deterministic hash for a group of elements\n */\n private hashElements(elements: ElementSignature[]): string {\n if (elements.length === 0) return 'empty';\n\n // Sort elements deterministically for consistent hashing\n const sortedElements = elements.sort((a, b) => {\n // Primary sort: tag name\n if (a.tag !== b.tag) return a.tag.localeCompare(b.tag);\n \n // Secondary sort: id attribute \n const aId = a.attributes.id || '';\n const bId = b.attributes.id || '';\n if (aId !== bId) return aId.localeCompare(bId);\n\n // Tertiary sort: class attribute\n const aClass = a.attributes.class || '';\n const bClass = b.attributes.class || '';\n if (aClass !== bClass) return aClass.localeCompare(bClass);\n\n // Final sort: text content\n const aText = a.textContent || '';\n const bText = b.textContent || '';\n return aText.localeCompare(bText);\n });\n\n // Create signature string\n const signatureData = sortedElements.map(el => {\n let sig = el.tag;\n \n // Add key attributes in consistent order\n if (el.attributes.id) sig += `#${el.attributes.id}`;\n if (el.attributes.class) sig += `.${el.attributes.class.replace(/\\s+/g, '.')}`;\n if (el.attributes.type) sig += `[type=\"${el.attributes.type}\"]`;\n if (el.attributes.role) sig += `[role=\"${el.attributes.role}\"]`;\n if (el.attributes.name) sig += `[name=\"${el.attributes.name}\"]`;\n\n // Add text content if enabled and available\n if (this.options.includeTextContent && el.textContent) {\n sig += `{${el.textContent.substring(0, 50)}}`;\n }\n\n // Add position if enabled\n if (this.options.includePositions && el.position !== undefined) {\n sig += `@${el.position}`;\n }\n\n return sig;\n }).join('|');\n\n // Generate hash\n return crypto.createHash('md5').update(signatureData).digest('hex').substring(0, 16);\n }\n\n /**\n * Get cached signature if valid\n */\n private getCachedSignature(url: string): CachedSignature | null {\n const cached = this.signatureCache.get(url);\n if (!cached) return null;\n\n const now = Date.now();\n if (now - cached.timestamp > this.options.cacheTTL) {\n this.signatureCache.delete(url);\n return null;\n }\n\n return cached;\n }\n\n /**\n * Cache signature result\n */\n private cacheSignature(url: string, signature: DOMSignatureResult): void {\n this.signatureCache.set(url, {\n signature,\n timestamp: Date.now(),\n url\n });\n }\n\n /**\n * Start cleanup timer for expired cache entries\n */\n private startCleanupTimer(): void {\n this.cleanupTimer = setInterval(() => {\n const now = Date.now();\n const expiredKeys: string[] = [];\n\n for (const [url, cached] of this.signatureCache) {\n if (now - cached.timestamp > this.options.cacheTTL) {\n expiredKeys.push(url);\n }\n }\n\n expiredKeys.forEach(url => this.signatureCache.delete(url));\n \n if (expiredKeys.length > 0) {\n console.error(`[DOMSignature] Cleaned up ${expiredKeys.length} expired cache entries`);\n }\n }, this.options.cacheTTL);\n }\n\n /**\n * Get cache statistics\n */\n getCacheStats(): { size: number; hits: number; misses: number } {\n return {\n size: this.signatureCache.size,\n hits: 0, // Would need to track these\n misses: 0 // Would need to track these \n };\n }\n\n /**\n * Clear signature cache\n */\n clearCache(): void {\n this.signatureCache.clear();\n }\n\n /**\n * Close and cleanup\n */\n close(): void {\n if (this.cleanupTimer) {\n clearInterval(this.cleanupTimer);\n }\n this.clearCache();\n }\n\n /**\n * Validate DOM signature format\n */\n static isValidSignature(signature: string): boolean {\n return /^[a-f0-9]{1,16}:[a-f0-9]{1,16}:[a-f0-9]{1,16}$/.test(signature);\n }\n\n /**\n * Parse DOM signature into components \n */\n static parseSignature(signature: string): { critical: string; important: string; context: string } | null {\n if (!this.isValidSignature(signature)) return null;\n \n const [critical, important, context] = signature.split(':');\n return { critical, important, context };\n }\n}\n\n/**\n * Static utility functions for DOM signature operations\n */\nexport const DOMSignatureUtils = {\n /**\n * Compare two DOM signatures for similarity\n */\n calculateSimilarity(sig1: string, sig2: string): number {\n const parsed1 = DOMSignatureManager.parseSignature(sig1);\n const parsed2 = DOMSignatureManager.parseSignature(sig2);\n \n if (!parsed1 || !parsed2) return 0;\n\n let matches = 0;\n let total = 0;\n\n // Compare each level with different weights\n if (parsed1.critical === parsed2.critical) matches += 3; // Critical elements are weighted higher\n total += 3;\n\n if (parsed1.important === parsed2.important) matches += 2; // Important elements medium weight\n total += 2;\n\n if (parsed1.context === parsed2.context) matches += 1; // Context elements lower weight\n total += 1;\n\n return matches / total;\n },\n\n /**\n * Check if signature indicates significant page change\n */\n hasSignificantChange(oldSignature: string, newSignature: string, threshold: number = 0.7): boolean {\n const similarity = this.calculateSimilarity(oldSignature, newSignature);\n return similarity < threshold;\n },\n\n /**\n * Generate cache key incorporating DOM signature\n */\n generateCacheKey(baseKey: string, domSignature: string, profile?: string): string {\n const components = [baseKey, domSignature];\n if (profile) components.push(profile);\n \n return crypto.createHash('md5')\n .update(components.join(':'))\n .digest('hex');\n }\n};\n\nexport type { DOMSignatureResult, DOMSignatureOptions, ElementSignature };","import crypto from 'crypto';\nimport { SmartNormalizer } from './smart-normalizer.js';\nimport { DOMSignatureManager, DOMSignatureResult } from '../utils/dom-signature.js';\n\n/**\n * Enhanced Cache Key Schema for Phase 2.2\n * Provides improved cross-environment cache matching with structured test patterns\n */\n\nexport interface EnhancedCacheKey {\n test_name_normalized: string; // SmartNormalizer processed test name\n url_pattern: string; // Domain + path pattern, not full URL\n dom_signature: string; // Page structure fingerprint from Phase 2.1\n steps_structure_hash: string; // Test structure without sensitive values\n profile: string; // Browser profile\n version: number; // Schema version for migration\n}\n\nexport interface CacheKeyComponents {\n baseKey: string; // Original cache key\n enhancedKey: EnhancedCacheKey; // Enhanced structured key\n legacyKey?: string; // Backward compatibility key\n}\n\nexport interface TestStep {\n action: 'navigate' | 'click' | 'type' | 'wait' | 'assert' | 'screenshot';\n target?: string;\n value?: string;\n selector?: string;\n timeout?: number;\n description: string;\n}\n\nexport interface StepsStructureAnalysis {\n actionPattern: string[]; // Sequence of actions: ['navigate', 'type', 'click']\n selectorTypes: string[]; // Types of selectors: ['url', 'input', 'button']\n conditionalLogic: boolean; // Has conditional steps\n loopsDetected: boolean; // Has repeated patterns\n structureComplexity: 'simple' | 'medium' | 'complex';\n}\n\nexport interface URLPatternComponents {\n protocol?: string; // http/https\n domain?: string;