@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,646 @@
1
+ # -*- coding: utf-8 -*-
2
+ # v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
3
+ import os as _o, sys as _s
4
+ _f = _o.path.abspath(__file__)
5
+ for _ in range(10):
6
+ _f = _o.path.dirname(_f)
7
+ _cp = _o.path.join(_f, 'foundation')
8
+ if _o.path.isfile(_o.path.join(_cp, 'bootstrap.py')):
9
+ break
10
+ if _cp not in _s.path: _s.path.insert(0, _cp)
11
+ from bootstrap import setup; setup()
12
+
13
+ """autotest_opencli.py — 真功能测试执行器 (OpenCLI 复用已登录 Chrome).
14
+
15
+ 与 autotest_run.py 平级, 镜像同一个 task.json 契约, 执行引擎换成 OpenCLI.
16
+ 吃同样的 --task 文件, 吐同样结构的 result JSON, 便于 _compare_engines.py 并排对比.
17
+
18
+ 用法: python autotest_opencli.py --task <任务文件> [--session <名>]
19
+ 任务文件 JSON: {
20
+ "page_path": "/veh/vehicle/vehAffair/insurance",
21
+ "page_name": "保险管理",
22
+ "cases": [ {case_id, title, steps:[{intent,desc,value,anchor,check,expect_api}], expected} ]
23
+ }
24
+
25
+ 与 autotest_run.py 的差异:
26
+ - 登录态: 不伪造 localStorage. 直接 bind 到用户手动登好的真实 Chrome tab (无验证码/无 RSA).
27
+ - 执行: 每步 subprocess 调 opencli browser <session> <cmd>, 全 stdout JSON 解析.
28
+ - 断言: 兼容旧 check (行数/URL/body文本), 新增 expect_api 抓 network 验后端 JSON 字段值.
29
+
30
+ 前置: 用户先手动 Chrome 登录目标环境 + 装好 OpenCLI 扩展 + doctor green.
31
+ """
32
+ import json, sys, time, os, re, argparse, subprocess
33
+
34
+ # ===== 配置 (路径自检测, 与 autotest_run.py 一致) =====
35
+ SCRIPTS = os.path.dirname(os.path.abspath(__file__))
36
+ # test 目录在 .qoder/scripts/test, 回项目根要上 3 级.
37
+ # 用"向上找含 .qoder 的目录"兜底, 避免 ../.. 层级写错 (autotest_run.py 的同款 bug).
38
+ _d = SCRIPTS
39
+ while _d != os.path.dirname(_d):
40
+ if os.path.isdir(os.path.join(_d, '.qoder')):
41
+ PROJECT = _d
42
+ break
43
+ _d = os.path.dirname(_d)
44
+ else:
45
+ PROJECT = os.path.normpath(os.path.join(SCRIPTS, '..', '..', '..'))
46
+
47
+
48
+ def _read_developer():
49
+ """读 .qoder/.developer 拿当前开发者, 身份隔离用 (绝不 fallback, 防串号)."""
50
+ dev_file = os.path.join(PROJECT, '.qoder', '.developer')
51
+ if os.path.isfile(dev_file):
52
+ for ln in open(dev_file, encoding='utf-8'):
53
+ if ln.strip().startswith('name='):
54
+ return ln.strip().split('=', 1)[1].strip()
55
+ raise RuntimeError('未检测到开发者身份(.qoder/.developer)。请先运行 /wl-init。')
56
+
57
+
58
+ DEVELOPER = _read_developer()
59
+ MEMBER_DIR = os.path.join(PROJECT, 'workspace', 'members', DEVELOPER)
60
+ AUTOLEARN = os.path.join(SCRIPTS, 'autotest.py')
61
+ BASE = 'https://icsqas.inforecloud.com'
62
+
63
+ # OpenCLI 默认 session 名 (一次对比/一个任务用一个, 避免缓存串)
64
+ DEFAULT_SESSION = 'wltest'
65
+
66
+
67
+ # ===== OpenCLI 环境探测 (友好诊断 + 回退指引) =====
68
+ # opencli 的绝对路径缓存 (跨平台 PATH 解析). 50 人环境各异, subprocess 不一定继承到
69
+ # Git Bash/cmd 的完整 PATH, 所以要主动从 npm 全局目录 + PATHEXT 里找.
70
+ _OPENCLI_BIN = None
71
+
72
+
73
+ def _find_opencli():
74
+ """定位 opencli 可执行文件绝对路径. 返回路径或 None.
75
+ 顺序: 直接 PATH → npm bin -g → 常见全局目录. 缓存结果."""
76
+ global _OPENCLI_BIN
77
+ if _OPENCLI_BIN:
78
+ return _OPENCLI_BIN
79
+ # 1. 直接试 PATH (可能成功)
80
+ try:
81
+ subprocess.run(['opencli', '--version'], capture_output=True, timeout=10,
82
+ shell=(os.name == 'nt'))
83
+ _OPENCLI_BIN = 'opencli'
84
+ return _OPENCLI_BIN
85
+ except (FileNotFoundError, OSError):
86
+ pass
87
+ # 2. 问 npm 全局 bin 目录在哪
88
+ candidates = []
89
+ try:
90
+ r = subprocess.run(['npm', 'bin', '-g'], capture_output=True, timeout=10,
91
+ shell=(os.name == 'nt'), text=True)
92
+ if r.returncode == 0 and r.stdout.strip():
93
+ d = r.stdout.strip()
94
+ candidates.append(os.path.join(d, 'opencli'))
95
+ candidates.append(os.path.join(d, 'opencli.cmd'))
96
+ candidates.append(os.path.join(d, 'opencli.cmd'))
97
+ except Exception:
98
+ pass
99
+ try:
100
+ r = subprocess.run(['npm', 'root', '-g'], capture_output=True, timeout=10,
101
+ shell=(os.name == 'nt'), text=True)
102
+ if r.returncode == 0 and r.stdout.strip():
103
+ candidates.append(os.path.join(r.stdout.strip(), '..', '.bin', 'opencli'))
104
+ candidates.append(os.path.join(r.stdout.strip(), '..', 'opencli'))
105
+ except Exception:
106
+ pass
107
+ # 3. 检查每个候选
108
+ for c in candidates:
109
+ if c and os.path.isfile(c):
110
+ _OPENCLI_BIN = c
111
+ return _OPENCLI_BIN
112
+ return None
113
+
114
+
115
+ def _opencli_ready():
116
+ """逐级探测 opencli 环境, 返回 (ok, diagnosis).
117
+ diagnosis = {stage, reason, fix, fallback}. 让没装 opencli 的人零报错自动回退."""
118
+ # Stage 1: opencli CLI 是否安装/可定位 (跨平台 PATH 兜底)
119
+ bin_path = _find_opencli()
120
+ if not bin_path:
121
+ return False, _diag('cli', 'opencli 未安装或不在 PATH',
122
+ 'npm i -g @jackwener/opencli', '不装也行, 用 playwright 引擎 (autotest_run.py)')
123
+ global _OPENCLI_BIN
124
+ _OPENCLI_BIN = bin_path
125
+
126
+ # Stage 2: doctor (daemon + 扩展连接) —— 按行状态标记精准解析
127
+ try:
128
+ r = _run(['doctor'], timeout=20)
129
+ out = (r.stdout + r.stderr).decode('utf-8', 'ignore')
130
+ except Exception as e:
131
+ return False, _diag('doctor', 'opencli doctor 执行失败: %s' % str(e)[:60],
132
+ '重试 opencli doctor', '回退 playwright 引擎')
133
+ lo = out.lower()
134
+ lines = [ln.strip() for ln in out.splitlines() if ln.strip()]
135
+
136
+ # 按 doctor 的逐行 [OK]/[MISSING]/[FAIL] 标记判断, 不做全文 grep (避免误判)
137
+ def _line_status(key):
138
+ """返回某关键组件那行的标记: ok / missing / fail / none."""
139
+ for ln in lines:
140
+ if key in ln.lower():
141
+ low = ln.lower()
142
+ if '[ok]' in low: return 'ok'
143
+ if '[missing]' in low: return 'missing'
144
+ if '[fail]' in low: return 'fail'
145
+ return 'none'
146
+
147
+ # daemon
148
+ if _line_status('daemon') != 'ok':
149
+ return False, _diag('daemon', 'opencli daemon 未运行',
150
+ 'opencli daemon start', '回退 playwright 引擎')
151
+ # 扩展 (最常见的卡点)
152
+ if _line_status('extension') != 'ok':
153
+ ext_dir = os.path.join(os.path.expanduser('~'), 'opencli-extension')
154
+ return False, _diag('extension', 'OpenCLI 浏览器扩展未连接',
155
+ 'Chrome → chrome://extensions/ → 开发者模式 → 加载已解压 → 选 %s → 启用它' % ext_dir,
156
+ '不装扩展也行, 用 playwright 引擎')
157
+ # connectivity 综合
158
+ if _line_status('connectivity') == 'fail':
159
+ return False, _diag('connect', '浏览器桥接连通性失败',
160
+ '确保 Chrome 开着 + OpenCLI 扩展启用, 跑 opencli daemon restart',
161
+ '回退 playwright 引擎')
162
+ return True, {}
163
+
164
+
165
+ def _diag(stage, reason, fix, fallback):
166
+ """结构化诊断: 让上层决定是"提示后中止"还是"提示后回退"."""
167
+ return {'stage': stage, 'reason': reason, 'fix': fix, 'fallback': fallback}
168
+
169
+
170
+ def _print_not_ready(d, base_url, explicit=False):
171
+ """没就绪时打印友好提示. explicit=用户显式要 opencli → 给详细修复; 否则侧重回退."""
172
+ print('')
173
+ print('╔══ OpenCLI 引擎未就绪 ════════════════════════════════════════')
174
+ print('║ 阶段: %s' % d['stage'])
175
+ print('║ 原因: %s' % d['reason'])
176
+ print('║ ────────────────────────────────────────────────────────────')
177
+ print('║ 怎么修: %s' % d['fix'])
178
+ if not explicit:
179
+ print('║')
180
+ print('║ ✅ 已自动回退: 改用 playwright 引擎跑 (autotest_run.py)')
181
+ print('║ playwright 开箱即用, 不需要装 opencli/扩展/登录.')
182
+ print('║ opencli 是可选增强 —— 被验证码/数据断言卡住时再装即可.')
183
+ print('╚══════════════════════════════════════════════════════════════')
184
+ print('')
185
+
186
+
187
+ def _print_bind_fail(info, base_url, explicit=False):
188
+ """bind 失败(扩展连了但没登/没聚焦)的友好提示."""
189
+ print('')
190
+ print('╔══ OpenCLI 无法绑定已登录标签页 ══════════════════════════════')
191
+ print('║ 原因: %s' % str(info)[:58])
192
+ print('║ ────────────────────────────────────────────────────────────')
193
+ print('║ 怎么修:')
194
+ print('║ 1. 在 Chrome 里打开 %s' % base_url)
195
+ print('║ 2. 手动登录 (账号密码 + 验证码, 你过一次即可)')
196
+ print('║ 3. 把登录成功的标签页切到最前台')
197
+ print('║ 4. 重新跑本命令')
198
+ if not explicit:
199
+ print('║')
200
+ print('║ ✅ 已自动回退: 改用 playwright 引擎 (它自己处理登录)')
201
+ print('╚══════════════════════════════════════════════════════════════')
202
+ print('')
203
+
204
+
205
+ # ===== subprocess 调 opencli 命令的统一封装 =====
206
+
207
+
208
+ # ===== subprocess 调 opencli 命令的统一封装 =====
209
+ def _run(args, timeout=30):
210
+ """跑一条 opencli 命令, 返回 subprocess.CompletedProcess.
211
+ args 不含 'opencli' 前缀. stdout 是 JSON 时由调用方解析.
212
+ 用定位到的绝对路径 (_OPENCLI_BIN), 绕开 subprocess PATH 不全的坑."""
213
+ exe = _OPENCLI_BIN or 'opencli'
214
+ cmd = [exe] + [str(a) for a in args]
215
+ try:
216
+ return subprocess.run(cmd, capture_output=True, timeout=timeout,
217
+ shell=(os.name == 'nt' and not os.path.sep in exe))
218
+ except subprocess.TimeoutExpired as e:
219
+ class _TO:
220
+ returncode = 75 # TEMPFAIL (opencli sysexits 约定)
221
+ stdout = b''
222
+ stderr = ('timeout %ds: %s' % (timeout, ' '.join(cmd))).encode()
223
+ return _TO()
224
+
225
+
226
+ def _run_json(args, timeout=30):
227
+ """跑 opencli 命令并解析 stdout JSON. 失败返回 ({}, rc)."""
228
+ r = _run(args, timeout=timeout)
229
+ try:
230
+ j = json.loads(r.stdout.decode('utf-8', 'ignore')) if r.stdout else {}
231
+ except Exception:
232
+ j = {}
233
+ return j, r.returncode
234
+
235
+
236
+ def _anchor_to_flags(anchor):
237
+ """把 anchor {role,name,placeholder,field,text} 转 opencli 的 --role/--name 等 flag.
238
+ opencli 的 click/type/fill 支持语义 flag, 比 CSS 稳."""
239
+ if not anchor:
240
+ return []
241
+ flags = []
242
+ role = anchor.get('role', '')
243
+ name = anchor.get('name', '')
244
+ ph = anchor.get('placeholder', '')
245
+ field = anchor.get('field', '')
246
+ text = anchor.get('text', '')
247
+ # opencli --role 用 ARIA role; 这里 anchor.role 是我们自己的语义角色
248
+ if role and role in ('button', 'textbox', 'link', 'tab', 'checkbox', 'radio', 'menuitem'):
249
+ flags += ['--role', role]
250
+ if name:
251
+ flags += ['--name', name]
252
+ elif ph:
253
+ flags += ['--label', ph] # placeholder 当 label 找
254
+ elif text:
255
+ flags += ['--text', text]
256
+ if field and not (name or ph or text):
257
+ # 退化: input[name=xxx] → CSS
258
+ flags += ['--css', "input[name='%s']" % field]
259
+ return flags
260
+
261
+
262
+ # ===== 登录态: bind 到用户已登录的真实 Chrome tab =====
263
+ def init_session(session):
264
+ """绑定到用户手动登好的 Chrome tab. 复用真实登录态, 无验证码/无伪造."""
265
+ j, rc = _run_json(['browser', session, 'bind'], timeout=20)
266
+ if rc != 0:
267
+ return False, j.get('error', {}).get('message', 'bind 失败 (rc=%d)' % rc)
268
+ url = j.get('url', '')
269
+ return True, url
270
+
271
+
272
+ def unbind_session(session):
273
+ """解绑, 保留用户 tab (不关闭)."""
274
+ try:
275
+ _run(['browser', session, 'unbind'], timeout=15)
276
+ except Exception:
277
+ pass
278
+
279
+
280
+ # ===== 表格等待 (与 autotest_run.py 等价语义) =====
281
+ def _wait_table_stable(session, max_wait=8):
282
+ """轮询页面 .vxe-body--row 行数 (用 eval), 等表格加载稳定."""
283
+ deadline = time.time() + max_wait
284
+ zero_streak = 0
285
+ last = -1
286
+ while time.time() < deadline:
287
+ # eval 只读, 数行数 (与 autotest_run.py 同样的 DOM 判据)
288
+ r = _run(['browser', session, 'eval',
289
+ "document.querySelectorAll('.vxe-body--row').length"], timeout=8)
290
+ try:
291
+ n = int(re.search(r'\d+', r.stdout.decode('utf-8', 'ignore') or '0').group())
292
+ except Exception:
293
+ n = 0
294
+ if n > 0:
295
+ time.sleep(0.6)
296
+ r2 = _run(['browser', session, 'eval',
297
+ "document.querySelectorAll('.vxe-body--row').length"], timeout=8)
298
+ try:
299
+ n2 = int(re.search(r'\d+', r2.stdout.decode('utf-8', 'ignore') or '0').group())
300
+ except Exception:
301
+ n2 = 0
302
+ if n2 >= n:
303
+ return n2
304
+ else:
305
+ zero_streak += 1
306
+ if zero_streak >= 3:
307
+ return 0
308
+ time.sleep(0.5)
309
+ return last
310
+
311
+
312
+ # ===== 单步执行 (镜像 autotest_run.py:138 exec_step 的结构) =====
313
+ def exec_step(session, step, timeout=8):
314
+ """执行一步, 返回 (ok, detail). 镜像 autotest_run.py exec_step 的契约."""
315
+ intent = step.get('intent', '')
316
+ desc = step.get('desc', '')
317
+ anchor = step.get('anchor', {}) or {}
318
+ val = step.get('value', '')
319
+
320
+ try:
321
+ if intent == 'goto':
322
+ url = BASE + val if val.startswith('/') else val
323
+ _run(['browser', session, 'open', url], timeout=20)
324
+ time.sleep(2)
325
+ _wait_table_stable(session, max_wait=8)
326
+ return True, 'navigated'
327
+
328
+ if intent == 'fill':
329
+ flags = _anchor_to_flags(anchor)
330
+ if not flags:
331
+ return False, '元素未找到(anchor 空:%s)' % desc
332
+ r = _run(['browser', session, 'fill'] + flags + [str(val)], timeout=timeout)
333
+ j = {}
334
+ try:
335
+ j = json.loads(r.stdout.decode('utf-8', 'ignore')) if r.stdout else {}
336
+ except Exception:
337
+ pass
338
+ if r.returncode != 0:
339
+ err = j.get('error', {}).get('code', 'rc=%d' % r.returncode)
340
+ return False, '元素未找到(%s/%s)' % (desc, err)
341
+ # fill 带 verified 字段 (opencli 设值后会读回校验)
342
+ verified = j.get('verified', False)
343
+ return (bool(verified), 'filled' if verified else 'verify_fail(actual=%s)' % j.get('actual'))
344
+
345
+ if intent == 'click':
346
+ flags = _anchor_to_flags(anchor)
347
+ if not flags:
348
+ return False, '元素未找到(anchor 空:%s)' % desc
349
+ r = _run(['browser', session, 'click'] + flags, timeout=timeout)
350
+ time.sleep(1.5)
351
+ j = {}
352
+ try:
353
+ j = json.loads(r.stdout.decode('utf-8', 'ignore')) if r.stdout else {}
354
+ except Exception:
355
+ pass
356
+ if r.returncode != 0:
357
+ err = j.get('error', {}).get('code', 'rc=%d' % r.returncode)
358
+ return False, '元素未找到(%s/%s)' % (desc, err)
359
+ ml = j.get('match_level', '?')
360
+ mn = j.get('matches_n', '?')
361
+ return True, '%s(匹配%s)' % (ml, mn)
362
+
363
+ if intent == 'observe':
364
+ return True, 'observed'
365
+
366
+ if intent == 'extract':
367
+ # 优先 expect_api (network 抓后端响应, 强断言)
368
+ if step.get('expect_api'):
369
+ return _check_api(session, step['expect_api'])
370
+ # 否则走旧语义: body 文本包含 check 关键词
371
+ check = step.get('check', '')
372
+ r = _run(['browser', session, 'eval', "document.body.innerText"], timeout=10)
373
+ body = r.stdout.decode('utf-8', 'ignore') if r.stdout else ''
374
+ if check:
375
+ hit = check in body
376
+ return (hit, '命中:%s' % check if hit else '未命中:%s' % check)
377
+ return True, 'extracted(no check)'
378
+
379
+ if intent == 'assert':
380
+ return _check_assert(session, step.get('check', ''))
381
+
382
+ except Exception as e:
383
+ return False, str(e)[:60]
384
+ return False, '未知intent:%s' % intent
385
+
386
+
387
+ # ===== 旧 check 断言 (兼容 autotest_run.py 的行数/URL/文本语义) =====
388
+ def _check_assert(session, check):
389
+ """镜像 autotest_run.py 的 assert 分支: rows>/rows==/url_contains/input_empty."""
390
+ if not check:
391
+ return True, 'assert(no check)'
392
+ if check.startswith('rows>'):
393
+ n = int(check[5:])
394
+ _wait_table_stable(session, max_wait=6)
395
+ r = _run(['browser', session, 'eval', "document.querySelectorAll('.vxe-body--row').length"], timeout=8)
396
+ try:
397
+ rows = int(re.search(r'\d+', r.stdout.decode('utf-8', 'ignore') or '0').group())
398
+ except Exception:
399
+ rows = 0
400
+ return (rows > n, '行数%d>%d' % (rows, n) if rows > n else '行数%d<=%d' % (rows, n))
401
+ if check.startswith('rows=='):
402
+ n = int(check[6:])
403
+ _wait_table_stable(session, max_wait=6)
404
+ r = _run(['browser', session, 'eval', "document.querySelectorAll('.vxe-body--row').length"], timeout=8)
405
+ try:
406
+ rows = int(re.search(r'\d+', r.stdout.decode('utf-8', 'ignore') or '0').group())
407
+ except Exception:
408
+ rows = 0
409
+ return (rows == n, '行数=%d' % rows)
410
+ if check.startswith('url_contains'):
411
+ kw = check.split(':', 1)[1] if ':' in check else ''
412
+ r = _run(['browser', session, 'get', 'url'], timeout=8)
413
+ cur = r.stdout.decode('utf-8', 'ignore').strip() if r.stdout else ''
414
+ return (kw in cur, 'url含%s' % kw if kw in cur else 'url不含%s' % kw)
415
+ if check.startswith('input_empty:'):
416
+ field = check.split(':', 1)[1]
417
+ r = _run(['browser', session, 'eval', "document.querySelector(\"input[name='%s']\")?.value||''" % field], timeout=8)
418
+ v = r.stdout.decode('utf-8', 'ignore').strip().strip('"') if r.stdout else ''
419
+ return (v == '', '框值=%r' % v)
420
+ return True, 'assert(未知check:%s)' % check[:20]
421
+
422
+
423
+ # ===== 新增: expect_api 网络断言 (OpenCLI 核心增量) =====
424
+ def _check_api(session, expect):
425
+ """抓 network 验证后端 JSON 字段值. expect = {endpoint, jsonpath, equals, filter?}.
426
+
427
+ 流程: wait xhr(endpoint) → network 找 key → detail 拉 body → jsonpath 取值比对.
428
+ 这是 OpenCLI 相对 playwright 链路最强的增量: 直接断言后端返回, 不依赖 DOM 渲染.
429
+ """
430
+ endpoint = expect.get('endpoint', '')
431
+ jp = expect.get('jsonpath', '')
432
+ want = expect.get('equals')
433
+ filt = expect.get('filter', '')
434
+
435
+ # 1. 等目标请求完成 (endpoint 作为 url 片段匹配)
436
+ if endpoint:
437
+ _run(['browser', session, 'wait', 'xhr', endpoint, '--timeout', '10000'], timeout=15)
438
+
439
+ # 2. 读 network 列表 (默认 list 模式, 含 shape 不含 body, 省 token)
440
+ args = ['browser', session, 'network']
441
+ if filt:
442
+ args += ['--filter', filt]
443
+ j, rc = _run_json(args, timeout=15)
444
+ if rc != 0 or not j:
445
+ return False, 'network读取失败(%s)' % j.get('error', {}).get('code', 'rc=%d' % rc)
446
+ entries = j.get('entries', [])
447
+ if not entries:
448
+ return False, 'network无捕获(可能被默认过滤, 试加 --all 或检查endpoint)'
449
+
450
+ # 3. 找匹配 endpoint 的 entry (url 含 endpoint 片段)
451
+ entry = None
452
+ for e in entries:
453
+ if endpoint in (e.get('url', '') or '') or endpoint in (e.get('key', '') or ''):
454
+ entry = e
455
+ break
456
+ if not entry:
457
+ keys = [e.get('key', '?') for e in entries[:5]]
458
+ return False, '未捕获%s(captured:%s)' % (endpoint, keys)
459
+ key = entry.get('key', '')
460
+
461
+ # 4. 拉完整 body (--detail <key>)
462
+ dj, drc = _run_json(['browser', session, 'network', '--detail', key], timeout=15)
463
+ if drc != 0:
464
+ return False, 'detail读取失败(%s)' % dj.get('error', {}).get('code', 'rc=%d' % drc)
465
+ body = dj.get('body')
466
+ if body is None:
467
+ return False, 'body为空(shape:%s)' % dj.get('shape')
468
+
469
+ # 5. jsonpath 取值 (轻量实现, 只支持点路径, 不引依赖)
470
+ actual = _dig(body, jp.lstrip('$.').lstrip('.'))
471
+ ok = (actual == want)
472
+ return ok, '%s=%s(期望%s)' % (jp, _short(actual), _short(want))
473
+
474
+
475
+ def _dig(obj, path):
476
+ """简单点路径取值: data.total / data.rows[0].id. 不引 jsonpath 依赖."""
477
+ if not path:
478
+ return obj
479
+ cur = obj
480
+ for part in re.split(r'\.', path):
481
+ # 处理数组下标 rows[0]
482
+ m = re.match(r'([^\[]+)(\[(\d+)\])?', part)
483
+ if not m:
484
+ return None
485
+ key = m.group(1)
486
+ idx = m.group(3)
487
+ if isinstance(cur, dict) and key in cur:
488
+ cur = cur[key]
489
+ elif isinstance(cur, list) and key.isdigit() and int(key) < len(cur):
490
+ cur = cur[int(key)]
491
+ else:
492
+ return None
493
+ if idx is not None and isinstance(cur, list) and int(idx) < len(cur):
494
+ cur = cur[int(idx)]
495
+ return cur
496
+
497
+
498
+ def _short(v, n=30):
499
+ s = str(v)
500
+ return s if len(s) <= n else s[:n] + '...'
501
+
502
+
503
+ # ===== 跑一个用例 (镜像 autotest_run.py:199 run_case) =====
504
+ def run_case(session, case):
505
+ cid = case.get('case_id', '?')
506
+ steps = case.get('steps', [])
507
+ details = []
508
+ all_ok = True
509
+ for step in steps:
510
+ ok, detail = exec_step(session, step)
511
+ details.append('%s:%s(%s)' % (step.get('intent', '?'), '✓' if ok else '✗', detail))
512
+ if not ok:
513
+ all_ok = False
514
+ break # 失败即停 (前置步骤失败后续无意义)
515
+ return {'case_id': cid, 'title': case.get('title', ''), 'pass': all_ok,
516
+ 'expected': case.get('expected', ''), 'details': details}
517
+
518
+
519
+ # ===== 沉淀锚点 (与 autotest_run.py 一致, 复用 autotest.py learn) =====
520
+ def collect_anchors(session):
521
+ """从当前页面收集可交互元素锚点 (用 eval, 与 autotest_run.py 同样的选择器)."""
522
+ js = '''()=>{const out=[];document.querySelectorAll('button,.ant-tabs-tab,input[placeholder],input[name],[role=tab]').forEach(el=>{const name=(el.innerText||el.getAttribute('placeholder')||el.getAttribute('name')||'').trim();const role=el.getAttribute('role')||(el.tagName==='INPUT'?'textbox':el.tagName.toLowerCase());if(name&&name.length<25&&!/^[0-9\s]+$/.test(name))out.push({role,name});});return out.slice(0,30);}'''
523
+ r = _run(['browser', session, 'eval', js], timeout=10)
524
+ try:
525
+ els = json.loads(r.stdout.decode('utf-8', 'ignore')) if r.stdout else []
526
+ except Exception:
527
+ els = []
528
+ seen = set(); uniq = []
529
+ for el in els:
530
+ k = (el.get('role'), el.get('name'))
531
+ if k not in seen:
532
+ seen.add(k); uniq.append(el)
533
+ return uniq
534
+
535
+
536
+ def sediment(page_path, anchors):
537
+ if not anchors:
538
+ return 0
539
+ try:
540
+ pj = json.dumps({'url': page_path, 'elements': [
541
+ {'role': a.get('role'), 'name': a.get('name'), 'desc': a.get('name')} for a in anchors
542
+ ]}, ensure_ascii=False)
543
+ subprocess.run([sys.executable, AUTOLEARN, 'learn', '--url', page_path, '--page-json', pj],
544
+ capture_output=True, timeout=20)
545
+ return len(anchors)
546
+ except Exception:
547
+ return 0
548
+
549
+
550
+ # ===== 主 =====
551
+ def main():
552
+ ap = argparse.ArgumentParser()
553
+ ap.add_argument('--task', required=True, help='任务文件 JSON')
554
+ ap.add_argument('--session', default=DEFAULT_SESSION, help='OpenCLI session 名 (默认 wltest)')
555
+ ap.add_argument('--url', default='', help='临时覆盖 base url')
556
+ ap.add_argument('--force-opencli', action='store_true',
557
+ help='强制用 opencli 引擎, 没装也不回退 (默认: 没装自动回退 playwright)')
558
+ args = ap.parse_args()
559
+
560
+ global BASE
561
+ if args.url:
562
+ BASE = args.url.rstrip('/')
563
+
564
+ task = json.load(open(args.task, encoding='utf-8'))
565
+ page_path = task['page_path']
566
+ cases = task.get('cases', [])
567
+
568
+ # 1. 环境探测 (没装/没连 → 友好提示; 隐式调用时自动回退 playwright, 50 人零报错)
569
+ ok, diag = _opencli_ready()
570
+ if not ok:
571
+ _print_not_ready(diag, BASE, explicit=args.force_opencli)
572
+ # 显式要 opencli 但没装 → 中止 (用户明确要它, 不能偷换引擎)
573
+ if args.force_opencli:
574
+ result = {'engine': 'opencli', 'error': 'OPENCLI_NOT_READY(%s)' % diag.get('stage'),
575
+ 'case_results': [], 'page': page_path}
576
+ print(json.dumps(result, ensure_ascii=False))
577
+ sys.exit(1)
578
+ # 隐式调用 → 自动回退 playwright (开箱即用的关键)
579
+ print('>>> 自动转用 playwright 引擎 (autotest_run.py) ...')
580
+ runner = os.path.join(SCRIPTS, 'autotest_run.py')
581
+ cmd = [sys.executable, runner, '--task', args.task]
582
+ if args.url:
583
+ cmd += ['--url', args.url]
584
+ r = subprocess.run(cmd, cwd=PROJECT)
585
+ sys.exit(r.returncode)
586
+
587
+ session = args.session
588
+ t0 = time.time()
589
+ result = {'page': page_path, 'page_name': task.get('page_name', ''),
590
+ 'engine': 'opencli', 'case_results': [], 'anchors_count': 0, 'error': '', 'video': ''}
591
+ try:
592
+ # 2. 绑定已登录 tab (复用真实登录态, 这一步替代 get_token + init_session 整条链)
593
+ bound, info = init_session(session)
594
+ if not bound:
595
+ _print_bind_fail(info, BASE, explicit=args.force_opencli)
596
+ if args.force_opencli:
597
+ result['error'] = 'BIND_FAIL: ' + str(info)
598
+ print(json.dumps(result, ensure_ascii=False))
599
+ sys.exit(1)
600
+ print('>>> 自动转用 playwright 引擎 ...')
601
+ runner = os.path.join(SCRIPTS, 'autotest_run.py')
602
+ r = subprocess.run([sys.executable, runner, '--task', args.task], cwd=PROJECT)
603
+ sys.exit(r.returncode)
604
+ print(' 已绑定: %s' % info)
605
+
606
+ # 3. 先 goto 目标页
607
+ _run(['browser', session, 'open', BASE + page_path], timeout=20)
608
+ time.sleep(2.5)
609
+ _wait_table_stable(session, max_wait=8)
610
+
611
+ # 4. 跑每个用例
612
+ for case in cases:
613
+ _run(['browser', session, 'open', BASE + page_path], timeout=20) # 每用例前重新 goto
614
+ time.sleep(2)
615
+ cr = run_case(session, case)
616
+ result['case_results'].append(cr)
617
+ tag = '✓' if cr['pass'] else '✗'
618
+ print(' %s %s %s' % (tag, cr['case_id'], cr['title'][:30]))
619
+ for d in cr['details']:
620
+ print(' %s' % d)
621
+
622
+ # 5. 收集沉淀
623
+ anc = collect_anchors(session)
624
+ result['anchors_count'] = len(anc)
625
+ sediment(page_path, anc)
626
+
627
+ except Exception as e:
628
+ result['error'] = str(e)[:100]
629
+ finally:
630
+ # 解绑 (保留用户 tab)
631
+ unbind_session(session)
632
+
633
+ result['duration'] = round(time.time() - t0, 1)
634
+ passed = sum(1 for c in result['case_results'] if c['pass'])
635
+ total = len(result['case_results'])
636
+ result['summary'] = '%d/%d 通过' % (passed, total)
637
+ print()
638
+ print('汇总: %s 锚点%d 耗时%ss %s' % (result['summary'], result['anchors_count'], result['duration'], result['error']))
639
+ # 结果写文件 (与 autotest_run.py 同样的 _run.json 命名, 加 engine 区分)
640
+ out = args.task.replace('.json', '_opencli.json')
641
+ json.dump(result, open(out, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
642
+ print('结果: %s' % out)
643
+
644
+
645
+ if __name__ == '__main__':
646
+ main()