|
10 | 10 | */ |
11 | 11 |
|
12 | 12 | import * as vscode from "vscode"; |
| 13 | +import * as fs from "fs"; |
13 | 14 | import * as path from "path"; |
14 | 15 | import { EngineClient, WsMessage, TestReportResponse, StepDetail, BugDetail } from "./engineClient"; |
15 | 16 |
|
| 17 | +type BlueprintIssue = { level: "error" | "warning"; message: string }; |
| 18 | + |
16 | 19 | export class SidebarProvider implements vscode.WebviewViewProvider { |
17 | 20 | public static readonly viewType = "testpilot-ai.panel"; |
18 | 21 |
|
19 | 22 | private _view?: vscode.WebviewView; |
20 | 23 | private _client: EngineClient; |
21 | 24 | private _context: vscode.ExtensionContext; |
| 25 | + private _preflightChannel?: vscode.OutputChannel; |
22 | 26 |
|
23 | 27 | constructor( |
24 | 28 | private readonly _extensionUri: vscode.Uri, |
@@ -350,11 +354,25 @@ export class SidebarProvider implements vscode.WebviewViewProvider { |
350 | 354 | return; |
351 | 355 | } |
352 | 356 | try { |
| 357 | + const platform = (msg.platform || "web").toLowerCase(); |
| 358 | + const issues = await this._runBlueprintPreflight(msg.blueprint_path, platform); |
| 359 | + if (issues.length > 0) { |
| 360 | + const preflightChoice = await this._showPreflightResult(issues, msg.blueprint_path); |
| 361 | + if (preflightChoice === "open") { |
| 362 | + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(msg.blueprint_path)); |
| 363 | + await vscode.window.showTextDocument(doc, { preview: false }); |
| 364 | + this._postMessage({ command: "testError", data: { error: "蓝本预检发现高风险问题,请先修正后再运行" } }); |
| 365 | + return; |
| 366 | + } |
| 367 | + if (preflightChoice === "fix" || preflightChoice === "cancel") { |
| 368 | + this._postMessage({ command: "testError", data: { error: "已取消运行:蓝本预检未通过" } }); |
| 369 | + return; |
| 370 | + } |
| 371 | + } |
| 372 | + |
353 | 373 | // 测试前确保 WebSocket 已连接,保证步骤进度能实时推送到 WebView |
354 | 374 | this._client.ensureWsConnected(); |
355 | 375 | this._postMessage({ command: "testStarted" }); |
356 | | - |
357 | | - const platform = (msg.platform || "web").toLowerCase(); |
358 | 376 | let report: TestReportResponse; |
359 | 377 |
|
360 | 378 | if (platform === "miniprogram") { |
@@ -425,13 +443,31 @@ export class SidebarProvider implements vscode.WebviewViewProvider { |
425 | 443 | this._postMessage({ command: "testStarted" }); |
426 | 444 | this._postMessage({ command: "batchTestStarted", count: msg.blueprint_paths.length }); |
427 | 445 |
|
| 446 | + const platform = (msg.platform || "web").toLowerCase(); |
| 447 | + for (const bp of msg.blueprint_paths) { |
| 448 | + const issues = await this._runBlueprintPreflight(bp, platform); |
| 449 | + if (issues.length === 0) { |
| 450 | + continue; |
| 451 | + } |
| 452 | + const preflightChoice = await this._showPreflightResult(issues, bp, "继续批量运行"); |
| 453 | + if (preflightChoice === "open") { |
| 454 | + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(bp)); |
| 455 | + await vscode.window.showTextDocument(doc, { preview: false }); |
| 456 | + this._postMessage({ command: "testError", data: { error: "已停止批量运行:请先修正蓝本预检问题" } }); |
| 457 | + return; |
| 458 | + } |
| 459 | + if (preflightChoice === "fix" || preflightChoice === "cancel") { |
| 460 | + this._postMessage({ command: "testError", data: { error: "已取消批量运行:蓝本预检未通过" } }); |
| 461 | + return; |
| 462 | + } |
| 463 | + } |
| 464 | + |
428 | 465 | // 依次执行每个蓝本,汇总结果(用户停止时中断后续蓝本) |
429 | 466 | const results: TestReportResponse[] = []; |
430 | 467 | let userStopped = false; |
431 | 468 | for (const bp of msg.blueprint_paths) { |
432 | 469 | if (userStopped) { break; } |
433 | 470 | try { |
434 | | - const platform = (msg.platform || "web").toLowerCase(); |
435 | 471 | let report: TestReportResponse; |
436 | 472 |
|
437 | 473 | if (platform === "miniprogram") { |
@@ -821,13 +857,20 @@ export class SidebarProvider implements vscode.WebviewViewProvider { |
821 | 857 | lines.push(`当前项目路径:${projectDir.replace(/\\/g, "/")}`); |
822 | 858 | lines.push(``); |
823 | 859 | } |
824 | | - lines.push(`请为当前【${pName}】项目生成或更新测试蓝本。`); |
| 860 | + lines.push(`请为当前【${pName}】项目生成或更新测试蓝本。不要凭经验猜选择器,必须先读源码再写。`); |
825 | 861 | lines.push(``); |
826 | 862 | lines.push(`请按顺序完成以下步骤:`); |
827 | 863 | lines.push(`1. 阅读本项目的 AGENTS.md`); |
828 | 864 | lines.push(`2. 阅读 .testpilot/platforms/${platform}.md`); |
829 | | - lines.push(`3. 扫描项目源码,确认已实现的功能`); |
| 865 | + lines.push(`3. 扫描项目源码,确认已实现的功能、真实路由方式、真实选择器属性`); |
830 | 866 | lines.push(`4. 按两个文件中的规则,生成或更新 testpilot/ 目录下的蓝本`); |
| 867 | + lines.push(`5. 输出前强制自检以下项目,不通过就继续改,不要交付半成品:`); |
| 868 | + lines.push(` - 不允许出现裸 button/a/div/span 选择器`); |
| 869 | + lines.push(` - 所有 [title]/[placeholder]/[aria-label]/[data-testid]/[name] 值必须能在源码中逐字搜到`); |
| 870 | + lines.push(` - 所有 assert_text 的 expected 必须能在源码或真实渲染文本中逐字对应`); |
| 871 | + lines.push(` - 若项目不是 react-router-dom / vue-router,而是状态切页,则所有页面必须 flow:false,不能编造 /business /reports 这类 URL 页面`); |
| 872 | + lines.push(` - 生成后全文检查选择器唯一性,匹配过多时必须加父级约束或换属性`); |
| 873 | + lines.push(`6. 如果拿不准某个选择器或断言,就继续读源码,不要猜。`); |
831 | 874 |
|
832 | 875 | const prompt = lines.join("\n"); |
833 | 876 | await vscode.env.clipboard.writeText(prompt); |
@@ -1239,6 +1282,191 @@ ${commonRules}`; |
1239 | 1282 | return parts.join("/"); |
1240 | 1283 | } |
1241 | 1284 |
|
| 1285 | + private _getPreflightChannel(): vscode.OutputChannel { |
| 1286 | + if (!this._preflightChannel) { |
| 1287 | + this._preflightChannel = vscode.window.createOutputChannel("TestPilot 蓝本预检"); |
| 1288 | + } |
| 1289 | + return this._preflightChannel; |
| 1290 | + } |
| 1291 | + |
| 1292 | + private async _showPreflightResult( |
| 1293 | + issues: BlueprintIssue[], |
| 1294 | + blueprintPath: string, |
| 1295 | + continueLabel: string = "继续运行" |
| 1296 | + ): Promise<"fix" | "continue" | "open" | "cancel"> { |
| 1297 | + const ch = this._getPreflightChannel(); |
| 1298 | + ch.clear(); |
| 1299 | + ch.appendLine("=== TestPilot 蓝本预检报告 ==="); |
| 1300 | + ch.appendLine(`文件:${blueprintPath}`); |
| 1301 | + ch.appendLine(`时间:${new Date().toLocaleString("zh-CN")}`); |
| 1302 | + const errCount = issues.filter((i) => i.level === "error").length; |
| 1303 | + const warnCount = issues.filter((i) => i.level === "warning").length; |
| 1304 | + ch.appendLine(`发现 ${errCount} 个错误,${warnCount} 个警告`); |
| 1305 | + ch.appendLine(""); |
| 1306 | + issues.forEach((issue, idx) => { |
| 1307 | + ch.appendLine(`[${issue.level === "error" ? "❌ 错误" : "⚠️ 警告"}] ${idx + 1}. ${issue.message}`); |
| 1308 | + }); |
| 1309 | + ch.appendLine('\n请点击"复制修复提示词",将提示词粘贴给 AI 修改蓝本。'); |
| 1310 | + ch.show(true); |
| 1311 | + |
| 1312 | + const summary = |
| 1313 | + errCount > 0 |
| 1314 | + ? `蓝本预检发现 ${errCount} 个错误${warnCount > 0 ? `、${warnCount} 个警告` : ""},详见"TestPilot 蓝本预检"面板` |
| 1315 | + : `蓝本预检发现 ${warnCount} 个警告,详见"TestPilot 蓝本预检"面板`; |
| 1316 | + |
| 1317 | + const choice = await vscode.window.showWarningMessage( |
| 1318 | + summary, |
| 1319 | + { modal: true }, |
| 1320 | + "复制修复提示词", |
| 1321 | + continueLabel, |
| 1322 | + "打开蓝本", |
| 1323 | + ); |
| 1324 | + |
| 1325 | + if (choice === "复制修复提示词") { |
| 1326 | + const prompt = this._buildFixPrompt(issues, blueprintPath); |
| 1327 | + await vscode.env.clipboard.writeText(prompt); |
| 1328 | + vscode.window.showInformationMessage("✅ 修复提示词已复制,请粘贴给 AI 修改蓝本"); |
| 1329 | + return "fix"; |
| 1330 | + } |
| 1331 | + if (choice === continueLabel) { return "continue"; } |
| 1332 | + if (choice === "打开蓝本") { return "open"; } |
| 1333 | + return "cancel"; |
| 1334 | + } |
| 1335 | + |
| 1336 | + private _buildFixPrompt(issues: BlueprintIssue[], blueprintPath: string): string { |
| 1337 | + const lines = [ |
| 1338 | + `请修复以下测试蓝本文件中检测到的问题:`, |
| 1339 | + `文件路径:${blueprintPath}`, |
| 1340 | + ``, |
| 1341 | + `检测到的问题(共 ${issues.length} 项):`, |
| 1342 | + ...issues.map((issue, idx) => `${idx + 1}. [${issue.level === "error" ? "错误" : "警告"}] ${issue.message}`), |
| 1343 | + ``, |
| 1344 | + `修复要求:`, |
| 1345 | + `- 只修复以上列出的问题,不要修改其他内容`, |
| 1346 | + `- 所有选择器的属性值必须从源码中实际存在的属性复制,不能猜测`, |
| 1347 | + `- 如果没有稳定 id/class,使用 button:has-text('按钮文字') 格式`, |
| 1348 | + `- assert_text 的 expected 值必须是页面实际渲染的文字,从源码 JSX/HTML 中复制`, |
| 1349 | + `- 修复完成后保存文件`, |
| 1350 | + ]; |
| 1351 | + return lines.join("\n"); |
| 1352 | + } |
| 1353 | + |
| 1354 | + private async _runBlueprintPreflight(blueprintPath: string, platform: string): Promise<BlueprintIssue[]> { |
| 1355 | + try { |
| 1356 | + const raw = await vscode.workspace.fs.readFile(vscode.Uri.file(blueprintPath)); |
| 1357 | + const blueprint = JSON.parse(Buffer.from(raw).toString("utf-8")); |
| 1358 | + const issues: BlueprintIssue[] = []; |
| 1359 | + if (platform === "web") { |
| 1360 | + const projectDir = this._guessProjectPathFromBlueprint(blueprintPath); |
| 1361 | + issues.push(...this._lintWebBlueprint(blueprint, projectDir)); |
| 1362 | + } |
| 1363 | + return issues; |
| 1364 | + } catch (err) { |
| 1365 | + const message = err instanceof Error ? err.message : String(err); |
| 1366 | + return [{ level: "error", message: `蓝本解析失败:${message}` }]; |
| 1367 | + } |
| 1368 | + } |
| 1369 | + |
| 1370 | + private _lintWebBlueprint(blueprint: any, projectDir: string): BlueprintIssue[] { |
| 1371 | + const issues: BlueprintIssue[] = []; |
| 1372 | + const pages = Array.isArray(blueprint?.pages) ? blueprint.pages : []; |
| 1373 | + const sourceText = this._buildProjectSourceIndex(projectDir); |
| 1374 | + const stateBasedRouting = this._isStateBasedRoutingProject(projectDir, sourceText); |
| 1375 | + const seen = new Set<string>(); |
| 1376 | + const pushIssue = (level: "error" | "warning", message: string) => { |
| 1377 | + const key = `${level}:${message}`; |
| 1378 | + if (!seen.has(key)) { |
| 1379 | + seen.add(key); |
| 1380 | + issues.push({ level, message }); |
| 1381 | + } |
| 1382 | + }; |
| 1383 | + |
| 1384 | + for (const page of pages) { |
| 1385 | + const pageName = page?.name || page?.url || "未命名页面"; |
| 1386 | + if (stateBasedRouting && page?.flow === true) { |
| 1387 | + pushIssue("error", `页面“${pageName}”检测到状态切页项目却设置了 flow: true`); |
| 1388 | + } |
| 1389 | + for (const scenario of Array.isArray(page?.scenarios) ? page.scenarios : []) { |
| 1390 | + for (const step of Array.isArray(scenario?.steps) ? scenario.steps : []) { |
| 1391 | + const target = typeof step?.target === "string" ? step.target.trim() : ""; |
| 1392 | + const action = typeof step?.action === "string" ? step.action : ""; |
| 1393 | + if (action === "click" && /^(button|a|div|span|input|form|svg)$/i.test(target)) { |
| 1394 | + pushIssue("error", `场景“${scenario?.name || "未命名场景"}”存在裸选择器:${target}`); |
| 1395 | + } |
| 1396 | + if (/:contains\(/i.test(target) || /nth-child\s*\(/i.test(target)) { |
| 1397 | + pushIssue("error", `场景“${scenario?.name || "未命名场景"}”使用了脆弱或非法选择器:${target}`); |
| 1398 | + } |
| 1399 | + for (const match of target.matchAll(/\[(title|placeholder|aria-label|data-testid|name)=['"]([^'"]+)['"]\]/gi)) { |
| 1400 | + const attr = match[1]; |
| 1401 | + const value = match[2]; |
| 1402 | + if (!sourceText.includes(`${attr}="${value}"`) && !sourceText.includes(`${attr}='${value}'`)) { |
| 1403 | + pushIssue("warning", `选择器 ${target} 中的 ${attr}="${value}" 未在源码中找到,可能是猜测值`); |
| 1404 | + } |
| 1405 | + } |
| 1406 | + if (stateBasedRouting && action === "navigate" && typeof step?.value === "string") { |
| 1407 | + const value = step.value.trim(); |
| 1408 | + const route = value.replace(/^https?:\/\/[^/]+/i, "") || "/"; |
| 1409 | + if (route !== "/" && route !== "/login") { |
| 1410 | + pushIssue("warning", `状态切页项目使用了 URL 导航 ${route},这类页面通常应通过登录后点击进入`); |
| 1411 | + } |
| 1412 | + } |
| 1413 | + if (action === "assert_text" && typeof step?.expected === "string") { |
| 1414 | + const expected = step.expected.trim(); |
| 1415 | + if (expected.length >= 2 && sourceText && !sourceText.includes(expected)) { |
| 1416 | + pushIssue("warning", `断言文本“${expected}”未在源码中找到,可能不是实际渲染文案`); |
| 1417 | + } |
| 1418 | + } |
| 1419 | + } |
| 1420 | + } |
| 1421 | + } |
| 1422 | + return issues; |
| 1423 | + } |
| 1424 | + |
| 1425 | + private _buildProjectSourceIndex(projectDir: string): string { |
| 1426 | + if (!projectDir || !fs.existsSync(projectDir)) { |
| 1427 | + return ""; |
| 1428 | + } |
| 1429 | + const chunks: string[] = []; |
| 1430 | + const walk = (dir: string) => { |
| 1431 | + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { |
| 1432 | + const fullPath = path.join(dir, entry.name); |
| 1433 | + if (entry.isDirectory()) { |
| 1434 | + if (["node_modules", ".git", "dist", "build", ".next", "coverage", ".turbo"].includes(entry.name)) { |
| 1435 | + continue; |
| 1436 | + } |
| 1437 | + walk(fullPath); |
| 1438 | + continue; |
| 1439 | + } |
| 1440 | + if (!/\.(tsx|ts|jsx|js|vue|html|wxml|xml|swift|kt|java)$/i.test(entry.name)) { |
| 1441 | + continue; |
| 1442 | + } |
| 1443 | + try { |
| 1444 | + chunks.push(fs.readFileSync(fullPath, "utf-8")); |
| 1445 | + } catch { |
| 1446 | + // ignore unreadable files |
| 1447 | + } |
| 1448 | + } |
| 1449 | + }; |
| 1450 | + walk(projectDir); |
| 1451 | + return chunks.join("\n"); |
| 1452 | + } |
| 1453 | + |
| 1454 | + private _isStateBasedRoutingProject(projectDir: string, sourceText: string): boolean { |
| 1455 | + const packageJsonPath = path.join(projectDir, "package.json"); |
| 1456 | + if (fs.existsSync(packageJsonPath)) { |
| 1457 | + try { |
| 1458 | + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); |
| 1459 | + const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }; |
| 1460 | + if (deps["react-router-dom"] || deps["vue-router"] || deps["@angular/router"] || deps["next"]) { |
| 1461 | + return false; |
| 1462 | + } |
| 1463 | + } catch { |
| 1464 | + // ignore parse errors |
| 1465 | + } |
| 1466 | + } |
| 1467 | + return /currentApp|setCurrentApp|if\s*\([^)]*===\s*['"][^'"]+['"]\)\s*\{?\s*return\s*</.test(sourceText); |
| 1468 | + } |
| 1469 | + |
1242 | 1470 | private async _handlePlatformPrecheck(msg: { platform?: string; blueprint_path?: string }): Promise<void> { |
1243 | 1471 | const platform = (msg.platform || "web").toLowerCase(); |
1244 | 1472 | try { |
|
0 commit comments