ai-debug-local-mcp
Version:
🎯 ENHANCED AI GUIDANCE v4.1.2: Dramatically improved tool descriptions help AI users choose the right tools instead of 'close enough' options. Ultra-fast keyboard automation (10x speed), universal recording, multi-ecosystem debugging support, and compreh
382 lines • 17.4 kB
JavaScript
export class SelectorAutoUpdater {
changeDetector;
fileSystemOperations;
backupStrategy;
testRunner;
constructor(changeDetector) {
this.changeDetector = changeDetector;
}
setFileSystemOperations(operations) {
this.fileSystemOperations = operations;
}
setBackupStrategy(strategy) {
this.backupStrategy = strategy;
}
setTestRunner(runner) {
this.testRunner = runner;
}
async analyzeAndUpdateSelectors({ componentFile, testFiles }) {
const result = {
hasUpdates: false,
selectorUpdates: [],
suggestions: [],
dynamicClassWarnings: []
};
// Detect AST changes first
const fileType = this.detectFileType(componentFile.path);
const astChanges = this.changeDetector.detectChanges(componentFile.originalCode, componentFile.modifiedCode, fileType);
// Analyze selector changes from AST
const selectorAnalysis = await this.analyzeSelectorChangesFromAST(astChanges, componentFile.path);
// Process each test file
for (const testFile of testFiles) {
const updates = await this.findSelectorUpdatesInTestFile(testFile, selectorAnalysis, componentFile);
result.selectorUpdates.push(...updates);
}
// Add suggestions based on changes detected
if (selectorAnalysis.hasChanges) {
if (selectorAnalysis.classChanges.some(c => c.hasDataTestId)) {
result.suggestions.push('Use data-testid selectors for more stable tests');
}
if (this.isPhoenixTemplate(componentFile.path)) {
result.suggestions.push('Use data-testid attributes for reliable LiveView testing');
}
}
// Detect dynamic class names and warnings
result.dynamicClassWarnings = this.detectDynamicClassWarnings(componentFile);
result.hasUpdates = result.selectorUpdates.length > 0;
return result;
}
async analyzeSelectorChangesFromAST(astChanges, fileName) {
const analysis = {
hasChanges: false,
classChanges: [],
suggestions: [],
newElements: []
};
if (!astChanges.hasChanges) {
return analysis;
}
// Extract class changes from AST changes
// Process selector changes
let dataTestIdsAdded = false;
for (const selectorChange of astChanges.selectorChanges) {
if (selectorChange.changeType === 'data_attributes_added') {
dataTestIdsAdded = true;
analysis.suggestions.push(selectorChange.suggestedUpdate);
// Mark all existing classes as having data-testid alternatives
if (selectorChange.oldClasses) {
for (const oldClass of selectorChange.oldClasses) {
analysis.classChanges.push({
oldClass,
newClass: oldClass, // Class name stays same but data-testid is available
hasDataTestId: true,
dataTestId: this.inferDataTestId(oldClass)
});
}
}
}
else if (selectorChange.oldClasses && selectorChange.newClasses) {
// For dynamic class names
if (selectorChange.changeType === 'dynamic_class_names') {
const classChange = {
oldClass: selectorChange.oldClasses.join(' '),
newClass: selectorChange.newPattern || '',
hasDataTestId: false,
isDynamic: true
};
analysis.classChanges.push(classChange);
analysis.suggestions.push(selectorChange.suggestedUpdate);
}
else if (selectorChange.changeType === 'class_names_modified') {
// For static class changes
selectorChange.oldClasses.forEach((oldClass, index) => {
if (selectorChange.newClasses && selectorChange.newClasses[index]) {
const classChange = {
oldClass,
newClass: selectorChange.newClasses[index],
hasDataTestId: false
};
analysis.classChanges.push(classChange);
}
});
}
}
}
// Process template changes (for class changes)
for (const templateChange of astChanges.templateChanges) {
if (templateChange.changedClasses) {
for (const [oldClass, newClass] of Object.entries(templateChange.changedClasses)) {
const classChange = {
oldClass,
newClass: String(newClass),
hasDataTestId: false
};
analysis.classChanges.push(classChange);
}
}
// Check for data-testid additions
if (templateChange.addedAttributes?.includes('data-testid')) {
analysis.suggestions.push('Use data-testid for more stable test selectors');
}
}
// Detect new elements from component changes
const newElements = new Set();
for (const componentChange of astChanges.componentChanges) {
if (componentChange.addedElements) {
componentChange.addedElements.forEach((el) => newElements.add(el));
}
}
analysis.newElements = Array.from(newElements);
// Add suggestions from AST changes
analysis.suggestions.push(...astChanges.testSuggestions);
analysis.hasChanges = analysis.classChanges.length > 0 || analysis.newElements.length > 0;
return analysis;
}
async findSelectorUpdatesInTestFile(testFile, selectorAnalysis, componentFile) {
const updates = [];
const lines = testFile.code.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNumber = i + 1;
// Find CSS class selectors
for (const classChange of selectorAnalysis.classChanges) {
// Handle .className patterns
const classRegex = new RegExp(`\\.${this.escapeRegex(classChange.oldClass)}\\b`, 'g');
if (classRegex.test(line)) {
const newSelector = classChange.hasDataTestId
? `[data-testid="${classChange.dataTestId}"]`
: `.${classChange.newClass}`;
updates.push({
testFile: testFile.path,
line: lineNumber,
oldSelector: `.${classChange.oldClass}`,
newSelector: newSelector,
selectorType: 'css_selector',
confidence: classChange.hasDataTestId ? 'high' : 'medium',
reason: classChange.hasDataTestId
? 'Replaced brittle class selector with stable data-testid'
: 'Updated class name to match component changes'
});
}
// Handle toHaveClass() patterns
const classMethodRegex = new RegExp(`toHaveClass\\(['"]${this.escapeRegex(classChange.oldClass)}['"]\\)`, 'g');
if (classMethodRegex.test(line)) {
updates.push({
testFile: testFile.path,
line: lineNumber,
oldSelector: classChange.oldClass,
newSelector: classChange.newClass,
selectorType: 'class',
confidence: 'high'
});
}
}
// Handle Phoenix/LiveView patterns
if (this.isPhoenixTestFile(testFile.path)) {
for (const classChange of selectorAnalysis.classChanges) {
const phoenixRegex = new RegExp(`["']\\.${this.escapeRegex(classChange.oldClass)}["']`, 'g');
if (phoenixRegex.test(line)) {
updates.push({
testFile: testFile.path,
line: lineNumber,
oldSelector: `.${classChange.oldClass}`,
newSelector: classChange.hasDataTestId
? `[data-testid="${classChange.dataTestId}"]`
: `.${classChange.newClass}`,
selectorType: 'css_class',
confidence: classChange.hasDataTestId ? 'high' : 'medium',
reason: classChange.hasDataTestId
? 'Replaced class selector with stable data-testid'
: 'Updated class name to match template changes'
});
}
}
}
}
return updates;
}
async applySelectorsToTestFile(testFileContent, selectorUpdates) {
let updatedContent = testFileContent;
// Sort updates by line number (descending) to avoid line number shifts
const sortedUpdates = selectorUpdates.sort((a, b) => (b.line || 0) - (a.line || 0));
for (const update of sortedUpdates) {
// Apply the selector update
if (update.selectorType === 'class') {
// Handle toHaveClass() updates
const oldPattern = new RegExp(`toHaveClass\\(['"]${this.escapeRegex(update.oldSelector)}['"]\\)`, 'g');
updatedContent = updatedContent.replace(oldPattern, `toHaveClass('${update.newSelector}')`);
}
else {
// Handle CSS selector updates
const oldPattern = new RegExp(this.escapeRegex(update.oldSelector), 'g');
updatedContent = updatedContent.replace(oldPattern, update.newSelector);
}
}
return updatedContent;
}
async batchUpdateTestFiles(componentChanges, testFiles) {
const result = {
updatedFiles: [],
totalChanges: 0
};
if (!this.fileSystemOperations) {
throw new Error('File system operations not configured');
}
for (const testFilePath of testFiles) {
try {
const testFileContent = await this.fileSystemOperations.readFile(testFilePath);
let hasChanges = false;
let totalFileChanges = 0;
// Create backup first
const backupPath = await this.createTestFileBackup(testFilePath, testFileContent);
let updatedContent = testFileContent;
// Process each component change
for (const [componentPath, change] of Object.entries(componentChanges)) {
const analysisResult = await this.analyzeAndUpdateSelectors({
componentFile: {
path: componentPath,
originalCode: change.originalCode,
modifiedCode: change.modifiedCode
},
testFiles: [{ path: testFilePath, code: updatedContent }]
});
if (analysisResult.hasUpdates) {
updatedContent = await this.applySelectorsToTestFile(updatedContent, analysisResult.selectorUpdates);
totalFileChanges += analysisResult.selectorUpdates.length;
hasChanges = true;
}
}
if (hasChanges) {
await this.fileSystemOperations.writeFile(testFilePath, updatedContent);
result.updatedFiles.push({
filePath: testFilePath,
changesApplied: totalFileChanges,
backupCreated: true
});
result.totalChanges += totalFileChanges;
}
}
catch (error) {
console.error(`Failed to update test file ${testFilePath}:`, error);
}
}
return result;
}
async createTestFileBackup(filePath, content) {
if (!this.backupStrategy) {
// Default backup strategy
const timestamp = Date.now();
const backupPath = `${filePath}.backup.${timestamp}`;
return backupPath;
}
return this.backupStrategy.createBackup(filePath, content);
}
async updateWithRollbackProtection(testFile, originalContent, updatedContent) {
if (!this.testRunner || !this.backupStrategy || !this.fileSystemOperations) {
throw new Error('Test runner, backup strategy, and file system operations must be configured');
}
try {
// Run tests on original content first
const originalTestResult = await this.testRunner.runTests(testFile);
if (!originalTestResult.success) {
return {
success: false,
rolledBack: false,
reason: 'Original tests were already failing'
};
}
// Create backup and apply update
await this.createTestFileBackup(testFile, originalContent);
await this.fileSystemOperations.writeFile(testFile, updatedContent);
// Run tests on updated content
const updatedTestResult = await this.testRunner.runTests(testFile);
if (!updatedTestResult.success) {
// Rollback on failure
await this.backupStrategy.restoreBackup(testFile);
return {
success: false,
rolledBack: true,
reason: `Test failures detected after update: ${updatedTestResult.failures.join(', ')}`
};
}
return {
success: true,
rolledBack: false
};
}
catch (error) {
return {
success: false,
rolledBack: false,
reason: `Error during update: ${error instanceof Error ? error.message : error}`
};
}
}
async validateSelectorUpdates(updates) {
for (const update of updates) {
if (update.selectorType === 'css_selector' || update.selectorType === 'css_class') {
try {
// Basic CSS selector validation
if (update.newSelector.includes('..') || update.newSelector.match(/[^\w\-\[\]="':.,#\s]/)) {
throw new Error(`Invalid CSS selector: ${update.newSelector}`);
}
}
catch (error) {
throw new Error(`Invalid CSS selector: ${update.newSelector}`);
}
}
}
}
detectDynamicClassWarnings(componentFile) {
const warnings = [];
// Detect template literals in className attributes
const dynamicClassRegex = /className=\{[^}]*\$\{[^}]+\}[^}]*\}/g;
const matches = componentFile.modifiedCode.match(dynamicClassRegex);
if (matches) {
warnings.push({
component: this.extractComponentName(componentFile.path),
issue: 'Dynamic class names detected',
suggestion: 'Use data-testid="{status}" for reliable testing'
});
}
return warnings;
}
detectFileType(filePath) {
if (filePath.endsWith('.tsx') || filePath.endsWith('.ts'))
return 'typescript';
if (filePath.endsWith('.jsx') || filePath.endsWith('.js'))
return 'javascript';
if (filePath.endsWith('.heex') || filePath.endsWith('.eex'))
return 'phoenix';
return 'unknown';
}
isPhoenixTemplate(filePath) {
return filePath.endsWith('.heex') || filePath.endsWith('.eex');
}
isPhoenixTestFile(filePath) {
return filePath.endsWith('.exs') || filePath.endsWith('_test.exs');
}
extractComponentName(filePath) {
const fileName = filePath.split('/').pop() || '';
return fileName.split('.')[0];
}
escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
inferDataTestId(className) {
// Convert class name to data-testid format
// e.g., "login-form" -> "login-form"
// e.g., "submit-btn" -> "login-submit"
if (className.includes('form')) {
return className;
}
else if (className.includes('btn')) {
return className.replace('-btn', '-submit');
}
else if (className.includes('input')) {
return className.replace('-input', '-field');
}
return className;
}
}
//# sourceMappingURL=selector-auto-updater.js.map