Winson HTML Dashboard 自动化测试 + AI 自动修复 Skill
一、核心哲学
1.1 唯一铁律
测试发现问题只是开始,AI 自动修复才是目标。
传统的自动化测试只负责"发现问题",把修复工作丢给开发者。这个 Skill 的铁律是:测试失败 → AI 自动诊断 → 生成修复代码 → 应用修复 → 重新验证。人工只在最后一步确认,中间全部自动化。
1.2 三个底层认知
| 认知 | 说明 | |------|------| | 测试是手段,修复是目的 | 只测不修等于浪费算力。AI 修复能力让这个 Skill 从"监控工具"升级为"自治系统"。 | | 循环不是重复测试,是修复验证闭环 | 每次循环不是简单重测,而是"测试→修复→重测"的完整闭环,直到问题消失。 | | 人工确认是最后防线,不是日常负担 | AI 修复后需要人工 review,但日常开发中开发者只需关注最终通过的绿色结果。 |
1.3 核心公式
自治质量保障 = (测试发现问题 + AI 自动修复 + 循环验证) × 持续运行
二、完整执行流程
Phase 1:环境准备(5分钟)
Step 1.1:安装核心工具链
# 安装 Playwright(测试引擎)
npm init playwright@latest
# 安装性能测试依赖
npm install --save-dev playwright-lighthouse get-port
# 安装文件监听(本地持续运行用)
npm install --save-dev chokidar
# 安装 AI 修复辅助库
npm install --save-dev @anthropic-ai/sdk # 或 openai, 根据你用的 AI 服务
检验标准:运行 npx playwright --version 显示版本号 ≥ 1.40
Step 1.2:配置 Playwright + AI 修复
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 0, // 不重试,失败直接触发 AI 修复
workers: 1, // 单 worker,确保修复顺序执行
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'test-results/results.json' }],
['./reporters/ai-fix-reporter.ts'], // AI 修复报告器
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on',
screenshot: 'on',
video: 'on',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'Performance', use: { ...devices['Desktop Chrome'] } },
],
});
检验标准:运行 npx playwright test --list 能列出所有测试项目
Phase 2:AI 自动修复核心引擎
Step 2.1:AI 修复报告器(测试失败时自动触发)
// reporters/ai-fix-reporter.ts
import type { Reporter, TestCase, TestResult, FullConfig, Suite } from '@playwright/test/reporter';
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
class AIFixReporter implements Reporter {
private failures: Array<{
test: string;
error: string;
tracePath: string;
screenshotPath: string;
}> = [];
private maxFixAttempts = 3;
private currentAttempt = 0;
onTestEnd(test: TestCase, result: TestResult) {
if (result.status === 'failed') {
// 收集失败证据
const tracePath = result.attachments.find(a => a.name === 'trace')?.path || '';
const screenshotPath = result.attachments.find(a => a.name === 'screenshot')?.path || '';
this.failures.push({
test: test.title,
error: result.error?.message || 'Unknown error',
tracePath,
screenshotPath,
});
}
}
async onEnd() {
if (this.failures.length === 0) {
console.log('✅ 所有测试通过,无需修复');
return;
}
console.log(`\n🤖 检测到 ${this.failures.length} 个测试失败,启动 AI 自动修复...\n`);
for (const failure of this.failures) {
await this.attemptFix(failure);
}
}
private async attemptFix(failure: any) {
this.currentAttempt++;
if (this.currentAttempt > this.maxFixAttempts) {
console.error(`❌ 达到最大修复次数 (${this.maxFixAttempts}),放弃修复: ${failure.test}`);
return;
}
console.log(`\n🔧 尝试修复 (${this.currentAttempt}/${this.maxFixAttempts}): ${failure.test}`);
// 1. 收集上下文信息
const context = await this.gatherContext(failure);
// 2. 调用 AI 生成修复
const fix = await this.callAI(context);
if (!fix) {
console.log('❌ AI 无法生成修复方案');
return;
}
// 3. 应用修复
await this.applyFix(fix);
// 4. 重新运行失败的测试验证
console.log('🔄 重新运行测试验证修复...');
const passed = await this.reRunTest(failure.test);
if (passed) {
console.log(`✅ 修复成功: ${failure.test}`);
} else {
console.log(`❌ 修复未通过,继续尝试...`);
await this.attemptFix(failure);
}
}
private async gatherContext(failure: any) {
// 读取相关源代码
const srcFiles = this.findRelatedFiles(failure.test);
const testFile = this.findTestFile(failure.test);
return {
testName: failure.test,
errorMessage: failure.error,
tracePath: failure.tracePath,
screenshotPath: failure.screenshotPath,
sourceFiles: srcFiles,
testFile: testFile,
projectRoot: process.cwd(),
};
}
private findRelatedFiles(testName: string): string[] {
// 根据测试名称推断相关源文件
const srcDir = path.join(process.cwd(), 'src');
const files: string[] = [];
// 读取 src 目录下的所有文件
const readDir = (dir: string) => {
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
readDir(fullPath);
} else if (/\.(js|ts|jsx|tsx|vue|html|css)$/.test(item)) {
files.push(fullPath);
}
}
};
if (fs.existsSync(srcDir)) {
readDir(srcDir);
}
return files;
}
private findTestFile(testName: string): string {
const testDir = path.join(process.cwd(), 'tests');
const files = fs.readdirSync(testDir);
return files.find(f => f.includes(testName.split(' ')[0])) || '';
}
private async callAI(context: any): Promise<{ filePath: string; originalCode: string; fixedCode: string } | null> {
// 读取截图(base64 编码)
let screenshotBase64 = '';
if (context.screenshotPath && fs.existsSync(context.screenshotPath)) {
screenshotBase64 = fs.readFileSync(context.screenshotPath).toString('base64');
}
// 读取关键源文件内容
const sourceContents = context.sourceFiles.slice(0, 5).map((file: string) => {
try {
return { path: file, content: fs.readFileSync(file, 'utf-8') };
} catch {
return null;
}
}).filter(Boolean);
// 构建 AI Prompt
const prompt = this.buildPrompt(context, sourceContents, screenshotBase64);
// 调用 AI API(示例用 Claude,可替换为 OpenAI 等)
try {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY || '',
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-3-sonnet-20240229',
max_tokens: 4096,
messages: [{ role: 'user', content: prompt }],
}),
});
const data = await response.json();
const aiResponse = data.content?.[0]?.text || '';
// 解析 AI 返回的修复代码
return this.parseFixResponse(aiResponse, context.sourceFiles);
} catch (error) {
console.error('AI 调用失败:', error);
return null;
}
}
private buildPrompt(context: any, sourceContents: any[], screenshotBase64: string): string {
return `你是一个前端代码修复专家。请分析以下测试失败信息,并生成修复代码。
## 测试失败信息
- 测试名称: ${context.testName}
- 错误信息: ${context.errorMessage}
## 相关源代码文件
${sourceContents.map((f: any) => `
### ${f.path}
\`\`\`
${f.content.substring(0, 3000)}
\`\`\`
`).join('\n')}
## 任务
1. 分析错误原因
2. 确定需要修改的文件
3. 生成修复后的完整代码
4. 用以下格式返回修复:
FIX_START
文件路径: [文件路径]
原始代码:
[需要替换的原始代码块]
修复后代码:
[修复后的代码块]
FIX_END
如果无法确定修复方案,请说明原因。`;
}
private parseFixResponse(response: string, sourceFiles: string[]): { filePath: string; originalCode: string; fixedCode: string } | null {
const fixMatch = response.match(/FIX_START\s*文件路径:\s*(.+?)\s*原始代码:\s*([\s\S]+?)\s*修复后代码:\s*([\s\S]+?)\s*FIX_END/);
if (!fixMatch) {
return null;
}
return {
filePath: fixMatch[1].trim(),
originalCode: fixMatch[2].trim(),
fixedCode: fixMatch[3].trim(),
};
}
private async applyFix(fix: { filePath: string; originalCode: string; fixedCode: string }) {
console.log(`📝 应用修复到: ${fix.filePath}`);
try {
const content = fs.readFileSync(fix.filePath, 'utf-8');
if (!content.includes(fix.originalCode)) {
console.error('❌ 原始代码不匹配,无法应用修复');
return;
}
// 备份原文件
const backupPath = `${fix.filePath}.backup.${Date.now()}`;
fs.writeFileSync(backupPath, content);
// 应用修复
const newContent = content.replace(fix.originalCode, fix.fixedCode);
fs.writeFileSync(fix.filePath, newContent);
console.log(`✅ 修复已应用,备份保存到: ${backupPath}`);
} catch (error) {
console.error('❌ 应用修复失败:', error);
}
}
private async reRunTest(testName: string): Promise<boolean> {
try {
execSync(`npx playwright test --grep "${testName}" --reporter=line`, {
stdio: 'inherit',
timeout: 120000,
});
return true;
} catch {
return false;
}
}
}
export default AIFixReporter;
检验标准:运行测试失败后,控制台显示 "🤖 启动 AI 自动修复..."
Phase 3:测试层(功能 + 视觉 + 性能)
测试用例与原版相同,但增加了更详细的错误信息收集,方便 AI 诊断。
Step 3.1:增强版功能测试(带诊断信息)
// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Dashboard 核心功能', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/dashboard');
await page.waitForSelector('[data-testid="dashboard-grid"]', {
state: 'visible',
timeout: 10000
});
});
test('所有关键组件正常渲染', async ({ page }) => {
const components = ['stats-cards', 'main-chart', 'data-table'];
for (const component of components) {
const element = page.getByTestId(component);
const isVisible = await element.isVisible().catch(() => false);
if (!isVisible) {
// 收集诊断信息
const html = await page.content();
const screenshot = await page.screenshot();
console.error(`组件 ${component} 未找到。当前页面 HTML 片段:`,
html.substring(0, 500));
}
await expect(element, `组件 ${component} 应该可见`).toBeVisible();
}
});
test('图表交互正常', async ({ page }) => {
const chart = page.getByTestId('main-chart');
await test.step('悬停显示 tooltip', async () => {
await chart.hover();
const tooltip = page.locator('.chart-tooltip');
await expect(tooltip, 'Tooltip 应该在悬停后显示').toBeVisible({ timeout: 5000 });
});
await test.step('点击筛选数据', async () => {
await chart.click();
await expect(page, '点击后应该跳转到筛选页面').toHaveURL(/.*filtered=true/);
});
});
test('数据表格排序与分页', async ({ page }) => {
const table = page.getByTestId('data-table');
await test.step('点击表头排序', async () => {
const sortHeader = table.locator('th[data-sort="date"]');
await sortHeader.click();
const firstCell = table.locator('tbody tr:first-child td:first-child');
await expect(firstCell, '排序后第一行应该包含 2024').toContainText('2024');
});
await test.step('分页切换', async () => {
const nextButton = page.getByRole('button', { name: '下一页' });
await nextButton.click();
const rows = table.locator('tbody tr');
const count = await rows.count();
expect(count, `分页后应该有 10 行,实际有 ${count} 行`).toBe(10);
});
});
});
Step 3.2:视觉回归测试(AI 可对比截图差异)
// tests/visual.spec.ts
import { test, expect } from '@playwright/test';
test.describe('视觉回归测试', () => {
test('Dashboard 首页截图对比', async ({ page }) => {
await page.goto('/dashboard');
// 等待图表渲染完成
await page.waitForFunction(() => {
const charts = document.querySelectorAll('.chart-container');
return Array.from(charts).every(c => c.getAttribute('data-rendered') === 'true');
}, { timeout: 10000 });
// 截图并对比
await expect(page).toHaveScreenshot('dashboard-fullpage.png', {
fullPage: true,
maxDiffPixels: 100,
});
});
test('响应式布局 - 多设备截图', async ({ page }) => {
const viewports = [
{ name: 'mobile', width: 375, height: 667 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1920, height: 1080 },
];
for (const viewport of viewports) {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('/dashboard');
await expect(page).toHaveScreenshot(`dashboard-${viewport.name}.png`, {
fullPage: true,
});
}
});
});
Step 3.3:性能测试(阈值失败触发 AI 优化)
// tests/performance/metrics.spec.ts
import { test, expect } from '@playwright/test';
test.describe('核心性能指标', () => {
test('LCP 小于 2.5 秒', async ({ page }) => {
await page.goto('/dashboard');
const lcp = await page.evaluate(() => {
return new Promise<number>((resolve) => {
new PerformanceObserver((list) => {
const entries = list.getEntries();
resolve(entries[entries.length - 1].startTime);
}).observe({ entryTypes: ['largest-contentful-paint'] });
});
});
expect(lcp, `LCP 为 ${lcp}ms,应小于 2500ms`).toBeLessThan(2500);
});
test('内存使用监控', async ({ page }) => {
await page.goto('/dashboard');
// 执行交互后检查内存
await page.click('[data-testid="refresh-data"]');
await page.waitForTimeout(1000);
const memory = await page.evaluate(() => {
return (performance as any).memory?.usedJSHeapSize || 0;
});
const memoryMB = Math.round(memory / 1024 / 1024);
expect(memory, `内存使用为 ${memoryMB}MB,应小于 100MB`).toBeLessThan(100 * 1024 * 1024);
});
});
Phase 4:循环运行方案(测试 → 修复 → 验证 闭环)
Step 4.1:AI 修复循环脚本
// scripts/ai-fix-loop.js
const { spawn } = require('child_process');
const fs = require('fs');
const CONFIG = {
interval: 30 * 1000, // 测试间隔 30 秒
maxFixAttempts: 3, // 单次失败最大修复次数
maxTotalLoops: 100, // 最大总循环次数
requireHumanConfirm: true, // 是否人工确认修复
};
let loopCount = 0;
let consecutiveFailures = 0;
async function runLoop() {
loopCount++;
console.log(`\n========== 循环 ${loopCount}/${CONFIG.maxTotalLoops} ==========`);
console.log(`[${new Date().toLocaleString()}] 开始测试运行...`);
// 1. 运行测试
const testPassed = await runTests();
if (testPassed) {
console.log('✅ 所有测试通过');
consecutiveFailures = 0;
} else {
console.log('❌ 测试失败,AI 修复流程已启动(见上方输出)');
consecutiveFailures++;
if (consecutiveFailures >= 3) {
console.error('🚨 告警:连续 3 轮测试失败,请人工介入检查');
process.exit(1);
}
}
// 2. 检查是否需要人工确认
if (fs.existsSync('.pending-confirm')) {
console.log('⏸️ 有修复待人工确认,暂停循环');
console.log(' 请检查修改后运行: node scripts/confirm-fix.js');
return; // 暂停循环
}
// 3. 定时下一次
if (loopCount < CONFIG.maxTotalLoops) {
setTimeout(runLoop, CONFIG.interval);
} else {
console.log('🏁 达到最大循环次数,停止运行');
}
}
function runTests() {
return new Promise((resolve) => {
const proc = spawn('npx', ['playwright', 'test', '--reporter=line'], {
stdio: 'inherit',
shell: true,
});
proc.on('close', (code) => {
resolve(code === 0);
});
});
}
// 启动
console.log('🚀 启动 AI 自动修复测试循环');
console.log(`配置: 间隔 ${CONFIG.interval / 1000}s, 最大修复 ${CONFIG.maxFixAttempts} 次`);
runLoop();
Step 4.2:人工确认脚本
// scripts/confirm-fix.js
const fs = require('fs');
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log('🔍 待确认的修复:');
// 读取备份文件列表
const backups = fs.readdirSync('.').filter(f => f.endsWith('.backup'));
if (backups.length === 0) {
console.log('没有待确认的修复');
process.exit(0);
}
backups.forEach((backup, i) => {
const original = backup.replace(/\.backup\.\d+$/, '');
console.log(`${i + 1}. ${original}`);
console.log(` 备份: ${backup}`);
});
rl.question('\n确认应用这些修复? (y/n): ', (answer) => {
if (answer.toLowerCase() === 'y') {
// 删除备份(确认修复)
backups.forEach(backup => fs.unlinkSync(backup));
fs.unlinkSync('.pending-confirm');
console.log('✅ 修复已确认');
} else {
// 回滚修复
backups.forEach(backup => {
const original = backup.replace(/\.backup\.\d+$/, '');
fs.copyFileSync(backup, original);
fs.unlinkSync(backup);
console.log(`↩️ 已回滚: ${original}`);
});
fs.unlinkSync('.pending-confirm');
console.log('↩️ 所有修复已回滚');
}
rl.close();
});
Step 4.3:package.json 脚本
{
"scripts": {
"test": "playwright test",
"test:watch": "node scripts/watch-test.js",
"test:loop": "node scripts/ai-fix-loop.js",
"test:confirm": "node scripts/confirm-fix.js",
"test:ui": "playwright test --ui"
}
}
Phase 5:AI 修复 Prompt 模板库
Step 5.1:常见错误类型的 AI Prompt
// lib/ai-prompts.ts
export const prompts = {
// 元素未找到
elementNotFound: (error: string, html: string) => `
测试报错: ${error}
当前页面 HTML 结构:
\`\`\`html
${html.substring(0, 2000)}
\`\`\`
问题分析:
1. 检查选择器是否正确
2. 检查元素是否在 DOM 中
3. 检查是否有异步加载导致元素未出现
修复方案:
- 如果元素是异步加载的,添加等待逻辑
- 如果选择器错误,修正选择器
- 如果元素被条件渲染,检查条件逻辑
请生成修复代码。
`,
// 视觉回归差异
visualRegression: (diffPixels: number, screenshotPath: string) => `
视觉回归测试失败: 截图差异 ${diffPixels} 像素
截图路径: ${screenshotPath}
可能原因:
1. CSS 样式变更导致布局变化
2. 字体渲染差异
3. 动画/过渡效果未禁用
4. 动态内容(时间、随机数)导致差异
修复方案:
- 检查最近的 CSS 修改
- 在测试环境中禁用动画
- 对动态内容使用 mock 数据
请生成修复代码。
`,
// 性能不达标
performance: (metric: string, value: number, threshold: number) => `
性能测试失败: ${metric}
当前值: ${value}
阈值: ${threshold}
优化建议:
- 检查是否有阻塞渲染的资源
- 优化图片/资源加载
- 减少 JavaScript 执行时间
- 使用代码分割/懒加载
请生成性能优化代码。
`,
// 交互失败
interaction: (action: string, expected: string, actual: string) => `
交互测试失败:
操作: ${action}
期望结果: ${expected}
实际结果: ${actual}
可能原因:
1. 事件监听未正确绑定
2. 异步操作未完成
3. 状态管理错误
4. DOM 更新延迟
请生成修复代码。
`,
};
三、完整工作流示例
场景:你改了一个 CSS,导致按钮点不了
你修改了 src/components/Button.css
↓
运行 npm run test:loop
↓
🔄 循环 1: 测试运行
❌ dashboard.spec.ts: 按钮点击无响应
↓
🤖 AI 自动修复启动
📸 收集截图证据
📄 读取 Button.css 和 Button.tsx
🧠 调用 AI 分析...
↓
AI 诊断: "CSS 中 pointer-events: none 导致按钮无法点击"
↓
📝 生成修复: 移除 pointer-events: none
↓
✅ 应用修复,备份原文件
↓
🔄 重新运行测试
✅ 测试通过!
↓
⏸️ 暂停循环,等待人工确认
↓
你运行 npm run test:confirm
查看修改 → 确认无误 → 输入 y
↓
✅ 修复确认,删除备份
↓
🔄 循环继续...
四、常见陷阱与避坑指南
| 陷阱 | 表现 | 修正方案 | |------|------|----------| | AI 修复越修越坏 | 连续修复后代码无法运行 | 设置 maxFixAttempts=3,超过后停止并告警 | | AI 修改了不该改的文件 | 修复范围扩散 | 限制 AI 只修改与错误相关的文件 | | 修复后功能对了但样式崩了 | 视觉测试未通过 | 功能修复后必须重新运行全部测试 | | AI 生成代码不符合项目规范 | 代码风格不一致 | 在 prompt 中加入项目代码规范 | | 循环消耗大量 API 费用 | AI 调用次数过多 | 设置合理的间隔和最大尝试次数 |
五、质量检验清单
- [ ] Playwright 安装完成,AI API Key 配置正确
- [ ] AI 修复报告器能正常触发
- [ ] 测试失败后 AI 能生成修复代码
- [ ] 修复能正确应用到源代码
- [ ] 修复后重新测试能验证通过
- [ ] 人工确认机制正常工作
- [ ] 备份和回滚功能正常
- [ ] 循环运行 10 轮以上无异常
- [ ] API 费用在可接受范围内
六、执行原则
- AI 修复是默认行为,不是可选项:测试失败必须触发 AI 修复,不能只是报告
- 人工确认是最后防线:AI 修复后必须人工 review,不能直接提交到生产
- 修复范围要受限:AI 只能修改与错误直接相关的代码,不能大范围重构
- 保留回滚能力:每次修复必须备份原文件,确保可回滚
- 控制 API 成本:设置合理的修复尝试次数和循环间隔
Scan to join WeChat group