@hupan56/wlkj 2.7.12 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/bin/cli.js +1435 -1107
  2. package/package.json +29 -29
  3. package/templates/qoder/agents/insight-planning.md +1 -1
  4. package/templates/qoder/agents/insight-research.md +28 -15
  5. package/templates/qoder/commands/optional/wl-insight.md +161 -161
  6. package/templates/qoder/commands/optional/wl-report.md +20 -39
  7. package/templates/qoder/commands/optional/wl-status.md +4 -4
  8. package/templates/qoder/commands/wl-code.md +2 -2
  9. package/templates/qoder/commands/wl-design.md +6 -6
  10. package/templates/qoder/commands/wl-init.md +3 -3
  11. package/templates/qoder/commands/wl-prd.md +26 -13
  12. package/templates/qoder/commands/wl-req.md +43 -0
  13. package/templates/qoder/commands/wl-search.md +78 -47
  14. package/templates/qoder/commands/wl-task.md +343 -40
  15. package/templates/qoder/commands/wl-test.md +43 -17
  16. package/templates/qoder/config.yaml +52 -14
  17. package/templates/qoder/hooks/post-tool-use.py +181 -0
  18. package/templates/qoder/hooks/session-start.py +94 -23
  19. package/templates/qoder/hooks/stop-eval.py +207 -0
  20. package/templates/qoder/hooks/user-prompt-submit.py +139 -0
  21. package/templates/qoder/rules/wl-pipeline.md +85 -49
  22. package/templates/qoder/scripts/README.md +102 -0
  23. package/templates/qoder/scripts/capability/__init__.py +26 -0
  24. package/templates/qoder/scripts/capability/__main__.py +72 -0
  25. package/templates/qoder/scripts/capability/adapters/__init__.py +29 -0
  26. package/templates/qoder/scripts/capability/adapters/cli.py +316 -0
  27. package/templates/qoder/scripts/capability/adapters/mcp.py +334 -0
  28. package/templates/qoder/scripts/capability/adapters/qw.py +271 -0
  29. package/templates/qoder/scripts/capability/caps/__init__.py +27 -0
  30. package/templates/qoder/scripts/capability/caps/context.py +36 -0
  31. package/templates/qoder/scripts/capability/caps/cron.py +50 -0
  32. package/templates/qoder/scripts/capability/caps/identity.py +43 -0
  33. package/templates/qoder/scripts/capability/caps/memory.py +71 -0
  34. package/templates/qoder/scripts/capability/caps/notify.py +41 -0
  35. package/templates/qoder/scripts/capability/caps/present.py +48 -0
  36. package/templates/qoder/scripts/capability/caps/repo.py +31 -0
  37. package/templates/qoder/scripts/capability/caps/sandbox.py +43 -0
  38. package/templates/qoder/scripts/capability/chain.py +92 -0
  39. package/templates/qoder/scripts/capability/memory_chain.py +41 -0
  40. package/templates/qoder/scripts/capability/registry.py +246 -0
  41. package/templates/qoder/scripts/capability/registry_mcp.py +250 -0
  42. package/templates/qoder/scripts/capability/smoke_test.py +211 -0
  43. package/templates/qoder/scripts/capability/smoke_test_report.json +94 -0
  44. package/templates/qoder/scripts/deployment/setup/__init__.py +11 -0
  45. package/templates/qoder/scripts/deployment/setup/carriers.py +669 -0
  46. package/templates/qoder/scripts/deployment/setup/carriers_verify.py +148 -0
  47. package/templates/qoder/scripts/{init_doctor.py → deployment/setup/init_doctor.py} +100 -39
  48. package/templates/qoder/scripts/{install_qoderwork.py → deployment/setup/install_qoderwork.py} +48 -30
  49. package/templates/qoder/scripts/{platform_doctor.py → deployment/setup/platform_doctor.py} +271 -259
  50. package/templates/qoder/scripts/{repo_root.py → deployment/setup/repo_root.py} +9 -2
  51. package/templates/qoder/scripts/{setup.py → deployment/setup/setup.py} +143 -14
  52. package/templates/qoder/scripts/{setup_lanhu.py → deployment/setup/setup_lanhu.py} +979 -963
  53. package/templates/qoder/scripts/domain/design/__init__.py +0 -0
  54. package/templates/qoder/scripts/{fill_prototype.py → domain/design/fill_prototype.py} +17 -5
  55. package/templates/qoder/scripts/{gen_design_doc.py → domain/design/gen_design_doc.py} +406 -394
  56. package/templates/qoder/scripts/domain/kg/__init__.py +11 -0
  57. package/templates/qoder/scripts/domain/kg/build/__init__.py +0 -0
  58. package/templates/qoder/scripts/domain/kg/build/build_entity_registry.py +201 -0
  59. package/templates/qoder/scripts/domain/kg/build/build_relations.py +132 -0
  60. package/templates/qoder/scripts/{build_style_index.py → domain/kg/build/build_style_index.py} +50 -15
  61. package/templates/qoder/scripts/domain/kg/build/build_workflows.py +149 -0
  62. package/templates/qoder/scripts/{kg_build.py → domain/kg/build/kg_build.py} +685 -612
  63. package/templates/qoder/scripts/{kg_build_db.py → domain/kg/build/kg_build_db.py} +338 -327
  64. package/templates/qoder/scripts/{kg_incremental.py → domain/kg/build/kg_incremental.py} +425 -393
  65. package/templates/qoder/scripts/{learn_aggregate.py → domain/kg/build/learn_aggregate.py} +212 -201
  66. package/templates/qoder/scripts/domain/kg/extract/__init__.py +0 -0
  67. package/templates/qoder/scripts/{common → domain/kg/extract}/extract.py +430 -419
  68. package/templates/qoder/scripts/domain/kg/extract/test_extract.py +115 -0
  69. package/templates/qoder/scripts/{common → domain/kg/extract}/ts_extract.py +614 -536
  70. package/templates/qoder/scripts/domain/kg/graph/__init__.py +0 -0
  71. package/templates/qoder/scripts/{kg_link_db.py → domain/kg/graph/kg_link_db.py} +235 -224
  72. package/templates/qoder/scripts/domain/kg/graph/kg_semantic.py +683 -0
  73. package/templates/qoder/scripts/{kg.py → domain/kg/kg.py} +871 -708
  74. package/templates/qoder/scripts/domain/kg/kg_capabilities.py +182 -0
  75. package/templates/qoder/scripts/domain/kg/search/__init__.py +0 -0
  76. package/templates/qoder/scripts/{context_pack.py → domain/kg/search/context_pack.py} +45 -17
  77. package/templates/qoder/scripts/{enrich_prompt.py → domain/kg/search/enrich_prompt.py} +238 -226
  78. package/templates/qoder/scripts/domain/kg/search/prefetch.py +384 -0
  79. package/templates/qoder/scripts/{common → domain/kg/search}/search_engine.py +207 -205
  80. package/templates/qoder/scripts/{search_index.py → domain/kg/search/search_index.py} +210 -88
  81. package/templates/qoder/scripts/domain/kg/server/__init__.py +0 -0
  82. package/templates/qoder/scripts/domain/kg/server/kgd.py +549 -0
  83. package/templates/qoder/scripts/domain/kg/server/perf_bench.py +197 -0
  84. package/templates/qoder/scripts/domain/kg/storage/__init__.py +0 -0
  85. package/templates/qoder/scripts/{kg_duckdb.py → domain/kg/storage/kg_duckdb.py} +70 -45
  86. package/templates/qoder/scripts/domain/learning/__init__.py +0 -0
  87. package/templates/qoder/scripts/{learn.py → domain/learning/learn.py} +157 -146
  88. package/templates/qoder/scripts/domain/report/__init__.py +11 -0
  89. package/templates/qoder/scripts/{add_session.py → domain/report/add_session.py} +256 -244
  90. package/templates/qoder/scripts/{export.py → domain/report/export.py} +72 -4
  91. package/templates/qoder/scripts/{report.py → domain/report/report.py} +292 -281
  92. package/templates/qoder/scripts/domain/report/report_snapshot.py +261 -0
  93. package/templates/qoder/scripts/domain/report/role.py +39 -0
  94. package/templates/qoder/scripts/{status.py → domain/report/status.py} +737 -628
  95. package/templates/qoder/scripts/domain/report/status_snapshot.py +191 -0
  96. package/templates/qoder/scripts/domain/requirement/__init__.py +0 -0
  97. package/templates/qoder/scripts/{archive_prd.py → domain/requirement/archive_prd.py} +389 -377
  98. package/templates/qoder/scripts/domain/requirement/req.py +228 -0
  99. package/templates/qoder/scripts/domain/task/__init__.py +11 -0
  100. package/templates/qoder/scripts/{git_sync.py → domain/task/git_sync.py} +90 -50
  101. package/templates/qoder/scripts/{syncgate.py → domain/task/syncgate.py} +18 -7
  102. package/templates/qoder/scripts/domain/task/task.py +229 -0
  103. package/templates/qoder/scripts/domain/task/task_lifecycle.py +606 -0
  104. package/templates/qoder/scripts/domain/task/task_query.py +162 -0
  105. package/templates/qoder/scripts/domain/task/task_relations.py +425 -0
  106. package/templates/qoder/scripts/{team_sync.py → domain/task/team_sync.py} +101 -23
  107. package/templates/qoder/scripts/domain/task/zentao_sync.py +370 -0
  108. package/templates/qoder/scripts/foundation/__init__.py +0 -0
  109. package/templates/qoder/scripts/foundation/bootstrap.py +145 -0
  110. package/templates/qoder/scripts/foundation/core/__init__.py +0 -0
  111. package/templates/qoder/scripts/foundation/core/cmd_registry.py +112 -0
  112. package/templates/qoder/scripts/foundation/core/config.py +187 -0
  113. package/templates/qoder/scripts/{common → foundation/core}/paths.py +706 -400
  114. package/templates/qoder/scripts/foundation/data/__init__.py +0 -0
  115. package/templates/qoder/scripts/foundation/data/contract.py +317 -0
  116. package/templates/qoder/scripts/{common → foundation/data}/events.py +2 -2
  117. package/templates/qoder/scripts/foundation/data/result.py +223 -0
  118. package/templates/qoder/scripts/{common → foundation/data}/task_utils.py +427 -392
  119. package/templates/qoder/scripts/foundation/identity/__init__.py +0 -0
  120. package/templates/qoder/scripts/foundation/identity/check_publish.py +103 -0
  121. package/templates/qoder/scripts/{common → foundation/identity}/developer.py +239 -238
  122. package/templates/qoder/scripts/foundation/identity/guard.py +159 -0
  123. package/templates/qoder/scripts/{common → foundation/identity}/identity.py +132 -9
  124. package/templates/qoder/scripts/foundation/identity/roles.py +60 -0
  125. package/templates/qoder/scripts/foundation/integrations/__init__.py +0 -0
  126. package/templates/qoder/scripts/{common → foundation/integrations}/active_task.py +30 -15
  127. package/templates/qoder/scripts/{common → foundation/integrations}/feishu.py +11 -10
  128. package/templates/qoder/scripts/{common → foundation/integrations}/terms.py +3 -3
  129. package/templates/qoder/scripts/foundation/integrations/zentao_client.py +220 -0
  130. package/templates/qoder/scripts/foundation/io/__init__.py +0 -0
  131. package/templates/qoder/scripts/{common → foundation/io}/filelock.py +1 -1
  132. package/templates/qoder/scripts/{common → foundation/io}/reqid.py +1 -1
  133. package/templates/qoder/scripts/foundation/protocol/__init__.py +0 -0
  134. package/templates/qoder/scripts/foundation/protocol/duckdb_conn.py +39 -0
  135. package/templates/qoder/scripts/foundation/protocol/mcp_base.py +268 -0
  136. package/templates/qoder/scripts/foundation/utils/__init__.py +0 -0
  137. package/templates/qoder/scripts/{common → foundation/utils}/eval_api.py +12 -1
  138. package/templates/qoder/scripts/{common → foundation/utils}/pip_install.py +23 -6
  139. package/templates/qoder/scripts/{common → foundation/utils}/platform_guard.py +1 -1
  140. package/templates/qoder/scripts/orchestration/__init__.py +0 -0
  141. package/templates/qoder/scripts/orchestration/wlkj.py +185 -0
  142. package/templates/qoder/scripts/protocol/__init__.py +0 -0
  143. package/templates/qoder/scripts/protocol/browser/README.md +207 -0
  144. package/templates/qoder/scripts/protocol/browser/SKILL.md +265 -0
  145. package/templates/qoder/scripts/protocol/browser/config.env +9 -0
  146. package/templates/qoder/scripts/protocol/browser/references/cdp-api.md +110 -0
  147. package/templates/qoder/scripts/protocol/browser/references/migration-2.5.3.md +72 -0
  148. package/templates/qoder/scripts/protocol/browser/references/site-patterns/.gitkeep +0 -0
  149. package/templates/qoder/scripts/protocol/browser/scripts/browser-discovery.mjs +138 -0
  150. package/templates/qoder/scripts/protocol/browser/scripts/cdp-proxy.mjs +672 -0
  151. package/templates/qoder/scripts/protocol/browser/scripts/check-deps.mjs +206 -0
  152. package/templates/qoder/scripts/protocol/browser/scripts/find-url.mjs +253 -0
  153. package/templates/qoder/scripts/protocol/browser/scripts/match-site.mjs +46 -0
  154. package/templates/qoder/scripts/protocol/browser/templates/config.env.template +9 -0
  155. package/templates/qoder/scripts/protocol/mcp/__init__.py +11 -0
  156. package/templates/qoder/scripts/protocol/mcp/kg_mcp_server.py +478 -0
  157. package/templates/qoder/scripts/{lanhu_stdio_wrapper.py → protocol/mcp/lanhu_stdio_wrapper.py} +131 -119
  158. package/templates/qoder/scripts/protocol/mcp/mcp_doctor.py +548 -0
  159. package/templates/qoder/scripts/protocol/mcp/mcp_launcher.py +600 -0
  160. package/templates/qoder/scripts/protocol/mcp/mysql_mcp_server.py +461 -0
  161. package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +1920 -0
  162. package/templates/qoder/scripts/protocol/transports/__init__.py +56 -0
  163. package/templates/qoder/scripts/protocol/transports/base.py +87 -0
  164. package/templates/qoder/scripts/protocol/transports/cli.py +132 -0
  165. package/templates/qoder/scripts/protocol/transports/http.py +141 -0
  166. package/templates/qoder/scripts/protocol/transports/stdio.py +293 -0
  167. package/templates/qoder/scripts/run_weekly_update.bat +27 -18
  168. package/templates/qoder/scripts/run_weekly_update.sh +22 -13
  169. package/templates/qoder/scripts/validation/__init__.py +0 -0
  170. package/templates/qoder/scripts/validation/eval/__init__.py +0 -0
  171. package/templates/qoder/scripts/validation/eval/perf-report-2026-06-24.md +139 -0
  172. package/templates/qoder/scripts/validation/eval/qwork_harness.py +617 -0
  173. package/templates/qoder/scripts/validation/eval/report-commands.md +220 -0
  174. package/templates/qoder/scripts/validation/eval/transcript_timing.py +386 -0
  175. package/templates/qoder/scripts/validation/metrics/__init__.py +0 -0
  176. package/templates/qoder/scripts/{eval_prd.py → validation/metrics/eval_prd.py} +84 -15
  177. package/templates/qoder/scripts/validation/metrics/usability_score.py +750 -0
  178. package/templates/qoder/scripts/validation/test/__init__.py +11 -0
  179. package/templates/qoder/scripts/validation/test/assertion_gen.py +551 -0
  180. package/templates/qoder/scripts/{autotest.py → validation/test/autotest.py} +1234 -1751
  181. package/templates/qoder/scripts/validation/test/autotest_auth.py +109 -0
  182. package/templates/qoder/scripts/{autotest_batch.py → validation/test/autotest_batch.py} +255 -224
  183. package/templates/qoder/scripts/validation/test/autotest_data.py +680 -0
  184. package/templates/qoder/scripts/validation/test/autotest_opencli.py +646 -0
  185. package/templates/qoder/scripts/{autotest_run.py → validation/test/autotest_run.py} +323 -297
  186. package/templates/qoder/scripts/validation/test/autotest_webaccess.py +571 -0
  187. package/templates/qoder/scripts/{benchmark.py → validation/test/benchmark.py} +17 -5
  188. package/templates/qoder/scripts/{kg_auto_login.py → validation/test/kg_auto_login.py} +208 -196
  189. package/templates/qoder/scripts/{kg_test_runner.py → validation/test/kg_test_runner.py} +13 -2
  190. package/templates/qoder/scripts/{page_probe.py → validation/test/page_probe.py} +470 -459
  191. package/templates/qoder/scripts/validation/test/smoke_all_commands.py +170 -0
  192. package/templates/qoder/settings.json +23 -2
  193. package/templates/qoder/skills/design-import/SKILL.md +235 -226
  194. package/templates/qoder/skills/design-review/SKILL.md +91 -82
  195. package/templates/qoder/skills/prd-generator/SKILL.md +38 -24
  196. package/templates/qoder/skills/prd-review/SKILL.md +8 -6
  197. package/templates/qoder/skills/prototype-generator/SKILL.md +296 -247
  198. package/templates/qoder/skills/spec-coder/SKILL.md +24 -15
  199. package/templates/qoder/skills/spec-generator/SKILL.md +24 -12
  200. package/templates/qoder/skills/test-generator/SKILL.md +9 -7
  201. package/templates/qoder/skills/wl-code/SKILL.md +25 -13
  202. package/templates/qoder/skills/wl-commit/SKILL.md +10 -9
  203. package/templates/qoder/skills/wl-design/SKILL.md +7 -5
  204. package/templates/qoder/skills/wl-init/SKILL.md +8 -8
  205. package/templates/qoder/skills/wl-insight/SKILL.md +25 -12
  206. package/templates/qoder/skills/wl-prd-full/SKILL.md +59 -8
  207. package/templates/qoder/skills/wl-prd-quick/SKILL.md +7 -7
  208. package/templates/qoder/skills/wl-prd-review/SKILL.md +18 -6
  209. package/templates/qoder/skills/wl-report/SKILL.md +27 -25
  210. package/templates/qoder/skills/wl-search/SKILL.md +151 -80
  211. package/templates/qoder/skills/wl-spec/SKILL.md +9 -7
  212. package/templates/qoder/skills/wl-status/SKILL.md +50 -18
  213. package/templates/qoder/skills/wl-task/SKILL.md +94 -12
  214. package/templates/qoder/skills/wl-test/SKILL.md +154 -45
  215. package/templates/qoder/templates/prd-full-template.md +7 -0
  216. package/templates/root/AGENTS.md +275 -237
  217. package/templates/root/requirements.txt +3 -0
  218. package/templates/root//344/275/277/347/224/250/350/257/264/346/230/216.md +3 -3
  219. package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +2 -2
  220. package/templates/qoder/hooks/inject-workflow-state.py +0 -169
  221. package/templates/qoder/scripts/__pycache__/check_mcp_launch.cpython-39.pyc +0 -0
  222. package/templates/qoder/scripts/__pycache__/install_qoderwork.cpython-39.pyc +0 -0
  223. package/templates/qoder/scripts/__pycache__/mcp_launcher.cpython-39.pyc +0 -0
  224. package/templates/qoder/scripts/__pycache__/platform_doctor.cpython-39.pyc +0 -0
  225. package/templates/qoder/scripts/check_carriers.py +0 -238
  226. package/templates/qoder/scripts/check_mcp.py +0 -298
  227. package/templates/qoder/scripts/check_mcp_launch.py +0 -183
  228. package/templates/qoder/scripts/check_qoderwork_consistency.py +0 -166
  229. package/templates/qoder/scripts/collect_prds.py +0 -31
  230. package/templates/qoder/scripts/common/mentions.py +0 -134
  231. package/templates/qoder/scripts/common/utf8.py +0 -38
  232. package/templates/qoder/scripts/extract_api_params.py +0 -246
  233. package/templates/qoder/scripts/extract_routes.py +0 -54
  234. package/templates/qoder/scripts/extract_routes_tree.py +0 -78
  235. package/templates/qoder/scripts/handoff.py +0 -22
  236. package/templates/qoder/scripts/init_developer.py +0 -76
  237. package/templates/qoder/scripts/kg_mcp_server.py +0 -801
  238. package/templates/qoder/scripts/kg_semantic.py +0 -150
  239. package/templates/qoder/scripts/mcp_launcher.py +0 -414
  240. package/templates/qoder/scripts/mysql_mcp_server.py +0 -396
  241. package/templates/qoder/scripts/parse_prds.py +0 -33
  242. package/templates/qoder/scripts/role.py +0 -51
  243. package/templates/qoder/scripts/sync_carriers.py +0 -259
  244. package/templates/qoder/scripts/task.py +0 -1261
  245. package/templates/qoder/scripts/workspace_init.py +0 -102
  246. package/templates/qoder/scripts/zentao_mcp_server.py +0 -424
  247. package/templates/qoder/skills/prompt-enrich/SKILL.md +0 -90
  248. /package/templates/qoder/scripts/{common → deployment}/__init__.py +0 -0
  249. /package/templates/qoder/{skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp → scripts/domain/__init__.py} +0 -0
  250. /package/templates/qoder/scripts/{common → domain/kg/graph}/graph_traverse.py +0 -0
  251. /package/templates/qoder/scripts/{common → domain/kg/server}/repowiki.py +0 -0
  252. /package/templates/qoder/scripts/{common → foundation/io}/atomicio.py +0 -0
  253. /package/templates/qoder/scripts/{secure-ls.js → validation/test/secure-ls.js} +0 -0
@@ -0,0 +1,680 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """autotest_data.py - 自动化测试数据层 (v3.0 f5 从 autotest.py 拆出)
4
+
5
+ 拆分自 autotest.py(1824行) 的数据层: 测试数据/登录态/cases/anchors 读写。
6
+ autotest.py(命令层) 通过 from autotest_data import * 使用。
7
+ """
8
+ # v3.0 路径自举
9
+ import os as _o, sys as _s
10
+ _f = _o.path.abspath(__file__)
11
+ for _ in range(10):
12
+ _f = _o.path.dirname(_f)
13
+ _cp = _o.path.join(_f, 'foundation')
14
+ if _o.path.isfile(_o.path.join(_cp, 'bootstrap.py')):
15
+ break
16
+ if _cp not in _s.path: _s.path.insert(0, _cp)
17
+ from bootstrap import setup; setup()
18
+
19
+ import os, sys, json, shutil
20
+ from pathlib import Path
21
+ from foundation.core.paths import get_repo_root, get_developer
22
+ from foundation.data.task_utils import load_task_json, resolve_task_dir
23
+
24
+
25
+ # 路径常量
26
+ # ---------------------------------------------------------------------------
27
+
28
+ BASE = get_repo_root()
29
+ CONFIG_PATH = os.path.join(str(BASE), '.qoder', 'config.yaml')
30
+ HISTORY_PATH = os.path.join(str(BASE), '.qoder', 'learning', 'autotest-history.jsonl')
31
+ # 个人测试数据文档: workspace/members/{dev}/autotest-data.yaml
32
+ # ✅ 用户可见、自己填(在个人文件夹里); ✅ .gitignore 覆盖(workspace/members/*/autotest-data.yaml),
33
+ # team_sync 永不 push/共享。跟 .signing_key/exports/journal 同级隔离。
34
+ # ⚠️ config.yaml 是团队共用会 push, 绝不能放账号密码。
35
+ LOCAL_DATA_FILENAME = 'autotest-data.yaml'
36
+ # 登录态记录(同目录, 同样 gitignored): 记住哪个环境已登录, 避免重复登录(尤其验证码慢)
37
+ LOGIN_STATE_FILENAME = 'autotest-login-state.yaml'
38
+ CASES_FILENAME = 'autotest-cases.jsonl'
39
+
40
+ SUPPORTED_ENVS = ['test', 'pre', 'prod']
41
+ SUPPORTED_PLATFORMS = ['web', 'app']
42
+ # steps.action 白名单 (生成时校验; 不在表内 → 警告但不阻塞)
43
+ KNOWN_ACTIONS = {
44
+ 'goto', 'click', 'fill', 'select', 'hover', 'press',
45
+ 'assert_visible', 'assert_text', 'assert_not_visible',
46
+ 'wait', 'screenshot', 'scroll', 'upload',
47
+ 'solve_captcha', # 验证码: QoderWork Agent 用自带视觉识别(截图→算数学题→填入)
48
+ }
49
+
50
+ # ── 意图式 step (A方案: 意图+锚点) ──
51
+ # 新格式: {intent, desc, anchor?, value?} —— 执行时靠 read_page 的 a11y 树语义匹配,
52
+ # 有 anchor(role/name/placeholder)则确定性命中(快), 无则 LLM 兜底(self-heal)。
53
+ # ref 不存(会变), 只存稳定锚点。兼容旧 action 式(有 action 字段=legacy)。
54
+ KNOWN_INTENTS = {
55
+ 'goto', # 导航: {intent:'goto', desc:'登录页', value:'{{base_url}}/auth/login'}
56
+ 'observe', # 读页面: {intent:'observe'} 触发一次 read_page 拿 a11y 树
57
+ 'click', # 点击: {intent:'click', desc:'登录按钮', anchor:{role:'button',name:'登录'}}
58
+ 'fill', # 填表: {intent:'fill', desc:'账号框', value:'{{ask:登录账号}}', anchor:{placeholder:'尊敬的管理员'}}
59
+ 'extract', # 语义断言: {intent:'extract', desc:'是否已进入首页(离开登录页)'}
60
+ 'ask_human', # 人机协同: {intent:'ask_human', desc:'请手动完成验证码并点登录'}
61
+ 'assert', # 确定性断言: {intent:'assert', desc:'URL 不再是 /auth/login'}
62
+ }
63
+ # anchor 允许的稳定锚点 key (role/name 来自 a11y 树, placeholder/text 来自属性; 都不随刷新变)
64
+ ANCHOR_KEYS = {'role', 'name', 'placeholder', 'text'}
65
+
66
+
67
+ def is_intent_step(s):
68
+ """step 是新意图式(有 intent 字段)还是旧命令式(有 action 字段)。"""
69
+ return isinstance(s, dict) and 'intent' in s and 'action' not in s
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # 配置加载 (仿 common.feishu._load_config: yaml 优先, 缺失则简单解析)
74
+ # ---------------------------------------------------------------------------
75
+
76
+ def load_config_section(section='autotest'):
77
+ """加载 config.yaml 的指定段 (v3.0: 优先 Config 单例, 失败回退)。"""
78
+ # 优先: Config 单例 (带 mtime 缓存)
79
+ try:
80
+ from foundation.core.config import Config
81
+ return Config.load().section(section, {}) or {}
82
+ except Exception:
83
+ pass
84
+ # 回退: 直接 yaml 解析
85
+ try:
86
+ import yaml
87
+ with open(CONFIG_PATH, encoding='utf-8') as f:
88
+ full = yaml.safe_load(f) or {}
89
+ return full.get(section) or {}
90
+ except Exception:
91
+ # PyYAML 缺失或解析失败 —— 简单兜底 (只兜 default_env, base_url 解析仍报错)
92
+ return _fallback_parse_autotest()
93
+
94
+
95
+ def _fallback_parse_autotest():
96
+ """无 PyYAML 时的极简兜底: 只抓 default_env。base_url 解析交给上层报错。"""
97
+ config = {}
98
+ try:
99
+ with open(CONFIG_PATH, encoding='utf-8') as f:
100
+ for line in f:
101
+ if line.strip().startswith('default_env:'):
102
+ val = line.split(':', 1)[1].strip().strip('"').strip("'")
103
+ config['default_env'] = val
104
+ break
105
+ except Exception:
106
+ pass
107
+ return config
108
+
109
+
110
+ def resolve_base_url(env, platform, override=None):
111
+ """解析目标域名。优先级: override(--url) > config > None。
112
+
113
+ Returns: (base_url, source) source ∈ {'cli', 'config', 'none'}
114
+ """
115
+ if override:
116
+ return override.rstrip('/'), 'cli'
117
+ cfg = load_config_section('autotest')
118
+ envs = cfg.get('environments') or {}
119
+ env_cfg = envs.get(env) or {}
120
+ plat_cfg = env_cfg.get(platform) or {}
121
+ url = (plat_cfg.get('base_url') or '').strip()
122
+ if url:
123
+ return url.rstrip('/'), 'config'
124
+ return None, 'none'
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # 个人测试数据文档 (workspace/members/{dev}/autotest-data.yaml)
129
+ # ✅ 用户可见, 自己填(就在个人文件夹里)
130
+ # ✅ .gitignore 覆盖 (workspace/members/*/autotest-data.yaml) → team_sync 永不 push
131
+ # ⚠️ config.yaml 是团队共用会 push, 绝不能放账号密码
132
+ # ---------------------------------------------------------------------------
133
+
134
+ def _require_developer():
135
+ """获取当前开发者名, 身份缺失直接抛错。
136
+
137
+ 健壮性: 绝不 fallback 到 'default'。多人共用 workspace 时,
138
+ 无身份 = 会写到共享垃圾目录 = 串号。必须先 /wl-init 确立身份。
139
+ """
140
+ dev = get_developer()
141
+ if not dev:
142
+ raise RuntimeError(
143
+ '未检测到开发者身份(无 .qoder/.developer)。请先运行 /wl-init 注册身份。'
144
+ ' (多人共用 workspace 时, 身份隔离靠它——没身份会串到别人的目录)'
145
+ )
146
+ return dev
147
+
148
+
149
+ def local_data_path():
150
+ """当前开发者的个人测试数据文档路径。
151
+
152
+ v3.0: 收进 .private/autotest-data.yaml (向后兼容老位置)。
153
+ """
154
+ dev = _require_developer()
155
+ try:
156
+ from foundation.core.paths import get_private_file
157
+ return Path(str(get_private_file(LOCAL_DATA_FILENAME, dev)))
158
+ except Exception:
159
+ return Path(str(MEMBERS_DIR)) / dev / LOCAL_DATA_FILENAME
160
+
161
+
162
+ def load_local_test_data(env=None, platform=None):
163
+ """读个人测试数据文档。返回扁平 {描述: 值} 字典(已按 env/platform 合并)。
164
+
165
+ 优先级(文档内): 指定 env+platform 的 > 仅 platform > 仅 env > 顶层通用。
166
+ 文档不存在/解析失败 → {} (不报错)。
167
+
168
+ 文档格式 (autotest-data.yaml):
169
+ # 顶层 = 通用(所有环境/平台都可用)
170
+ 测试手机号: "13800000000"
171
+
172
+ # 按平台分(不分环境)
173
+ web:
174
+ 登录账号: admin
175
+ app:
176
+ 登录账号: appuser
177
+
178
+ # 按环境分
179
+ test:
180
+ 登录账号: testuser
181
+ 登录密码: "123456"
182
+
183
+ # 精确到 环境+平台 (优先级最高)
184
+ prod:
185
+ web:
186
+ 登录账号: prod-admin
187
+ """
188
+ p = local_data_path()
189
+ if not p.is_file():
190
+ return {}
191
+ try:
192
+ import yaml
193
+ with open(str(p), encoding='utf-8') as f:
194
+ raw = yaml.safe_load(f) or {}
195
+ except ImportError:
196
+ raw = _parse_yaml_fallback(p)
197
+ except Exception:
198
+ return {}
199
+ if not isinstance(raw, dict):
200
+ return {}
201
+ # 合并优先级: 通用 < env < platform < env.platform
202
+ merged = {}
203
+ # 顶层(非 env/platform 的键)
204
+ for k, v in raw.items():
205
+ if k in SUPPORTED_ENVS or k in SUPPORTED_PLATFORMS:
206
+ continue
207
+ if isinstance(v, dict):
208
+ continue
209
+ merged[str(k)] = v
210
+ if platform and isinstance(raw.get(platform), dict):
211
+ for k, v in raw[platform].items():
212
+ if not isinstance(v, dict):
213
+ merged[str(k)] = v
214
+ if env and isinstance(raw.get(env), dict):
215
+ for k, v in raw[env].items():
216
+ if not isinstance(v, dict):
217
+ merged[str(k)] = v
218
+ # env.platform (精确)
219
+ if platform and isinstance(raw[env].get(platform), dict):
220
+ for k, v in raw[env][platform].items():
221
+ merged[str(k)] = v
222
+ return merged
223
+
224
+
225
+ def _parse_yaml_fallback(p):
226
+ """无 PyYAML 时, 极简解析顶层 "key: value"(不支持嵌套)。"""
227
+ data = {}
228
+ try:
229
+ for line in p.read_text(encoding='utf-8').splitlines():
230
+ line = line.rstrip()
231
+ if not line.strip() or line.lstrip().startswith('#'):
232
+ continue
233
+ if line.startswith(' ') or line.startswith('\t'):
234
+ continue # 跳过嵌套行(无 yaml 库时不解析嵌套)
235
+ if ':' not in line:
236
+ continue
237
+ k, _, v = line.partition(':')
238
+ data[k.strip()] = v.strip().strip('"').strip("'")
239
+ except Exception:
240
+ pass
241
+ return data
242
+
243
+
244
+ def save_local_test_data(key, value, env=None, platform=None):
245
+ """原子写一条测试数据到个人文档。
246
+
247
+ 策略: 没指定 env/platform → 写顶层; 指定了 → 写到对应嵌套块。
248
+ 保留文档里已有的其他内容。
249
+ """
250
+ import yaml as _yaml
251
+ p = local_data_path()
252
+ os.makedirs(str(p.parent), exist_ok=True)
253
+ # 读已有(保留注释做不到, 但数据完整保留)
254
+ existing = {}
255
+ if p.is_file():
256
+ try:
257
+ with open(str(p), encoding='utf-8') as f:
258
+ existing = _yaml.safe_load(f) or {}
259
+ except Exception:
260
+ existing = {}
261
+ if not isinstance(existing, dict):
262
+ existing = {}
263
+ # 定位写入点
264
+ if env and platform:
265
+ existing.setdefault(env, {})
266
+ if not isinstance(existing[env], dict):
267
+ existing[env] = {}
268
+ existing[env].setdefault(platform, {})
269
+ if not isinstance(existing[env][platform], dict):
270
+ existing[env][platform] = {}
271
+ existing[env][platform][key] = value
272
+ elif env:
273
+ existing.setdefault(env, {})
274
+ if not isinstance(existing[env], dict):
275
+ existing[env] = {}
276
+ existing[env][key] = value
277
+ elif platform:
278
+ existing.setdefault(platform, {})
279
+ if not isinstance(existing[platform], dict):
280
+ existing[platform] = {}
281
+ existing[platform][key] = value
282
+ else:
283
+ existing[key] = value
284
+ from foundation.io.atomicio import atomic_write_text
285
+ header = ('# 个人测试数据 (本地, 已 gitignore, 不会 push/共享给团队)\n'
286
+ '# 用 autotest.py set-data 管理, 或直接编辑本文件。\n'
287
+ '# 顶层=通用; web/app=按平台; test/pre/prod=按环境; 环境.平台=最精确(优先级最高)\n\n')
288
+ atomic_write_text(str(p), header + _yaml.safe_dump(
289
+ existing, allow_unicode=True, sort_keys=False, default_flow_style=False))
290
+
291
+
292
+ def init_local_data_doc():
293
+ """生成带注释的空模板文档(若不存在)。"""
294
+ p = local_data_path()
295
+ if p.is_file():
296
+ return str(p), False # 已存在, 不覆盖
297
+ os.makedirs(str(p.parent), exist_ok=True)
298
+ from foundation.io.atomicio import atomic_write_text
299
+ template = (
300
+ '# 个人测试数据 (本地, 已 gitignore, 不会 push/共享给团队)\n'
301
+ '# 在这里填你的测试账号/密码。每个人只填自己的 members/{自己}/autotest-data.yaml。\n'
302
+ '#\n'
303
+ '# 结构(优先级从低到高):\n'
304
+ '# 顶层 key → 所有环境/平台通用\n'
305
+ '# web: / app: → 按平台分\n'
306
+ '# test: / pre: / prod: → 按环境分\n'
307
+ '# 环境.平台 → 最精确, 优先级最高\n'
308
+ '#\n'
309
+ '# 用例里用 {{ask:描述}} 占位, 描述要跟这里的 key 一致(如 {{ask:登录账号}})。\n\n'
310
+ '# ===== 通用(不分环境/平台) =====\n'
311
+ '测试手机号: "13800000000"\n\n'
312
+ '# ===== 按平台 =====\n'
313
+ 'web:\n'
314
+ ' 登录账号: admin\n'
315
+ 'app:\n'
316
+ ' 登录账号: appuser\n\n'
317
+ '# ===== 按环境(test/pre/prod) =====\n'
318
+ 'test:\n'
319
+ ' 登录账号: testuser\n'
320
+ ' 登录密码: "123456"\n'
321
+ ' # 也可精确到 环境+平台:\n'
322
+ ' # web:\n'
323
+ ' # 登录账号: test-web-admin\n'
324
+ )
325
+ atomic_write_text(str(p), template)
326
+ return str(p), True
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # 登录态记录 (workspace/members/{dev}/autotest-login-state.yaml, gitignored)
331
+ # QoderWork 保持同一个浏览器会话 → 登录一次后 cookie 不丢 → 后续测新功能
332
+ # 直接 goto 目标页, 不用重复登录(尤其数学题验证码慢)。
333
+ # ---------------------------------------------------------------------------
334
+
335
+ def login_state_path():
336
+ dev = _require_developer()
337
+ return Path(str(MEMBERS_DIR)) / dev / LOGIN_STATE_FILENAME
338
+
339
+
340
+ def load_login_state():
341
+ """读登录态。返回 {env: {platform: {time, url}}} 或 {}。"""
342
+ p = login_state_path()
343
+ if not p.is_file():
344
+ return {}
345
+ try:
346
+ import yaml
347
+ with open(str(p), encoding='utf-8') as f:
348
+ data = yaml.safe_load(f) or {}
349
+ return data if isinstance(data, dict) else {}
350
+ except Exception:
351
+ return {}
352
+
353
+
354
+ def is_logged_in(env, platform):
355
+ """该 env/platform 是否已标记登录。返回 (bool, time_str)。"""
356
+ state = load_login_state()
357
+ entry = (state.get(env) or {}).get(platform)
358
+ if isinstance(entry, dict):
359
+ return True, entry.get('time', '')
360
+ return False, ''
361
+
362
+
363
+ def mark_logged_in(env, platform, base_url):
364
+ """标记某 env/platform 已登录(登录用例 pass 后调用)。"""
365
+ import yaml as _yaml
366
+ from foundation.io.atomicio import atomic_write_text
367
+ p = login_state_path()
368
+ os.makedirs(str(p.parent), exist_ok=True)
369
+ state = load_login_state()
370
+ state.setdefault(env, {})
371
+ state[env][platform] = {
372
+ 'time': datetime.now().isoformat()[:19],
373
+ 'url': base_url,
374
+ }
375
+ atomic_write_text(str(p), '# 登录态记录 (本地, gitignored)。'
376
+ 'QoderWork 保持会话, 登录一次后后续直接 goto 目标页。\n'
377
+ + _yaml.safe_dump(state, allow_unicode=True, sort_keys=False))
378
+
379
+
380
+ def clear_login_state(env=None, platform=None):
381
+ """清除登录态。env/platform 都不给 = 全清。"""
382
+ from foundation.io.atomicio import atomic_write_text
383
+ p = login_state_path()
384
+ state = load_login_state()
385
+ if env is None and platform is None:
386
+ state = {}
387
+ elif env and platform:
388
+ (state.get(env) or {}).pop(platform, None)
389
+ elif env:
390
+ state.pop(env, None)
391
+ elif platform:
392
+ for e in list(state.keys()):
393
+ (state.get(e) or {}).pop(platform, None)
394
+ if state:
395
+ atomic_write_text(str(p), '# 登录态记录 (本地, gitignored)\n'
396
+ + __import__('yaml').safe_dump(
397
+ state, allow_unicode=True, sort_keys=False))
398
+ elif p.is_file():
399
+ p.unlink()
400
+
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # 测试页锚点索引 (data/index/test-pages.json, 团队共享)
404
+ # A方案核心: 记住"页面→稳定锚点(role/name/placeholder)", 下次测同页直接命中, 跳过 LLM。
405
+ # 只存稳定锚点(不随刷新变), 绝不存 ref(会变)。
406
+ # ---------------------------------------------------------------------------
407
+
408
+ def test_pages_path():
409
+ """data/index/test-pages.json 路径(团队共享, 随 team_sync 分发)。"""
410
+ return index_path('test_pages')
411
+
412
+
413
+ def _load_test_pages():
414
+ """读 test-pages.json。不存在返回空骨架。"""
415
+ from foundation.io.atomicio import safe_read_json
416
+ p = test_pages_path()
417
+ data = safe_read_json(str(p), default=None)
418
+ if not isinstance(data, dict):
419
+ return {'version': '1.0', 'updated': '', 'pages': {}, 'stats': {'total_pages': 0, 'total_anchors': 0}}
420
+ data.setdefault('pages', {})
421
+ data.setdefault('stats', {})
422
+ return data
423
+
424
+
425
+ def _save_test_pages(data):
426
+ """原子写 test-pages.json, 自动更新 updated + stats。"""
427
+ from foundation.io.atomicio import atomic_write_json
428
+ data['updated'] = datetime.now().isoformat()[:19]
429
+ pages = data.get('pages', {})
430
+ n_anchors = sum(len(pg.get('elements', [])) for pg in pages.values())
431
+ data['stats'] = {'total_pages': len(pages), 'total_anchors': n_anchors}
432
+ atomic_write_json(str(test_pages_path()), data)
433
+
434
+
435
+ def _strip_msys_path_mangle(u):
436
+ """修 Git-bash/MSYS 的路径转换副作用。
437
+
438
+ Windows 上 Git-bash 会把以 '/' 开头的参数(如 /auth/login)当成 POSIX 绝对路径,
439
+ 转成其安装目录下的 Windows 路径, 例如:
440
+ /auth/login → D:/programming/tools/Git/Git/auth/login (MSYS 去/加盘符)
441
+ /auth/login → /D:/programming/tools/Git/Git/auth/login (有时带前导斜杠)
442
+ 特征: 形如 [可选/]盘符:/... 的 Windows 绝对路径, 末尾挂着真实 URL 段。
443
+ 识别后只保留从真实 URL 段开始的部分。
444
+ """
445
+ import re as _re
446
+ # 匹配 /D:/... 或 D:/... (盘符后跟 : 和 / 或 :)
447
+ m = _re.match(r'^/?([A-Za-z]:[/:])(.*)', u)
448
+ if m:
449
+ tail = m.group(2)
450
+ # tail 形如 "programming/tools/Git/Git/auth/login" —— 找已知 URL 段作为恢复点
451
+ for seg in ('/auth/', '/auth', '/login', '/api/', '/veh/', '/asset/', '/attendance/',
452
+ '/insurance', '/salary', '/abnormal', '/org/', '/user/', '/system/'):
453
+ idx = tail.lower().find(seg)
454
+ if idx >= 0:
455
+ return tail[idx:]
456
+ # 兜底: 末尾若有 /xxx/yyy 这种双段路径, 取之
457
+ parts = tail.split('/')
458
+ if len(parts) >= 2:
459
+ return '/' + '/'.join(parts[-2:])
460
+ return u
461
+
462
+
463
+ def _normalize_url_pattern(url, base_url=None):
464
+ """把完整 URL 规整成可复用的 pattern(去域名, 保留路径)。
465
+
466
+ https://icsqas.inforecloud.com/auth/login → /auth/login
467
+ 已是相对路径 → 原样返回。
468
+
469
+ 会先修 Git-bash 的路径转换副作用(见 _strip_msys_path_mangle),
470
+ 所以 AI 直接传 'auth/login'(无前导斜杠, 避开 MSYS 转换)或 '/auth/login' 都行。
471
+ """
472
+ if not url:
473
+ return '/'
474
+ u = url.strip()
475
+ u = _strip_msys_path_mangle(u) # 先修 MSYS 副作用
476
+ if base_url and u.startswith(base_url):
477
+ return u[len(base_url):] or '/'
478
+ # 去 protocol + domain
479
+ if '://' in u:
480
+ u = u.split('://', 1)[1]
481
+ if '/' in u:
482
+ u = '/' + u.split('/', 1)[1]
483
+ else:
484
+ u = '/'
485
+ if not u.startswith('/'):
486
+ u = '/' + u
487
+ # 去 query (?redirect=...) —— 登录页常带 redirect, 不影响页面元素
488
+ u = u.split('?', 1)[0]
489
+ return u or '/'
490
+
491
+
492
+ def learn_page_anchors(url_pattern, elements, intent_map=None):
493
+ """把一批页面元素锚点沉淀进 test-pages.json。
494
+
495
+ elements: read_page 返回的元素列表, 每个含 role/name/placeholder/text/ref(等)。
496
+ 只提取稳定锚点(role/name/placeholder/text), 丢弃 ref/坐标。
497
+ intent_map: 可选 {元素描述: {intent, anchor}} —— AI 标注"这个元素对应什么意图"。
498
+ 有则把 intent 一起存, recall 时能直接生成 step。
499
+ 返回 (新增数, 总数)。
500
+ """
501
+ intent_map = intent_map or {}
502
+ data = _load_test_pages()
503
+ pages = data['pages']
504
+ existing = pages.get(url_pattern, {}).get('elements', [])
505
+ # 以 anchor 的规范化签名去重
506
+ def _sig(el):
507
+ a = el.get('anchor', {})
508
+ return '|'.join('%s=%s' % (k, a.get(k, '')) for k in sorted(a))
509
+ seen_sigs = {_sig(e) for e in existing}
510
+ new_anchors = []
511
+ for el in elements:
512
+ if not isinstance(el, dict):
513
+ continue
514
+ # 从 read_page 元素提取稳定锚点
515
+ anchor = {}
516
+ for k in ANCHOR_KEYS:
517
+ v = el.get(k)
518
+ if v:
519
+ anchor[k] = str(v)
520
+ if not anchor:
521
+ continue # 无稳定锚点的元素(只有 ref/坐标)不存
522
+ desc = el.get('desc') or el.get('name') or el.get('text') or ''
523
+ # 查 intent_map: 用 desc/name 匹配
524
+ im = None
525
+ for key, val in intent_map.items():
526
+ if key in desc or desc in key:
527
+ im = val
528
+ break
529
+ entry = {
530
+ 'desc': desc,
531
+ 'anchor': anchor,
532
+ 'intent': (im or {}).get('intent', el.get('intent', '')),
533
+ }
534
+ sig = _sig(entry)
535
+ if sig in seen_sigs:
536
+ continue
537
+ seen_sigs.add(sig)
538
+ new_anchors.append(entry)
539
+ merged = existing + new_anchors
540
+ pages[url_pattern] = {
541
+ 'elements': merged,
542
+ 'last_seen': datetime.now().isoformat()[:19],
543
+ }
544
+ data['pages'] = pages
545
+ _save_test_pages(data)
546
+ return len(new_anchors), len(merged)
547
+
548
+
549
+ def recall_page_anchors(url_pattern):
550
+ """查某页已沉淀的锚点。返回 elements 列表(可能空)。"""
551
+ data = _load_test_pages()
552
+ pg = data.get('pages', {}).get(url_pattern)
553
+ if not pg:
554
+ return []
555
+ return pg.get('elements', [])
556
+
557
+
558
+ # ---------------------------------------------------------------------------
559
+ # 任务 / 用例文件 IO
560
+ # ---------------------------------------------------------------------------
561
+
562
+ def _cases_path(task_dir):
563
+ return Path(task_dir) / CASES_FILENAME
564
+
565
+
566
+ def read_cases(task_dir):
567
+ """读取某任务的全部用例 (list[dict])。无文件返回 []。"""
568
+ p = _cases_path(task_dir)
569
+ if not p.is_file():
570
+ return []
571
+ cases = []
572
+ for lineno, raw in enumerate(p.read_text(encoding='utf-8').splitlines(), 1):
573
+ raw = raw.strip()
574
+ if not raw:
575
+ continue
576
+ try:
577
+ cases.append(json.loads(raw))
578
+ except json.JSONDecodeError as e:
579
+ print('警告: %s 第 %d 行 JSON 损坏, 跳过 (%s)' % (p.name, lineno, e),
580
+ file=sys.stderr)
581
+ return cases
582
+
583
+
584
+ def write_cases(task_dir, cases):
585
+ """原子覆盖写全部用例为 jsonl。"""
586
+ from foundation.io.atomicio import atomic_write_text
587
+ p = _cases_path(task_dir)
588
+ lines = [json.dumps(c, ensure_ascii=False, sort_keys=True) for c in cases]
589
+ atomic_write_text(str(p), '\n'.join(lines) + ('\n' if lines else ''))
590
+
591
+
592
+ # ---------------------------------------------------------------------------
593
+ # 用例骨架生成 (脚本只造结构; 业务步骤由 AI 读 skill 后填充)
594
+ # ---------------------------------------------------------------------------
595
+
596
+ def _skeleton_case(task_name, case_no, platform, env):
597
+ """造一条占位用例骨架。AI 应在此基础上据 PRD 补充 steps/expected。
598
+
599
+ 骨架用意图式(intent+anchor)格式 —— 执行时靠 a11y 树语义匹配。
600
+ 需要真实测试数据(账号/密码)的地方, 用 {{ask:描述}} 占位。
601
+ """
602
+ cid = 'AT-%s-%03d' % (task_name, case_no)
603
+ return {
604
+ 'case_id': cid,
605
+ 'title': '(待补充)用例 %d 标题' % case_no,
606
+ 'platform': platform,
607
+ 'env': env,
608
+ 'preconditions': '',
609
+ 'steps': [
610
+ {'intent': 'goto', 'desc': '打开页面', 'value': '{{base_url}}/'},
611
+ # 需要真实测试数据时用 {{ask:描述}} 占位, 例:
612
+ # {'intent':'fill','desc':'账号框','value':'{{ask:登录账号}}','anchor':{'role':'textbox'}}
613
+ {'intent': 'extract', 'desc': '页面正常加载, 出现关键元素'},
614
+ ],
615
+ 'expected': '(待补充)预期结果',
616
+ }
617
+
618
+
619
+ def generate_skeleton(task_dir, env, platform, count=3):
620
+ """为任务生成 count 条骨架用例 (不覆盖已有; 追加)。"""
621
+ task_name = Path(task_dir).name
622
+ existing = read_cases(task_dir)
623
+ start_no = len(existing) + 1
624
+ new_cases = [_skeleton_case(task_name, start_no + i, platform, env)
625
+ for i in range(count)]
626
+ write_cases(task_dir, existing + new_cases)
627
+ return new_cases
628
+
629
+
630
+ def validate_cases(cases, strict=False):
631
+ """校验用例结构, 返回 (ok, warnings)。strict=True 时缺字段视为错误。"""
632
+ warns = []
633
+ ids = set()
634
+ for i, c in enumerate(cases, 1):
635
+ cid = c.get('case_id', '')
636
+ if not cid:
637
+ warns.append('第 %d 条缺 case_id' % i)
638
+ elif cid in ids:
639
+ warns.append('case_id 重复: %s' % cid)
640
+ else:
641
+ ids.add(cid)
642
+ for f in ('title', 'steps', 'expected'):
643
+ if f not in c:
644
+ warns.append('%s 缺字段 %s' % (cid or ('第%d条' % i), f))
645
+ steps = c.get('steps') or []
646
+ if not isinstance(steps, list):
647
+ warns.append('%s steps 不是数组' % cid)
648
+ continue
649
+ for j, s in enumerate(steps, 1):
650
+ if not isinstance(s, dict):
651
+ warns.append('%s step%d 不是对象' % (cid, j))
652
+ continue
653
+ # 新意图式: 校验 intent + anchor 结构
654
+ if is_intent_step(s):
655
+ it = s.get('intent', '')
656
+ if it and it not in KNOWN_INTENTS:
657
+ warns.append('%s step%d 未知 intent: %s' % (cid, j, it))
658
+ if not s.get('desc') and it not in ('observe',):
659
+ warns.append('%s step%d (intent=%s) 缺 desc' % (cid, j, it))
660
+ anc = s.get('anchor')
661
+ if anc is not None:
662
+ if not isinstance(anc, dict):
663
+ warns.append('%s step%d anchor 不是对象' % (cid, j))
664
+ elif not any(k in ANCHOR_KEYS for k in anc):
665
+ warns.append('%s step%d anchor 无稳定键(需 %s 之一)'
666
+ % (cid, j, '/'.join(sorted(ANCHOR_KEYS))))
667
+ else:
668
+ # 旧命令式: 校验 action
669
+ act = s.get('action', '')
670
+ if not act:
671
+ warns.append('%s step%d 既无 intent 也无 action' % (cid, j))
672
+ elif act not in KNOWN_ACTIONS:
673
+ warns.append('%s step%d 未知 action: %s' % (cid, j, act))
674
+ return (len(warns) == 0) if strict else True, warns
675
+
676
+
677
+ # ---------------------------------------------------------------------------
678
+ # 共享: 解析环境+注入数据 / 打印执行计划 (run 和 quick 复用)
679
+ # ---------------------------------------------------------------------------
680
+