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