Skip to content

Commit e386200

Browse files
committed
fix: engine stability + blueprint rules refactor (v14.3)
- automator.py: add ensure_healthy() - lightweight context reset without engine restart - blueprint_runner.py: add _cancellable_sleep() - stop button responds in <100ms - sidebarProvider.ts: stop button immediate disabled feedback - routes.py: desktop mode reads start_command as fallback, wait 10s->30s - rulesInjector.ts v15: Electron projects -> web (not desktop); platform table updated - web.md v11: add 3 CSS-specific prohibitions + platform field must-not-change rule - AGENTS.md template v15: remove CSS-specific iron rules 3/4/5 (moved to web.md)
1 parent 81a3871 commit e386200

7 files changed

Lines changed: 282 additions & 37 deletions

File tree

extension/src/rulesInjector.ts

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

8686
/** 从文件内容中提取版本号,找不到返回 0(旧版无版本标记) */
8787
function extractVersion(content: string): number {
@@ -128,17 +128,26 @@ function getTemplateContent(): string {
128128
| \`pubspec.yaml\` / \`AndroidManifest.xml\` / \`*.kt\` / \`*.java\` | \`android\` |
129129
| \`*.xcodeproj\` / \`*.swift\` / \`Info.plist\` | \`ios\` |
130130
| \`app.json\` + \`pages/\` 目录(小程序结构) | \`miniprogram\` |
131-
| \`*.xaml\` / \`tkinter\` / \`pywinauto\` / Electron + \`BrowserWindow\` | \`desktop\` |
131+
| \`*.xaml\` / \`tkinter\` / \`pywinauto\`(纯原生桌面,无内嵌网页) | \`desktop\` |
132+
| \`package.json\` + \`electron\` 依赖(Electron 套壳 Web App) | **\`web\`**(不是 desktop!Electron 内容是 Web,用 Playwright 测更稳定) |
132133
| \`package.json\` + \`*.html\` / React / Vue / Angular | \`web\` |
133134
135+
> ⚠️ **Electron 项目必须用 \`web\` 而不是 \`desktop\`**:Electron 只是套壳,内容是 Web 页面,\`desktop\` 模式依赖 AI 视觉截图、精准度极低;\`web\` 模式用 Playwright CSS 选择器,更快更准。
136+
134137
2. **检查已有蓝本的 platform 字段** — 但必须与代码特征核对,若矛盾以代码为准
135138
3. **读 \`testpilot/CHANGELOG.md\`**(如存在)— 里面可能已注明平台
136139
4. **以上都无法确定 → 停下来问用户**:「这是 Web / Android / iOS / 小程序 / 桌面 中的哪种?」
137140
138-
### 空项目或无法判断时的处理规则
141+
### 无蓝本时的三种处理规则(必须区分清楚)
142+
143+
| 情况 | 判断 | 动作 |
144+
|------|------|------|
145+
| **项目完全为空**(无任何源码文件) | 无可测功能 | **静默等待**,不写蓝本,不打扰用户 |
146+
| **有源码,平台可识别,蓝本不存在**(包括被手动删除) | 能生成 | **直接扫描源码,立即生成完整蓝本,不询问用户,不等待确认** |
147+
| **有源码,但平台无法识别** | 无法生成 | **先创建最简占位蓝本**(仅有框架结构,\`scenarios\` 留空),同时询问用户平台类型;确认后立即补全蓝本 |
148+
149+
> ⚠️ **严禁在「有源码且平台已识别」时停下来询问用户**「要不要生成蓝本」——必须直接生成!
139150
140-
- **项目完全为空**(无源码)→ **不写蓝本,静默等待**,不要打扰用户
141-
- **代码特征模糊** → 同上,等待出现明确代码文件
142151
- **用户描述与代码矛盾** → 以代码为准,告知用户
143152
144153
### 发现平台写错时如何纠正
@@ -210,12 +219,38 @@ function getTemplateContent(): string {
210219
211220
---
212221
213-
## 四、所有详细规则 → 参见平台规则文件(必须阅读!)
222+
## 四、两条跨平台铁规则(所有平台通用,无例外)
223+
224+
> 以下两条规则适用于 Web / Android / iOS / 小程序 / 桌面所有平台。
225+
> 各平台专属的选择器禁则(如 Web 的 \`:contains()\`、Tailwind 小数类等)在平台规则文件中定义,务必通读。
226+
227+
### 🚨 铁规则一:\`target\` 属性值必须从源码复制,不得凭推测
228+
229+
使用 \`[title='xxx']\`、\`[placeholder='xxx']\`、\`[aria-label='xxx']\`、\`accessibility_id:xxx\` 等属性选择器时:
230+
1. **必须先打开该元素所在的源文件**
231+
2. **在代码中搜索该属性名**,确认属性确实存在且值与蓝本完全一致
232+
3. **禁止凭经验、常识或用户描述推测属性值**
233+
234+
> 典型错误:看到箭头图标按钮就猜 \`button[title='返回']\`,实际源码根本没写 \`title\` 属性 → 永远找不到元素,超时失败。
235+
236+
### 🚨 铁规则二:\`assert_text\` 的 \`expected\` 必须从源码原文复制
237+
238+
1. **必须从源码的 JSX/WXML/Swift/XML/Kotlin 中找到对应文字节点**,原文复制到 \`expected\`
239+
2. **禁止自行创造、意译或总结文字**(如源码写"收支记录",不得断言"收支统计")
240+
3. **禁止断言从未出现在 DOM/视图树里的文字**(如组件内部变量名、注释文字)
241+
242+
> 典型错误:源码只有"总收入""总支出""结余"三张卡片,却断言"收支统计"——该词在任何源文件里都不存在 → 断言必然失败。
243+
244+
---
245+
246+
## 五、所有详细规则 → 参见平台规则文件(必须阅读!)
214247
215-
**蓝本的选择器、动作表、完整模板、连续流模式(flow)写法、自检清单、描述规范、踩坑清单等全部详细规则,都在平台专属规则文件中定义。**
248+
**蓝本的选择器禁则、动作表、完整模板、连续流模式(flow)写法、自检清单、等待策略、踩坑清单等全部详细规则,都在平台专属规则文件中定义。**
216249
217250
确定 \`platform\` 后,**立即打开并通读** \`.testpilot/platforms/{platform}.md\`,严格遵守该文件中的所有规则。
218251
252+
> ⚠️ 平台规则文件是蓝本质量的最终权威。AGENTS.md 只定义通用原则,具体的选择器格式、禁止语法、等待策略必须以平台规则文件为准。
253+
219254
**禁止在不阅读平台规则文件的情况下编写蓝本。**
220255
`;
221256
}
@@ -620,40 +655,84 @@ function ensureSkeletonBlueprint(
620655
const folderName = path.basename(workspaceRoot);
621656
let appName = folderName;
622657
let platform = "web";
658+
let platformDetected = false;
659+
660+
const listDir = (dir: string): string[] => {
661+
try { return fs.readdirSync(dir); } catch { return []; }
662+
};
663+
const rootFiles = listDir(workspaceRoot);
623664

624665
// Flutter / Dart
625666
if (fs.existsSync(path.join(workspaceRoot, "pubspec.yaml"))) {
626667
platform = "android";
668+
platformDetected = true;
627669
try {
628670
const pubspec = fs.readFileSync(path.join(workspaceRoot, "pubspec.yaml"), "utf-8");
629671
const nameMatch = pubspec.match(/^name:\s*(.+)$/m);
630672
if (nameMatch) appName = nameMatch[1].trim();
631673
} catch { /* ignore */ }
632674
}
633-
// Node.js / Web
675+
// Android 原生(AndroidManifest.xml 或 .kt/.java 源码)
676+
else if (
677+
fs.existsSync(path.join(workspaceRoot, "app", "src", "main", "AndroidManifest.xml")) ||
678+
fs.existsSync(path.join(workspaceRoot, "AndroidManifest.xml"))
679+
) {
680+
platform = "android";
681+
platformDetected = true;
682+
}
683+
// iOS / Swift(.xcodeproj / .xcworkspace / Package.swift)
684+
else if (
685+
fs.existsSync(path.join(workspaceRoot, "Package.swift")) ||
686+
rootFiles.some((f: string) => f.endsWith(".xcodeproj") || f.endsWith(".xcworkspace"))
687+
) {
688+
platform = "ios";
689+
platformDetected = true;
690+
}
691+
// Node.js / Web / Electron
634692
else if (fs.existsSync(path.join(workspaceRoot, "package.json"))) {
635693
try {
636694
const pkg = JSON.parse(fs.readFileSync(path.join(workspaceRoot, "package.json"), "utf-8"));
637695
if (pkg.name) appName = pkg.name;
696+
// Electron 检测:Electron 项目本质是 Web 内容套了一个桌面壳,用 web 模式测试更准确(Playwright CSS选择器)
697+
// 只有纯桌面原生应用(无 web 内容、无 base_url)才应用 desktop 模式
698+
// 因此即使有 electron 依赖,也判定为 web——让 AI 用 Playwright 测试 Web 内容
699+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
700+
if (allDeps["electron"]) {
701+
platform = "web"; // Electron 套壳 Web App,内容是 Web,用 web 模式更稳定
702+
platformDetected = true;
703+
}
638704
} catch { /* ignore */ }
639-
// 小程序检测
640-
if (fs.existsSync(path.join(workspaceRoot, "app.json")) && fs.existsSync(path.join(workspaceRoot, "app.wxss"))) {
641-
platform = "miniprogram";
705+
if (!platformDetected) {
706+
// 小程序检测
707+
if (fs.existsSync(path.join(workspaceRoot, "app.json")) && fs.existsSync(path.join(workspaceRoot, "app.wxss"))) {
708+
platform = "miniprogram";
709+
platformDetected = true;
710+
} else {
711+
platform = "web";
712+
platformDetected = true;
713+
}
642714
}
643715
}
644716
// 微信小程序(无 package.json)
645717
else if (fs.existsSync(path.join(workspaceRoot, "app.json")) && fs.existsSync(path.join(workspaceRoot, "app.js"))) {
646718
platform = "miniprogram";
719+
platformDetected = true;
647720
}
648-
// iOS / Swift
649-
else if (fs.existsSync(path.join(workspaceRoot, "Package.swift")) || fs.readdirSync(workspaceRoot).some((f: string) => f.endsWith(".xcodeproj") || f.endsWith(".xcworkspace"))) {
650-
platform = "ios";
721+
// Python 桌面应用(tkinter / pywinauto)
722+
else if (rootFiles.some((f: string) => f.endsWith(".py"))) {
723+
platform = "desktop";
724+
platformDetected = true;
651725
}
652726

727+
// 构造 description:未能识别时明确提示 AI 需要先判断 platform
728+
const platformNote = platformDetected
729+
? `(已自动识别为 platform="${platform}",请编程AI在生成蓝本前先核对代码特征确认无误)`
730+
: `(⚠️ 平台类型无法自动识别,编程AI必须先检查代码文件特征确认 platform 是 web/android/ios/miniprogram/desktop 中的哪一种,然后再生成蓝本)`;
731+
653732
// 创建空壳蓝本
654733
const skeleton = {
655734
app_name: appName,
656-
description: `请让编程AI为 ${appName} 生成完整的测试蓝本(当前为空壳,尚无测试场景)`,
735+
description: `【占位蓝本,需编程AI补全】请先阅读 .testpilot/platforms/ 下对应平台规则文件,再扫描源码生成完整测试场景。${platformNote}`,
657736
base_url: platform === "web" ? "http://localhost:3000" : "",
658737
platform: platform,
659738
start_command: "",

extension/src/sidebarProvider.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2614,6 +2614,11 @@ ${commonRules}`;
26142614
vscode.postMessage({ command: "controlTest", action: "resume" });
26152615
});
26162616
document.getElementById("btnStop").addEventListener("click", () => {
2617+
const btn = document.getElementById("btnStop");
2618+
if (btn) {
2619+
btn.disabled = true;
2620+
btn.textContent = "停止中…";
2621+
}
26172622
vscode.postMessage({ command: "controlTest", action: "stop" });
26182623
});
26192624
@@ -3072,6 +3077,9 @@ ${commonRules}`;
30723077
});
30733078
saveBugRetryMap();
30743079
controlSection.classList.add("hidden");
3080+
// 恢复停止按钮状态
3081+
var _btnStop = document.getElementById("btnStop");
3082+
if (_btnStop) { _btnStop.disabled = false; _btnStop.textContent = "停止"; }
30753083
screenshotSection.classList.add("hidden");
30763084
addLog("测试完成!", "success");
30773085
@@ -3229,6 +3237,9 @@ ${commonRules}`;
32293237
function onTestError(data) {
32303238
if (testingTimer) { clearInterval(testingTimer); testingTimer = null; }
32313239
controlSection.classList.add("hidden");
3240+
// 恢复停止按钮状态
3241+
var _btnStop2 = document.getElementById("btnStop");
3242+
if (_btnStop2) { _btnStop2.disabled = false; _btnStop2.textContent = "停止"; }
32323243
addLog("测试失败: " + data.error, "error");
32333244
}
32343245

extension/templates/platforms/web.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<!-- TestPilot-Template-Version: 9 -->
1+
<!-- TestPilot-Template-Version: 11 -->
22
# Web 平台蓝本规则(platform = "web"�?
33

44
> 本文件定�?Web 应用(React/Vue/Angular/纯HTML)蓝本的完整规则�?
@@ -172,7 +172,35 @@ AI 常犯的错误:看到代码�?`<Select>` 组件就写 `div[class*='Select
172172
| `div:has(h3:contains('生活账本'))` | 读源码,用父容器或按钮的真实 class |
173173

174174
**唯一正确做法**:读源码,用代码中真实存在的 `#id``.class``[attribute]` 属性定位元素。如果元素没有稳定的 id/class,查看其 `type``title``placeholder` 等属性,或建议开发者添�?`data-testid`�?
175+
### 🚨 绝对禁止:Tailwind 小数类出现在 `target` 中(会导致 SyntaxError 崩溃)
175176

177+
Tailwind 的小数工具类(如 `gap-0.5``translate-y-0.5``space-x-2.5``inset-0.5`)在 CSS 选择器中包含 `.5`,会被 Playwright 的 CSS 解析器识别为无效语法,**立即抛出 `SyntaxError: Unexpected token ".5"` 并跳过整个场景**,导致后续所有步骤连锁失败。
178+
179+
| ❌ 禁止(必定 SyntaxError) | ✅ 替代方案 |
180+
|---|---|
181+
| `div.gap-0.5` | 打开源码,找该元素的 `id`、语义 class 或 `title` 属性 |
182+
| `header div.flex-col.items-end.gap-0.5 button` | 读源码找按钮自身的 `title` 属性,如 `button[title='财务报表']` |
183+
| `div.translate-y-0.5` | 找父容器或子元素的稳定语义属性 |
184+
185+
> 铁律:含小数点的 Tailwind 类(数字中间有点,如 `0.5``1.5``2.5`**永远不能出现在 `target`**
186+
187+
### 🚨 绝对禁止:猜测子元素 HTML 标签(写 :first-child 前必须确认)
188+
189+
`> div:first-child``> li:last-child` 等子元素选择器前,**必须打开源码确认子元素的实际 HTML 标签**。看到父容器是 flex 布局,子元素可能是 `button``a``li` 等,**不一定是 `div`**
190+
191+
| ❌ 错误(猜测标签) | ✅ 正确(读源码确认) |
192+
|---|---|
193+
| `div.flex > div:first-child` | 打开源码发现子元素是 `<button>``div.flex > button:first-child` |
194+
| `ul.list > div:nth-child(2)` | 打开源码发现子元素是 `<li>``ul.list > li:nth-child(2)` |
195+
196+
### 🚨 绝对禁止:表单内多个 button 时用模糊选择器
197+
198+
`<form>` 内同时存在功能按钮(如密码眼睛图标 `type="button"`)和提交按钮(`type="submit"`)时,**禁止用 `form button` 这类模糊选择器**。Playwright 点击的是第一个匹配元素,可能是工具按钮而非提交按钮,导致表单从未提交,登录/注册等关键流程永远失败。
199+
200+
| ❌ 禁止(模糊,点到工具按钮) | ✅ 正确(精确) |
201+
|---|---|
202+
| `form button` | 先读源码确认提交按钮的 type,若有 `type="submit"` 则用 `form button[type='submit']` |
203+
| `button:last-child` | 若提交按钮无 type,用文字内容组合:`.space-y-4 > button:last-of-type` 或建议开发者加 `data-testid` |
176204
---
177205

178206
## 四、瞬�?UI 不可断言清单
@@ -426,8 +454,8 @@ Web 应用通常把登�?token 存在 localStorage/sessionStorage�?*即使刷
426454

427455
| 错误 | 后果 | 正确做法 |
428456
|------|------|---------|
429-
| **`start_cwd` �?`"."`** | 引擎在错误目录启动,`exit code 1`,所有步�?`ERR_CONNECTION_REFUSED` | 填被测项目绝对路径,�?`"D:\\projects\\my-app"` |
430-
| **依赖未安装就跑测�?* | `npm run dev` 失败退出,30秒超时后继续跑,全部失败 | 先手�?`npm install`,确认项目能启动再测�?|
457+
| **`start_cwd` �?`"."`** | 引擎在错误目录启动,`exit code 1`,所有步�?`ERR_CONNECTION_REFUSED` | 填被测项目绝对路径,�?`"D:\\projects\\my-app"` || **修复Bug时擅自改 `platform` 字段** | Web应用被改成 `desktop` 后走桌面引擎,CSS选择器全部失效,问题更多 | **`platform` 字段一旦确定禁止修改**;修Bug只改选择器和断言,不改platform/base_url/start_command |
458+
| **`electron:start` 启动未构建的Electron应用** | Electron加载 `http://localhost:5173` 但vite未启动→白屏→窗口无内容 | 桌面模式必须用 `electron:dev`(同时启动vite+electron),或先 `npm run build` 再用 `electron:start` || **依赖未安装就跑测�?* | `npm run dev` 失败退出,30秒超时后继续跑,全部失败 | 先手�?`npm install`,确认项目能启动再测�?|
431459
| **端口已被占用** | 新进程启动失败,引擎等超时后继续,全部失�?| 手动确认端口空闲,或修改 `base_url` 端口 |
432460
| �?`<select>` �?`fill` | 引擎报错 | �?`select` 动作 |
433461
| `expected` 写了 toast 文字 | 断言失败(文字已消失�?| 断言页面持久化状�?|

src/api/routes.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -301,12 +301,11 @@ async def run_test(req: RunTestRequest) -> TestReportResponse:
301301
detail="AI 客户端未配置,请设置 TP_AI_API_KEY 环境变量",
302302
)
303303

304-
# 确保浏览器已启动
305-
if browser_automator._page is None:
306-
try:
307-
await browser_automator.launch()
308-
except BrowserError as e:
309-
raise HTTPException(status_code=500, detail=f"浏览器启动失败: {e}")
304+
# 确保浏览器健康可用(自动重置损坏的页面,无需重启引擎)
305+
try:
306+
await browser_automator.ensure_healthy()
307+
except BrowserError as e:
308+
raise HTTPException(status_code=500, detail=f"浏览器启动失败: {e}")
310309

311310
orchestrator = TestOrchestrator(ai_client, browser_automator, memory_store)
312311

@@ -521,12 +520,11 @@ async def _on_step_m(step: int, status: str, desc: str) -> None:
521520
except Exception as _ce:
522521
logger.warning("积分校验失败(允许继续,游客模式): {}", _ce)
523522

524-
# 确保浏览器已启动
525-
if browser_automator._page is None:
526-
try:
527-
await browser_automator.launch()
528-
except BrowserError as e:
529-
raise HTTPException(status_code=500, detail=f"浏览器启动失败: {e}")
523+
# 确保浏览器健康可用(自动重置损坏的页面,无需重启引擎)
524+
try:
525+
await browser_automator.ensure_healthy()
526+
except BrowserError as e:
527+
raise HTTPException(status_code=500, detail=f"浏览器启动失败: {e}")
530528

531529
# 执行蓝本测试(v2.0:注入控制器 + 截图推送 + 步骤通知)
532530
async def _on_screenshot(step: int, img_b64: str) -> None:
@@ -1100,11 +1098,17 @@ async def run_desktop_blueprint_test(req: dict) -> TestReportResponse:
11001098
import json
11011099
raw_bp = json.loads(bp_file.read_text(encoding="utf-8"))
11021100
window_title = req.get("window_title", "") or raw_bp.get("window_title", "") or blueprint.app_name
1103-
app_exe = req.get("app_exe", "") or raw_bp.get("app_exe", "")
1101+
# app_exe 是桌面专用字段;start_command 是通用字段,桌面模式下作为 fallback
1102+
app_exe = (req.get("app_exe", "") or raw_bp.get("app_exe", "")
1103+
or raw_bp.get("start_command", ""))
11041104
bp_dir = bp_file.parent # 蓝本所在目录,用于相对路径执行
11051105
# 如果蓝本在 testpilot/ 子目录下,工作目录应为其父目录(项目根目录)
11061106
if bp_dir.name == "testpilot":
11071107
bp_dir = bp_dir.parent
1108+
# start_cwd 优先作为工作目录(对 npm run electron:dev 等复杂命令至关重要)
1109+
start_cwd = raw_bp.get("start_cwd", "") or req.get("start_cwd", "")
1110+
if start_cwd:
1111+
bp_dir = Path(start_cwd)
11081112

11091113
# 自动启动/重启被测应用(确保干净状态)
11101114
if app_exe:
@@ -1130,8 +1134,8 @@ async def run_desktop_blueprint_test(req: dict) -> TestReportResponse:
11301134
if venv_python.exists():
11311135
launch_cmd = f'"{venv_python}" {launch_cmd[7:]}'
11321136
subprocess.Popen(launch_cmd, shell=True, cwd=str(bp_dir))
1133-
# 等窗口出现(最多10秒),而不是固定等3秒
1134-
for _wait in range(20):
1137+
# 等待窗口出现,最多30秒(npm run electron:dev 需要先启动vite,耗时较长)
1138+
for _wait in range(60):
11351139
time.sleep(0.5)
11361140
if user32.FindWindowW(None, window_title):
11371141
break

0 commit comments

Comments
 (0)