- Create apps/web/scripts/verify-frontend.js (無頭偵察兵) - Detects Console errors (zero-error policy) - Verifies DOM content (INC-*, RPS, Latency) - Takes full-page screenshot for evidence - Update SRE Skill with mandatory verification step Constitutional Law 14/15: No curl-only verification allowed! Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
168 lines
5.7 KiB
JavaScript
168 lines
5.7 KiB
JavaScript
/**
|
|
* AWOOOI 無頭偵察兵 - Playwright 前端視覺驗證
|
|
* ==================================================
|
|
*
|
|
* 職責:
|
|
* 1. 驅動真實瀏覽器訪問正式環境
|
|
* 2. 擷取 Console 錯誤 (紅字檢測)
|
|
* 3. 檢查關鍵 DOM 元素是否渲染真實數據
|
|
* 4. 等待 SSE 串流與 React Hydration
|
|
*
|
|
* 用法:
|
|
* node scripts/verify-frontend.js
|
|
*
|
|
* 憲法條款 14: 嚴禁依賴 curl 盲目驗收
|
|
* 憲法條款 15: 必須親眼確認 DOM 渲染結果
|
|
*/
|
|
|
|
const { chromium } = require('playwright');
|
|
|
|
const TARGET_URL = process.env.TARGET_URL || 'https://awoooi.wooo.work';
|
|
const WAIT_TIME = parseInt(process.env.WAIT_TIME || '8000', 10);
|
|
|
|
async function runQA() {
|
|
console.log('============================================================');
|
|
console.log(' AWOOOI 無頭偵察兵 - Playwright 視覺驗證');
|
|
console.log('============================================================');
|
|
console.log(`目標: ${TARGET_URL}`);
|
|
console.log(`等待時間: ${WAIT_TIME}ms`);
|
|
console.log('');
|
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
const context = await browser.newContext({
|
|
viewport: { width: 1920, height: 1080 },
|
|
userAgent: 'AWOOOI-QA-Bot/1.0 Playwright',
|
|
});
|
|
const page = await context.newPage();
|
|
|
|
// 收集 Console 錯誤
|
|
const errors = [];
|
|
const warnings = [];
|
|
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') {
|
|
errors.push(msg.text());
|
|
} else if (msg.type() === 'warning') {
|
|
warnings.push(msg.text());
|
|
}
|
|
});
|
|
|
|
page.on('pageerror', (err) => {
|
|
errors.push(`[PageError] ${err.message}`);
|
|
});
|
|
|
|
// Request 失敗追蹤
|
|
page.on('requestfailed', (request) => {
|
|
errors.push(`[RequestFailed] ${request.url()} - ${request.failure()?.errorText}`);
|
|
});
|
|
|
|
try {
|
|
console.log('🚀 導航至目標頁面...');
|
|
await page.goto(TARGET_URL, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 60000,
|
|
});
|
|
|
|
console.log(`⏳ 等待 ${WAIT_TIME}ms (SSE 串流 + React Hydration)...`);
|
|
await page.waitForTimeout(WAIT_TIME);
|
|
|
|
// =========================================================================
|
|
// DOM 內容檢查
|
|
// =========================================================================
|
|
console.log('\n📋 DOM 內容分析...');
|
|
|
|
const bodyText = await page.textContent('body');
|
|
const pageTitle = await page.title();
|
|
|
|
// 關鍵指標檢查
|
|
const checks = {
|
|
// 事件數據檢查
|
|
hasIncidentId: bodyText.includes('INC-'),
|
|
hasAlertKeywords: bodyText.includes('告警') || bodyText.includes('警告') || bodyText.includes('事件'),
|
|
|
|
// GlobalPulse 指標檢查 (非零數據)
|
|
hasRpsData: /\d+\.\d+\s*(req\/s|RPS)/i.test(bodyText),
|
|
hasLatencyData: /\d+\s*(ms|毫秒)/i.test(bodyText),
|
|
|
|
// 基本頁面結構
|
|
hasTitle: pageTitle.length > 0,
|
|
hasNavigation: bodyText.includes('戰情室') || bodyText.includes('Dashboard'),
|
|
};
|
|
|
|
// 截圖保存
|
|
const screenshotPath = `/tmp/awoooi-qa-${Date.now()}.png`;
|
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
console.log(`📸 截圖已保存: ${screenshotPath}`);
|
|
|
|
// =========================================================================
|
|
// 報告輸出
|
|
// =========================================================================
|
|
console.log('\n============================================================');
|
|
console.log(' 🔍 視覺驗證報告');
|
|
console.log('============================================================');
|
|
console.log(`📄 頁面標題: ${pageTitle}`);
|
|
console.log('');
|
|
|
|
// Console 錯誤報告
|
|
console.log('🔴 Console 錯誤:');
|
|
if (errors.length === 0) {
|
|
console.log(' ✅ 零錯誤 (Clean)');
|
|
} else {
|
|
console.log(` ❌ 發現 ${errors.length} 個錯誤:`);
|
|
errors.slice(0, 10).forEach((e, i) => {
|
|
console.log(` ${i + 1}. ${e.substring(0, 100)}...`);
|
|
});
|
|
}
|
|
|
|
// Console 警告報告
|
|
if (warnings.length > 0) {
|
|
console.log(`\n⚠️ Console 警告: ${warnings.length} 個`);
|
|
}
|
|
|
|
// DOM 數據檢查報告
|
|
console.log('\n📊 DOM 數據檢查:');
|
|
console.log(` ${checks.hasIncidentId ? '✅' : '❌'} 事件 ID (INC-*)`);
|
|
console.log(` ${checks.hasAlertKeywords ? '✅' : '❌'} 告警關鍵字`);
|
|
console.log(` ${checks.hasRpsData ? '✅' : '❌'} RPS 數據`);
|
|
console.log(` ${checks.hasLatencyData ? '✅' : '❌'} Latency 數據`);
|
|
console.log(` ${checks.hasNavigation ? '✅' : '❌'} 導航元素`);
|
|
|
|
// 最終判定
|
|
const hasRealData = checks.hasIncidentId || checks.hasAlertKeywords || checks.hasRpsData;
|
|
const isClean = errors.length === 0;
|
|
|
|
console.log('\n============================================================');
|
|
console.log(' 📋 最終判定');
|
|
console.log('============================================================');
|
|
console.log(`Console 狀態: ${isClean ? '✅ 零紅字' : '❌ 有錯誤'}`);
|
|
console.log(`數據渲染狀態: ${hasRealData ? '✅ 有真實數據' : '⚠️ 數據不足'}`);
|
|
console.log('');
|
|
|
|
await browser.close();
|
|
|
|
// 退出碼決定
|
|
if (!isClean) {
|
|
console.log('❌ 驗收失敗: Console 存在錯誤');
|
|
process.exit(1);
|
|
}
|
|
|
|
// 數據不足不阻塞,但輸出警告
|
|
if (!hasRealData) {
|
|
console.log('⚠️ 警告: 畫面數據不足,可能需要注入告警或等待 SSE');
|
|
}
|
|
|
|
console.log('✅ 視覺驗證通過');
|
|
process.exit(0);
|
|
|
|
} catch (err) {
|
|
console.error('❌ 驗證過程發生錯誤:', err.message);
|
|
await browser.close();
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
runQA().catch((err) => {
|
|
console.error('❌ 腳本執行失敗:', err);
|
|
process.exit(1);
|
|
});
|