UNPKG

claude-playwright

Version:

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

1,337 lines (1,324 loc) 322 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __esm = (fn, res) => function __init() { return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; }; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/utils/project-paths.ts var project_paths_exports = {}; __export(project_paths_exports, { ProjectPaths: () => ProjectPaths }); var import_path, fs, ProjectPaths; var init_project_paths = __esm({ "src/utils/project-paths.ts"() { "use strict"; import_path = __toESM(require("path")); fs = __toESM(require("fs-extra")); ProjectPaths = class { static { this.projectRoot = null; } static { this.baseDir = null; } /** * Find the project root by looking for package.json, .git, or other markers */ static findProjectRoot(startDir = process.cwd()) { if (this.projectRoot) { return this.projectRoot; } let currentDir = import_path.default.resolve(startDir); const root = import_path.default.parse(currentDir).root; while (currentDir !== root) { const markers = ["package.json", ".git", ".mcp.json", "tsconfig.json", "composer.json"]; for (const marker of markers) { try { if (fs.pathExistsSync(import_path.default.join(currentDir, marker))) { this.projectRoot = currentDir; return currentDir; } } catch (error) { } } currentDir = import_path.default.dirname(currentDir); } this.projectRoot = process.cwd(); return this.projectRoot; } /** * Get the base .claude-playwright directory (project-local) */ static getBaseDir() { if (this.baseDir) { return this.baseDir; } const projectRoot = this.findProjectRoot(); this.baseDir = import_path.default.join(projectRoot, ".claude-playwright"); return this.baseDir; } /** * Get the cache directory */ static getCacheDir() { return import_path.default.join(this.getBaseDir(), "cache"); } /** * Get the sessions directory */ static getSessionsDir() { return import_path.default.join(this.getBaseDir(), "sessions"); } /** * Get the profiles directory */ static getProfilesDir() { return import_path.default.join(this.getBaseDir(), "profiles"); } /** * Get the logs directory */ static getLogsDir() { return import_path.default.join(this.getBaseDir(), "logs"); } /** * Ensure all required directories exist */ static async ensureDirectories() { const dirs = [ this.getBaseDir(), this.getCacheDir(), this.getSessionsDir(), this.getProfilesDir(), this.getLogsDir() ]; for (const dir of dirs) { await fs.ensureDir(dir); } } /** * Reset cached paths (for testing) */ static reset() { this.projectRoot = null; this.baseDir = null; } /** * Get project-relative path for display purposes */ static getRelativePath(fullPath) { const projectRoot = this.findProjectRoot(); return import_path.default.relative(projectRoot, fullPath); } /** * Check if we're in a valid project (has project markers) */ static isValidProject() { const projectRoot = this.findProjectRoot(); const markers = ["package.json", ".git", ".mcp.json", "tsconfig.json"]; return markers.some((marker) => { try { return fs.pathExistsSync(import_path.default.join(projectRoot, marker)); } catch (error) { return false; } }); } }; } }); // src/core/context-aware-similarity.ts var context_aware_similarity_exports = {}; __export(context_aware_similarity_exports, { ContextAwareSimilarity: () => ContextAwareSimilarity, SIMILARITY_THRESHOLDS: () => SIMILARITY_THRESHOLDS, contextAwareSimilarity: () => contextAwareSimilarity }); function extractDomain(url) { try { const urlObj = new URL(url); return urlObj.hostname; } catch { return url.split("/")[0] || url; } } var SIMILARITY_THRESHOLDS, ACTION_CONFLICTS, ContextAwareSimilarity, contextAwareSimilarity; var init_context_aware_similarity = __esm({ "src/core/context-aware-similarity.ts"() { "use strict"; init_smart_normalizer(); SIMILARITY_THRESHOLDS = { /** Stricter for test matching to prevent false positives */ test_search: 0.35, /** Permissive for selector variation tolerance */ cache_lookup: 0.15, /** Moderate for pattern recognition */ pattern_match: 0.25, /** Very strict for cross-environment matching */ cross_env: 0.4, /** Default fallback threshold */ default: 0.2 }; ACTION_CONFLICTS = { "login": ["logout", "signout", "disconnect"], "logout": ["login", "signin", "connect"], "create": ["delete", "remove", "destroy"], "delete": ["create", "add", "new"], "open": ["close", "minimize", "hide"], "close": ["open", "maximize", "show"], "start": ["stop", "end", "finish"], "stop": ["start", "begin", "resume"], "enable": ["disable", "deactivate"], "disable": ["enable", "activate"], "save": ["discard", "cancel", "reset"], "cancel": ["save", "submit", "confirm"] }; ContextAwareSimilarity = class _ContextAwareSimilarity { constructor() { this.normalizer = new SmartNormalizer(); } /** * Calculate context-aware similarity between two texts * Returns enhanced similarity score with context considerations */ calculateSimilarity(query, candidate, context) { if (!query || !candidate) return 0; const queryNorm = this.normalizer.normalize(query); const candidateNorm = this.normalizer.normalize(candidate); let similarity = this.calculateBaseSimilarity(queryNorm, candidateNorm); similarity = this.applyContextEnhancements(similarity, query, candidate, context); similarity = this.applyActionLogic(similarity, queryNorm, candidateNorm); similarity = this.applyDomainMatching(similarity, context); return Math.max(0, Math.min(1, similarity)); } /** * Check if query has exact action match with candidate */ hasExactActionMatch(query, candidate) { const queryActions = this.extractActions(query); const candidateActions = this.extractActions(candidate); return queryActions.some( (qAction) => candidateActions.some((cAction) => qAction === cAction) ); } /** * Check if query has conflicting actions with candidate * Returns true if actions should prevent matching */ hasConflictingActions(query, candidate) { const queryActions = this.extractActions(query); const candidateActions = this.extractActions(candidate); for (const qAction of queryActions) { const conflicts = ACTION_CONFLICTS[qAction] || []; for (const cAction of candidateActions) { if (conflicts.includes(cAction)) { return true; } } } return false; } /** * Get appropriate threshold for given context */ getThresholdForContext(context) { if (context.operationType && context.operationType in SIMILARITY_THRESHOLDS) { return SIMILARITY_THRESHOLDS[context.operationType]; } return SIMILARITY_THRESHOLDS.default; } /** * Check if similarity meets threshold for context */ meetsThreshold(similarity, context) { const threshold = this.getThresholdForContext(context); return similarity >= threshold; } /** * Calculate base Jaccard similarity using normalized results */ calculateBaseSimilarity(queryNorm, candidateNorm) { return this.normalizer.calculateSimilarity(queryNorm, candidateNorm); } /** * Apply context-aware enhancements to similarity score */ applyContextEnhancements(baseSimilarity, query, candidate, context) { let enhanced = baseSimilarity; if (context.metadata?.environment) { if (query.toLowerCase().includes(context.metadata.environment) || candidate.toLowerCase().includes(context.metadata.environment)) { enhanced += 0.1; } } if (context.metadata?.pageType) { if (query.toLowerCase().includes(context.metadata.pageType) || candidate.toLowerCase().includes(context.metadata.pageType)) { enhanced += 0.1; } } if (context.metadata?.intentConfidence !== void 0) { enhanced *= context.metadata.intentConfidence; } if (context.profile !== "default") { if (context.profile.includes("mobile") && (query.includes("tap") || candidate.includes("tap"))) { enhanced += 0.05; } } return enhanced; } /** * Apply action-specific logic (boosts and conflicts) */ applyActionLogic(similarity, queryNorm, candidateNorm) { const queryText = queryNorm.normalized; const candidateText = candidateNorm.normalized; if (this.hasExactActionMatch(queryText, candidateText)) { similarity += 0.2; } if (this.hasConflictingActions(queryText, candidateText)) { similarity -= 0.5; } const actionBoost = this.calculateActionSynonymBoost(queryText, candidateText); similarity += actionBoost; return similarity; } /** * Apply domain matching logic for cross-environment scenarios */ applyDomainMatching(similarity, context) { if (context.domainMatch) { return similarity + 0.05; } else { const domains = this.extractDomainPatterns(context.currentUrl); if (domains.some((domain) => ["localhost", "staging", "dev", "test"].some((pattern) => domain.includes(pattern)))) { return similarity - 0.05; } return similarity - 0.1; } } /** * Extract actions from normalized text */ extractActions(text) { const actions = []; const normalized = text.toLowerCase(); const actionPatterns = [ /\b(click|tap|press|select|choose)\b/g, /\b(type|enter|input|fill|write)\b/g, /\b(navigate|go|open|visit|load)\b/g, /\b(login|signin|authenticate)\b/g, /\b(logout|signout|disconnect)\b/g, /\b(create|add|new|make)\b/g, /\b(delete|remove|destroy|clear)\b/g, /\b(save|submit|confirm)\b/g, /\b(cancel|discard|reset)\b/g, /\b(start|begin|initiate)\b/g, /\b(stop|end|finish|close)\b/g, /\b(enable|activate|turn\s+on)\b/g, /\b(disable|deactivate|turn\s+off)\b/g ]; for (const pattern of actionPatterns) { const matches = normalized.match(pattern); if (matches) { actions.push(...matches); } } return [...new Set(actions)]; } /** * Calculate boost for action synonyms */ calculateActionSynonymBoost(query, candidate) { const queryActions = this.extractActions(query); const candidateActions = this.extractActions(candidate); let boost = 0; const synonymGroups = [ ["click", "tap", "press", "select"], ["type", "enter", "input", "fill"], ["navigate", "go", "open", "visit"], ["login", "signin", "authenticate"], ["logout", "signout", "disconnect"], ["create", "add", "new"], ["delete", "remove", "destroy"], ["save", "submit", "confirm"], ["cancel", "discard", "reset"] ]; for (const group of synonymGroups) { const queryHasSynonym = queryActions.some( (action) => group.some((synonym) => action.includes(synonym)) ); const candidateHasSynonym = candidateActions.some( (action) => group.some((synonym) => action.includes(synonym)) ); if (queryHasSynonym && candidateHasSynonym) { boost += 0.1; } } return Math.min(boost, 0.3); } /** * Extract domain patterns for analysis */ extractDomainPatterns(url) { const domain = extractDomain(url); const parts = domain.split("."); return [ domain, ...parts, parts.slice(-2).join(".") // TLD + domain ]; } /** * Create similarity context from available information */ static createContext(currentUrl, operationType = "default", options = {}) { const context = { currentUrl, operationType, profile: options.profile || "default", domainMatch: false, ...options }; if (options.domSignature && context.currentUrl) { const currentDomain = extractDomain(context.currentUrl); const contextDomain = extractDomain(options.domSignature); context.domainMatch = currentDomain === contextDomain; } return context; } /** * Enhanced similarity with automatic context detection */ calculateSimilarityWithAutoContext(query, candidate, currentUrl, operationType = "default") { const context = _ContextAwareSimilarity.createContext(currentUrl, operationType); return this.calculateSimilarity(query, candidate, context); } /** * Batch similarity calculation with context */ calculateBatchSimilarity(query, candidates, context) { return candidates.map((candidate) => { const similarity = this.calculateSimilarity(query, candidate, context); return { candidate, similarity, meetsThreshold: this.meetsThreshold(similarity, context) }; }); } /** * Find best matches with context awareness */ findBestMatches(query, candidates, context, maxResults = 5) { const results = this.calculateBatchSimilarity(query, candidates, context).filter((result) => result.meetsThreshold).sort((a, b) => b.similarity - a.similarity).slice(0, maxResults); return results.map((result, index) => ({ candidate: result.candidate, similarity: result.similarity, rank: index + 1 })); } }; contextAwareSimilarity = new ContextAwareSimilarity(); } }); // src/core/smart-normalizer.ts var import_crypto, SmartNormalizer; var init_smart_normalizer = __esm({ "src/core/smart-normalizer.ts"() { "use strict"; import_crypto = __toESM(require("crypto")); SmartNormalizer = class { constructor() { this.POSITION_KEYWORDS = [ "before", "after", "first", "last", "next", "previous", "above", "below", "top", "bottom", "left", "right" ]; this.RELATION_KEYWORDS = [ "in", "of", "from", "to", "with", "by", "for" ]; this.STOP_WORDS = [ "the", "a", "an", "and", "or", "but", "at", "on" ]; this.ACTION_SYNONYMS = { "click": ["click", "press", "tap", "hit", "select", "choose"], "type": ["type", "enter", "input", "fill", "write"], "navigate": ["go", "navigate", "open", "visit", "load"], "hover": ["hover", "mouseover", "move"] }; } normalize(input) { const original = input.trim(); const syntaxFixed = this.fixPlaywrightSyntax(original); const features = this.extractFeatures(syntaxFixed); let text = syntaxFixed.toLowerCase(); const quotedStrings = []; text = text.replace(/(["'])((?:(?!\1)[^\\]|\\.)*)(\1)/g, (match, quote, content) => { quotedStrings.push(content); return `QUOTED_${quotedStrings.length - 1}`; }); const positions = this.extractPositions(text); text = this.normalizeActions(text); text = this.removeCommonPatterns(text); const words = text.split(/\s+/).filter((word) => word.length > 0); const tokens = []; const preserved = []; for (let i = 0; i < words.length; i++) { const word = words[i]; if (this.POSITION_KEYWORDS.includes(word)) { preserved.push({ word, position: i, context: words[i + 1] || null }); } else if (!this.STOP_WORDS.includes(word) && !this.RELATION_KEYWORDS.includes(word) && !["button", "field", "element"].includes(word)) { tokens.push(word); } } tokens.sort(); let normalized = tokens.join(" "); if (preserved.length > 0) { const posInfo = preserved.map( (p) => `${p.word}${p.context ? "-" + p.context : ""}` ).join(","); normalized += ` _pos:${posInfo}`; } if (quotedStrings.length > 0) { normalized += ` _quoted:${quotedStrings.join(",")}`; } const hash = import_crypto.default.createHash("md5").update(normalized).digest("hex"); return { normalized, tokens, positions, features, hash }; } extractFeatures(input) { const text = input.toLowerCase(); return { hasId: /#[\w-]+/.test(input), hasClass: /\.[\w-]+/.test(input), hasQuoted: /"[^"]+"|'[^']+'/.test(input), numbers: input.match(/\d+/g) || [], positions: this.extractPositions(text), attributes: this.extractAttributes(input), wordCount: input.split(/\s+/).length, hasImperative: /^(click|press|tap|select|enter|type|fill)/i.test(input), casePattern: this.detectCasePattern(input), isNavigation: /^(go|navigate|open|visit)/i.test(input), isFormAction: /(submit|enter|fill|type|input)/i.test(input), hasDataTestId: /data-test|testid|data-cy/i.test(input) }; } extractPositions(text) { const positions = []; const words = text.split(/\s+/); for (let i = 0; i < words.length; i++) { const word = words[i]; if (this.POSITION_KEYWORDS.includes(word)) { positions.push({ word, position: i, context: words[i + 1] || words[i - 1] || void 0 }); } } return positions; } extractAttributes(input) { const attributes = []; const patterns = [ /\[([^\]]+)\]/g, // [attribute=value] /data-[\w-]+/g, // data-testid /aria-[\w-]+/g, // aria-label /role="[\w-]+"/g, // role="button" /type="[\w-]+"/g, // type="submit" /placeholder="[^"]+"/g // placeholder="text" ]; for (const pattern of patterns) { const matches = input.match(pattern); if (matches) { attributes.push(...matches); } } return attributes; } detectCasePattern(input) { const hasLower = /[a-z]/.test(input); const hasUpper = /[A-Z]/.test(input); if (!hasLower && hasUpper) return "upper"; if (hasLower && !hasUpper) return "lower"; const words = input.split(/\s+/); const isTitleCase = words.every( (word) => /^[A-Z][a-z]*$/.test(word) || /^[a-z]+$/.test(word) ); return isTitleCase ? "title" : "mixed"; } /** * Fix common Playwright CSS selector syntax errors early in the process */ fixPlaywrightSyntax(input) { let fixed = input.trim(); fixed = fixed.replace(/:text\(/g, ":has-text(").replace(/\btext\(/g, "text=").replace(/:first\b/g, ":first-of-type").replace(/:last\b/g, ":last-of-type").replace(/>>(\s+)first(\s|$)/g, ">> nth=0").replace(/>>(\s+)last(\s|$)/g, ">> nth=-1"); if (fixed !== input) { console.error(`[SmartNormalizer] Syntax fixed: "${input}" \u2192 "${fixed}"`); } return fixed; } normalizeActions(text) { for (const [canonical, synonyms] of Object.entries(this.ACTION_SYNONYMS)) { for (const synonym of synonyms) { const regex = new RegExp(`\\b${synonym}\\b`, "g"); text = text.replace(regex, canonical); } } return text; } removeCommonPatterns(text) { text = text.replace(/^(click|press|tap)(\s+on)?(\s+the)?/i, "click"); text = text.replace(/\s+(button|element|field)$/i, ""); text = text.replace(/button\s+/i, ""); text = text.replace(/\b(the|a|an)\b/g, ""); text = text.replace(/[^\w\s#._-]/g, " "); text = text.replace(/\s+/g, " ").trim(); return text; } // Utility methods for similarity calculateSimilarity(result1, result2) { const set1 = new Set(result1.tokens); const set2 = new Set(result2.tokens); const intersection = new Set([...set1].filter((x) => set2.has(x))); const union = /* @__PURE__ */ new Set([...set1, ...set2]); let similarity = intersection.size / union.size; const quoted1 = result1.normalized.match(/_quoted:([^_]*)/)?.[1] || ""; const quoted2 = result2.normalized.match(/_quoted:([^_]*)/)?.[1] || ""; if (quoted1 === quoted2 && quoted1.length > 0) { similarity += 0.2; } const pos1 = result1.normalized.match(/_pos:([^_]*)/)?.[1] || ""; const pos2 = result2.normalized.match(/_pos:([^_]*)/)?.[1] || ""; if (pos1 !== pos2 && (pos1.length > 0 || pos2.length > 0)) { similarity -= 0.3; } return Math.max(0, Math.min(1, similarity)); } // Fuzzy matching for typo tolerance damerauLevenshtein(a, b) { const da = {}; const maxdist = a.length + b.length; const H = []; H[-1] = []; H[-1][-1] = maxdist; for (let i = 0; i <= a.length; i++) { H[i] = []; H[i][-1] = maxdist; H[i][0] = i; } for (let j = 0; j <= b.length; j++) { H[-1][j] = maxdist; H[0][j] = j; } for (let i = 1; i <= a.length; i++) { let db = 0; for (let j = 1; j <= b.length; j++) { const k = da[b[j - 1]] || 0; const l = db; let cost = 1; if (a[i - 1] === b[j - 1]) { cost = 0; db = j; } H[i][j] = Math.min( H[i - 1][j] + 1, // insertion H[i][j - 1] + 1, // deletion H[i - 1][j - 1] + cost, // substitution H[k - 1][l - 1] + (i - k - 1) + 1 + (j - l - 1) // transposition ); } da[a[i - 1]] = i; } return H[a.length][b.length]; } // Create fuzzy variations for learning generateVariations(input) { const variations = [input]; const normalized = this.normalize(input); variations.push(input.toLowerCase()); variations.push(input.replace(/\s+/g, " ").trim()); variations.push(input.replace(/^(click|press)\s+/i, "tap ")); variations.push(input.replace(/\s+button$/i, "")); if (normalized.tokens.length <= 4) { const permutations = this.generateTokenPermutations(normalized.tokens); variations.push(...permutations.slice(0, 3)); } return [...new Set(variations)]; } generateTokenPermutations(tokens) { if (tokens.length <= 1) return tokens; if (tokens.length > 4) return []; const result = []; const permute = (arr, start = 0) => { if (start === arr.length) { result.push(arr.join(" ")); return; } for (let i = start; i < arr.length; i++) { [arr[start], arr[i]] = [arr[i], arr[start]]; permute(arr, start + 1); [arr[start], arr[i]] = [arr[i], arr[start]]; } }; permute([...tokens]); return result; } /** * Calculate Jaccard similarity between two texts * Returns similarity score between 0 (no similarity) and 1 (identical) */ calculateJaccardSimilarity(text1, text2) { if (!text1 || !text2) return 0; const tokens1 = new Set(text1.toLowerCase().split(/\s+/).filter((t) => t.length > 0)); const tokens2 = new Set(text2.toLowerCase().split(/\s+/).filter((t) => t.length > 0)); const intersection = new Set([...tokens1].filter((x) => tokens2.has(x))); const union = /* @__PURE__ */ new Set([...tokens1, ...tokens2]); return union.size > 0 ? intersection.size / union.size : 0; } /** * Context-aware similarity calculation * Integrates with ContextAwareSimilarity for enhanced matching */ calculateContextAwareSimilarity(text1, text2, context) { const { contextAwareSimilarity: contextAwareSimilarity2 } = (init_context_aware_similarity(), __toCommonJS(context_aware_similarity_exports)); if (!context || !context.currentUrl) { return this.calculateJaccardSimilarity(text1, text2); } const similarityContext = { currentUrl: context.currentUrl, profile: context.profile || "default", domainMatch: context.domainMatch || false, operationType: context.operationType || "default" }; return contextAwareSimilarity2.calculateSimilarity(text1, text2, similarityContext); } /** * Enhanced similarity that automatically detects action conflicts * Returns -1 if actions conflict (should prevent matching) */ calculateSimilarityWithActionDetection(text1, text2) { const { contextAwareSimilarity: contextAwareSimilarity2 } = (init_context_aware_similarity(), __toCommonJS(context_aware_similarity_exports)); if (contextAwareSimilarity2.hasConflictingActions(text1, text2)) { return -1; } const baseSimilarity = this.calculateJaccardSimilarity(text1, text2); if (contextAwareSimilarity2.hasExactActionMatch(text1, text2)) { return Math.min(1, baseSimilarity + 0.2); } return baseSimilarity; } /** * Get context-appropriate threshold for similarity matching */ getThresholdForOperation(operationType = "default") { const { SIMILARITY_THRESHOLDS: SIMILARITY_THRESHOLDS2 } = (init_context_aware_similarity(), __toCommonJS(context_aware_similarity_exports)); return SIMILARITY_THRESHOLDS2[operationType] || SIMILARITY_THRESHOLDS2.default; } /** * Check if similarity meets context-appropriate threshold */ meetsThresholdForOperation(similarity, operationType = "default") { const threshold = this.getThresholdForOperation(operationType); return similarity >= threshold; } }; } }); // src/utils/dom-signature.ts var dom_signature_exports = {}; __export(dom_signature_exports, { DOMSignatureManager: () => DOMSignatureManager, DOMSignatureUtils: () => DOMSignatureUtils }); var import_crypto2, DOMSignatureManager, DOMSignatureUtils; var init_dom_signature = __esm({ "src/utils/dom-signature.ts"() { "use strict"; import_crypto2 = __toESM(require("crypto")); DOMSignatureManager = class _DOMSignatureManager { constructor(options = {}) { this.signatureCache = /* @__PURE__ */ new Map(); this.options = { cacheTTL: options.cacheTTL ?? 6e4, // 1 minute default includeTextContent: options.includeTextContent ?? true, includePositions: options.includePositions ?? false, maxElementsPerLevel: options.maxElementsPerLevel ?? 50 }; this.startCleanupTimer(); } /** * Generate hierarchical DOM signature for a page * @param page Playwright Page object or DOM content string * @param url Current page URL for caching * @returns Promise<DOMSignatureResult> with hierarchical hashes */ async generateSignature(page, url) { const cached = this.getCachedSignature(url); if (cached) { return cached.signature; } try { let domElements; if (typeof page === "string") { domElements = this.extractElementsFromHTML(page); } else if (page.evaluate) { domElements = await page.evaluate(_DOMSignatureManager.getDOMExtractionScript()); } else { throw new Error("Invalid page parameter: must be Playwright Page or HTML string"); } const signature = this.createHierarchicalSignature(domElements); this.cacheSignature(url, signature); return signature; } catch (error) { console.error("[DOMSignature] Error generating signature:", error); return { criticalHash: "fallback", importantHash: "fallback", contextHash: "fallback", fullSignature: "fallback:fallback:fallback", elementCounts: { critical: 0, important: 0, context: 0 } }; } } /** * Extract elements from raw HTML string */ extractElementsFromHTML(html) { const elements = []; const buttonMatches = html.match(/<button[^>]*>.*?<\/button>/gi) || []; buttonMatches.forEach((match, index) => { const attributes = this.extractAttributesFromHTML(match); const textContent = match.replace(/<[^>]*>/g, "").trim(); elements.push({ tag: "button", attributes, textContent, position: index }); }); const inputMatches = html.match(/<input[^>]*\/?>/gi) || []; inputMatches.forEach((match, index) => { const attributes = this.extractAttributesFromHTML(match); elements.push({ tag: "input", attributes, position: index }); }); return elements; } /** * Extract attributes from HTML string */ extractAttributesFromHTML(html) { const attributes = {}; const attrMatches = html.match(/\s+(\w+)=["']([^"']*)["']/g) || []; attrMatches.forEach((match) => { const [, name, value] = match.match(/\s+(\w+)=["']([^"']*)["']/) || []; if (name && value !== void 0) { attributes[name] = value; } }); return attributes; } /** * Extract elements from live DOM (runs in browser context) * This method would be injected into page.evaluate() * Note: This is a placeholder - actual implementation should be converted to string * and executed in browser context via page.evaluate() */ extractElementsFromDOM() { throw new Error("extractElementsFromDOM should only be used in browser context via page.evaluate()"); } /** * Get the DOM extraction function as a string for page.evaluate() */ static getDOMExtractionScript() { return ` function extractElementsFromDOM() { const elements = []; // Critical interactive elements const criticalSelectors = [ 'button', 'input', 'textarea', 'select', 'form', '[role="button"]', '[onclick]', '[type="submit"]', '.btn', '.button', '.form-control' ]; // Important structural elements const importantSelectors = [ 'a', 'nav', 'header', 'footer', 'aside', 'section', '[role="navigation"]', '[role="main"]', '.nav', '.navbar', '.menu', '.container', '.content' ]; // Context elements const contextSelectors = [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'main', 'article', '.title', '.heading', '.page-title', '[role="heading"]' ]; const allSelectors = [...criticalSelectors, ...importantSelectors, ...contextSelectors]; allSelectors.forEach(selector => { try { const elements_found = document.querySelectorAll(selector); Array.from(elements_found).forEach((el, index) => { const attributes = {}; // Extract key attributes ['id', 'class', 'type', 'name', 'role', 'data-testid'].forEach(attr => { const value = el.getAttribute(attr); if (value) attributes[attr] = value; }); elements.push({ tag: el.tagName.toLowerCase(), attributes, textContent: el.textContent?.trim().substring(0, 100), // Limit text length position: index }); }); } catch (error) { // Ignore selector errors } }); return elements; } return extractElementsFromDOM(); `; } /** * Create hierarchical signature from extracted elements */ createHierarchicalSignature(elements) { const critical = elements.filter( (el) => ["button", "input", "textarea", "select", "form"].includes(el.tag) || el.attributes.role === "button" || el.attributes.onclick || el.attributes.type === "submit" || el.attributes.class?.includes("btn") || el.attributes.class?.includes("button") ); const important = elements.filter( (el) => ["a", "nav", "header", "footer", "aside", "section"].includes(el.tag) || el.attributes.role === "navigation" || el.attributes.role === "main" || ["nav", "navbar", "menu", "container", "content"].some( (cls) => el.attributes.class?.includes(cls) ) ); const context = elements.filter( (el) => ["h1", "h2", "h3", "h4", "h5", "h6", "main", "article"].includes(el.tag) || el.attributes.role === "heading" || ["title", "heading", "page-title"].some( (cls) => el.attributes.class?.includes(cls) ) ); const criticalHash = this.hashElements(critical.slice(0, this.options.maxElementsPerLevel)); const importantHash = this.hashElements(important.slice(0, this.options.maxElementsPerLevel)); const contextHash = this.hashElements(context.slice(0, this.options.maxElementsPerLevel)); const fullSignature = `${criticalHash}:${importantHash}:${contextHash}`; return { criticalHash, importantHash, contextHash, fullSignature, elementCounts: { critical: critical.length, important: important.length, context: context.length } }; } /** * Generate deterministic hash for a group of elements */ hashElements(elements) { if (elements.length === 0) return "empty"; const sortedElements = elements.sort((a, b) => { if (a.tag !== b.tag) return a.tag.localeCompare(b.tag); const aId = a.attributes.id || ""; const bId = b.attributes.id || ""; if (aId !== bId) return aId.localeCompare(bId); const aClass = a.attributes.class || ""; const bClass = b.attributes.class || ""; if (aClass !== bClass) return aClass.localeCompare(bClass); const aText = a.textContent || ""; const bText = b.textContent || ""; return aText.localeCompare(bText); }); const signatureData = sortedElements.map((el) => { let sig = el.tag; if (el.attributes.id) sig += `#${el.attributes.id}`; if (el.attributes.class) sig += `.${el.attributes.class.replace(/\s+/g, ".")}`; if (el.attributes.type) sig += `[type="${el.attributes.type}"]`; if (el.attributes.role) sig += `[role="${el.attributes.role}"]`; if (el.attributes.name) sig += `[name="${el.attributes.name}"]`; if (this.options.includeTextContent && el.textContent) { sig += `{${el.textContent.substring(0, 50)}}`; } if (this.options.includePositions && el.position !== void 0) { sig += `@${el.position}`; } return sig; }).join("|"); return import_crypto2.default.createHash("md5").update(signatureData).digest("hex").substring(0, 16); } /** * Get cached signature if valid */ getCachedSignature(url) { const cached = this.signatureCache.get(url); if (!cached) return null; const now = Date.now(); if (now - cached.timestamp > this.options.cacheTTL) { this.signatureCache.delete(url); return null; } return cached; } /** * Cache signature result */ cacheSignature(url, signature) { this.signatureCache.set(url, { signature, timestamp: Date.now(), url }); } /** * Start cleanup timer for expired cache entries */ startCleanupTimer() { this.cleanupTimer = setInterval(() => { const now = Date.now(); const expiredKeys = []; for (const [url, cached] of this.signatureCache) { if (now - cached.timestamp > this.options.cacheTTL) { expiredKeys.push(url); } } expiredKeys.forEach((url) => this.signatureCache.delete(url)); if (expiredKeys.length > 0) { console.error(`[DOMSignature] Cleaned up ${expiredKeys.length} expired cache entries`); } }, this.options.cacheTTL); } /** * Get cache statistics */ getCacheStats() { return { size: this.signatureCache.size, hits: 0, // Would need to track these misses: 0 // Would need to track these }; } /** * Clear signature cache */ clearCache() { this.signatureCache.clear(); } /** * Close and cleanup */ close() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); } this.clearCache(); } /** * Validate DOM signature format */ static isValidSignature(signature) { return /^[a-f0-9]{1,16}:[a-f0-9]{1,16}:[a-f0-9]{1,16}$/.test(signature); } /** * Parse DOM signature into components */ static parseSignature(signature) { if (!this.isValidSignature(signature)) return null; const [critical, important, context] = signature.split(":"); return { critical, important, context }; } }; DOMSignatureUtils = { /** * Compare two DOM signatures for similarity */ calculateSimilarity(sig1, sig2) { const parsed1 = DOMSignatureManager.parseSignature(sig1); const parsed2 = DOMSignatureManager.parseSignature(sig2); if (!parsed1 || !parsed2) return 0; let matches = 0; let total = 0; if (parsed1.critical === parsed2.critical) matches += 3; total += 3; if (parsed1.important === parsed2.important) matches += 2; total += 2; if (parsed1.context === parsed2.context) matches += 1; total += 1; return matches / total; }, /** * Check if signature indicates significant page change */ hasSignificantChange(oldSignature, newSignature, threshold = 0.7) { const similarity = this.calculateSimilarity(oldSignature, newSignature); return similarity < threshold; }, /** * Generate cache key incorporating DOM signature */ generateCacheKey(baseKey, domSignature, profile) { const components = [baseKey, domSignature]; if (profile) components.push(profile); return import_crypto2.default.createHash("md5").update(components.join(":")).digest("hex"); } }; } }); // src/core/enhanced-cache-key.ts var import_crypto3, EnhancedCacheKeyManager; var init_enhanced_cache_key = __esm({ "src/core/enhanced-cache-key.ts"() { "use strict"; import_crypto3 = __toESM(require("crypto")); init_smart_normalizer(); init_dom_signature(); EnhancedCacheKeyManager = class { constructor() { this.CURRENT_VERSION = 1; this.normalizer = new SmartNormalizer(); this.domSignatureManager = new DOMSignatureManager(); } /** * Generate enhanced cache key from components */ generateEnhancedKey(testName, url, domSignature, steps, profile = "default") { const normalizationResult = this.normalizer.normalize(testName); const testNameNormalized = normalizationResult.normalized; const urlPattern = this.extractURLPattern(url); const domSig = domSignature || this.generateFallbackDOMSignature(url); const stepsStructureHash = steps ? this.generateStepsStructureHash(steps) : "no-steps"; return { test_name_normalized: testNameNormalized, url_pattern: urlPattern, dom_signature: domSig, steps_structure_hash: stepsStructureHash, profile, version: this.CURRENT_VERSION }; } /** * Generate cache key components for storage and lookup */ generateCacheKeyComponents(testName, url, domSignature, steps, profile = "default") { const enhancedKey = this.generateEnhancedKey(testName, url, domSignature, steps, profile); const baseKey = this.generateBaseKey(enhancedKey); const legacyKey = this.generateLegacyKey(testName, url, profile); return { baseKey, enhancedKey, legacyKey }; } /** * Extract URL pattern from full URL - replaces IDs with wildcards */ extractURLPattern(url) { try { const urlObj = new URL(url); let pathPattern = urlObj.pathname; pathPattern = pathPattern.replace(/\/\d+/g, "/*"); pathPattern = pathPattern.replace(/\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/gi, "/*"); pathPattern = pathPattern.replace(/\/[a-zA-Z0-9_-]{10,}/g, "/*"); if (urlObj.hostname === "localhost" || urlObj.hostname.startsWith("127.") || urlObj.hostname.endsWith(".local")) { return "*" + pathPattern; } if (urlObj.hostname.includes("staging") || urlObj.hostname.includes("dev")) { const parts2 = urlObj.hostname.split("."); if (parts2.length >= 2) { const baseDomain = parts2.slice(-2).join("."); return `*.${baseDomain}${pathPattern}`; } return "*" + pathPattern; } const domainPattern = urlObj.hostname.replace(/^(www\.)/, "*."); const parts = urlObj.hostname.split("."); if (parts.length > 2 && !urlObj.hostname.startsWith("www.")) { const baseDomain = parts.slice(-2).join("."); return `*.${baseDomain}${pathPattern}`; } return domainPattern + pathPattern; } catch (error) { console.error("[EnhancedCacheKey] URL pattern extraction failed:", error); return url.replace(/https?:\/\/[^/]+/, "*").replace(/\/\d+/g, "/*"); } } /** * Analyze steps structure and generate hash */ analyzeStepsStructure(steps) { if (!steps || steps.length === 0) { return { actionPattern: [], selectorTypes: [], conditionalLogic: false, loopsDetected: false, structureComplexity: "simple" }; } const actionPattern = steps.map((step) => step.action); const selectorTypes = steps.filter((step) => step.selector || step.target).map((step) => this.classifySelector(step.selector || step.target || "")); const conditionalLogic = steps.some( (step) => step.description?.toLowerCase().includes("if") || step.description?.toLowerCase().includes("when") || step.action === "wait" ); const loopsDetected = this.detectActionLoops(actionPattern); let structureComplexity = "simple"; if (steps.length > 10 || loopsDetected || conditionalLogic) { structureComplexity = "complex"; } else if (steps.length > 5 || selectorTypes.length > 3) { structureComplexity = "medium"; } return { actionPattern, selectorTypes: [...new Set(selectorTypes)], // Remove duplicates conditionalLogic, loopsDetected, structureComplexity }; } /** * Generate steps structure hash without sensitive values */ generateStepsStructureHash(steps) { const analysis = this.analyzeStepsStructure(steps); const structureSignature = { actions: analysis.actionPattern, selectors: analysis.selectorTypes, conditional: analysis.conditionalLogic, loops: analysis.loopsDetected, complexity: analysis.structureComplexity, stepCount: steps.length }; const signatureString = JSON.stringify(structureSignature, Object.keys(structureSignature).sort()); return import_crypto3.default.createHash("md5").update(signatureString).digest("hex").substring(0, 12); } /** * Classify selector type for pattern analysis */ classifySelector(selector) { if (!selector) return "unknown"; if (selector.startsWith("http") || selector.includes("://")) { return "url"; } if (selector.includes("input") || selector.includes("textarea") || selector.includes("[type=")) { return "input"; } if (selector.includes("button") || selector.includes('[role="button"]') || selector.includes(".btn")) { return "button"; } if (selector.includes("a[") || selector.includes("link") || selector.includes("[href]")) { return "link"; } if (selector.includes("nav") || selector.includes("menu") || selector.includes('[role="navigation"]')) { return "navigation"; } if (selector.includes("form") || selector.includes("select") || selector.includes("option")) { return "form"; } if (selector.startsWith("#") || selector.startsWith(".") || selector.includes("[")) { return "element"; } return "text"; } /** * Detect repeated action patterns in steps */ detectActionLoops(actions) { if (actions.length < 4) return false; for (let i = 0; i < actions.length - 3; i++) { const pattern = actions.slice(i, i + 2); const remaining = actions.slice(i + 2); for (let j = 0; j <= remaining.length - 2; j++) { const candidate = remaining.slice(j, j + 2); if (pattern[0] === candidate[0] && pattern[1] === candidate[1]) { return true; } } } return false; } /** * Generate fallback DOM signature when page is not available */ generateFallbackDOMSignature(url) { const urlHash = import_crypto3.default.createHash("md5").update(url).digest("hex").substring(0, 8); return `fallback:${urlHash}:${urlHash}`; } /** * Generate base storage key from enhanced key */ generateBaseKey(enhancedKey) { const components = [ `v${enh