UNPKG

chrome-extension-test-framework

Version:

Universal testing framework for Chrome extensions - Fast static analysis without browser dependencies

395 lines (336 loc) 12.7 kB
/** * IncrementalTester - 変更されたファイルのみをテストする機能 */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); class IncrementalTester { constructor(options = {}) { this.cacheFile = options.cacheFile || path.join(process.cwd(), '.cext-cache.json'); this.extensionPath = options.extensionPath || process.cwd(); this.cache = this.loadCache(); this.excludeManager = options.excludeManager; this.gitEnabled = this.checkGitAvailable(); } /** * キャッシュファイルを読み込む */ loadCache() { try { if (fs.existsSync(this.cacheFile)) { const content = fs.readFileSync(this.cacheFile, 'utf8'); return JSON.parse(content); } } catch (error) { console.warn('⚠️ Failed to load cache file:', error.message); } return { lastRun: null, fileHashes: {}, testResults: {}, dependencies: {} }; } /** * キャッシュを保存 */ saveCache() { try { fs.writeFileSync(this.cacheFile, JSON.stringify(this.cache, null, 2)); } catch (error) { console.error('❌ Failed to save cache:', error.message); } } /** * Gitが利用可能かチェック */ checkGitAvailable() { try { const gitDir = path.join(this.extensionPath, '.git'); return fs.existsSync(gitDir); } catch { return false; } } /** * ファイルのハッシュを計算 */ calculateFileHash(filePath) { try { const content = fs.readFileSync(filePath); return crypto.createHash('md5').update(content).digest('hex'); } catch (error) { return null; } } /** * 変更されたファイルを検出 */ async getChangedFiles(options = {}) { const changedFiles = new Set(); if (options.useGit && this.gitEnabled) { // Gitを使用して変更を検出 const gitChanges = await this.getGitChangedFiles(); gitChanges.forEach(file => changedFiles.add(file)); } if (options.useHash !== false) { // ハッシュベースの変更検出 const hashChanges = this.getHashChangedFiles(); hashChanges.forEach(file => changedFiles.add(file)); } if (options.sinceTime) { // 時間ベースの変更検出 const timeChanges = this.getTimeChangedFiles(options.sinceTime); timeChanges.forEach(file => changedFiles.add(file)); } return Array.from(changedFiles); } /** * Gitで変更されたファイルを取得 */ async getGitChangedFiles() { try { const { exec } = require('child_process'); const { promisify } = require('util'); const execAsync = promisify(exec); // ステージングされたファイルと変更されたファイルを取得 const { stdout } = await execAsync('git diff --name-only HEAD', { cwd: this.extensionPath }); const files = stdout.trim().split('\n').filter(Boolean); return files.map(file => path.join(this.extensionPath, file)); } catch (error) { console.warn('⚠️ Git detection failed:', error.message); return []; } } /** * ハッシュベースで変更されたファイルを検出 */ getHashChangedFiles() { const changedFiles = []; const allFiles = this.getAllFiles(this.extensionPath); allFiles.forEach(file => { const relativePath = path.relative(this.extensionPath, file); const currentHash = this.calculateFileHash(file); const cachedHash = this.cache.fileHashes[relativePath]; if (currentHash && currentHash !== cachedHash) { changedFiles.push(file); // キャッシュを更新 this.cache.fileHashes[relativePath] = currentHash; } }); return changedFiles; } /** * 時間ベースで変更されたファイルを検出 */ getTimeChangedFiles(sinceTime) { const changedFiles = []; const allFiles = this.getAllFiles(this.extensionPath); const sinceTimestamp = typeof sinceTime === 'number' ? sinceTime : Date.parse(sinceTime); allFiles.forEach(file => { try { const stats = fs.statSync(file); if (stats.mtimeMs > sinceTimestamp) { changedFiles.push(file); } } catch (error) { // ファイルアクセスエラーは無視 } }); return changedFiles; } /** * すべてのファイルを取得 */ getAllFiles(dir, files = []) { try { const items = fs.readdirSync(dir); items.forEach(item => { const fullPath = path.join(dir, item); // 除外チェック if (this.excludeManager && this.excludeManager.shouldExclude(fullPath)) { return; } try { const stat = fs.statSync(fullPath); if (stat.isDirectory()) { // デフォルトで除外するディレクトリ const excludeDirs = ['node_modules', '.git', 'dist', 'build']; if (!excludeDirs.includes(item)) { this.getAllFiles(fullPath, files); } } else if (stat.isFile()) { files.push(fullPath); } } catch (error) { // アクセスできないファイルは無視 } }); } catch (error) { console.warn(`⚠️ Failed to read directory: ${dir}`); } return files; } /** * 影響を受けるファイルを特定 */ getAffectedFiles(changedFiles) { const affected = new Set(changedFiles); // 依存関係を考慮 changedFiles.forEach(file => { const relativePath = path.relative(this.extensionPath, file); const dependents = this.cache.dependencies[relativePath] || []; dependents.forEach(dep => affected.add(path.join(this.extensionPath, dep))); }); // manifest.jsonが変更された場合は全テストを実行 if (changedFiles.some(file => path.basename(file) === 'manifest.json')) { console.log(' ℹ️ manifest.json changed - running all tests'); return null; // nullは全テストを意味する } return Array.from(affected); } /** * テスト対象を決定 */ async determineTestTargets(options = {}) { const result = { mode: 'full', files: [], suites: [], reason: '' }; // 強制的に全テストを実行 if (options.all || (!this.cache.lastRun && options.sinceLastRun)) { result.mode = 'full'; result.reason = options.all ? 'Forced full test' : 'No previous test run found'; return result; } // 変更されたファイルを取得 const changedFiles = await this.getChangedFiles({ useGit: options.useGit, useHash: true, sinceTime: options.sinceLastRun ? this.cache.lastRun : null }); if (changedFiles.length === 0) { result.mode = 'none'; result.reason = 'No changes detected'; return result; } // 影響を受けるファイルを特定 const affectedFiles = this.getAffectedFiles(changedFiles); if (affectedFiles === null) { result.mode = 'full'; result.reason = 'Critical file changed'; return result; } result.mode = 'incremental'; result.files = affectedFiles; result.reason = `${changedFiles.length} files changed`; // 変更されたファイルタイプに基づいてテストスイートを決定 result.suites = this.determineSuites(affectedFiles); return result; } /** * ファイルタイプに基づいてテストスイートを決定 */ determineSuites(files) { const suites = new Set(); files.forEach(file => { const basename = path.basename(file); const ext = path.extname(file); // manifest.json関連 if (basename === 'manifest.json') { suites.add('manifest'); suites.add('structure'); } // JavaScript関連 if (ext === '.js') { suites.add('security'); suites.add('performance'); if (basename.includes('background') || basename.includes('service-worker')) { suites.add('manifest'); } } // CSS関連 if (ext === '.css') { suites.add('performance'); } // HTML関連 if (ext === '.html') { suites.add('security'); suites.add('structure'); } // ローカライゼーション関連 if (file.includes('_locales')) { suites.add('localization'); } }); return Array.from(suites); } /** * テスト実行を記録 */ recordTestRun(results) { this.cache.lastRun = new Date().toISOString(); this.cache.testResults = { timestamp: this.cache.lastRun, summary: results.summary, mode: results.mode || 'full' }; this.saveCache(); } /** * 依存関係を更新 */ updateDependencies(file, dependencies) { const relativePath = path.relative(this.extensionPath, file); this.cache.dependencies[relativePath] = dependencies.map(dep => path.relative(this.extensionPath, dep) ); } /** * キャッシュをクリア */ clearCache() { this.cache = { lastRun: null, fileHashes: {}, testResults: {}, dependencies: {} }; try { if (fs.existsSync(this.cacheFile)) { fs.unlinkSync(this.cacheFile); } } catch (error) { console.error('❌ Failed to clear cache:', error.message); } } /** * 統計情報を取得 */ getStats() { const stats = { lastRun: this.cache.lastRun, cachedFiles: Object.keys(this.cache.fileHashes).length, lastTestMode: this.cache.testResults?.mode, lastTestSummary: this.cache.testResults?.summary }; if (this.cache.lastRun) { const lastRunDate = new Date(this.cache.lastRun); const now = new Date(); const diffMs = now - lastRunDate; const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMins / 60); if (diffHours > 0) { stats.timeSinceLastRun = `${diffHours} hours ago`; } else { stats.timeSinceLastRun = `${diffMins} minutes ago`; } } return stats; } } module.exports = IncrementalTester;