UNPKG

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