UNPKG

claude-playwright

Version:

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

741 lines (734 loc) 22.3 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 __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/core/cache-integration.ts var cache_integration_exports = {}; __export(cache_integration_exports, { CacheIntegration: () => CacheIntegration }); module.exports = __toCommonJS(cache_integration_exports); // src/core/cache-manager.ts var import_better_sqlite3 = __toESM(require("better-sqlite3")); var path = __toESM(require("path")); var os = __toESM(require("os")); var fs = __toESM(require("fs")); var import_crypto = __toESM(require("crypto")); var CacheManager = class { constructor(options = {}) { this.options = { maxSizeMB: options.maxSizeMB ?? 50, selectorTTL: options.selectorTTL ?? 3e5, // 5 minutes stateTTL: options.stateTTL ?? 2e3, // 2 seconds snapshotTTL: options.snapshotTTL ?? 18e5, // 30 minutes cleanupInterval: options.cleanupInterval ?? 6e4 // 1 minute }; 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, "selector-cache.db"); this.db = new import_better_sqlite3.default(dbPath); this.db.pragma("journal_mode = WAL"); this.db.pragma("synchronous = NORMAL"); this.initializeDatabase(); this.startCleanupTimer(); } initializeDatabase() { this.db.exec(` CREATE TABLE IF NOT EXISTS cache ( id INTEGER PRIMARY KEY AUTOINCREMENT, cache_key TEXT NOT NULL, cache_type TEXT NOT NULL, url TEXT NOT NULL, data TEXT NOT NULL, ttl INTEGER NOT NULL, created_at INTEGER NOT NULL, accessed_at INTEGER NOT NULL, hit_count INTEGER DEFAULT 0, profile TEXT, UNIQUE(cache_key, cache_type, profile) ); CREATE INDEX IF NOT EXISTS idx_cache_key ON cache(cache_key); CREATE INDEX IF NOT EXISTS idx_cache_type ON cache(cache_type); CREATE INDEX IF NOT EXISTS idx_url ON cache(url); CREATE INDEX IF NOT EXISTS idx_ttl ON cache(created_at, ttl); CREATE INDEX IF NOT EXISTS idx_profile ON cache(profile); -- Metrics table for performance tracking CREATE TABLE IF NOT EXISTS cache_metrics ( id INTEGER PRIMARY KEY AUTOINCREMENT, cache_type TEXT NOT NULL, hits INTEGER DEFAULT 0, misses INTEGER DEFAULT 0, evictions INTEGER DEFAULT 0, timestamp INTEGER NOT NULL ); `); } generateCacheKey(input) { const data = typeof input === "string" ? input : JSON.stringify(input); return import_crypto.default.createHash("md5").update(data).digest("hex"); } async get(key, type, profile) { const cacheKey = this.generateCacheKey(key); const now = Date.now(); try { const stmt = this.db.prepare(` SELECT * FROM cache WHERE cache_key = ? AND cache_type = ? AND (profile = ? OR (profile IS NULL AND ? IS NULL)) AND (created_at + ttl) > ? `); const entry = stmt.get(cacheKey, type, profile, profile, now); if (entry) { const updateStmt = this.db.prepare(` UPDATE cache SET hit_count = hit_count + 1, accessed_at = ? WHERE id = ? `); updateStmt.run(now, entry.id); this.recordHit(type); return JSON.parse(entry.data); } this.recordMiss(type); return null; } catch (error) { console.error("Cache get error:", error); return null; } } async set(key, value, type, options = {}) { const cacheKey = this.generateCacheKey(key); const now = Date.now(); const ttl = options.ttl ?? this.getTTLForType(type); try { await this.evictIfNeeded(); const stmt = this.db.prepare(` INSERT OR REPLACE INTO cache (cache_key, cache_type, url, data, ttl, created_at, accessed_at, hit_count, profile) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?) `); stmt.run( cacheKey, type, options.url || "", JSON.stringify(value), ttl, now, now, options.profile ); } catch (error) { console.error("Cache set error:", error); } } async invalidate(options = {}) { let query = "DELETE FROM cache WHERE 1=1"; const params = []; if (options.url) { query += " AND url = ?"; params.push(options.url); } if (options.type) { query += " AND cache_type = ?"; params.push(options.type); } if (options.profile !== void 0) { query += " AND (profile = ? OR (profile IS NULL AND ? IS NULL))"; params.push(options.profile, options.profile); } try { const stmt = this.db.prepare(query); const result = stmt.run(...params); if (result.changes > 0) { this.recordEvictions(result.changes); } } catch (error) { console.error("Cache invalidate error:", error); } } async clear() { try { this.db.exec("DELETE FROM cache"); this.db.exec("DELETE FROM cache_metrics"); } catch (error) { console.error("Cache clear error:", error); } } getTTLForType(type) { switch (type) { case "selector": return this.options.selectorTTL; case "state": return this.options.stateTTL; case "snapshot": return this.options.snapshotTTL; default: return this.options.selectorTTL; } } async evictIfNeeded() { try { const stats = fs.statSync(path.join(this.cacheDir, "selector-cache.db")); const sizeMB = stats.size / (1024 * 1024); if (sizeMB > this.options.maxSizeMB) { const deleteCount = Math.floor(this.db.prepare("SELECT COUNT(*) as count FROM cache").get().count * 0.2); const stmt = this.db.prepare(` DELETE FROM cache WHERE id IN ( SELECT id FROM cache ORDER BY accessed_at ASC LIMIT ? ) `); const result = stmt.run(deleteCount); if (result.changes > 0) { this.recordEvictions(result.changes); } } } catch (error) { console.error("Eviction error:", error); } } cleanup() { try { const now = Date.now(); const stmt = this.db.prepare("DELETE FROM cache WHERE (created_at + ttl) < ?"); const result = stmt.run(now); if (result.changes > 0) { this.recordEvictions(result.changes); } } catch (error) { console.error("Cleanup error:", error); } } startCleanupTimer() { this.cleanupTimer = setInterval(() => { this.cleanup(); }, this.options.cleanupInterval); } recordHit(type) { this.updateMetrics(type, "hits"); } recordMiss(type) { this.updateMetrics(type, "misses"); } recordEvictions(count) { try { const stmt = this.db.prepare(` INSERT INTO cache_metrics (cache_type, evictions, timestamp) VALUES ('all', ?, ?) `); stmt.run(count, Date.now()); } catch (error) { console.error("Metrics error:", error); } } updateMetrics(type, metric) { try { const stmt = this.db.prepare(` INSERT INTO cache_metrics (cache_type, ${metric}, timestamp) VALUES (?, 1, ?) `); stmt.run(type, Date.now()); } catch (error) { console.error("Metrics error:", error); } } async getMetrics() { try { const stmt = this.db.prepare(` SELECT cache_type, SUM(hits) as total_hits, SUM(misses) as total_misses, SUM(evictions) as total_evictions FROM cache_metrics GROUP BY cache_type `); return stmt.all(); } catch (error) { console.error("Get metrics error:", error); return []; } } close() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); } this.db.close(); } }; // src/core/selector-cache.ts var SelectorCache = class { constructor(cacheManager) { this.currentUrl = ""; this.cacheManager = cacheManager; } setContext(page, url, profile) { this.page = page; this.currentUrl = url; this.currentProfile = profile; } async getCachedSelector(elementDescription, elementRef) { const cacheKey = { description: elementDescription, ref: elementRef, url: this.currentUrl }; const cached = await this.cacheManager.get( cacheKey, "selector", this.currentProfile ); if (cached) { if (this.page && await this.validateSelector(cached)) { return cached; } else { await this.invalidateSelector(elementDescription, elementRef); return null; } } return null; } async cacheSelector(elementDescription, selector, strategy, elementRef, additionalData) { const cacheKey = { description: elementDescription, ref: elementRef, url: this.currentUrl }; const entry = { selector, strategy, ...additionalData }; await this.cacheManager.set( cacheKey, entry, "selector", { url: this.currentUrl, profile: this.currentProfile } ); } async getCachedElementState(selector) { const cacheKey = { selector, url: this.currentUrl }; return await this.cacheManager.get( cacheKey, "state", this.currentProfile ); } async cacheElementState(selector, state) { const cacheKey = { selector, url: this.currentUrl }; await this.cacheManager.set( cacheKey, state, "state", { url: this.currentUrl, profile: this.currentProfile } ); } async getElementState(locator) { const selector = locator.toString(); const cached = await this.getCachedElementState(selector); if (cached) { return cached; } const [isVisible, isEnabled, isEditable, boundingBox, text, value] = await Promise.all([ locator.isVisible(), locator.isEnabled(), locator.isEditable(), locator.boundingBox(), locator.textContent(), locator.inputValue().catch(() => "") ]); const attributes = await locator.evaluate((el) => { const attrs = {}; for (const attr of el.attributes) { attrs[attr.name] = attr.value; } return attrs; }); const state = { isVisible, isEnabled, isEditable, boundingBox, text: text || "", value, attributes }; await this.cacheElementState(selector, state); return state; } async validateSelector(entry) { if (!this.page) return false; try { let locator; switch (entry.strategy) { case "css": locator = this.page.locator(entry.selector); break; case "xpath": locator = this.page.locator(`xpath=${entry.selector}`); break; case "text": locator = this.page.getByText(entry.selector); break; case "role": locator = this.page.getByRole(entry.selector); break; case "testid": locator = this.page.getByTestId(entry.selector); break; default: return false; } const count = await locator.count(); return count > 0; } catch { return false; } } async invalidateSelector(elementDescription, elementRef) { const cacheKey = { description: elementDescription, ref: elementRef, url: this.currentUrl }; await this.cacheManager.invalidate({ url: this.currentUrl, type: "selector", profile: this.currentProfile }); } async invalidateForUrl(url) { await this.cacheManager.invalidate({ url, profile: this.currentProfile }); } async invalidateAll() { await this.cacheManager.invalidate({ profile: this.currentProfile }); } async batchCacheSelectors(entries) { await Promise.all( entries.map( (entry) => this.cacheSelector( entry.description, entry.selector, entry.strategy, entry.ref ) ) ); } async preloadCommonSelectors() { if (!this.page) return; const commonSelectors = [ { selector: 'input[type="text"]', strategy: "css", description: "text input" }, { selector: 'input[type="email"]', strategy: "css", description: "email input" }, { selector: 'input[type="password"]', strategy: "css", description: "password input" }, { selector: 'button[type="submit"]', strategy: "css", description: "submit button" }, { selector: "form", strategy: "css", description: "form" }, { selector: "a", strategy: "css", description: "link" }, { selector: "select", strategy: "css", description: "dropdown" } ]; const validSelectors = []; for (const entry of commonSelectors) { const locator = this.page.locator(entry.selector); const count = await locator.count(); if (count > 0) { validSelectors.push(entry); } } await this.batchCacheSelectors(validSelectors); } }; // src/core/snapshot-cache.ts var import_crypto2 = __toESM(require("crypto")); var SnapshotCache = class { constructor(cacheManager) { this.currentUrl = ""; this.cacheManager = cacheManager; } setContext(page, url, profile) { this.page = page; this.currentUrl = url; this.currentProfile = profile; } async getCachedSnapshot() { if (!this.page) return null; const domHash = await this.computeDomHash(); const cacheKey = { url: this.currentUrl, domHash }; const cached = await this.cacheManager.get( cacheKey, "snapshot", this.currentProfile ); if (cached) { this.lastDomHash = domHash; return cached.snapshot; } return null; } async cacheSnapshot(snapshot) { if (!this.page) return; const domHash = await this.computeDomHash(); const viewportSize = await this.page.viewportSize(); const entry = { snapshot, url: this.currentUrl, timestamp: Date.now(), domHash, viewportSize: viewportSize || { width: 1280, height: 720 } }; const cacheKey = { url: this.currentUrl, domHash }; await this.cacheManager.set( cacheKey, entry, "snapshot", { url: this.currentUrl, profile: this.currentProfile, ttl: 18e5 // 30 minutes } ); this.lastDomHash = domHash; } async getOrCreateSnapshot() { const cached = await this.getCachedSnapshot(); if (cached) { return cached; } if (!this.page) { throw new Error("Page context not set"); } const snapshot = await this.page.accessibility.snapshot(); await this.cacheSnapshot(snapshot); return snapshot; } async computeDomHash() { if (!this.page) return ""; try { const domStructure = await this.page.evaluate(() => { const getStructure = (element, depth = 0) => { if (depth > 5) return ""; let structure = element.tagName; const id = element.id ? `#${element.id}` : ""; const classes = element.className ? `.${element.className.split(" ").join(".")}` : ""; structure += id + classes; const children = Array.from(element.children).slice(0, 10).map((child) => getStructure(child, depth + 1)).filter((s) => s).join(","); if (children) { structure += `[${children}]`; } return structure; }; return getStructure(document.body); }); return import_crypto2.default.createHash("md5").update(domStructure).digest("hex"); } catch (error) { console.error("Error computing DOM hash:", error); return import_crypto2.default.createHash("md5").update(this.currentUrl + Date.now()).digest("hex"); } } async hasPageChanged() { if (!this.page || !this.lastDomHash) return true; const currentHash = await this.computeDomHash(); return currentHash !== this.lastDomHash; } async invalidateIfChanged() { if (await this.hasPageChanged()) { await this.invalidateSnapshot(); return true; } return false; } async invalidateSnapshot() { await this.cacheManager.invalidate({ url: this.currentUrl, type: "snapshot", profile: this.currentProfile }); this.lastDomHash = void 0; } async prefetchRelatedPages(urls) { } async getSnapshotMetrics() { const metrics = await this.cacheManager.getMetrics(); return metrics.find((m) => m.cache_type === "snapshot"); } }; // src/core/cache-integration.ts var CacheIntegration = class _CacheIntegration { constructor() { this.currentUrl = ""; this.navigationCount = 0; this.cacheManager = new CacheManager({ maxSizeMB: 50, selectorTTL: 3e5, // 5 minutes stateTTL: 2e3, // 2 seconds snapshotTTL: 18e5, // 30 minutes cleanupInterval: 6e4 // 1 minute }); this.selectorCache = new SelectorCache(this.cacheManager); this.snapshotCache = new SnapshotCache(this.cacheManager); } static { this.instance = null; } static getInstance() { if (!_CacheIntegration.instance) { _CacheIntegration.instance = new _CacheIntegration(); } return _CacheIntegration.instance; } setPage(page, url, profile) { this.currentPage = page; this.currentUrl = url; this.currentProfile = profile; this.selectorCache.setContext(page, url, profile); this.snapshotCache.setContext(page, url, profile); this.setupPageListeners(page); } setupPageListeners(page) { page.on("framenavigated", async (frame) => { if (frame === page.mainFrame()) { const newUrl = frame.url(); if (newUrl !== this.currentUrl) { console.error(`[Cache] Navigation detected: ${this.currentUrl} -> ${newUrl}`); await this.handleNavigation(newUrl); } } }); page.on("load", async () => { console.error("[Cache] Page loaded, checking for changes..."); await this.snapshotCache.invalidateIfChanged(); }); } async handleNavigation(newUrl) { this.navigationCount++; await this.cacheManager.invalidate({ url: this.currentUrl, type: "state", profile: this.currentProfile }); this.currentUrl = newUrl; if (this.currentPage) { this.selectorCache.setContext(this.currentPage, newUrl, this.currentProfile); this.snapshotCache.setContext(this.currentPage, newUrl, this.currentProfile); } await this.selectorCache.preloadCommonSelectors(); } async getCachedSelector(description, ref) { return await this.selectorCache.getCachedSelector(description, ref); } async cacheSelector(description, selector, strategy, ref) { await this.selectorCache.cacheSelector(description, selector, strategy, ref); } async getCachedSnapshot() { return await this.snapshotCache.getCachedSnapshot(); } async cacheSnapshot(snapshot) { await this.snapshotCache.cacheSnapshot(snapshot); } async getOrCreateSnapshot() { return await this.snapshotCache.getOrCreateSnapshot(); } async getCachedElementState(selector) { return await this.selectorCache.getCachedElementState(selector); } async invalidateAll() { await this.cacheManager.clear(); } async invalidateForUrl(url) { await this.cacheManager.invalidate({ url, profile: this.currentProfile }); } async getMetrics() { const metrics = await this.cacheManager.getMetrics(); return { metrics, navigationCount: this.navigationCount, currentUrl: this.currentUrl, currentProfile: this.currentProfile }; } close() { this.cacheManager.close(); _CacheIntegration.instance = null; } // Helper method for MCP tools async wrapSelectorOperation(description, ref, operation, fallbackSelector) { const cached = await this.getCachedSelector(description, ref); if (cached && cached.selector) { console.error(`[Cache] Using cached selector for "${description}": ${cached.selector}`); try { return await operation(cached.selector); } catch (error) { console.error(`[Cache] Cached selector failed, using fallback: ${fallbackSelector}`); await this.selectorCache.invalidateSelector(description, ref); } } const result = await operation(fallbackSelector); await this.cacheSelector(description, fallbackSelector, "css", ref); console.error(`[Cache] Cached new selector for "${description}": ${fallbackSelector}`); return result; } }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { CacheIntegration }); //# sourceMappingURL=cache-integration.cjs.map