UNPKG

claude-playwright

Version:

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

717 lines (712 loc) 23.9 kB
// src/core/bidirectional-cache.ts import Database from "better-sqlite3"; // src/core/smart-normalizer.ts import crypto from "crypto"; var 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 features = this.extractFeatures(original); let text = original.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 = crypto.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"; } 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; } }; // src/core/bidirectional-cache.ts import crypto2 from "crypto"; import * as path from "path"; import * as os from "os"; import * as fs from "fs"; var BidirectionalCache = class { constructor(options = {}) { this.stats = { hits: { exact: 0, normalized: 0, reverse: 0, fuzzy: 0 }, misses: 0, sets: 0, learnings: 0 }; this.options = { maxSizeMB: options.maxSizeMB ?? 50, selectorTTL: options.selectorTTL ?? 3e5, // 5 minutes cleanupInterval: options.cleanupInterval ?? 6e4, // 1 minute maxVariationsPerSelector: options.maxVariationsPerSelector ?? 20 }; this.normalizer = new SmartNormalizer(); this.cacheDir = path.join(os.homedir(), ".claude-playwright", "cache"); if (!fs.existsSync(this.cacheDir)) { fs.mkdirSync(this.cacheDir, { recursive: true }); } const dbPath = path.join(this.cacheDir, "bidirectional-cache.db"); this.db = new Database(dbPath); this.db.pragma("journal_mode = WAL"); this.db.pragma("synchronous = NORMAL"); this.initializeDatabase(); this.startCleanupTimer(); } initializeDatabase() { this.db.exec(` -- Enhanced selector cache table CREATE TABLE IF NOT EXISTS selector_cache_v2 ( id INTEGER PRIMARY KEY AUTOINCREMENT, selector TEXT NOT NULL, selector_hash TEXT NOT NULL UNIQUE, url TEXT NOT NULL, confidence REAL DEFAULT 0.5, created_at INTEGER NOT NULL, last_used INTEGER NOT NULL, use_count INTEGER DEFAULT 1 ); -- Bidirectional input mappings CREATE TABLE IF NOT EXISTS input_mappings ( id INTEGER PRIMARY KEY AUTOINCREMENT, selector_hash TEXT NOT NULL, input TEXT NOT NULL, normalized_input TEXT NOT NULL, input_tokens TEXT NOT NULL, url TEXT NOT NULL, success_count INTEGER DEFAULT 1, last_used INTEGER NOT NULL, confidence REAL DEFAULT 0.5, learned_from TEXT DEFAULT 'direct', FOREIGN KEY (selector_hash) REFERENCES selector_cache_v2(selector_hash), UNIQUE(selector_hash, normalized_input, url) ); -- Performance indices CREATE INDEX IF NOT EXISTS idx_selector_hash_v2 ON selector_cache_v2(selector_hash); CREATE INDEX IF NOT EXISTS idx_url_v2 ON selector_cache_v2(url); CREATE INDEX IF NOT EXISTS idx_input_normalized ON input_mappings(normalized_input, url); CREATE INDEX IF NOT EXISTS idx_mapping_selector_hash ON input_mappings(selector_hash); CREATE INDEX IF NOT EXISTS idx_url_selector ON input_mappings(url, selector_hash); CREATE INDEX IF NOT EXISTS idx_tokens ON input_mappings(input_tokens); -- Migration: Copy from old cache if exists INSERT OR IGNORE INTO selector_cache_v2 (selector, selector_hash, url, confidence, created_at, last_used) SELECT data as selector, substr(cache_key, 1, 32) as selector_hash, url, 0.5 as confidence, created_at, accessed_at as last_used FROM cache WHERE cache_type = 'selector' AND NOT EXISTS (SELECT 1 FROM selector_cache_v2 WHERE selector_hash = substr(cache.cache_key, 1, 32)); `); } async set(input, url, selector) { const now = Date.now(); const selectorHash = this.createSelectorHash(selector); const normalizedResult = this.normalizer.normalize(input); try { const transaction = this.db.transaction(() => { const selectorStmt = this.db.prepare(` INSERT INTO selector_cache_v2 (selector, selector_hash, url, confidence, created_at, last_used, use_count) VALUES (?, ?, ?, 0.5, ?, ?, 1) ON CONFLICT(selector_hash) DO UPDATE SET last_used = excluded.last_used, use_count = use_count + 1, confidence = MIN(confidence * 1.02, 1.0) `); selectorStmt.run(selector, selectorHash, url, now, now); const mappingStmt = this.db.prepare(` INSERT INTO input_mappings (selector_hash, input, normalized_input, input_tokens, url, last_used, learned_from) VALUES (?, ?, ?, ?, ?, ?, 'direct') ON CONFLICT(selector_hash, normalized_input, url) DO UPDATE SET success_count = success_count + 1, confidence = MIN(confidence * 1.05, 1.0), last_used = excluded.last_used, input = CASE WHEN length(excluded.input) > length(input) THEN excluded.input ELSE input END `); mappingStmt.run( selectorHash, input, normalizedResult.normalized, JSON.stringify(normalizedResult.tokens), url, now ); }); transaction(); this.stats.sets++; setImmediate(() => this.learnRelatedInputs(selectorHash, input, url, normalizedResult)); } catch (error) { console.error("[BidirectionalCache] Set error:", error); } } async get(input, url) { const normalizedResult = this.normalizer.normalize(input); const exactResult = await this.exactMatch(input, url); if (exactResult) { this.stats.hits.exact++; return { ...exactResult, source: "exact", cached: true }; } const normalizedMatch = await this.normalizedMatch(normalizedResult.normalized, url); if (normalizedMatch) { this.stats.hits.normalized++; return { ...normalizedMatch, source: "normalized", cached: true }; } const reverseMatch = await this.reverseLookup(normalizedResult, url); if (reverseMatch) { this.stats.hits.reverse++; return { ...reverseMatch, source: "reverse", cached: true }; } const fuzzyMatch = await this.fuzzyMatch(normalizedResult, url); if (fuzzyMatch) { this.stats.hits.fuzzy++; return { ...fuzzyMatch, source: "fuzzy", cached: true }; } this.stats.misses++; return null; } async exactMatch(input, url) { try { const stmt = this.db.prepare(` SELECT sc.selector, im.confidence FROM input_mappings im JOIN selector_cache_v2 sc ON sc.selector_hash = im.selector_hash WHERE im.input = ? AND im.url = ? ORDER BY im.confidence DESC, im.success_count DESC LIMIT 1 `); const result = stmt.get(input, url); if (result) { await this.updateUsage(result.selector, url); return result; } } catch (error) { console.error("[BidirectionalCache] Exact match error:", error); } return null; } async normalizedMatch(normalized, url) { try { const stmt = this.db.prepare(` SELECT sc.selector, im.confidence FROM input_mappings im JOIN selector_cache_v2 sc ON sc.selector_hash = im.selector_hash WHERE im.normalized_input = ? AND im.url = ? ORDER BY im.confidence DESC, im.success_count DESC LIMIT 1 `); const result = stmt.get(normalized, url); if (result) { await this.updateUsage(result.selector, url); return result; } } catch (error) { console.error("[BidirectionalCache] Normalized match error:", error); } return null; } async reverseLookup(normalizedResult, url) { try { const stmt = this.db.prepare(` SELECT sc.selector, im.confidence, im.input_tokens, im.success_count, GROUP_CONCAT(im.input) as all_inputs FROM input_mappings im JOIN selector_cache_v2 sc ON sc.selector_hash = im.selector_hash WHERE im.url = ? AND json_array_length(im.input_tokens) > 0 GROUP BY sc.selector_hash ORDER BY im.confidence DESC, im.success_count DESC LIMIT 10 `); const candidates = stmt.all(url); let bestMatch = null; let bestScore = 0; for (const candidate of candidates) { try { const candidateTokens = JSON.parse(candidate.input_tokens); const similarity = this.calculateJaccardSimilarity( normalizedResult.tokens, candidateTokens ); const boostedScore = similarity * (1 + Math.log(1 + candidate.success_count) * 0.1) * candidate.confidence; if (boostedScore > 0.6 && boostedScore > bestScore) { bestScore = boostedScore; bestMatch = { selector: candidate.selector, confidence: candidate.confidence * 0.9 // Slight penalty for reverse lookup }; } } catch (e) { continue; } } if (bestMatch) { await this.updateUsage(bestMatch.selector, url); return bestMatch; } } catch (error) { console.error("[BidirectionalCache] Reverse lookup error:", error); } return null; } async fuzzyMatch(normalizedResult, url) { try { const stmt = this.db.prepare(` SELECT sc.selector, im.normalized_input, im.confidence FROM input_mappings im JOIN selector_cache_v2 sc ON sc.selector_hash = im.selector_hash WHERE im.url = ? AND im.last_used > ? ORDER BY im.confidence DESC, im.success_count DESC LIMIT 20 `); const candidates = stmt.all(url, Date.now() - 36e5); for (const candidate of candidates) { const distance = this.normalizer.damerauLevenshtein( normalizedResult.normalized, candidate.normalized_input ); const maxDistance = Math.floor(normalizedResult.normalized.length / 8); if (distance <= maxDistance && distance > 0) { await this.updateUsage(candidate.selector, url); return { selector: candidate.selector, confidence: candidate.confidence * (1 - distance / 10) // Penalty for typos }; } } } catch (error) { console.error("[BidirectionalCache] Fuzzy match error:", error); } return null; } calculateJaccardSimilarity(set1, set2) { const intersection = set1.filter((x) => set2.includes(x)); const union = [.../* @__PURE__ */ new Set([...set1, ...set2])]; return intersection.length / union.length; } createSelectorHash(selector) { return crypto2.createHash("md5").update(selector).digest("hex"); } async updateUsage(selector, url) { try { const now = Date.now(); const selectorHash = this.createSelectorHash(selector); const stmt = this.db.prepare(` UPDATE selector_cache_v2 SET last_used = ?, use_count = use_count + 1 WHERE selector_hash = ? AND url = ? `); stmt.run(now, selectorHash, url); } catch (error) { console.error("[BidirectionalCache] Update usage error:", error); } } async learnRelatedInputs(selectorHash, newInput, url, normalizedResult) { try { const stmt = this.db.prepare(` SELECT input, normalized_input, input_tokens, confidence FROM input_mappings WHERE selector_hash = ? AND url = ? AND input != ? AND success_count > 1 ORDER BY confidence DESC LIMIT 5 `); const related = stmt.all(selectorHash, url, newInput); for (const rel of related) { const pattern = this.findCommonPattern(newInput, rel.input); if (pattern && pattern.confidence > 0.7) { await this.saveLearnedPattern(pattern, selectorHash, url); this.stats.learnings++; } } } catch (error) { console.error("[BidirectionalCache] Learn related inputs error:", error); } } findCommonPattern(input1, input2) { const norm1 = this.normalizer.normalize(input1); const norm2 = this.normalizer.normalize(input2); const commonTokens = norm1.tokens.filter((t) => norm2.tokens.includes(t)); if (commonTokens.length >= 2) { return { pattern: commonTokens.sort().join(" "), confidence: commonTokens.length / Math.max(norm1.tokens.length, norm2.tokens.length) }; } return null; } async saveLearnedPattern(pattern, selectorHash, url) { try { const now = Date.now(); const stmt = this.db.prepare(` INSERT OR IGNORE INTO input_mappings (selector_hash, input, normalized_input, input_tokens, url, last_used, confidence, learned_from) VALUES (?, ?, ?, ?, ?, ?, ?, 'pattern') `); stmt.run( selectorHash, `Pattern: ${pattern.pattern}`, pattern.pattern, JSON.stringify(pattern.pattern.split(" ")), url, now, pattern.confidence ); } catch (error) { } } startCleanupTimer() { this.cleanupTimer = setInterval(() => { this.cleanup(); }, this.options.cleanupInterval); } cleanup() { try { const now = Date.now(); const expiredStmt = this.db.prepare(` DELETE FROM input_mappings WHERE (last_used + ?) < ? `); expiredStmt.run(this.options.selectorTTL, now); const limitStmt = this.db.prepare(` DELETE FROM input_mappings WHERE id NOT IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER ( PARTITION BY selector_hash, url ORDER BY confidence DESC, success_count DESC, last_used DESC ) as rn FROM input_mappings ) WHERE rn <= ? ) `); limitStmt.run(this.options.maxVariationsPerSelector); const orphanStmt = this.db.prepare(` DELETE FROM selector_cache_v2 WHERE selector_hash NOT IN ( SELECT DISTINCT selector_hash FROM input_mappings ) `); orphanStmt.run(); } catch (error) { console.error("[BidirectionalCache] Cleanup error:", error); } } async getStats() { try { const dbStats = this.db.prepare(` SELECT COUNT(DISTINCT sc.selector_hash) as unique_selectors, COUNT(im.id) as total_mappings, AVG(im.success_count) as avg_success_count, COUNT(im.id) * 1.0 / COUNT(DISTINCT sc.selector_hash) as avg_inputs_per_selector, SUM(CASE WHEN im.learned_from = 'inferred' THEN 1 ELSE 0 END) * 100.0 / COUNT(im.id) as learning_rate FROM input_mappings im JOIN selector_cache_v2 sc ON sc.selector_hash = im.selector_hash `).get(); const hitRate = Object.values(this.stats.hits).reduce((a, b) => a + b, 0) / (Object.values(this.stats.hits).reduce((a, b) => a + b, 0) + this.stats.misses); return { performance: { hitRate: hitRate || 0, hits: this.stats.hits, misses: this.stats.misses, totalLookups: Object.values(this.stats.hits).reduce((a, b) => a + b, 0) + this.stats.misses }, storage: dbStats, operations: { sets: this.stats.sets, learnings: this.stats.learnings } }; } catch (error) { console.error("[BidirectionalCache] Get stats error:", error); return {}; } } async clear() { try { this.db.exec("DELETE FROM input_mappings"); this.db.exec("DELETE FROM selector_cache_v2"); this.stats = { hits: { exact: 0, normalized: 0, reverse: 0, fuzzy: 0 }, misses: 0, sets: 0, learnings: 0 }; } catch (error) { console.error("[BidirectionalCache] Clear error:", error); } } close() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); } this.db.close(); } }; export { BidirectionalCache }; //# sourceMappingURL=bidirectional-cache.js.map