claude-playwright
Version:
Seamless integration between Claude Code and Playwright MCP for efficient browser automation and testing
717 lines (712 loc) • 23.9 kB
JavaScript
// 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