Skip to content

Commit 34190af

Browse files
committed
feat: 蓝本预检增强 - Output Channel+修复提示词弹窗(v16模板)
1 parent ec00333 commit 34190af

3 files changed

Lines changed: 309 additions & 7 deletions

File tree

extension/src/rulesInjector.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ function detectAllIDEs(): string[] {
8181
* 模板版本号。每次更新模板内容时递增。
8282
* rulesInjector 会检测已注入文件的版本号,低于此版本则自动更新。
8383
*/
84-
const TEMPLATE_VERSION = 15;
84+
const TEMPLATE_VERSION = 16;
8585

8686
/** 从文件内容中提取版本号,找不到返回 0(旧版无版本标记) */
8787
function extractVersion(content: string): number {
@@ -256,6 +256,34 @@ and strictly follow all rules in that file.
256256
> ⚠️ The platform rule file is the final authority on blueprint quality. AGENTS.md defines general principles only; specific selector formats, forbidden syntax, and wait strategies are governed solely by the platform rule file.
257257
258258
**NEVER write a blueprint without reading the platform rule file.**
259+
260+
---
261+
262+
## SIX: Mandatory Self-Check Before Saving Blueprint
263+
264+
Before saving any blueprint file, the AI MUST self-check all of the following:
265+
266+
1. No bare tag selectors:
267+
- Forbidden:
268+
- \`button\`
269+
- \`div\`
270+
- \`span\`
271+
- \`a\`
272+
- Every click target must include a real attribute, stable class, parent scope, or verified \`:has-text()\`
273+
2. Every attribute selector value must be searchable in source code:
274+
- \`[title='x']\`
275+
- \`[placeholder='x']\`
276+
- \`[aria-label='x']\`
277+
- \`[data-testid='x']\`
278+
- \`[name='x']\`
279+
If the exact value cannot be found in source code, do NOT use that selector.
280+
3. Every \`assert_text.expected\` must be copied verbatim from source-rendered UI text, not summarized or paraphrased.
281+
4. Routing mode must be checked before writing \`flow\` or page URLs:
282+
- If the project has no router library and uses store/state to switch components, treat it as state-based routing
283+
- In state-based routing projects, pages must default to \`flow: false\` and scenarios must enter submodules by UI clicks, not fake URLs
284+
5. After writing selectors, perform a uniqueness review across the codebase. If a selector is likely to match multiple elements, refine it before saving.
285+
286+
If any self-check item fails, keep reading source code and revise the blueprint. Never guess and never save a half-correct blueprint.
259287
`;
260288
}
261289

extension/src/sidebarProvider.ts

Lines changed: 233 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,19 @@
1010
*/
1111

1212
import * as vscode from "vscode";
13+
import * as fs from "fs";
1314
import * as path from "path";
1415
import { EngineClient, WsMessage, TestReportResponse, StepDetail, BugDetail } from "./engineClient";
1516

17+
type BlueprintIssue = { level: "error" | "warning"; message: string };
18+
1619
export class SidebarProvider implements vscode.WebviewViewProvider {
1720
public static readonly viewType = "testpilot-ai.panel";
1821

1922
private _view?: vscode.WebviewView;
2023
private _client: EngineClient;
2124
private _context: vscode.ExtensionContext;
25+
private _preflightChannel?: vscode.OutputChannel;
2226

2327
constructor(
2428
private readonly _extensionUri: vscode.Uri,
@@ -350,11 +354,25 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
350354
return;
351355
}
352356
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+
353373
// 测试前确保 WebSocket 已连接,保证步骤进度能实时推送到 WebView
354374
this._client.ensureWsConnected();
355375
this._postMessage({ command: "testStarted" });
356-
357-
const platform = (msg.platform || "web").toLowerCase();
358376
let report: TestReportResponse;
359377

360378
if (platform === "miniprogram") {
@@ -425,13 +443,31 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
425443
this._postMessage({ command: "testStarted" });
426444
this._postMessage({ command: "batchTestStarted", count: msg.blueprint_paths.length });
427445

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+
428465
// 依次执行每个蓝本,汇总结果(用户停止时中断后续蓝本)
429466
const results: TestReportResponse[] = [];
430467
let userStopped = false;
431468
for (const bp of msg.blueprint_paths) {
432469
if (userStopped) { break; }
433470
try {
434-
const platform = (msg.platform || "web").toLowerCase();
435471
let report: TestReportResponse;
436472

437473
if (platform === "miniprogram") {
@@ -821,13 +857,20 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
821857
lines.push(`当前项目路径:${projectDir.replace(/\\/g, "/")}`);
822858
lines.push(``);
823859
}
824-
lines.push(`请为当前【${pName}】项目生成或更新测试蓝本。`);
860+
lines.push(`请为当前【${pName}】项目生成或更新测试蓝本。不要凭经验猜选择器,必须先读源码再写。`);
825861
lines.push(``);
826862
lines.push(`请按顺序完成以下步骤:`);
827863
lines.push(`1. 阅读本项目的 AGENTS.md`);
828864
lines.push(`2. 阅读 .testpilot/platforms/${platform}.md`);
829-
lines.push(`3. 扫描项目源码,确认已实现的功能`);
865+
lines.push(`3. 扫描项目源码,确认已实现的功能、真实路由方式、真实选择器属性`);
830866
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. 如果拿不准某个选择器或断言,就继续读源码,不要猜。`);
831874

832875
const prompt = lines.join("\n");
833876
await vscode.env.clipboard.writeText(prompt);
@@ -1239,6 +1282,191 @@ ${commonRules}`;
12391282
return parts.join("/");
12401283
}
12411284

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+
12421470
private async _handlePlatformPrecheck(msg: { platform?: string; blueprint_path?: string }): Promise<void> {
12431471
const platform = (msg.platform || "web").toLowerCase();
12441472
try {

开发备忘录.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,56 @@
11
# TestPilot AI 开发备忘录
22

3-
> 最后更新:2026-03-28(✅ v14.14 浏览器崩溃自动重建 + Checkpoint 导航路径恢复
3+
> 最后更新:2026-03-30(✅ 插件侧蓝本预检 + 生成提示词强化
44
> 项目结构详见 README.md,此处不重复。
55
66
---
77

8+
## ✅ 插件侧蓝本预检 + 生成提示词强化(2026-03-30)
9+
10+
### 背景
11+
12+
近期蓝本错误高频集中在两类:
13+
1. 编程AI虽然读到了规则文件,但侧边栏复制提示词过短,容易直接凭经验写蓝本,出现裸 `button`、猜测属性值、错误 `flow` 等系统性问题
14+
2. 插件在运行蓝本前没有静态体检,坏蓝本会直接进入引擎,导致用户误以为是引擎稳定性问题
15+
16+
### 修改文件
17+
- `extension/src/sidebarProvider.ts`
18+
- `extension/src/rulesInjector.ts`
19+
20+
### 功能 1 — 运行前蓝本预检
21+
22+
在侧边栏单蓝本/批量蓝本运行前新增静态预检:
23+
- 检测 Web 蓝本中的裸标签选择器(如 `button` / `div` / `span` / `a`
24+
- 检测 `:contains()``nth-child()` 等脆弱或非法选择器
25+
-`[title]``[placeholder]``[aria-label]``[data-testid]``[name]` 做源码存在性检查
26+
-`assert_text.expected` 做源码文本存在性检查
27+
- 检测状态切页项目误写 `flow: true` 或伪造 URL 子页面
28+
29+
若发现高风险问题,插件会先弹窗提示,允许用户打开蓝本修正,而不是直接把坏蓝本送进引擎。
30+
31+
### 功能 2 — 复制蓝本生成提示词增强
32+
33+
侧边栏的“复制蓝本生成提示词”从短口令升级为强约束提示,新增硬性要求:
34+
- 先确认真实路由方式,再决定 `flow`
35+
- 禁止裸 `button/a/div/span` 选择器
36+
- 属性选择器值必须能在源码中逐字搜索到
37+
- `assert_text.expected` 必须能对应源码或真实渲染文本
38+
- 状态切页项目禁止编造 `/business``/reports` 之类 URL 页面
39+
- 写完后必须做选择器唯一性复查
40+
41+
### 功能 3 — 注入模板版本升级
42+
43+
`rulesInjector.ts` 的模板版本从 v15 升到 v16,新增 `Mandatory Self-Check Before Saving Blueprint` 段落,让注入到用户项目里的 AGENTS.md / copilot-instructions 也同步具备自检约束。
44+
45+
### 设计结论
46+
47+
这次修复的重点不是“帮某一个项目改蓝本”,而是把问题前移到插件层:
48+
- 生成前强化提示
49+
- 运行前静态拦截
50+
- 让错误蓝本更难进入测试链路
51+
52+
---
53+
854
## ✅ v14.14 浏览器崩溃自动重建 + Checkpoint 导航路径恢复(2026-03-28)
955

1056
### 背景

0 commit comments

Comments
 (0)