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
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 __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