@hupan56/wlkj 2.7.11 → 3.0.0

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 (152) hide show
  1. package/bin/cli.js +375 -78
  2. package/package.json +29 -29
  3. package/templates/.qoder/.runtime/ctx-cache-5660152f1d6dd819.md +23 -0
  4. package/templates/.qoder/.runtime/ctx-cache-afdce0dac06b25b0.md +23 -0
  5. package/templates/.qoder/.runtime/search-cache-eae7644e7b122f35.txt +1 -0
  6. package/templates/.qoder/learning/eval-history.jsonl +28 -0
  7. package/templates/data/index/wiki-index.json +8 -0
  8. package/templates/qoder/agents/insight-planning.md +1 -1
  9. package/templates/qoder/agents/insight-research.md +28 -15
  10. package/templates/qoder/commands/optional/wl-insight.md +159 -161
  11. package/templates/qoder/commands/optional/wl-report.md +4 -4
  12. package/templates/qoder/commands/optional/wl-status.md +2 -2
  13. package/templates/qoder/commands/wl-code.md +2 -2
  14. package/templates/qoder/commands/wl-design.md +2 -2
  15. package/templates/qoder/commands/wl-init.md +3 -3
  16. package/templates/qoder/commands/wl-prd.md +2 -2
  17. package/templates/qoder/commands/wl-req.md +43 -0
  18. package/templates/qoder/commands/wl-search.md +8 -8
  19. package/templates/qoder/commands/wl-task.md +17 -17
  20. package/templates/qoder/commands/wl-test.md +41 -15
  21. package/templates/qoder/config.yaml +17 -1
  22. package/templates/qoder/hooks/session-start.py +12 -22
  23. package/templates/qoder/nul +4 -0
  24. package/templates/qoder/rules/wl-pipeline.md +22 -48
  25. package/templates/qoder/scripts/README.md +139 -0
  26. package/templates/qoder/scripts/common/autotest_auth.py +109 -0
  27. package/templates/qoder/scripts/common/bootstrap.py +145 -0
  28. package/templates/qoder/scripts/common/check_publish.py +98 -0
  29. package/templates/qoder/scripts/common/cmd_registry.py +112 -0
  30. package/templates/qoder/scripts/common/config.py +187 -0
  31. package/templates/qoder/scripts/common/contract.py +317 -0
  32. package/templates/qoder/scripts/common/developer.py +2 -1
  33. package/templates/qoder/scripts/common/feishu.py +10 -9
  34. package/templates/qoder/scripts/common/guard.py +159 -0
  35. package/templates/qoder/scripts/common/identity.py +121 -2
  36. package/templates/qoder/scripts/common/kg_capabilities.py +182 -0
  37. package/templates/qoder/scripts/common/mcp_base.py +268 -0
  38. package/templates/qoder/scripts/common/paths.py +187 -1
  39. package/templates/qoder/scripts/common/result.py +223 -0
  40. package/templates/qoder/scripts/common/roles.py +60 -0
  41. package/templates/qoder/scripts/common/task_utils.py +21 -9
  42. package/templates/qoder/scripts/common/test_extract.py +115 -0
  43. package/templates/qoder/scripts/kg/__init__.py +11 -0
  44. package/templates/qoder/scripts/kg/build_entity_registry.py +196 -0
  45. package/templates/qoder/scripts/kg/build_relations.py +127 -0
  46. package/templates/qoder/scripts/{build_style_index.py → kg/build_style_index.py} +39 -10
  47. package/templates/qoder/scripts/kg/build_workflows.py +144 -0
  48. package/templates/qoder/scripts/{context_pack.py → kg/context_pack.py} +18 -11
  49. package/templates/qoder/scripts/{enrich_prompt.py → kg/enrich_prompt.py} +232 -226
  50. package/templates/qoder/scripts/{extract_api_params.py → kg/extract.py} +398 -246
  51. package/templates/qoder/scripts/{kg.py → kg/kg.py} +638 -708
  52. package/templates/qoder/scripts/{kg_build.py → kg/kg_build.py} +618 -612
  53. package/templates/qoder/scripts/{kg_build_db.py → kg/kg_build_db.py} +333 -327
  54. package/templates/qoder/scripts/{kg_duckdb.py → kg/kg_duckdb.py} +38 -37
  55. package/templates/qoder/scripts/{kg_incremental.py → kg/kg_incremental.py} +420 -393
  56. package/templates/qoder/scripts/{kg_link_db.py → kg/kg_link_db.py} +230 -224
  57. package/templates/qoder/scripts/{kg_semantic.py → kg/kg_semantic.py} +156 -150
  58. package/templates/qoder/scripts/kg/prefetch.py +359 -0
  59. package/templates/qoder/scripts/{search_index.py → kg/search_index.py} +70 -14
  60. package/templates/qoder/scripts/mcp/__init__.py +11 -0
  61. package/templates/qoder/scripts/{kg_mcp_server.py → mcp/kg_mcp_server.py} +77 -272
  62. package/templates/qoder/scripts/{lanhu_stdio_wrapper.py → mcp/lanhu_stdio_wrapper.py} +125 -119
  63. package/templates/qoder/scripts/{check_mcp.py → mcp/mcp_doctor.py} +515 -298
  64. package/templates/qoder/scripts/{mcp_launcher.py → mcp/mcp_launcher.py} +442 -414
  65. package/templates/qoder/scripts/{mysql_mcp_server.py → mcp/mysql_mcp_server.py} +347 -396
  66. package/templates/qoder/scripts/{zentao_mcp_server.py → mcp/zentao_mcp_server.py} +384 -424
  67. package/templates/qoder/scripts/report/__init__.py +11 -0
  68. package/templates/qoder/scripts/{add_session.py → report/add_session.py} +250 -244
  69. package/templates/qoder/scripts/{archive_prd.py → report/archive_prd.py} +383 -377
  70. package/templates/qoder/scripts/{eval_prd.py → report/eval_prd.py} +73 -11
  71. package/templates/qoder/scripts/{export.py → report/export.py} +63 -0
  72. package/templates/qoder/scripts/{fill_prototype.py → report/fill_prototype.py} +6 -0
  73. package/templates/qoder/scripts/{gen_design_doc.py → report/gen_design_doc.py} +400 -394
  74. package/templates/qoder/scripts/{learn.py → report/learn.py} +152 -146
  75. package/templates/qoder/scripts/{learn_aggregate.py → report/learn_aggregate.py} +207 -201
  76. package/templates/qoder/scripts/{report.py → report/report.py} +287 -281
  77. package/templates/qoder/scripts/report/req.py +222 -0
  78. package/templates/qoder/scripts/report/role.py +33 -0
  79. package/templates/qoder/scripts/{status.py → report/status.py} +634 -628
  80. package/templates/qoder/scripts/setup/__init__.py +11 -0
  81. package/templates/qoder/scripts/setup/carriers.py +662 -0
  82. package/templates/qoder/scripts/{init_doctor.py → setup/init_doctor.py} +63 -26
  83. package/templates/qoder/scripts/{install_qoderwork.py → setup/install_qoderwork.py} +36 -26
  84. package/templates/qoder/scripts/{platform_doctor.py → setup/platform_doctor.py} +265 -259
  85. package/templates/qoder/scripts/{repo_root.py → setup/repo_root.py} +112 -106
  86. package/templates/qoder/scripts/{setup.py → setup/setup.py} +147 -0
  87. package/templates/qoder/scripts/{setup_lanhu.py → setup/setup_lanhu.py} +973 -963
  88. package/templates/qoder/scripts/task/__init__.py +11 -0
  89. package/templates/qoder/scripts/{git_sync.py → task/git_sync.py} +52 -31
  90. package/templates/qoder/scripts/{syncgate.py → task/syncgate.py} +6 -0
  91. package/templates/qoder/scripts/task/task.py +221 -0
  92. package/templates/qoder/scripts/task/task_lifecycle.py +596 -0
  93. package/templates/qoder/scripts/task/task_query.py +161 -0
  94. package/templates/qoder/scripts/task/task_relations.py +424 -0
  95. package/templates/qoder/scripts/{team_sync.py → task/team_sync.py} +93 -20
  96. package/templates/qoder/scripts/test/__init__.py +11 -0
  97. package/templates/qoder/scripts/{autotest.py → test/autotest.py} +1174 -1751
  98. package/templates/qoder/scripts/{autotest_batch.py → test/autotest_batch.py} +242 -224
  99. package/templates/qoder/scripts/test/autotest_data.py +675 -0
  100. package/templates/qoder/scripts/{autotest_run.py → test/autotest_run.py} +309 -297
  101. package/templates/qoder/scripts/{benchmark.py → test/benchmark.py} +6 -0
  102. package/templates/qoder/scripts/{kg_auto_login.py → test/kg_auto_login.py} +202 -196
  103. package/templates/qoder/scripts/{kg_test_runner.py → test/kg_test_runner.py} +7 -1
  104. package/templates/qoder/scripts/{page_probe.py → test/page_probe.py} +465 -459
  105. package/templates/qoder/scripts/wlkj.py +116 -0
  106. package/templates/qoder/settings.json +1 -10
  107. package/templates/qoder/skills/design-import/SKILL.md +226 -226
  108. package/templates/qoder/skills/design-review/SKILL.md +82 -82
  109. package/templates/qoder/skills/prd-generator/SKILL.md +26 -16
  110. package/templates/qoder/skills/prd-review/SKILL.md +5 -5
  111. package/templates/qoder/skills/prototype-generator/SKILL.md +256 -256
  112. package/templates/qoder/skills/spec-coder/SKILL.md +4 -4
  113. package/templates/qoder/skills/spec-generator/SKILL.md +4 -4
  114. package/templates/qoder/skills/test-generator/SKILL.md +5 -5
  115. package/templates/qoder/skills/wl-code/SKILL.md +4 -4
  116. package/templates/qoder/skills/wl-commit/SKILL.md +4 -4
  117. package/templates/qoder/skills/wl-design/SKILL.md +3 -3
  118. package/templates/qoder/skills/wl-init/SKILL.md +8 -8
  119. package/templates/qoder/skills/wl-insight/SKILL.md +5 -5
  120. package/templates/qoder/skills/wl-prd-full/SKILL.md +6 -6
  121. package/templates/qoder/skills/wl-prd-quick/SKILL.md +6 -6
  122. package/templates/qoder/skills/wl-prd-review/SKILL.md +4 -4
  123. package/templates/qoder/skills/wl-report/SKILL.md +7 -7
  124. package/templates/qoder/skills/wl-search/SKILL.md +13 -13
  125. package/templates/qoder/skills/wl-spec/SKILL.md +5 -5
  126. package/templates/qoder/skills/wl-status/SKILL.md +5 -5
  127. package/templates/qoder/skills/wl-task/SKILL.md +6 -6
  128. package/templates/qoder/skills/wl-test/SKILL.md +102 -39
  129. package/templates/root/AGENTS.md +39 -40
  130. package/templates/qoder/hooks/inject-workflow-state.py +0 -169
  131. package/templates/qoder/scripts/__pycache__/check_mcp_launch.cpython-39.pyc +0 -0
  132. package/templates/qoder/scripts/__pycache__/install_qoderwork.cpython-39.pyc +0 -0
  133. package/templates/qoder/scripts/__pycache__/mcp_launcher.cpython-39.pyc +0 -0
  134. package/templates/qoder/scripts/__pycache__/platform_doctor.cpython-39.pyc +0 -0
  135. package/templates/qoder/scripts/check_carriers.py +0 -238
  136. package/templates/qoder/scripts/check_mcp_launch.py +0 -183
  137. package/templates/qoder/scripts/check_qoderwork_consistency.py +0 -166
  138. package/templates/qoder/scripts/collect_prds.py +0 -31
  139. package/templates/qoder/scripts/common/mentions.py +0 -134
  140. package/templates/qoder/scripts/common/utf8.py +0 -38
  141. package/templates/qoder/scripts/extract_routes.py +0 -54
  142. package/templates/qoder/scripts/extract_routes_tree.py +0 -78
  143. package/templates/qoder/scripts/handoff.py +0 -22
  144. package/templates/qoder/scripts/init_developer.py +0 -76
  145. package/templates/qoder/scripts/parse_prds.py +0 -33
  146. package/templates/qoder/scripts/role.py +0 -51
  147. package/templates/qoder/scripts/sync_carriers.py +0 -259
  148. package/templates/qoder/scripts/task.py +0 -1261
  149. package/templates/qoder/scripts/workspace_init.py +0 -102
  150. package/templates/qoder/skills/prompt-enrich/SKILL.md +0 -90
  151. package/templates/qoder/skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp +0 -0
  152. /package/templates/qoder/scripts/{secure-ls.js → test/secure-ls.js} +0 -0
@@ -1,1751 +1,1174 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- QODER Pipeline - 浏览器自动化测试 (用例生成 + 运行编排)
5
-
6
- 设计原则 (遵循规则 8: QoderWork 只做可选增强, 绝不强依赖):
7
- - 本脚本是"用例生成器 + 运行编排器", 不内嵌任何浏览器驱动库
8
- (不 import playwright/selenium —— PM 本机装不动)。
9
- - 真正"开浏览器点击"由 QoderWork 的 Browser Use / Computer 控制连接器
10
- 在运行时承担 (Agent run 指令后调用连接器)
11
- - 纯 Qoder IDE / CLI 无连接器 → 静默降级为"人工核对清单", 不报错不阻塞。
12
- - 本地 (workspace/tasks/<task>/autotest-cases.jsonl) 是唯一事实源。
13
-
14
- 用例格式 (autotest-cases.jsonl, 每行一条):
15
- {
16
- "case_id": "AT-login-001",
17
- "title": "正确账号密码登录成功",
18
- "platform": "web", "env": "test",
19
- "preconditions": "已注册账号 test/123456",
20
- "steps": [
21
- {"action": "goto", "target": "{{base_url}}/login"},
22
- {"action": "fill", "target": "input[placeholder=账号]", "value": "{{ask:登录账号}}"},
23
- {"action": "fill", "target": "input[type=password]", "value": "{{ask:登录密码}}"},
24
- {"action": "click", "target": "button.login-btn"},
25
- {"action": "assert_visible", "target": ".home-page", "desc": "跳到首页"}
26
- ],
27
- "expected": "登录成功进入首页, 右上角显示用户名"
28
- }
29
- steps.action 取值: goto / click / fill / select / assert_visible /
30
- assert_text / wait / screenshot / (可扩展)
31
-
32
- 占位符 (run 时注入):
33
- {{base_url}} — 按环境(test/pre/prod)解析的目标域名
34
- {{ask:描述}} — 需要用户提供的测试数据(账号/密码/手机号等)。
35
- 生成用例时: 凡是需要真实测试数据的, 用 {{ask:描述}} 占位, 绝不编造。
36
- 执行时: 用 --data '{"登录账号":"test","登录密码":"123456"}' 传入;
37
- 未提供的会列清单提示补齐后再执行。
38
- 敏感数据(密码/验证码)建议走 --data 命令传, 避免明文进对话记录。
39
-
40
- 域名解析优先级: --url > --env(查 config.yaml) > 报错
41
-
42
- Usage:
43
- python autotest.py generate <task> [--env test|pre|prod] [--platform web|app]
44
- python autotest.py run <task> [--env ...] [--url <url>] [--platform ...]
45
- [--case <case_id> ...] [--data '<json>']
46
- [--record <result>]
47
- python autotest.py quick --cases '<JSON用例>' [--desc '<描述>'] [--env ...]
48
- [--url <url>] [--platform ...] [--data '<json>']
49
- [--record <result>] # 快速测试, 不需任务/PRD
50
- python autotest.py list <task>
51
- python autotest.py report <task>
52
-
53
- Exit: 0 = 成功, 1 = 一般错误(含缺测试数据)
54
- """
55
-
56
- import argparse
57
- import json
58
- import os
59
- import sys
60
- from datetime import datetime
61
- from pathlib import Path
62
-
63
- if sys.platform == 'win32':
64
- try:
65
- sys.stdout.reconfigure(encoding='utf-8')
66
- except (AttributeError, IOError):
67
- pass
68
-
69
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
70
-
71
- from common.paths import get_repo_root, get_tasks_dir, get_developer, MEMBERS_DIR, index_path # noqa: E402
72
- from common.task_utils import load_task_json, resolve_task_dir # noqa: E402
73
-
74
- # ---------------------------------------------------------------------------
75
- # 路径常量
76
- # ---------------------------------------------------------------------------
77
-
78
- BASE = get_repo_root()
79
- CONFIG_PATH = os.path.join(str(BASE), '.qoder', 'config.yaml')
80
- HISTORY_PATH = os.path.join(str(BASE), '.qoder', 'learning', 'autotest-history.jsonl')
81
- # 个人测试数据文档: workspace/members/{dev}/autotest-data.yaml
82
- # ✅ 用户可见、自己填(在个人文件夹里); ✅ .gitignore 覆盖(workspace/members/*/autotest-data.yaml),
83
- # team_sync 永不 push/共享。跟 .signing_key/exports/journal 同级隔离。
84
- # ⚠️ config.yaml 是团队共用会 push, 绝不能放账号密码。
85
- LOCAL_DATA_FILENAME = 'autotest-data.yaml'
86
- # 登录态记录(同目录, 同样 gitignored): 记住哪个环境已登录, 避免重复登录(尤其验证码慢)
87
- LOGIN_STATE_FILENAME = 'autotest-login-state.yaml'
88
- CASES_FILENAME = 'autotest-cases.jsonl'
89
-
90
- SUPPORTED_ENVS = ['test', 'pre', 'prod']
91
- SUPPORTED_PLATFORMS = ['web', 'app']
92
- # steps.action 白名单 (生成时校验; 不在表内 → 警告但不阻塞)
93
- KNOWN_ACTIONS = {
94
- 'goto', 'click', 'fill', 'select', 'hover', 'press',
95
- 'assert_visible', 'assert_text', 'assert_not_visible',
96
- 'wait', 'screenshot', 'scroll', 'upload',
97
- 'solve_captcha', # 验证码: QoderWork Agent 用自带视觉识别(截图→算数学题→填入)
98
- }
99
-
100
- # ── 意图式 step (A方案: 意图+锚点) ──
101
- # 新格式: {intent, desc, anchor?, value?} —— 执行时靠 read_page 的 a11y 树语义匹配,
102
- # 有 anchor(role/name/placeholder)则确定性命中(快), 无则 LLM 兜底(self-heal)。
103
- # ref 不存(会变), 只存稳定锚点。兼容旧 action 式(有 action 字段=legacy)
104
- KNOWN_INTENTS = {
105
- 'goto', # 导航: {intent:'goto', desc:'登录页', value:'{{base_url}}/auth/login'}
106
- 'observe', # 读页面: {intent:'observe'} 触发一次 read_page 拿 a11y 树
107
- 'click', # 点击: {intent:'click', desc:'登录按钮', anchor:{role:'button',name:'登录'}}
108
- 'fill', # 填表: {intent:'fill', desc:'账号框', value:'{{ask:登录账号}}', anchor:{placeholder:'尊敬的管理员'}}
109
- 'extract', # 语义断言: {intent:'extract', desc:'是否已进入首页(离开登录页)'}
110
- 'ask_human', # 人机协同: {intent:'ask_human', desc:'请手动完成验证码并点登录'}
111
- 'assert', # 确定性断言: {intent:'assert', desc:'URL 不再是 /auth/login'}
112
- }
113
- # anchor 允许的稳定锚点 key (role/name 来自 a11y 树, placeholder/text 来自属性; 都不随刷新变)
114
- ANCHOR_KEYS = {'role', 'name', 'placeholder', 'text'}
115
-
116
-
117
- def is_intent_step(s):
118
- """step 是新意图式(有 intent 字段)还是旧命令式(有 action 字段)。"""
119
- return isinstance(s, dict) and 'intent' in s and 'action' not in s
120
-
121
-
122
- # ---------------------------------------------------------------------------
123
- # 配置加载 (仿 common.feishu._load_config: yaml 优先, 缺失则简单解析)
124
- # ---------------------------------------------------------------------------
125
-
126
- def load_config_section(section='autotest'):
127
- """加载 config.yaml 的指定段。PyYAML 不可用时尽力解析 autotest 段。"""
128
- try:
129
- import yaml
130
- with open(CONFIG_PATH, encoding='utf-8') as f:
131
- full = yaml.safe_load(f) or {}
132
- return full.get(section) or {}
133
- except Exception:
134
- # PyYAML 缺失或解析失败 —— 简单兜底 (只兜 default_env, base_url 解析仍报错)
135
- return _fallback_parse_autotest()
136
-
137
-
138
- def _fallback_parse_autotest():
139
- """无 PyYAML 时的极简兜底: 只抓 default_env。base_url 解析交给上层报错。"""
140
- config = {}
141
- try:
142
- with open(CONFIG_PATH, encoding='utf-8') as f:
143
- for line in f:
144
- if line.strip().startswith('default_env:'):
145
- val = line.split(':', 1)[1].strip().strip('"').strip("'")
146
- config['default_env'] = val
147
- break
148
- except Exception:
149
- pass
150
- return config
151
-
152
-
153
- def resolve_base_url(env, platform, override=None):
154
- """解析目标域名。优先级: override(--url) > config > None。
155
-
156
- Returns: (base_url, source) source ∈ {'cli', 'config', 'none'}
157
- """
158
- if override:
159
- return override.rstrip('/'), 'cli'
160
- cfg = load_config_section('autotest')
161
- envs = cfg.get('environments') or {}
162
- env_cfg = envs.get(env) or {}
163
- plat_cfg = env_cfg.get(platform) or {}
164
- url = (plat_cfg.get('base_url') or '').strip()
165
- if url:
166
- return url.rstrip('/'), 'config'
167
- return None, 'none'
168
-
169
-
170
- # ---------------------------------------------------------------------------
171
- # 个人测试数据文档 (workspace/members/{dev}/autotest-data.yaml)
172
- # ✅ 用户可见, 自己填(就在个人文件夹里)
173
- # ✅ .gitignore 覆盖 (workspace/members/*/autotest-data.yaml) team_sync 永不 push
174
- # ⚠️ config.yaml 是团队共用会 push, 绝不能放账号密码
175
- # ---------------------------------------------------------------------------
176
-
177
- def _require_developer():
178
- """获取当前开发者名, 身份缺失直接抛错。
179
-
180
- 健壮性: 绝不 fallback 'default'。多人共用 workspace 时,
181
- 无身份 = 会写到共享垃圾目录 = 串号。必须先 /wl-init 确立身份。
182
- """
183
- dev = get_developer()
184
- if not dev:
185
- raise RuntimeError(
186
- '未检测到开发者身份( .qoder/.developer)。请先运行 /wl-init 注册身份。'
187
- ' (多人共用 workspace 时, 身份隔离靠它——没身份会串到别人的目录)'
188
- )
189
- return dev
190
-
191
-
192
- def local_data_path():
193
- """当前开发者的个人测试数据文档路径。
194
-
195
- workspace/members/{dev}/autotest-data.yaml —— 用户能直接看到、编辑。
196
- """
197
- dev = _require_developer()
198
- return Path(str(MEMBERS_DIR)) / dev / LOCAL_DATA_FILENAME
199
-
200
-
201
- def load_local_test_data(env=None, platform=None):
202
- """读个人测试数据文档。返回扁平 {描述: 值} 字典(已按 env/platform 合并)
203
-
204
- 优先级(文档内): 指定 env+platform 的 > 仅 platform > 仅 env > 顶层通用。
205
- 文档不存在/解析失败 → {} (不报错)。
206
-
207
- 文档格式 (autotest-data.yaml):
208
- # 顶层 = 通用(所有环境/平台都可用)
209
- 测试手机号: "13800000000"
210
-
211
- # 按平台分(不分环境)
212
- web:
213
- 登录账号: admin
214
- app:
215
- 登录账号: appuser
216
-
217
- # 按环境分
218
- test:
219
- 登录账号: testuser
220
- 登录密码: "123456"
221
-
222
- # 精确到 环境+平台 (优先级最高)
223
- prod:
224
- web:
225
- 登录账号: prod-admin
226
- """
227
- p = local_data_path()
228
- if not p.is_file():
229
- return {}
230
- try:
231
- import yaml
232
- with open(str(p), encoding='utf-8') as f:
233
- raw = yaml.safe_load(f) or {}
234
- except ImportError:
235
- raw = _parse_yaml_fallback(p)
236
- except Exception:
237
- return {}
238
- if not isinstance(raw, dict):
239
- return {}
240
- # 合并优先级: 通用 < env < platform < env.platform
241
- merged = {}
242
- # 顶层(非 env/platform 的键)
243
- for k, v in raw.items():
244
- if k in SUPPORTED_ENVS or k in SUPPORTED_PLATFORMS:
245
- continue
246
- if isinstance(v, dict):
247
- continue
248
- merged[str(k)] = v
249
- if platform and isinstance(raw.get(platform), dict):
250
- for k, v in raw[platform].items():
251
- if not isinstance(v, dict):
252
- merged[str(k)] = v
253
- if env and isinstance(raw.get(env), dict):
254
- for k, v in raw[env].items():
255
- if not isinstance(v, dict):
256
- merged[str(k)] = v
257
- # env.platform (精确)
258
- if platform and isinstance(raw[env].get(platform), dict):
259
- for k, v in raw[env][platform].items():
260
- merged[str(k)] = v
261
- return merged
262
-
263
-
264
- def _parse_yaml_fallback(p):
265
- """无 PyYAML 时, 极简解析顶层 "key: value"(不支持嵌套)。"""
266
- data = {}
267
- try:
268
- for line in p.read_text(encoding='utf-8').splitlines():
269
- line = line.rstrip()
270
- if not line.strip() or line.lstrip().startswith('#'):
271
- continue
272
- if line.startswith(' ') or line.startswith('\t'):
273
- continue # 跳过嵌套行(无 yaml 库时不解析嵌套)
274
- if ':' not in line:
275
- continue
276
- k, _, v = line.partition(':')
277
- data[k.strip()] = v.strip().strip('"').strip("'")
278
- except Exception:
279
- pass
280
- return data
281
-
282
-
283
- def save_local_test_data(key, value, env=None, platform=None):
284
- """原子写一条测试数据到个人文档。
285
-
286
- 策略: 没指定 env/platform → 写顶层; 指定了 → 写到对应嵌套块。
287
- 保留文档里已有的其他内容。
288
- """
289
- import yaml as _yaml
290
- p = local_data_path()
291
- os.makedirs(str(p.parent), exist_ok=True)
292
- # 读已有(保留注释做不到, 但数据完整保留)
293
- existing = {}
294
- if p.is_file():
295
- try:
296
- with open(str(p), encoding='utf-8') as f:
297
- existing = _yaml.safe_load(f) or {}
298
- except Exception:
299
- existing = {}
300
- if not isinstance(existing, dict):
301
- existing = {}
302
- # 定位写入点
303
- if env and platform:
304
- existing.setdefault(env, {})
305
- if not isinstance(existing[env], dict):
306
- existing[env] = {}
307
- existing[env].setdefault(platform, {})
308
- if not isinstance(existing[env][platform], dict):
309
- existing[env][platform] = {}
310
- existing[env][platform][key] = value
311
- elif env:
312
- existing.setdefault(env, {})
313
- if not isinstance(existing[env], dict):
314
- existing[env] = {}
315
- existing[env][key] = value
316
- elif platform:
317
- existing.setdefault(platform, {})
318
- if not isinstance(existing[platform], dict):
319
- existing[platform] = {}
320
- existing[platform][key] = value
321
- else:
322
- existing[key] = value
323
- from common.atomicio import atomic_write_text
324
- header = ('# 个人测试数据 (本地, 已 gitignore, 不会 push/共享给团队)\n'
325
- '# autotest.py set-data 管理, 或直接编辑本文件。\n'
326
- '# 顶层=通用; web/app=按平台; test/pre/prod=按环境; 环境.平台=最精确(优先级最高)\n\n')
327
- atomic_write_text(str(p), header + _yaml.safe_dump(
328
- existing, allow_unicode=True, sort_keys=False, default_flow_style=False))
329
-
330
-
331
- def init_local_data_doc():
332
- """生成带注释的空模板文档(若不存在)。"""
333
- p = local_data_path()
334
- if p.is_file():
335
- return str(p), False # 已存在, 不覆盖
336
- os.makedirs(str(p.parent), exist_ok=True)
337
- from common.atomicio import atomic_write_text
338
- template = (
339
- '# 个人测试数据 (本地, 已 gitignore, 不会 push/共享给团队)\n'
340
- '# 在这里填你的测试账号/密码。每个人只填自己的 members/{自己}/autotest-data.yaml。\n'
341
- '#\n'
342
- '# 结构(优先级从低到高):\n'
343
- '# 顶层 key → 所有环境/平台通用\n'
344
- '# web: / app: → 按平台分\n'
345
- '# test: / pre: / prod: → 按环境分\n'
346
- '# 环境.平台 → 最精确, 优先级最高\n'
347
- '#\n'
348
- '# 用例里用 {{ask:描述}} 占位, 描述要跟这里的 key 一致(如 {{ask:登录账号}})。\n\n'
349
- '# ===== 通用(不分环境/平台) =====\n'
350
- '测试手机号: "13800000000"\n\n'
351
- '# ===== 按平台 =====\n'
352
- 'web:\n'
353
- ' 登录账号: admin\n'
354
- 'app:\n'
355
- ' 登录账号: appuser\n\n'
356
- '# ===== 按环境(test/pre/prod) =====\n'
357
- 'test:\n'
358
- ' 登录账号: testuser\n'
359
- ' 登录密码: "123456"\n'
360
- ' # 也可精确到 环境+平台:\n'
361
- ' # web:\n'
362
- ' # 登录账号: test-web-admin\n'
363
- )
364
- atomic_write_text(str(p), template)
365
- return str(p), True
366
-
367
-
368
- # ---------------------------------------------------------------------------
369
- # 登录态记录 (workspace/members/{dev}/autotest-login-state.yaml, gitignored)
370
- # QoderWork 保持同一个浏览器会话 登录一次后 cookie 不丢 → 后续测新功能
371
- # 直接 goto 目标页, 不用重复登录(尤其数学题验证码慢)
372
- # ---------------------------------------------------------------------------
373
-
374
- def login_state_path():
375
- dev = _require_developer()
376
- return Path(str(MEMBERS_DIR)) / dev / LOGIN_STATE_FILENAME
377
-
378
-
379
- def load_login_state():
380
- """读登录态。返回 {env: {platform: {time, url}}} {}。"""
381
- p = login_state_path()
382
- if not p.is_file():
383
- return {}
384
- try:
385
- import yaml
386
- with open(str(p), encoding='utf-8') as f:
387
- data = yaml.safe_load(f) or {}
388
- return data if isinstance(data, dict) else {}
389
- except Exception:
390
- return {}
391
-
392
-
393
- def is_logged_in(env, platform):
394
- """该 env/platform 是否已标记登录。返回 (bool, time_str)。"""
395
- state = load_login_state()
396
- entry = (state.get(env) or {}).get(platform)
397
- if isinstance(entry, dict):
398
- return True, entry.get('time', '')
399
- return False, ''
400
-
401
-
402
- def mark_logged_in(env, platform, base_url):
403
- """标记某 env/platform 已登录(登录用例 pass 后调用)。"""
404
- import yaml as _yaml
405
- from common.atomicio import atomic_write_text
406
- p = login_state_path()
407
- os.makedirs(str(p.parent), exist_ok=True)
408
- state = load_login_state()
409
- state.setdefault(env, {})
410
- state[env][platform] = {
411
- 'time': datetime.now().isoformat()[:19],
412
- 'url': base_url,
413
- }
414
- atomic_write_text(str(p), '# 登录态记录 (本地, gitignored)。'
415
- 'QoderWork 保持会话, 登录一次后后续直接 goto 目标页。\n'
416
- + _yaml.safe_dump(state, allow_unicode=True, sort_keys=False))
417
-
418
-
419
- def clear_login_state(env=None, platform=None):
420
- """清除登录态。env/platform 都不给 = 全清。"""
421
- from common.atomicio import atomic_write_text
422
- p = login_state_path()
423
- state = load_login_state()
424
- if env is None and platform is None:
425
- state = {}
426
- elif env and platform:
427
- (state.get(env) or {}).pop(platform, None)
428
- elif env:
429
- state.pop(env, None)
430
- elif platform:
431
- for e in list(state.keys()):
432
- (state.get(e) or {}).pop(platform, None)
433
- if state:
434
- atomic_write_text(str(p), '# 登录态记录 (本地, gitignored)\n'
435
- + __import__('yaml').safe_dump(
436
- state, allow_unicode=True, sort_keys=False))
437
- elif p.is_file():
438
- p.unlink()
439
-
440
-
441
- # ---------------------------------------------------------------------------
442
- # 测试页锚点索引 (data/index/test-pages.json, 团队共享)
443
- # A方案核心: 记住"页面→稳定锚点(role/name/placeholder)", 下次测同页直接命中, 跳过 LLM。
444
- # 只存稳定锚点(不随刷新变), 绝不存 ref(会变)
445
- # ---------------------------------------------------------------------------
446
-
447
- def test_pages_path():
448
- """data/index/test-pages.json 路径(团队共享, 随 team_sync 分发)。"""
449
- return index_path('test_pages')
450
-
451
-
452
- def _load_test_pages():
453
- """读 test-pages.json。不存在返回空骨架。"""
454
- from common.atomicio import safe_read_json
455
- p = test_pages_path()
456
- data = safe_read_json(str(p), default=None)
457
- if not isinstance(data, dict):
458
- return {'version': '1.0', 'updated': '', 'pages': {}, 'stats': {'total_pages': 0, 'total_anchors': 0}}
459
- data.setdefault('pages', {})
460
- data.setdefault('stats', {})
461
- return data
462
-
463
-
464
- def _save_test_pages(data):
465
- """原子写 test-pages.json, 自动更新 updated + stats。"""
466
- from common.atomicio import atomic_write_json
467
- data['updated'] = datetime.now().isoformat()[:19]
468
- pages = data.get('pages', {})
469
- n_anchors = sum(len(pg.get('elements', [])) for pg in pages.values())
470
- data['stats'] = {'total_pages': len(pages), 'total_anchors': n_anchors}
471
- atomic_write_json(str(test_pages_path()), data)
472
-
473
-
474
- def _normalize_url_pattern(url, base_url=None):
475
- """把完整 URL 规整成可复用的 pattern(去域名, 保留路径)。
476
-
477
- https://icsqas.inforecloud.com/auth/login → /auth/login
478
- 已是相对路径 原样返回。
479
- """
480
- if not url:
481
- return '/'
482
- u = url.strip()
483
- if base_url and u.startswith(base_url):
484
- return u[len(base_url):] or '/'
485
- # protocol + domain
486
- if '://' in u:
487
- u = u.split('://', 1)[1]
488
- if '/' in u:
489
- u = '/' + u.split('/', 1)[1]
490
- else:
491
- u = '/'
492
- if not u.startswith('/'):
493
- u = '/' + u
494
- # query (?redirect=...) —— 登录页常带 redirect, 不影响页面元素
495
- u = u.split('?', 1)[0]
496
- return u or '/'
497
-
498
-
499
- def learn_page_anchors(url_pattern, elements, intent_map=None):
500
- """把一批页面元素锚点沉淀进 test-pages.json。
501
-
502
- elements: read_page 返回的元素列表, 每个含 role/name/placeholder/text/ref()。
503
- 只提取稳定锚点(role/name/placeholder/text), 丢弃 ref/坐标。
504
- intent_map: 可选 {元素描述: {intent, anchor}} —— AI 标注"这个元素对应什么意图"。
505
- 有则把 intent 一起存, recall 时能直接生成 step。
506
- 返回 (新增数, 总数)
507
- """
508
- intent_map = intent_map or {}
509
- data = _load_test_pages()
510
- pages = data['pages']
511
- existing = pages.get(url_pattern, {}).get('elements', [])
512
- # 以 anchor 的规范化签名去重
513
- def _sig(el):
514
- a = el.get('anchor', {})
515
- return '|'.join('%s=%s' % (k, a.get(k, '')) for k in sorted(a))
516
- seen_sigs = {_sig(e) for e in existing}
517
- new_anchors = []
518
- for el in elements:
519
- if not isinstance(el, dict):
520
- continue
521
- # read_page 元素提取稳定锚点
522
- anchor = {}
523
- for k in ANCHOR_KEYS:
524
- v = el.get(k)
525
- if v:
526
- anchor[k] = str(v)
527
- if not anchor:
528
- continue # 无稳定锚点的元素(只有 ref/坐标)不存
529
- desc = el.get('desc') or el.get('name') or el.get('text') or ''
530
- # intent_map: desc/name 匹配
531
- im = None
532
- for key, val in intent_map.items():
533
- if key in desc or desc in key:
534
- im = val
535
- break
536
- entry = {
537
- 'desc': desc,
538
- 'anchor': anchor,
539
- 'intent': (im or {}).get('intent', el.get('intent', '')),
540
- }
541
- sig = _sig(entry)
542
- if sig in seen_sigs:
543
- continue
544
- seen_sigs.add(sig)
545
- new_anchors.append(entry)
546
- merged = existing + new_anchors
547
- pages[url_pattern] = {
548
- 'elements': merged,
549
- 'last_seen': datetime.now().isoformat()[:19],
550
- }
551
- data['pages'] = pages
552
- _save_test_pages(data)
553
- return len(new_anchors), len(merged)
554
-
555
-
556
- def recall_page_anchors(url_pattern):
557
- """查某页已沉淀的锚点。返回 elements 列表(可能空)。"""
558
- data = _load_test_pages()
559
- pg = data.get('pages', {}).get(url_pattern)
560
- if not pg:
561
- return []
562
- return pg.get('elements', [])
563
-
564
-
565
- # ---------------------------------------------------------------------------
566
- # 任务 / 用例文件 IO
567
- # ---------------------------------------------------------------------------
568
-
569
- def _cases_path(task_dir):
570
- return Path(task_dir) / CASES_FILENAME
571
-
572
-
573
- def read_cases(task_dir):
574
- """读取某任务的全部用例 (list[dict])。无文件返回 []。"""
575
- p = _cases_path(task_dir)
576
- if not p.is_file():
577
- return []
578
- cases = []
579
- for lineno, raw in enumerate(p.read_text(encoding='utf-8').splitlines(), 1):
580
- raw = raw.strip()
581
- if not raw:
582
- continue
583
- try:
584
- cases.append(json.loads(raw))
585
- except json.JSONDecodeError as e:
586
- print('警告: %s 第 %d 行 JSON 损坏, 跳过 (%s)' % (p.name, lineno, e),
587
- file=sys.stderr)
588
- return cases
589
-
590
-
591
- def write_cases(task_dir, cases):
592
- """原子覆盖写全部用例为 jsonl。"""
593
- from common.atomicio import atomic_write_text
594
- p = _cases_path(task_dir)
595
- lines = [json.dumps(c, ensure_ascii=False, sort_keys=True) for c in cases]
596
- atomic_write_text(str(p), '\n'.join(lines) + ('\n' if lines else ''))
597
-
598
-
599
- # ---------------------------------------------------------------------------
600
- # 用例骨架生成 (脚本只造结构; 业务步骤由 AI 读 skill 后填充)
601
- # ---------------------------------------------------------------------------
602
-
603
- def _skeleton_case(task_name, case_no, platform, env):
604
- """造一条占位用例骨架。AI 应在此基础上据 PRD 补充 steps/expected。
605
-
606
- 骨架自带一条示范: 凡是需要真实测试数据(账号/密码)的地方, 用
607
- {{ask:描述}} 占位 —— 执行时会提示收集, 绝不编造。
608
- """
609
- cid = 'AT-%s-%03d' % (task_name, case_no)
610
- return {
611
- 'case_id': cid,
612
- 'title': '(待补充)用例 %d 标题' % case_no,
613
- 'platform': platform,
614
- 'env': env,
615
- 'preconditions': '',
616
- 'steps': [
617
- {'action': 'goto', 'target': '{{base_url}}/', 'desc': '打开页面'},
618
- # 需要真实测试数据时用 {{ask:描述}} 占位, 例:
619
- # {'action':'fill','target':'#account','value':'{{ask:登录账号}}'}
620
- {'action': 'assert_visible', 'target': 'body', 'desc': '页面正常加载'},
621
- ],
622
- 'expected': '(待补充)预期结果',
623
- }
624
-
625
-
626
- def generate_skeleton(task_dir, env, platform, count=3):
627
- """为任务生成 count 条骨架用例 (不覆盖已有; 追加)。"""
628
- task_name = Path(task_dir).name
629
- existing = read_cases(task_dir)
630
- start_no = len(existing) + 1
631
- new_cases = [_skeleton_case(task_name, start_no + i, platform, env)
632
- for i in range(count)]
633
- write_cases(task_dir, existing + new_cases)
634
- return new_cases
635
-
636
-
637
- def validate_cases(cases, strict=False):
638
- """校验用例结构, 返回 (ok, warnings)。strict=True 时缺字段视为错误。"""
639
- warns = []
640
- ids = set()
641
- for i, c in enumerate(cases, 1):
642
- cid = c.get('case_id', '')
643
- if not cid:
644
- warns.append(' %d 条缺 case_id' % i)
645
- elif cid in ids:
646
- warns.append('case_id 重复: %s' % cid)
647
- else:
648
- ids.add(cid)
649
- for f in ('title', 'steps', 'expected'):
650
- if f not in c:
651
- warns.append('%s 缺字段 %s' % (cid or ('第%d条' % i), f))
652
- steps = c.get('steps') or []
653
- if not isinstance(steps, list):
654
- warns.append('%s steps 不是数组' % cid)
655
- continue
656
- for j, s in enumerate(steps, 1):
657
- if not isinstance(s, dict):
658
- warns.append('%s step%d 不是对象' % (cid, j))
659
- continue
660
- # 新意图式: 校验 intent + anchor 结构
661
- if is_intent_step(s):
662
- it = s.get('intent', '')
663
- if it and it not in KNOWN_INTENTS:
664
- warns.append('%s step%d 未知 intent: %s' % (cid, j, it))
665
- if not s.get('desc') and it not in ('observe',):
666
- warns.append('%s step%d (intent=%s) desc' % (cid, j, it))
667
- anc = s.get('anchor')
668
- if anc is not None:
669
- if not isinstance(anc, dict):
670
- warns.append('%s step%d anchor 不是对象' % (cid, j))
671
- elif not any(k in ANCHOR_KEYS for k in anc):
672
- warns.append('%s step%d anchor 无稳定键(需 %s 之一)'
673
- % (cid, j, '/'.join(sorted(ANCHOR_KEYS))))
674
- else:
675
- # 旧命令式: 校验 action
676
- act = s.get('action', '')
677
- if not act:
678
- warns.append('%s step%d 既无 intent 也无 action' % (cid, j))
679
- elif act not in KNOWN_ACTIONS:
680
- warns.append('%s step%d 未知 action: %s' % (cid, j, act))
681
- return (len(warns) == 0) if strict else True, warns
682
-
683
-
684
- # ---------------------------------------------------------------------------
685
- # 共享: 解析环境+注入数据 / 打印执行计划 (run quick 复用)
686
- # ---------------------------------------------------------------------------
687
-
688
- def _resolve_env_platform(args):
689
- """解析域名 + 校验。返回 (base, src) 或 (None, None)。"""
690
- base, src = resolve_base_url(args.env, args.platform, override=getattr(args, 'url', None))
691
- if base is None:
692
- print('✗ %s 环境未配 base_url, 无法执行测试。' % args.env)
693
- print(' 解决方式 (任选其一):')
694
- print(' 1. 命令行临时指定: --url https://你的地址')
695
- print(' 2. 配 config.yaml: autotest.environments.%s.%s.base_url'
696
- % (args.env, args.platform))
697
- return None, None
698
- return base, src
699
-
700
-
701
- def _inject_and_check_data(cases, args):
702
- """注入 base_url + 测试数据, 检查完整性。返回 (cases, ok)。ok=False 表示缺数据已提示。"""
703
- cases = _inject_base_url(cases, getattr(args, '_base', ''))
704
- local_data = load_local_test_data(env=args.env, platform=args.platform)
705
- try:
706
- cli_data = json.loads(args.data) if getattr(args, 'data', None) else {}
707
- except json.JSONDecodeError as e:
708
- print('✗ --data 不是合法 JSON: %s' % e)
709
- print(' 示例: --data \'{"登录账号":"test","登录密码":"123456"}\'')
710
- return cases, False
711
- merged = dict(local_data)
712
- merged.update(cli_data)
713
- cases, missing = inject_data(cases, merged)
714
- if missing:
715
- print('=' * 60)
716
- print('⚠️ 需要测试数据才能执行 (%d 项)' % len(missing))
717
- print('=' * 60)
718
- print('以下用例引用了真实测试数据(账号/密码/手机号等), 不能编造:')
719
- for i, desc in enumerate(missing, 1):
720
- print(' %d. %s' % (i, desc))
721
- print('-' * 60)
722
- print('请向用户收集后, 用 --data 传入:')
723
- print(' --data \'{"%s":"<值>", "%s":"<值>"}\''
724
- % (missing[0], missing[1] if len(missing) > 1 else missing[0]))
725
- print()
726
- print('常用账号预置到个人文档(不进 git, 团队看不到):')
727
- print(' python .qoder/scripts/autotest.py set-data 登录账号 test --env test')
728
- return cases, False
729
- return cases, True
730
-
731
-
732
- def _fmt_anchor(anc):
733
- """格式化 anchor 为简短可读串, 如 "button '登录'" 或 "placeholder='尊敬的管理员'"。"""
734
- if not isinstance(anc, dict) or not anc:
735
- return ''
736
- parts = []
737
- for k in ('role', 'name', 'placeholder', 'text'):
738
- v = anc.get(k)
739
- if v:
740
- parts.append("%s='%s'" % (k, v))
741
- return ' '.join(parts)
742
-
743
-
744
- def _fmt_step(s, with_value=True):
745
- """格式化单步为可读文本。支持新意图式(intent+anchor)和旧命令式(action+target)。"""
746
- desc = s.get('desc', '')
747
- # ── 新意图式 ──
748
- if is_intent_step(s):
749
- it = s.get('intent', '?')
750
- anc = s.get('anchor')
751
- val = s.get('value')
752
- anchor_str = _fmt_anchor(anc)
753
- if it == 'ask_human':
754
- return '%-12s %s' % ('人机协同', desc or '(请用户操作)')
755
- if it == 'extract':
756
- return '%-12s %s' % ('extract', desc or '(语义断言)')
757
- if it == 'observe':
758
- return '%-12s 👁 %s' % ('observe', desc or '读取页面可交互元素')
759
- if it == 'goto':
760
- return '%-12s → %s' % ('goto', val or desc or '')
761
- # click / fill / assert
762
- tag = '[锚点: %s]' % anchor_str if anchor_str else '(待匹配·LLM兜底)'
763
- extra = ''
764
- if val is not None:
765
- extra = ' 输入=%s' % val
766
- return '%-12s %s %s%s' % (it, tag, desc, extra)
767
- # ── 旧命令式(兼容) ──
768
- act = s.get('action', '?')
769
- tgt = s.get('target', '')
770
- val = s.get('value')
771
- if act == 'solve_captcha':
772
- fill = s.get('fill_target', '')
773
- detail = '截图 %s → 用视觉识别(算数学题) → 答案填入 %s' % (tgt, fill)
774
- if desc:
775
- detail += ' (%s)' % desc
776
- return '%-16s %s' % (act, detail)
777
- detail = tgt
778
- if with_value and val is not None:
779
- detail = '%s 输入=%s' % (tgt, val)
780
- return '%-16s %s%s' % (act, detail, (' — ' + desc) if desc else '')
781
-
782
-
783
- def _print_execution_plan(cases, base, src, env, platform, title, record_hint):
784
- """打印执行编排指令 (QoderWork 浏览器执行 / Qoder 降级人工核对)
785
-
786
- record_hint: 回收结果时的命令提示文本 (run 用任务名, quick 用 --quick)。
787
- """
788
- is_qw, qw_hint = _detect_qoderwork()
789
- print('=' * 60)
790
- print('浏览器自动化测试 · 执行编排')
791
- print('=' * 60)
792
- print('测试: %s' % title)
793
- print('环境: %s 平台: %s' % (env, platform))
794
- print('目标: %s (来源: %s)' % (base, src))
795
- print('用例: %d 条' % len(cases))
796
- print('-' * 60)
797
-
798
- if is_qw:
799
- print()
800
- print('🧩 QoderWork 浏览器执行 (已检测到: %s)' % qw_hint)
801
- print('请用 Browser Use / Computer 控制连接器, 按下列要求执行:')
802
- print()
803
- print('1. 打开浏览器, 访问: %s' % base)
804
- print('2. 逐条执行用例 (共 %d 条):' % len(cases))
805
- for c in cases:
806
- print(' %s %s' % (c.get('case_id', '?'), c.get('title', '')))
807
- for s in (c.get('steps') or [])[:6]:
808
- print(' %s' % _fmt_step(s))
809
- print(' 预期: %s' % c.get('expected', ''))
810
- print()
811
- print('3. 每条用例记录 pass/fail, 跑完回传结果:')
812
- print(' %s' % record_hint)
813
- print(' (或直接把结果贴给我, 我帮你出报告)')
814
- else:
815
- print()
816
- print('[非 QoderWork 或未授权浏览器连接器 → 降级为人工核对]')
817
- print('目标地址: %s' % base)
818
- print()
819
- for c in cases:
820
- print('□ %s %s' % (c.get('case_id', '?'), c.get('title', '')))
821
- for s in (c.get('steps') or [])[:4]:
822
- print(' %s' % _fmt_step(s))
823
- print(' 预期: %s' % c.get('expected', ''))
824
- print()
825
- print(' QoderWork 桌面端 (Settings → Connectors 授权浏览器/Computer)')
826
- print('可让 Agent 自动执行; 结果用 --record 回传出报告。')
827
- print('-' * 60)
828
-
829
-
830
- # ---------------------------------------------------------------------------
831
- # 子命令: generate
832
- # ---------------------------------------------------------------------------
833
-
834
- def cmd_generate(args):
835
- task_dir = resolve_task_dir(args.task)
836
- if not task_dir.is_dir():
837
- print('任务不存在: %s' % args.task)
838
- print('提示: python .qoder/scripts/task.py list 查看已有任务')
839
- return 1
840
- task = load_task_json(task_dir) or {}
841
-
842
- env = args.env
843
- base, src = resolve_base_url(env, args.platform)
844
- # generate 不强求域名 (用例用 {{base_url}} 占位), 但提示一下
845
- if base is None:
846
- print('提示: %s 环境未配 base_url, 用例将用 {{base_url}} 占位, '
847
- 'run 时再注入 (或在 config.yaml autotest.environments 配置)。'
848
- % env)
849
-
850
- new_cases = generate_skeleton(task_dir, env, args.platform)
851
- all_cases = read_cases(task_dir)
852
- ok, warns = validate_cases(all_cases)
853
-
854
- print('已生成 %d 条用例骨架 → %s' % (len(new_cases), _cases_path(task_dir)))
855
- print('任务: %s 环境: %s 平台: %s' % (
856
- task.get('title', Path(task_dir).name), env, args.platform))
857
- print()
858
- print('⚠️ 这些是骨架占位。请按任务 PRD「验收标准」补充每条的:')
859
- print(' - title: 用例标题')
860
- print(' - steps: 操作步骤 (goto/click/fill/assert_visible ...)')
861
- print(' - expected: 预期结果')
862
- print(' (用 /wl-test 触发 AI 自动据 PRD 填充)')
863
- if warns:
864
- print()
865
- print('校验提醒:')
866
- for w in warns:
867
- print(' - ' + w)
868
- print()
869
- print('下一步: /wl-test run %s --env %s (生成+执行)'
870
- % (Path(task_dir).name, env))
871
- return 0
872
-
873
-
874
- # ---------------------------------------------------------------------------
875
- # 子命令: run
876
- # ---------------------------------------------------------------------------
877
-
878
- def _detect_qoderwork():
879
- """探测当前是否在 QoderWork 环境 (有浏览器连接器)
880
-
881
- QoderWork 会暴露运行时工具/环境标记。探测而非假设: 看不到就回退。
882
- 返回 (is_qoderwork: bool, hint: str)
883
- """
884
- # 1. 显式环境变量标记 (QoderWork 桌面端/CLI 会设置)
885
- for key in ('QODERWORK_SESSION', 'QODER_WORK', 'QW_CONNECTOR'):
886
- if os.environ.get(key):
887
- return True, 'env:' + key
888
- # 2. QoderWork 配置目录存在 (~/.qoderwork/)
889
- home = os.path.expanduser('~')
890
- if os.path.isdir(os.path.join(home, '.qoderwork')):
891
- return True, '~/.qoderwork'
892
- return False, ''
893
-
894
-
895
- def _iter_injectable_strings(s):
896
- """遍历一个 step 里所有可做占位符替换的字符串字段, yield (setter, value)
897
-
898
- 覆盖旧格式(target/value/desc) + 新格式(intent式 value/desc/anchor.* )
899
- setter 是个闭包, 传新值回去。anchor(dict) 递归到它的每个 str 值。
900
- """
901
- for field in ('target', 'value', 'desc'):
902
- val = s.get(field)
903
- if isinstance(val, str):
904
- yield (lambda v, f=field: s.__setitem__(f, v)), val
905
- # anchor (新格式): dict, 每个值都可能是占位符串
906
- anc = s.get('anchor')
907
- if isinstance(anc, dict):
908
- for k, v in list(anc.items()):
909
- if isinstance(v, str):
910
- yield (lambda nv, kk=k: anc.__setitem__(kk, nv)), v
911
-
912
-
913
- def _inject_base_url(cases, base_url):
914
- """把 steps 里的 {{base_url}} 占位替换为真实域名(覆盖 anchor.*)。"""
915
- for c in cases:
916
- for s in c.get('steps') or []:
917
- for setter, val in _iter_injectable_strings(s):
918
- if isinstance(val, str) and '{{base_url}}' in val:
919
- setter(val.replace('{{base_url}}', base_url))
920
- return cases
921
-
922
-
923
- def collect_ask_placeholders(cases):
924
- """扫出全部 {{ask:描述}} 占位, 返回去重后的描述列表(保持出现顺序)。"""
925
- import re as _re
926
- asks = []
927
- seen = set()
928
- pat = _re.compile(r'\{\{ask:([^}]+)\}\}')
929
- for c in cases:
930
- for s in c.get('steps') or []:
931
- for setter, val in _iter_injectable_strings(s):
932
- if not isinstance(val, str):
933
- continue
934
- for desc in pat.findall(val):
935
- if desc.strip() not in seen:
936
- seen.add(desc.strip())
937
- asks.append(desc.strip())
938
- return asks
939
-
940
-
941
- def inject_data(cases, data):
942
- """把 {{ask:描述}} data 字典注入(覆盖 anchor.*)。返回 (cases, still_missing)。"""
943
- import re as _re
944
- pat = _re.compile(r'\{\{ask:([^}]+)\}\}')
945
- provided = {k.strip(): v for k, v in (data or {}).items()}
946
- still_missing = []
947
- for c in cases:
948
- for s in c.get('steps') or []:
949
- for setter, val in _iter_injectable_strings(s):
950
- if not isinstance(val, str) or '{{ask:' not in val:
951
- continue
952
- for desc in pat.findall(val):
953
- key = desc.strip()
954
- if key in provided:
955
- val = val.replace('{{ask:%s}}' % desc, str(provided[key]))
956
- for desc in pat.findall(val):
957
- if desc.strip() not in provided and desc.strip() not in still_missing:
958
- still_missing.append(desc.strip())
959
- setter(val)
960
- return cases, still_missing
961
-
962
-
963
- def cmd_quick(args):
964
- """快速测试: 不需要任务/PRD。AI 据描述直接生成用例并执行。
965
-
966
- 用法: autotest.py quick --cases '<JSON用例>' --env test --platform web
967
- (AI 把据用户描述生成的用例, 以 JSON 字符串传入; 脚本负责注入+执行编排)
968
-
969
- 也支持 --record 回收结果 (临时用例, 不落 task 历史, 只打印汇总)
970
- """
971
- # 1. 解析用例: 优先 --cases-file (文件, 不截断), 其次 --cases (内联, 短用例)
972
- raw = ''
973
- cases_file = getattr(args, 'cases_file', None)
974
- if cases_file:
975
- try:
976
- raw = open(cases_file, encoding='utf-8').read().strip()
977
- except (OSError, IOError) as e:
978
- print('✗ 读 --cases-file 失败: %s' % e)
979
- return 1
980
- if not raw:
981
- raw = (args.cases or '').strip()
982
- if not raw:
983
- print('✗ quick 模式需要用例 JSON。用文件传(推荐, 不截断):')
984
- print(' --cases-file <路径> # AI 把用例 JSON 写文件, 再传路径')
985
- print('或内联传(短用例): --cases \'<JSON>\'')
986
- return 1
987
- try:
988
- parsed = json.loads(raw)
989
- except json.JSONDecodeError as e:
990
- print(' 用例 JSON 解析失败: %s' % e)
991
- if cases_file:
992
- print(' (来自文件: %s)' % cases_file)
993
- return 1
994
- cases = parsed if isinstance(parsed, list) else [parsed]
995
- # 规范化: 补默认字段
996
- for i, c in enumerate(cases):
997
- if not isinstance(c, dict):
998
- print('✗ %d 条用例不是对象' % (i + 1))
999
- return 1
1000
- c.setdefault('case_id', 'Q-%d' % (i + 1))
1001
- c.setdefault('platform', args.platform)
1002
- c.setdefault('env', args.env)
1003
- c.setdefault('steps', [])
1004
- c.setdefault('expected', '')
1005
-
1006
- # 2. 回收结果模式 (临时用例, 只打印汇总)
1007
- if args.record:
1008
- try:
1009
- results = json.loads(args.record)
1010
- except json.JSONDecodeError as e:
1011
- print('✗ --record 不是合法 JSON: %s' % e)
1012
- return 1
1013
- total = len(results)
1014
- passed = sum(1 for v in results.values()
1015
- if str(v).lower() in ('pass', 'passed', 'ok', 'true', '1'))
1016
- failed_ids = [k for k, v in results.items()
1017
- if str(v).lower() not in ('pass', 'passed', 'ok', 'true', '1')]
1018
- # 登录用例通过 → 自动标记该 env/platform 已登录 (QoderWork 会话保持, 后续免登录)
1019
- login_passed = False
1020
- for c in cases:
1021
- cid = c.get('case_id', '')
1022
- if str(results.get(cid, '')).lower() in ('pass', 'passed', 'ok', 'true', '1'):
1023
- steps_txt = json.dumps(c.get('steps', []), ensure_ascii=False)
1024
- if '/auth/login' in steps_txt or 'solve_captcha' in steps_txt:
1025
- login_passed = True
1026
- break
1027
- if login_passed:
1028
- base, _ = resolve_base_url(args.env, args.platform)
1029
- if base:
1030
- mark_logged_in(args.env, args.platform, base)
1031
- # 🧠 自动沉淀锚点 (测完就学会, 下次跳过LLM):
1032
- # ① 若传了 --page-json (read_page 结果) 沉淀该页元素锚点
1033
- # ② 从通过的 intent 式用例里提取 goto URL + anchor → 沉淀 (用例自带锚点也存)
1034
- learned_pages = []
1035
- # ① page-json 方式: 每个 pj 是 {"url":"...", "elements":[...]} 或元素数组
1036
- for pj_raw in (args.page_json or []):
1037
- try:
1038
- parsed = json.loads(pj_raw)
1039
- except Exception:
1040
- continue
1041
- if isinstance(parsed, dict) and 'url' in parsed:
1042
- url_pattern = _normalize_url_pattern(parsed.get('url', ''))
1043
- els = parsed.get('elements', parsed.get('elements', []))
1044
- elif isinstance(parsed, list):
1045
- url_pattern = '/auto'
1046
- els = parsed
1047
- else:
1048
- continue
1049
- new_n, _ = learn_page_anchors(url_pattern, els if isinstance(els, list) else [])
1050
- if new_n:
1051
- learned_pages.append('%s (+%d)' % (url_pattern, new_n))
1052
- # ② 从通过的 intent 用例提取 goto URL + anchor 自动沉淀
1053
- for c in cases:
1054
- cid = c.get('case_id', '')
1055
- if str(results.get(cid, '')).lower() not in ('pass', 'passed', 'ok', 'true', '1'):
1056
- continue # 只沉淀通过用例的锚点 (失败的锚点可能不准)
1057
- current_url = '/auto'
1058
- for s in c.get('steps') or []:
1059
- if not is_intent_step(s):
1060
- continue
1061
- it = s.get('intent', '')
1062
- if it == 'goto' and s.get('value'):
1063
- current_url = _normalize_url_pattern(s['value'])
1064
- elif it in ('click', 'fill') and s.get('anchor'):
1065
- # 用例自带的 anchor → 直接沉淀(执行时验证过有效)
1066
- desc = s.get('desc', '')
1067
- im = {desc: {'intent': it, 'anchor': s['anchor']}} if desc else None
1068
- new_n, _ = learn_page_anchors(current_url, [s], intent_map=im)
1069
- if new_n and current_url not in [p.split(' ')[0] for p in learned_pages]:
1070
- learned_pages.append('%s (+%d)' % (current_url, new_n))
1071
- print('=' * 60)
1072
- print('快速测试结果')
1073
- print('=' * 60)
1074
- print('总计 %d · 通过 %d · 失败 %d' % (total, passed, len(failed_ids)))
1075
- if failed_ids:
1076
- print('失败: %s' % ', '.join(failed_ids))
1077
- if login_passed:
1078
- print('✓ 登录用例通过 → 已标记 %s/%s 为已登录' % (args.env, args.platform))
1079
- print(' 后续测新功能: 直接 goto 目标页, 不用重复登录(验证码慢)。')
1080
- if learned_pages:
1081
- print('🧠 自动沉淀锚点: %s' % ', '.join(learned_pages))
1082
- print(' 下次测同页 recall 命中 → 跳过 LLM → 秒级。')
1083
- print('=' * 60)
1084
- return 0 if not failed_ids else 1
1085
-
1086
- # 3. 解析环境
1087
- base, src = _resolve_env_platform(args)
1088
- if base is None:
1089
- return 1
1090
- args._base = base # _inject_and_check_data 用
1091
-
1092
- # 4. 注入数据 + 校验
1093
- cases, ok = _inject_and_check_data(cases, args)
1094
- if not ok:
1095
- return 1
1096
-
1097
- # 5. 打印执行计划 (登录态提示)
1098
- logged, login_time = is_logged_in(args.env, args.platform)
1099
- title = args.desc or ('快速测试 (%d 条)' % len(cases))
1100
- record_hint = ('python .qoder/scripts/autotest.py quick '
1101
- '--env %s --record \'{"%s":"pass"}\''
1102
- % (args.env, cases[0].get('case_id', 'Q-1')))
1103
- if logged:
1104
- print('=' * 60)
1105
- print('💡 已登录 %s/%s (%s) — QoderWork 会话保持中' % (args.env, args.platform, login_time))
1106
- print(' 浏览器 cookie 有效, 直接 goto 目标页即可, 不用重复登录!')
1107
- print(' (若已过期/被踢, login-state clear 再重新登录)')
1108
- print('=' * 60)
1109
- _print_execution_plan(cases, base, src, args.env, args.platform, title, record_hint)
1110
- return 0
1111
-
1112
-
1113
- def cmd_run(args):
1114
- task_dir = resolve_task_dir(args.task)
1115
- if not task_dir.is_dir():
1116
- print('任务不存在: %s' % args.task)
1117
- return 1
1118
- task = load_task_json(task_dir) or {}
1119
-
1120
- # 1. 域名解析 (--url > --env > config > 报错)
1121
- base, src = resolve_base_url(args.env, args.platform, override=args.url)
1122
- if base is None:
1123
- print('✗ %s 环境未配 base_url, 无法执行测试。' % args.env)
1124
- print(' 解决方式 (任选其一):')
1125
- print(' 1. 命令行临时指定: --url https://你的地址')
1126
- print(' 2. config.yaml: autotest.environments.%s.%s.base_url'
1127
- % (args.env, args.platform))
1128
- return 1
1129
-
1130
- # 2. 取用例 (无则自动生成骨架)
1131
- cases = read_cases(task_dir)
1132
- if not cases:
1133
- print('未找到用例, 自动生成骨架...')
1134
- cases = generate_skeleton(task_dir, args.env, args.platform)
1135
- cases = read_cases(task_dir)
1136
-
1137
- # 3. 指定 case_id 过滤
1138
- if args.case:
1139
- want = set(args.case)
1140
- cases = [c for c in cases if c.get('case_id') in want]
1141
- if not cases:
1142
- print('未匹配到指定 case_id: %s' % ', '.join(args.case))
1143
- print(' 已有用例: %s'
1144
- % ', '.join(c.get('case_id', '?')
1145
- for c in read_cases(task_dir)))
1146
- return 1
1147
-
1148
- # 4. 占位符注入: 先 base_url, 再测试数据
1149
- cases = _inject_base_url(cases, base)
1150
- # 测试数据 = 个人文档(members/{dev}/autotest-data.yaml, gitignored 不共享)
1151
- # + 命令行 --data(覆盖)。按 env/platform 取最精确的值。
1152
- # ⚠️ 绝不从 config.yaml 读测试账号 —— config 是团队共用会 push, 放账号=泄漏。
1153
- local_data = load_local_test_data(env=args.env, platform=args.platform)
1154
- try:
1155
- cli_data = json.loads(args.data) if args.data else {}
1156
- except json.JSONDecodeError as e:
1157
- print('✗ --data 不是合法 JSON: %s' % e)
1158
- print(' 示例: --data \'{"登录账号":"test","登录密码":"123456"}\'')
1159
- return 1
1160
- merged_data = dict(local_data)
1161
- merged_data.update(cli_data) # 命令行覆盖个人预置
1162
- cases, missing = inject_data(cases, merged_data)
1163
-
1164
- # 测试数据完整性检查: 有未注入的 {{ask:...}} → 列清单让 AI 问用户
1165
- if missing:
1166
- print('=' * 60)
1167
- print('⚠️ 需要测试数据才能执行 (%d 项)' % len(missing))
1168
- print('=' * 60)
1169
- print('以下用例引用了真实测试数据(账号/密码/手机号等), 不能编造:')
1170
- for i, desc in enumerate(missing, 1):
1171
- print(' %d. %s' % (i, desc))
1172
- print('-' * 60)
1173
- print('请向用户收集后, --data 传入:')
1174
- print(' --data \'{"%s":"<值>", "%s":"<值>"}\''
1175
- % (missing[0], missing[1] if len(missing) > 1 else missing[0]))
1176
- print()
1177
- print('常用账号预置到个人文档(不进 git, 团队看不到):')
1178
- print(' 先生成: python .qoder/scripts/autotest.py init-data')
1179
- print(' 再填: python .qoder/scripts/autotest.py set-data 登录账号 test')
1180
- return 1
1181
-
1182
- # 5. 校验
1183
- ok, warns = validate_cases(cases)
1184
- has_skeleton = any('待补充' in (c.get('title', '') + c.get('expected', ''))
1185
- for c in cases)
1186
-
1187
- # 6. 如果调用方已传入执行结果 (--record), 落历史并汇总
1188
- if args.record:
1189
- return _record_and_report(task_dir, args, base, src, args.record)
1190
-
1191
- # 7. 探测 QoderWork, 输出执行指令 (核心: 增强层)
1192
- is_qw, qw_hint = _detect_qoderwork()
1193
-
1194
- cases_path = _cases_path(task_dir)
1195
- print('=' * 60)
1196
- print('浏览器自动化测试 · 执行编排')
1197
- print('=' * 60)
1198
- print('任务: %s' % task.get('title', Path(task_dir).name))
1199
- print('环境: %s 平台: %s' % (args.env, args.platform))
1200
- print('目标: %s (来源: %s)' % (base, src))
1201
- print('用例: %d 条 → %s' % (len(cases), cases_path))
1202
- if has_skeleton:
1203
- print('⚠️ 含骨架占位用例 (标题/预期标"待补充"), 建议先补全再执行。')
1204
- if warns:
1205
- print('校验提醒:')
1206
- for w in warns[:10]:
1207
- print(' - ' + w)
1208
- print('-' * 60)
1209
-
1210
- if is_qw:
1211
- print()
1212
- print('🧩 QoderWork 浏览器执行 (已检测到: %s)' % qw_hint)
1213
- print('请用 Browser Use / Computer 控制连接器, 按下列要求执行:')
1214
- print()
1215
- print('1. 打开浏览器, 访问: %s' % base)
1216
- print('2. 逐条执行 %s 中的用例 (共 %d 条):' % (CASES_FILENAME, len(cases)))
1217
- for c in cases:
1218
- print(' ▸ %s %s' % (c.get('case_id', '?'), c.get('title', '')))
1219
- for s in (c.get('steps') or [])[:6]:
1220
- print(' %s' % _fmt_step(s))
1221
- print(' 预期: %s' % c.get('expected', ''))
1222
- print()
1223
- print('3. 每条用例记录 pass/fail, 全部跑完后回传结果:')
1224
- print(' python .qoder/scripts/autotest.py run %s \\'
1225
- % Path(task_dir).name)
1226
- print(' --env %s --record \'{"AT-...-001":"pass",'
1227
- '"AT-...-002":"fail"}\'' % args.env)
1228
- print(' (或直接把结果贴给我, 我帮你落历史 + 出报告)')
1229
- else:
1230
- print()
1231
- print('[非 QoderWork 或未授权浏览器连接器 → 降级为人工核对]')
1232
- print('用例已生成, 请人工按下方清单逐项检查 (或复制到 QoderWork 执行):')
1233
- print()
1234
- print('目标地址: %s' % base)
1235
- print()
1236
- for c in cases:
1237
- print('□ %s %s' % (c.get('case_id', '?'), c.get('title', '')))
1238
- for s in (c.get('steps') or [])[:4]:
1239
- print(' %s' % _fmt_step(s))
1240
- print(' 预期: %s' % c.get('expected', ''))
1241
- print()
1242
- print('在 QoderWork 桌面端 (Settings → Connectors 授权浏览器/Computer) ')
1243
- print('可让 Agent 自动执行; 结果用 --record 回传出报告。')
1244
- print('-' * 60)
1245
- return 0
1246
-
1247
-
1248
- def _record_and_report(task_dir, args, base, src, record_json):
1249
- """把执行结果落历史 jsonl 并打印汇总。"""
1250
- try:
1251
- results = json.loads(record_json)
1252
- except json.JSONDecodeError as e:
1253
- print('✗ --record 不是合法 JSON: %s' % e)
1254
- return 1
1255
- if not isinstance(results, dict):
1256
- print('✗ --record 需为 {case_id: "pass"|"fail"} 形式')
1257
- return 1
1258
-
1259
- total = len(results)
1260
- passed = sum(1 for v in results.values()
1261
- if str(v).lower() in ('pass', 'passed', 'ok', 'true', '1'))
1262
- failed_ids = [k for k, v in results.items()
1263
- if str(v).lower() not in ('pass', 'passed', 'ok', 'true', '1')]
1264
- status = 'pass' if (total > 0 and not failed_ids) else (
1265
- 'fail' if failed_ids else 'none')
1266
-
1267
- task = load_task_json(task_dir) or {}
1268
- record = {
1269
- 'time': datetime.now().isoformat(),
1270
- 'task': Path(task_dir).name,
1271
- 'task_title': task.get('title', ''),
1272
- 'env': args.env,
1273
- 'platform': args.platform,
1274
- 'base_url': base,
1275
- 'url_source': src,
1276
- 'total': total,
1277
- 'passed': passed,
1278
- 'failed': len(failed_ids),
1279
- 'status': status,
1280
- 'failed_cases': failed_ids,
1281
- 'runner': 'qoderwork-browser',
1282
- }
1283
-
1284
- try:
1285
- from common.atomicio import atomic_append_jsonl
1286
- os.makedirs(os.path.dirname(HISTORY_PATH), exist_ok=True)
1287
- atomic_append_jsonl(HISTORY_PATH, record, timeout=10)
1288
- except Exception as e:
1289
- print('⚠ 历史记录写入失败 (不影响本次结果): %s' % e, file=sys.stderr)
1290
-
1291
- print('=' * 60)
1292
- print('测试结果汇总')
1293
- print('=' * 60)
1294
- print('任务: %s' % record['task_title'])
1295
- print('环境: %s 目标: %s' % (args.env, base))
1296
- print('总计 %d 条 · 通过 %d · 失败 %d · 状态: %s'
1297
- % (total, passed, len(failed_ids), status.upper()))
1298
- if failed_ids:
1299
- print('失败用例:')
1300
- for cid in failed_ids:
1301
- print(' ✗ %s' % cid)
1302
- print('-' * 60)
1303
- print('已记录到 %s' % HISTORY_PATH)
1304
- if status == 'fail':
1305
- print('建议: 查失败用例, 修复后重跑 /wl-test run %s --env %s'
1306
- % (Path(task_dir).name, args.env))
1307
- return 1
1308
- print('全部通过! 可 /wl-commit 提交, 或 /wl-report 出报告。')
1309
- return 0
1310
-
1311
-
1312
- # ---------------------------------------------------------------------------
1313
- # 子命令: list
1314
- # ---------------------------------------------------------------------------
1315
-
1316
- def cmd_list(args):
1317
- task_dir = resolve_task_dir(args.task)
1318
- if not task_dir.is_dir():
1319
- print('任务不存在: %s' % args.task)
1320
- return 1
1321
- cases = read_cases(task_dir)
1322
- if not cases:
1323
- print('任务 %s 暂无用例。' % Path(task_dir).name)
1324
- print('生成: python .qoder/scripts/autotest.py generate %s'
1325
- % Path(task_dir).name)
1326
- return 0
1327
- task = load_task_json(task_dir) or {}
1328
- print('任务: %s' % task.get('title', Path(task_dir).name))
1329
- print('用例文件: %s' % _cases_path(task_dir))
1330
- print()
1331
- print('%-18s %-8s %-6s %s' % ('CASE_ID', 'ENV', 'PLAT', '标题'))
1332
- print('-' * 60)
1333
- for c in cases:
1334
- print('%-18s %-8s %-6s %s' % (
1335
- c.get('case_id', '?'),
1336
- c.get('env', '?'),
1337
- c.get('platform', '?'),
1338
- c.get('title', '')))
1339
- print('-' * 60)
1340
- print('共 %d 条' % len(cases))
1341
- return 0
1342
-
1343
-
1344
- # ---------------------------------------------------------------------------
1345
- # 子命令: set-data / list-data (个人测试数据, 本地 gitignored)
1346
- # ---------------------------------------------------------------------------
1347
-
1348
- def cmd_set_data(args):
1349
- """预置一条个人测试数据到本地文档(不进 git)。"""
1350
- env = getattr(args, 'env', None)
1351
- platform = getattr(args, 'platform', None)
1352
- save_local_test_data(args.key, args.value, env=env, platform=platform)
1353
- loc = ('%s.%s' % (env, platform)) if (env and platform) else (
1354
- env or platform or '通用(顶层)')
1355
- print('已保存到个人文档: %s' % local_data_path())
1356
- print(' %s = %s [位置: %s]' % (args.key, args.value, loc))
1357
- print()
1358
- print('🔒 此文档已被 .gitignore 覆盖 (workspace/members/*/autotest-data.yaml),')
1359
- print(' team_sync 不会 push, 团队/npm 包都看不到。')
1360
- print(' run 时自动注入用例的 {{ask:%s}} 占位。' % args.key)
1361
- if any(s in args.key for s in ('密码', '密', 'secret', 'token', '码')):
1362
- print(' (敏感数据, 存个人文档比每次对话明文输入更安全)')
1363
- return 0
1364
-
1365
-
1366
- def cmd_list_data(args):
1367
- """列出本地已预置的测试数据(可按 env/platform 过滤)。"""
1368
- env = getattr(args, 'env', None)
1369
- platform = getattr(args, 'platform', None)
1370
- p = local_data_path()
1371
- if not p.is_file():
1372
- print('个人测试数据文档不存在: %s' % p)
1373
- print()
1374
- print('生成模板: python .qoder/scripts/autotest.py init-data')
1375
- print('或直接预置: python .qoder/scripts/autotest.py set-data 登录账号 test')
1376
- return 0
1377
- data = load_local_test_data(env=env, platform=platform)
1378
- if not data:
1379
- scope = ('%s/%s' % (env, platform)) if (env and platform) else (
1380
- env or platform or '全部')
1381
- print('文档 %s 在 %s 范围内无数据。' % (p, scope))
1382
- return 0
1383
- print('个人测试数据 (%d 项)' % len(data))
1384
- print('文档: %s' % p)
1385
- print('-' * 50)
1386
- for k, v in data.items():
1387
- show = v
1388
- if any(s in k for s in ('密码', '密', 'secret', 'token')):
1389
- show = '*' * min(len(str(v)), 8) if str(v) else '(空)'
1390
- print(' %-16s = %s' % (k, show))
1391
- print('-' * 50)
1392
- print('🔒 个人文档, 已 gitignore, 不会 push/共享。run 时自动注入 {{ask:描述}}。')
1393
- return 0
1394
-
1395
-
1396
- def cmd_init_data(args):
1397
- """生成带注释的个人测试数据模板文档(若不存在)。"""
1398
- path, created = init_local_data_doc()
1399
- if created:
1400
- print('已生成模板: %s' % path)
1401
- print()
1402
- print('打开它, 填入你的测试账号密码(例子已给)。每人只填自己的 members/{自己}/。')
1403
- print('🔒 已 gitignore, 不会 push/共享给团队。')
1404
- else:
1405
- print('文档已存在(不覆盖): %s' % path)
1406
- print('直接编辑它, 或用 set-data 命令追加。')
1407
- return 0
1408
-
1409
-
1410
- # ---------------------------------------------------------------------------
1411
- # 子命令: login-state (登录态: 已登录就别重复登录, 尤其验证码慢)
1412
- # ---------------------------------------------------------------------------
1413
-
1414
- def cmd_login_state(args):
1415
- """查看/标记/清除 登录态。
1416
-
1417
- QoderWork 保持同一个浏览器会话 → 登录一次后 cookie 不丢。
1418
- 后续测新功能直接 goto 目标页, 不用重复登录(数学题验证码很慢)。
1419
- """
1420
- action = args.action # show / mark / clear
1421
- env = getattr(args, 'env', None) or _default_env()
1422
- platform = getattr(args, 'platform', None) or 'web'
1423
-
1424
- if action == 'mark':
1425
- base, src = resolve_base_url(env, platform)
1426
- if not base:
1427
- print('✗ %s/%s 未配域名, 无法标记登录。' % (env, platform))
1428
- return 1
1429
- mark_logged_in(env, platform, base)
1430
- print('已标记 %s/%s 为已登录 (浏览器会话保持中, cookie 有效)。' % (env, platform))
1431
- print('🔒 存个人文档 (gitignored), 不会 push。')
1432
- print()
1433
- print('后续 quick/browser 测新功能: 直接 goto 目标页, 不用再登录。')
1434
- return 0
1435
-
1436
- if action == 'clear':
1437
- clear_login_state(env if env and env != 'all' else None,
1438
- platform if env != 'all' else None)
1439
- print('已清除登录态: %s/%s。' % (env, platform))
1440
- print('下次测试会重新走登录流程。')
1441
- return 0
1442
-
1443
- # show (默认)
1444
- state = load_login_state()
1445
- if not state:
1446
- print('未记录任何登录态。')
1447
- print('登录用例通过后自动标记, 或手动: login-state mark --env %s --platform %s'
1448
- % (env, platform))
1449
- return 0
1450
- print('已记录登录态 (QoderWork 会话保持中, 后续测新功能可直接 goto 目标页):')
1451
- print('-' * 50)
1452
- for e, plats in state.items():
1453
- for pf, info in (plats or {}).items():
1454
- t = (info or {}).get('time', '?')[:19].replace('T', ' ')
1455
- url = (info or {}).get('url', '')
1456
- print(' %s/%s 已登录 (%s) %s' % (e, pf, t, url))
1457
- print('-' * 50)
1458
- print('🔒 个人文档, gitignored, 不 push。清除: login-state clear --env <环境>')
1459
- return 0
1460
-
1461
-
1462
- # ---------------------------------------------------------------------------
1463
- # 子命令: learn / recall (页面锚点沉淀 + 查询, A方案核心)
1464
- # ---------------------------------------------------------------------------
1465
-
1466
- def cmd_learn(args):
1467
- """把 read_page 返回的页面元素沉淀成稳定锚点 → data/index/test-pages.json。
1468
-
1469
- AI 执行 observe(read_page) 后, 把元素 JSON 回传, 本命令提取稳定锚点存盘。
1470
- 下次 recall 同页直接命中锚点, 跳过 LLM 匹配(Stagehand 式 action 缓存)。
1471
- """
1472
- url_pattern = _normalize_url_pattern(args.url, getattr(args, '_base', None))
1473
- # 解析 page-json (read_page 返回的元素数组, 或 {elements:[...]})
1474
- try:
1475
- parsed = json.loads(args.page_json)
1476
- except json.JSONDecodeError as e:
1477
- print('✗ --page-json 不是合法 JSON: %s' % e)
1478
- return 1
1479
- if isinstance(parsed, dict):
1480
- elements = parsed.get('elements') or parsed.get('elements') or []
1481
- elif isinstance(parsed, list):
1482
- elements = parsed
1483
- else:
1484
- print('✗ --page-json 应为元素数组或含 elements 的对象')
1485
- return 1
1486
- # 可选 intent-map
1487
- intent_map = None
1488
- if args.intent_map:
1489
- try:
1490
- intent_map = json.loads(args.intent_map)
1491
- except json.JSONDecodeError as e:
1492
- print('⚠ --intent-map 不是合法 JSON, 忽略: %s' % e)
1493
- new_n, total_n = learn_page_anchors(url_pattern, elements, intent_map=intent_map)
1494
- print('=' * 56)
1495
- print('锚点沉淀 → data/index/test-pages.json')
1496
- print('=' * 56)
1497
- print('页面: %s' % url_pattern)
1498
- print('本次新增 %d 个锚点, 该页累计 %d 个' % (new_n, total_n))
1499
- print('🔒 团队共享(随 team_sync 分发), 只存 role/name/placeholder, 不含密码/ref。')
1500
- print()
1501
- print('下次 recall %s 即可直接用锚点生成用例, 跳过 LLM 匹配。' % url_pattern)
1502
- return 0
1503
-
1504
-
1505
- def cmd_recall(args):
1506
- """查某页已沉淀的锚点。AI 生成用例前查它, 有锚点就直接填进 step。"""
1507
- url_pattern = _normalize_url_pattern(args.url)
1508
- anchors = recall_page_anchors(url_pattern)
1509
- print('=' * 56)
1510
- print('已沉淀锚点: %s' % url_pattern)
1511
- print('=' * 56)
1512
- if not anchors:
1513
- print('该页暂无锚点。执行 observe(read_page) 后用 learn 沉淀:')
1514
- print(' python .qoder/scripts/autotest.py learn --url %s --page-json \'<read_page结果>\'' % url_pattern)
1515
- return 0
1516
- print('%-10s %-22s %s' % ('INTENT', 'ANCHOR', '描述'))
1517
- print('-' * 56)
1518
- for a in anchors:
1519
- print('%-10s %-22s %s' % (
1520
- a.get('intent', '?'),
1521
- _fmt_anchor(a.get('anchor')),
1522
- a.get('desc', '')[:30]))
1523
- print('-' * 56)
1524
- print('共 %d 个锚点。生成用例时可直接填入 step.anchor, 跳过 LLM 匹配。' % len(anchors))
1525
- return 0
1526
-
1527
-
1528
- # ---------------------------------------------------------------------------
1529
- # 子命令: page (页面按钮+接口画像, 开发→测试交接物)
1530
- # ---------------------------------------------------------------------------
1531
-
1532
- def cmd_page(args):
1533
- """查页面按钮+接口画像。薄入口, 实际逻辑在 page_probe.py。
1534
- 数据来自知识图谱 traces 表, 无图谱则静默提示。
1535
- """
1536
- try:
1537
- import page_probe
1538
- except ImportError:
1539
- # 直接当脚本跑时, 补一下 sys.path
1540
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
1541
- import page_probe
1542
-
1543
- profile = page_probe.build_page_profile(keyword=getattr(args, 'kw', None),
1544
- url=getattr(args, 'url', None))
1545
- label = args.kw or args.url
1546
- print('## 页面画像: %s' % label)
1547
- print()
1548
- print(page_probe.render_profile_md(profile))
1549
- if profile.get('ok'):
1550
- n_pages = len(profile['pages'])
1551
- n_btns = sum(len(pg['buttons']) for pg in profile['pages'])
1552
- print()
1553
- print('[page] %d 个页面, %d 个交互点' % (n_pages, n_btns))
1554
- return 0
1555
-
1556
-
1557
- # ---------------------------------------------------------------------------
1558
- # 子命令: report
1559
- # ---------------------------------------------------------------------------
1560
-
1561
- def cmd_report(args):
1562
- task_dir = resolve_task_dir(args.task)
1563
- task_name = Path(task_dir).name
1564
- task = load_task_json(task_dir) or {}
1565
-
1566
- if not os.path.isfile(HISTORY_PATH):
1567
- print('暂无测试执行历史 (%s)' % HISTORY_PATH)
1568
- print('先跑: python .qoder/scripts/autotest.py run %s --env test'
1569
- % task_name)
1570
- return 0
1571
-
1572
- # 读该任务的全部历史 (倒序)
1573
- records = []
1574
- with open(HISTORY_PATH, encoding='utf-8') as f:
1575
- for line in f:
1576
- line = line.strip()
1577
- if not line:
1578
- continue
1579
- try:
1580
- r = json.loads(line)
1581
- except json.JSONDecodeError:
1582
- continue
1583
- if r.get('task') == task_name:
1584
- records.append(r)
1585
- if not records:
1586
- print('任务 %s 无执行记录。' % task_name)
1587
- return 0
1588
-
1589
- records.sort(key=lambda r: r.get('time', ''), reverse=True)
1590
- print('=' * 60)
1591
- print('测试历史 · %s' % task.get('title', task_name))
1592
- print('=' * 60)
1593
- print('%-20s %-6s %-7s %-5s %s' % (
1594
- '时间', 'ENV', '状态', 'P/F', '目标'))
1595
- print('-' * 60)
1596
- for r in records[:20]:
1597
- t = r.get('time', '')[:19].replace('T', ' ')
1598
- print('%-20s %-6s %-7s %d/%d %s' % (
1599
- t,
1600
- r.get('env', '?'),
1601
- r.get('status', '?').upper(),
1602
- r.get('passed', 0),
1603
- r.get('total', 0),
1604
- r.get('base_url', '')))
1605
- print('-' * 60)
1606
- total_runs = len(records)
1607
- pass_runs = sum(1 for r in records if r.get('status') == 'pass')
1608
- print('历史 %d 次执行 · 通过率 %.0f%% (最近 %d 次)'
1609
- % (total_runs, 100 * pass_runs / total_runs if total_runs else 0,
1610
- min(total_runs, 20)))
1611
- last = records[0]
1612
- if last.get('failed_cases'):
1613
- print('最近一次失败用例: %s' % ', '.join(last['failed_cases']))
1614
- return 0
1615
-
1616
-
1617
- # ---------------------------------------------------------------------------
1618
- # argparse
1619
- # ---------------------------------------------------------------------------
1620
-
1621
- def build_parser():
1622
- p = argparse.ArgumentParser(
1623
- prog='autotest.py',
1624
- description='浏览器自动化测试: 用例生成 + 运行编排 (QoderWork 浏览器执行, 纯 Qoder 降级人工)',
1625
- formatter_class=argparse.RawDescriptionHelpFormatter,
1626
- )
1627
- sub = p.add_subparsers(dest='cmd')
1628
-
1629
- def add_env_platform(sp):
1630
- sp.add_argument('--env', default=_default_env(),
1631
- choices=SUPPORTED_ENVS,
1632
- help='环境 (test/pre/prod), 默认 %(default)s')
1633
- sp.add_argument('--platform', default='web',
1634
- choices=SUPPORTED_PLATFORMS,
1635
- help='平台 (web=管理端 / app=移动端), 默认 %(default)s')
1636
-
1637
- pg = sub.add_parser('generate', help='从任务生成测试用例骨架')
1638
- pg.add_argument('task', help='任务名/路径 (如 06-14-login)')
1639
- add_env_platform(pg)
1640
- pg.set_defaults(func=cmd_generate)
1641
-
1642
- pr = sub.add_parser('run', help='生成(若无)+输出浏览器执行指令/人工清单')
1643
- pr.add_argument('task', help='任务名/路径')
1644
- add_env_platform(pr)
1645
- pr.add_argument('--url', default=None,
1646
- help='临时覆盖目标域名 (优先于 --env 配置)')
1647
- pr.add_argument('--case', nargs='*', default=None,
1648
- help='只执行指定 case_id (可多个)')
1649
- pr.add_argument('--data', default=None,
1650
- help='测试数据 JSON, 注入 {{ask:描述}} 占位, '
1651
- '如 \'{"登录账号":"test","登录密码":"123456"}\'')
1652
- pr.add_argument('--record', default=None,
1653
- help='回传执行结果 JSON, 如 \'{"AT-x-001":"pass"}\'')
1654
- pr.set_defaults(func=cmd_run)
1655
-
1656
- pq = sub.add_parser('quick', help='快速测试: 不需任务/PRD, AI 直接据描述生成并执行')
1657
- pq.add_argument('--cases', default=None,
1658
- help='JSON 用例 (单条对象或数组), AI 据用户描述生成后传入')
1659
- pq.add_argument('--cases-file', dest='cases_file', default=None,
1660
- help='用例 JSON 文件路径(推荐, 不截断)。AI 写文件后传路径')
1661
- pq.add_argument('--desc', default=None,
1662
- help='本次快速测试的描述/标题 (可选)')
1663
- add_env_platform(pq)
1664
- pq.add_argument('--url', default=None, help='临时覆盖目标域名')
1665
- pq.add_argument('--data', default=None, help='测试数据 JSON, 注入 {{ask:描述}}')
1666
- pq.add_argument('--record', default=None, help='回传执行结果 JSON')
1667
- pq.add_argument('--page-json', nargs='*', default=None,
1668
- help='read_page 返回的页面元素 JSON(多个), --record 时自动沉淀锚点')
1669
- pq.set_defaults(func=cmd_quick)
1670
-
1671
- pl = sub.add_parser('list', help='列出任务已有用例')
1672
- pl.add_argument('task', help='任务名/路径')
1673
- pl.set_defaults(func=cmd_list)
1674
-
1675
- pr2 = sub.add_parser('report', help='查看任务测试执行历史')
1676
- pr2.add_argument('task', help='任务名/路径')
1677
- pr2.set_defaults(func=cmd_report)
1678
-
1679
- psd = sub.add_parser('set-data', help='预置个人测试数据到个人文档(gitignored, 不共享)')
1680
- psd.add_argument('key', help='数据名(对应用例 {{ask:数据名}} 占位), 如 登录账号')
1681
- psd.add_argument('value', help='数据值, 如 test')
1682
- psd.add_argument('--env', default=None, choices=SUPPORTED_ENVS,
1683
- help='只对该环境生效(如 test/pre/prod)')
1684
- psd.add_argument('--platform', default=None, choices=SUPPORTED_PLATFORMS,
1685
- help='只对该平台生效(web/app)')
1686
- psd.set_defaults(func=cmd_set_data)
1687
-
1688
- pld = sub.add_parser('list-data', help='列出个人已预置的测试数据(可按 env/platform 过滤)')
1689
- pld.add_argument('--env', default=None, choices=SUPPORTED_ENVS)
1690
- pld.add_argument('--platform', default=None, choices=SUPPORTED_PLATFORMS)
1691
- pld.set_defaults(func=cmd_list_data)
1692
-
1693
- pid = sub.add_parser('init-data', help='生成个人测试数据模板文档(members/{dev}/autotest-data.yaml)')
1694
- pid.set_defaults(func=cmd_init_data)
1695
-
1696
- pls = sub.add_parser('login-state', help='登录态: 已登录就别重复登录(QoderWork 会话保持)')
1697
- pls.add_argument('action', nargs='?', default='show',
1698
- choices=['show', 'mark', 'clear'],
1699
- help='show=查看 / mark=标记已登录 / clear=清除(强制重新登录)')
1700
- add_env_platform(pls)
1701
- pls.set_defaults(func=cmd_login_state)
1702
-
1703
- ple = sub.add_parser('learn', help='沉淀页面锚点到 test-pages.json(observe后调, 团队共享)')
1704
- ple.add_argument('--url', required=True, help='页面 URL 或路径(如 /auth/login 或完整URL)')
1705
- ple.add_argument('--page-json', required=True,
1706
- help="read_page 返回的元素 JSON (数组 或 {elements:[...]})")
1707
- ple.add_argument('--intent-map', default=None,
1708
- help='可选: {元素描述:{intent,anchor}} 标注意图')
1709
- ple.set_defaults(func=cmd_learn)
1710
-
1711
- prc = sub.add_parser('recall', help='查某页已沉淀的锚点(生成用例前查, 跳过LLM匹配)')
1712
- prc.add_argument('--url', required=True, help='页面 URL 或路径(如 /auth/login)')
1713
- prc.set_defaults(func=cmd_recall)
1714
-
1715
- # page: 查页面按钮+接口画像 (开发→测试交接物, 数据来自知识图谱 traces 表)
1716
- ppg = sub.add_parser('page', help='查页面按钮+接口画像(开发交接给测试,照着写用例)')
1717
- ppg_grp = ppg.add_mutually_exclusive_group(required=True)
1718
- ppg_grp.add_argument('--kw', help='业务关键词(如 保险 / 排班)')
1719
- ppg_grp.add_argument('--url', help='页面 URL 或路径关键词(如 /veh/insurance)')
1720
- ppg.set_defaults(func=cmd_page)
1721
-
1722
- return p
1723
-
1724
-
1725
- def _default_env():
1726
- """config.yaml autotest.default_env, 缺失回退 test。"""
1727
- try:
1728
- return load_config_section('autotest').get('default_env') or 'test'
1729
- except Exception:
1730
- return 'test'
1731
-
1732
-
1733
- def main(argv=None):
1734
- parser = build_parser()
1735
- args = parser.parse_args(argv)
1736
- if not getattr(args, 'cmd', None):
1737
- parser.print_help()
1738
- return 1
1739
- try:
1740
- return args.func(args)
1741
- except RuntimeError as e:
1742
- # 身份缺失等 (多人共用 workspace 时的隔离保障)
1743
- print('✗ %s' % e)
1744
- return 1
1745
- except (ValueError, FileNotFoundError) as e:
1746
- print('✗ %s' % e)
1747
- return 1
1748
-
1749
-
1750
- if __name__ == '__main__':
1751
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
4
+ import os as _o, sys as _s
5
+ _cp = _o.path.join(_o.path.dirname(_o.path.dirname(_o.path.abspath(__file__))), 'common')
6
+ if _cp not in _s.path: _s.path.insert(0, _cp)
7
+ from bootstrap import setup; setup()
8
+
9
+ """
10
+ QODER Pipeline - 浏览器自动化测试 (用例生成 + 运行编排)
11
+
12
+ 设计原则 (遵循规则 8: QoderWork 只做可选增强, 绝不强依赖):
13
+ - 本脚本是"用例生成器 + 运行编排器", 不内嵌任何浏览器驱动库
14
+ ( import playwright/selenium —— PM 本机装不动)
15
+ - 真正"开浏览器点击"由 QoderWork 的 Browser Use / Computer 控制连接器
16
+ 在运行时承担 (Agent 读 run 指令后调用连接器)。
17
+ - 纯 Qoder IDE / CLI 无连接器 → 静默降级为"人工核对清单", 不报错不阻塞。
18
+ - 本地 (workspace/tasks/<task>/autotest-cases.jsonl) 是唯一事实源。
19
+
20
+ 用例格式 (autotest-cases.jsonl, 每行一条) —— 统一用意图式(intent+anchor), 禁止 CSS selector:
21
+
22
+ intent 取值: goto / observe / click / fill / extract / assert / ask_human
23
+ anchor 取值: {role, name, placeholder, text} 稳定锚点(来自 a11y 树, 不随刷新变)
24
+ 无锚点时 anchor 留空或省略, 执行时靠 snapshot 语义匹配(LLM 兜底)。
25
+
26
+ {
27
+ "case_id": "AT-login-001",
28
+ "title": "正确账号密码登录成功",
29
+ "platform": "web", "env": "test",
30
+ "preconditions": "已注册账号 test/123456",
31
+ "steps": [
32
+ {"intent":"goto","desc":"登录页","value":"{{base_url}}/auth/login"},
33
+ {"intent":"fill","desc":"账号输入框","value":"{{ask:登录账号}}","anchor":{"role":"textbox","name":"尊敬的管理员"}},
34
+ {"intent":"fill","desc":"密码输入框","value":"{{ask:登录密码}}","anchor":{"role":"textbox","name":"请输入您的密码"}},
35
+ {"intent":"click","desc":"登录按钮","anchor":{"role":"button","name":"登录"}},
36
+ {"intent":"extract","desc":"是否已离开登录页进入系统"}
37
+ ],
38
+ "expected": "登录成功进入首页"
39
+ }
40
+
41
+ ⚠️ URL 必须写成 {{base_url}}/auth/login (带斜杠)。base_url 已去尾斜杠。
42
+ ⚠️ 首次跑无锚点时, AI 应先 navigate+snapshot 拿到真实 anchor 再填, 禁止预猜 CSS selector。
43
+ ⚠️ 账号/密码等真实数据用 {{ask:描述}} 占位, 绝不编造; 执行时从个人文档注入。
44
+
45
+ 占位符 (run 时注入):
46
+ {{base_url}} — 按环境(test/pre/prod)解析的目标域名(已去尾斜杠)
47
+ {{ask:描述}} — 需要用户提供的测试数据(账号/密码/手机号等)。
48
+ 生成用例时: 凡是需要真实测试数据的, {{ask:描述}} 占位, 绝不编造。
49
+ 执行时: 用 --data '{"登录账号":"test","登录密码":"123456"}' 传入;
50
+ 未提供的会列清单提示补齐后再执行。
51
+ 敏感数据(密码/验证码)建议走 --data 命令传, 避免明文进对话记录。
52
+
53
+ ⚠️ recall 查锚点时, --url 用相对路径(如 auth/login)避开 Git-bash 路径转换坑
54
+ (Windows 上 /auth/login 会被 MSYS 转成 /D:/.../Git/auth/login)。
55
+
56
+ 域名解析优先级: --url > --env(查 config.yaml) > 报错
57
+
58
+ Usage:
59
+ python autotest.py generate <task> [--env test|pre|prod] [--platform web|app]
60
+ python autotest.py run <task> [--env ...] [--url <url>] [--platform ...]
61
+ [--case <case_id> ...] [--data '<json>']
62
+ [--record <result>]
63
+ python autotest.py quick --cases '<JSON用例>' [--desc '<描述>'] [--env ...]
64
+ [--url <url>] [--platform ...] [--data '<json>']
65
+ [--record <result>] # 快速测试, 不需任务/PRD
66
+ python autotest.py list <task>
67
+ python autotest.py report <task>
68
+
69
+ Exit: 0 = 成功, 1 = 一般错误(含缺测试数据)
70
+ """
71
+
72
+ import argparse
73
+ import json
74
+ import os
75
+ import sys
76
+ from datetime import datetime
77
+ from pathlib import Path
78
+
79
+ if sys.platform == 'win32':
80
+ try:
81
+ sys.stdout.reconfigure(encoding='utf-8')
82
+ except (AttributeError, IOError):
83
+ pass
84
+
85
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
86
+
87
+ from common.paths import get_repo_root, get_tasks_dir, get_developer, MEMBERS_DIR, index_path # noqa: E402
88
+ from common.task_utils import load_task_json, resolve_task_dir # noqa: E402
89
+
90
+ # ---------------------------------------------------------------------------
91
+
92
+ # v3.0 f5: 数据层拆到 autotest_data.py
93
+ from autotest_data import * # noqa: F401,F403 (常量+数据函数)
94
+
95
+ def _resolve_env_platform(args):
96
+ """解析域名 + 校验。返回 (base, src) 或 (None, None)。"""
97
+ base, src = resolve_base_url(args.env, args.platform, override=getattr(args, 'url', None))
98
+ if base is None:
99
+ print('✗ %s 环境未配 base_url, 无法执行测试。' % args.env)
100
+ print(' 解决方式 (任选其一):')
101
+ print(' 1. 命令行临时指定: --url https://你的地址')
102
+ print(' 2. config.yaml: autotest.environments.%s.%s.base_url'
103
+ % (args.env, args.platform))
104
+ return None, None
105
+ return base, src
106
+
107
+
108
+ def _inject_and_check_data(cases, args):
109
+ """注入 base_url + 测试数据, 检查完整性。返回 (cases, ok)。ok=False 表示缺数据已提示。"""
110
+ cases = _inject_base_url(cases, getattr(args, '_base', ''))
111
+ local_data = load_local_test_data(env=args.env, platform=args.platform)
112
+ try:
113
+ cli_data = json.loads(args.data) if getattr(args, 'data', None) else {}
114
+ except json.JSONDecodeError as e:
115
+ print('✗ --data 不是合法 JSON: %s' % e)
116
+ print(' 示例: --data \'{"登录账号":"test","登录密码":"123456"}\'')
117
+ return cases, False
118
+ merged = dict(local_data)
119
+ merged.update(cli_data)
120
+ cases, missing = inject_data(cases, merged)
121
+ if missing:
122
+ print('=' * 60)
123
+ print('⚠️ 需要测试数据才能执行 (%d 项)' % len(missing))
124
+ print('=' * 60)
125
+ print('以下用例引用了真实测试数据(账号/密码/手机号等), 不能编造:')
126
+ for i, desc in enumerate(missing, 1):
127
+ print(' %d. %s' % (i, desc))
128
+ print('-' * 60)
129
+ print('请向用户收集后, 用 --data 传入:')
130
+ print(' --data \'{"%s":"<值>", "%s":"<值>"}\''
131
+ % (missing[0], missing[1] if len(missing) > 1 else missing[0]))
132
+ print()
133
+ print('常用账号预置到个人文档(不进 git, 团队看不到):')
134
+ print(' python .qoder/scripts/autotest.py set-data 登录账号 test --env test')
135
+ return cases, False
136
+ return cases, True
137
+
138
+
139
+ def _fmt_anchor(anc):
140
+ """格式化 anchor 为简短可读串, 如 "button '登录'" 或 "placeholder='尊敬的管理员'"。"""
141
+ if not isinstance(anc, dict) or not anc:
142
+ return ''
143
+ parts = []
144
+ for k in ('role', 'name', 'placeholder', 'text'):
145
+ v = anc.get(k)
146
+ if v:
147
+ parts.append("%s='%s'" % (k, v))
148
+ return ' '.join(parts)
149
+
150
+
151
+ def _fmt_step(s, with_value=True):
152
+ """格式化单步为可读文本。支持新意图式(intent+anchor)和旧命令式(action+target)。"""
153
+ desc = s.get('desc', '')
154
+ # ── 新意图式 ──
155
+ if is_intent_step(s):
156
+ it = s.get('intent', '?')
157
+ anc = s.get('anchor')
158
+ val = s.get('value')
159
+ anchor_str = _fmt_anchor(anc)
160
+ if it == 'ask_human':
161
+ return '%-12s ⏸ %s' % ('人机协同', desc or '(请用户操作)')
162
+ if it == 'extract':
163
+ return '%-12s ✓ %s' % ('extract', desc or '(语义断言)')
164
+ if it == 'observe':
165
+ return '%-12s 👁 %s' % ('observe', desc or '读取页面可交互元素')
166
+ if it == 'goto':
167
+ return '%-12s → %s' % ('goto', val or desc or '')
168
+ # click / fill / assert
169
+ tag = '[锚点: %s]' % anchor_str if anchor_str else '(待匹配·LLM兜底)'
170
+ extra = ''
171
+ if val is not None:
172
+ extra = ' 输入=%s' % val
173
+ return '%-12s %s %s%s' % (it, tag, desc, extra)
174
+ # ── 旧命令式(兼容) ──
175
+ act = s.get('action', '?')
176
+ tgt = s.get('target', '')
177
+ val = s.get('value')
178
+ if act == 'solve_captcha':
179
+ fill = s.get('fill_target', '')
180
+ detail = '截图 %s → 用视觉识别(算数学题) → 答案填入 %s' % (tgt, fill)
181
+ if desc:
182
+ detail += ' (%s)' % desc
183
+ return '%-16s %s' % (act, detail)
184
+ detail = tgt
185
+ if with_value and val is not None:
186
+ detail = '%s 输入=%s' % (tgt, val)
187
+ return '%-16s %s%s' % (act, detail, (' — ' + desc) if desc else '')
188
+
189
+
190
+ def _print_execution_plan(cases, base, src, env, platform, title, record_hint):
191
+ """打印执行编排指令 (QoderWork 浏览器执行 / 纯 Qoder 降级人工核对)。
192
+
193
+ record_hint: 回收结果时的命令提示文本 (run 用任务名, quick 用 --quick)。
194
+ """
195
+ is_qw, qw_hint = _detect_qoderwork()
196
+ print('=' * 60)
197
+ print('浏览器自动化测试 · 执行编排')
198
+ print('=' * 60)
199
+ print('测试: %s' % title)
200
+ print('环境: %s 平台: %s' % (env, platform))
201
+ print('目标: %s (来源: %s)' % (base, src))
202
+ print('用例: %d 条' % len(cases))
203
+ print('-' * 60)
204
+
205
+ if is_qw:
206
+ print()
207
+ print('🧩 QoderWork 浏览器执行 (已检测到: %s)' % qw_hint)
208
+ print('请用 Browser Use / Computer 控制连接器, 按下列要求执行:')
209
+ print()
210
+ print('1. 打开浏览器, 访问: %s' % base)
211
+ print('2. 逐条执行用例 (共 %d 条):' % len(cases))
212
+ for c in cases:
213
+ print(' ▸ %s %s' % (c.get('case_id', '?'), c.get('title', '')))
214
+ for s in (c.get('steps') or [])[:6]:
215
+ print(' %s' % _fmt_step(s))
216
+ print(' 预期: %s' % c.get('expected', ''))
217
+ print()
218
+ print('3. 每条用例记录 pass/fail, 跑完回传结果:')
219
+ print(' %s' % record_hint)
220
+ print(' (或直接把结果贴给我, 我帮你出报告)')
221
+ else:
222
+ print()
223
+ print('[非 QoderWork 或未授权浏览器连接器 → 降级为人工核对]')
224
+ print('目标地址: %s' % base)
225
+ print()
226
+ for c in cases:
227
+ print('□ %s %s' % (c.get('case_id', '?'), c.get('title', '')))
228
+ for s in (c.get('steps') or [])[:4]:
229
+ print(' %s' % _fmt_step(s))
230
+ print(' 预期: %s' % c.get('expected', ''))
231
+ print()
232
+ print('在 QoderWork 桌面端 (Settings → Connectors 授权浏览器/Computer)')
233
+ print('可让 Agent 自动执行; 结果用 --record 回传出报告。')
234
+ print('-' * 60)
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # 子命令: generate
239
+ # ---------------------------------------------------------------------------
240
+
241
+ def cmd_generate(args):
242
+ task_dir = resolve_task_dir(args.task)
243
+ if not task_dir.is_dir():
244
+ print('任务不存在: %s' % args.task)
245
+ print('提示: python .qoder/scripts/task.py list 查看已有任务')
246
+ return 1
247
+ task = load_task_json(task_dir) or {}
248
+
249
+ env = args.env
250
+ base, src = resolve_base_url(env, args.platform)
251
+ # generate 不强求域名 (用例用 {{base_url}} 占位), 但提示一下
252
+ if base is None:
253
+ print('提示: %s 环境未配 base_url, 用例将用 {{base_url}} 占位, '
254
+ 'run 时再注入 (或在 config.yaml autotest.environments 配置)。'
255
+ % env)
256
+
257
+ new_cases = generate_skeleton(task_dir, env, args.platform)
258
+ all_cases = read_cases(task_dir)
259
+ ok, warns = validate_cases(all_cases)
260
+
261
+ print('已生成 %d 条用例骨架 → %s' % (len(new_cases), _cases_path(task_dir)))
262
+ print('任务: %s 环境: %s 平台: %s' % (
263
+ task.get('title', Path(task_dir).name), env, args.platform))
264
+ print()
265
+ print('⚠️ 这些是骨架占位。请按任务 PRD「验收标准」补充每条的:')
266
+ print(' - title: 用例标题')
267
+ print(' - steps: 操作步骤 (goto/click/fill/assert_visible ...)')
268
+ print(' - expected: 预期结果')
269
+ print(' (用 /wl-test 触发 AI 自动据 PRD 填充)')
270
+ if warns:
271
+ print()
272
+ print('校验提醒:')
273
+ for w in warns:
274
+ print(' - ' + w)
275
+ print()
276
+ print('下一步: /wl-test run %s --env %s (生成+执行)'
277
+ % (Path(task_dir).name, env))
278
+ return 0
279
+
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # 子命令: run
283
+ # ---------------------------------------------------------------------------
284
+
285
+ def _detect_qoderwork():
286
+ """探测当前是否在 QoderWork 环境 (有浏览器连接器)。
287
+
288
+ QoderWork 会暴露运行时工具/环境标记。探测而非假设: 看不到就回退。
289
+ 返回 (is_qoderwork: bool, hint: str)
290
+ """
291
+ # 1. 显式环境变量标记 (QoderWork 桌面端/CLI 会设置)
292
+ for key in ('QODERWORK_SESSION', 'QODER_WORK', 'QW_CONNECTOR'):
293
+ if os.environ.get(key):
294
+ return True, 'env:' + key
295
+ # 2. QoderWork 配置目录存在 (~/.qoderwork/)
296
+ home = os.path.expanduser('~')
297
+ if os.path.isdir(os.path.join(home, '.qoderwork')):
298
+ return True, '~/.qoderwork'
299
+ return False, ''
300
+
301
+
302
+ def _iter_injectable_strings(s):
303
+ """遍历一个 step 里所有可做占位符替换的字符串字段, yield (setter, value)。
304
+
305
+ 覆盖旧格式(target/value/desc) + 新格式(intent式 value/desc/anchor.* )
306
+ setter 是个闭包, 传新值回去。anchor(dict) 递归到它的每个 str 值。
307
+ """
308
+ for field in ('target', 'value', 'desc'):
309
+ val = s.get(field)
310
+ if isinstance(val, str):
311
+ yield (lambda v, f=field: s.__setitem__(f, v)), val
312
+ # anchor (新格式): dict, 每个值都可能是占位符串
313
+ anc = s.get('anchor')
314
+ if isinstance(anc, dict):
315
+ for k, v in list(anc.items()):
316
+ if isinstance(v, str):
317
+ yield (lambda nv, kk=k: anc.__setitem__(kk, nv)), v
318
+
319
+
320
+ def _inject_base_url(cases, base_url):
321
+ """把 steps 里的 {{base_url}} 占位替换为真实域名(覆盖 anchor.*)。
322
+
323
+ base_url 已去尾斜杠(见 resolve_base_url), 所以用例里必须写成
324
+ {{base_url}}/auth/login (带斜杠)。若检测到 {{base_url}} 后紧跟非斜杠字符
325
+ (会拼成 https://xxxauth/login), 打印告警提醒——不阻断, 但提示 AI 修正。
326
+ """
327
+ import re as _re
328
+ bad_pat = _re.compile(r'\{\{base_url\}\}([^\s/&?#])') # {{base_url}}后紧跟非分隔符 = 少斜杠
329
+ warned = False
330
+ for c in cases:
331
+ for s in c.get('steps') or []:
332
+ for setter, val in _iter_injectable_strings(s):
333
+ if isinstance(val, str) and '{{base_url}}' in val:
334
+ if not warned and bad_pat.search(val):
335
+ warned = True
336
+ setter(val.replace('{{base_url}}', base_url))
337
+ if warned:
338
+ print('⚠️ 检测到 {{base_url}} 后缺少斜杠(如 {{base_url}}auth/login)。'
339
+ 'base_url 已去尾斜杠, 正确写法是 {{base_url}}/auth/login。',
340
+ file=sys.stderr)
341
+ print(' 本次已按原样拼接(可能产生 https://xxxauth/login 这样的坏地址), '
342
+ '请修正用例后重跑。', file=sys.stderr)
343
+ return cases
344
+
345
+
346
+ def collect_ask_placeholders(cases):
347
+ """扫出全部 {{ask:描述}} 占位, 返回去重后的描述列表(保持出现顺序)。"""
348
+ import re as _re
349
+ asks = []
350
+ seen = set()
351
+ pat = _re.compile(r'\{\{ask:([^}]+)\}\}')
352
+ for c in cases:
353
+ for s in c.get('steps') or []:
354
+ for setter, val in _iter_injectable_strings(s):
355
+ if not isinstance(val, str):
356
+ continue
357
+ for desc in pat.findall(val):
358
+ if desc.strip() not in seen:
359
+ seen.add(desc.strip())
360
+ asks.append(desc.strip())
361
+ return asks
362
+
363
+
364
+ def inject_data(cases, data):
365
+ """把 {{ask:描述}} 用 data 字典注入(覆盖 anchor.*)。返回 (cases, still_missing)。"""
366
+ import re as _re
367
+ pat = _re.compile(r'\{\{ask:([^}]+)\}\}')
368
+ provided = {k.strip(): v for k, v in (data or {}).items()}
369
+ still_missing = []
370
+ for c in cases:
371
+ for s in c.get('steps') or []:
372
+ for setter, val in _iter_injectable_strings(s):
373
+ if not isinstance(val, str) or '{{ask:' not in val:
374
+ continue
375
+ for desc in pat.findall(val):
376
+ key = desc.strip()
377
+ if key in provided:
378
+ val = val.replace('{{ask:%s}}' % desc, str(provided[key]))
379
+ for desc in pat.findall(val):
380
+ if desc.strip() not in provided and desc.strip() not in still_missing:
381
+ still_missing.append(desc.strip())
382
+ setter(val)
383
+ return cases, still_missing
384
+
385
+
386
+ def cmd_quick(args):
387
+ """快速测试: 不需要任务/PRD。AI 据描述直接生成用例并执行。
388
+
389
+ 用法: autotest.py quick --cases '<JSON用例>' --env test --platform web
390
+ (AI 把据用户描述生成的用例, 以 JSON 字符串传入; 脚本负责注入+执行编排)
391
+
392
+ 也支持 --record 回收结果 (临时用例, 不落 task 历史, 只打印汇总)。
393
+ """
394
+ # 1. 解析用例: 优先 --cases-file (文件, 不截断), 其次 --cases (内联, 短用例)
395
+ raw = ''
396
+ cases_file = getattr(args, 'cases_file', None)
397
+ if cases_file:
398
+ try:
399
+ raw = open(cases_file, encoding='utf-8').read().strip()
400
+ except (OSError, IOError) as e:
401
+ print('✗ 读 --cases-file 失败: %s' % e)
402
+ return 1
403
+ if not raw:
404
+ raw = (args.cases or '').strip()
405
+ if not raw:
406
+ print('✗ quick 模式需要用例 JSON。用文件传(推荐, 不截断):')
407
+ print(' --cases-file <路径> # AI 把用例 JSON 写文件, 再传路径')
408
+ print('或内联传(短用例): --cases \'<JSON>\'')
409
+ return 1
410
+ try:
411
+ parsed = json.loads(raw)
412
+ except json.JSONDecodeError as e:
413
+ print('✗ 用例 JSON 解析失败: %s' % e)
414
+ if cases_file:
415
+ print(' (来自文件: %s)' % cases_file)
416
+ return 1
417
+ cases = parsed if isinstance(parsed, list) else [parsed]
418
+ # 规范化: 补默认字段
419
+ for i, c in enumerate(cases):
420
+ if not isinstance(c, dict):
421
+ print('✗ %d 条用例不是对象' % (i + 1))
422
+ return 1
423
+ c.setdefault('case_id', 'Q-%d' % (i + 1))
424
+ c.setdefault('platform', args.platform)
425
+ c.setdefault('env', args.env)
426
+ c.setdefault('steps', [])
427
+ c.setdefault('expected', '')
428
+
429
+ # 2. 回收结果模式 (临时用例, 只打印汇总)
430
+ if args.record:
431
+ try:
432
+ results = json.loads(args.record)
433
+ except json.JSONDecodeError as e:
434
+ print('✗ --record 不是合法 JSON: %s' % e)
435
+ return 1
436
+ total = len(results)
437
+ passed = sum(1 for v in results.values()
438
+ if str(v).lower() in ('pass', 'passed', 'ok', 'true', '1'))
439
+ failed_ids = [k for k, v in results.items()
440
+ if str(v).lower() not in ('pass', 'passed', 'ok', 'true', '1')]
441
+ # 登录用例通过 → 自动标记该 env/platform 已登录 (QoderWork 会话保持, 后续免登录)
442
+ login_passed = False
443
+ for c in cases:
444
+ cid = c.get('case_id', '')
445
+ if str(results.get(cid, '')).lower() in ('pass', 'passed', 'ok', 'true', '1'):
446
+ steps_txt = json.dumps(c.get('steps', []), ensure_ascii=False)
447
+ if '/auth/login' in steps_txt or 'solve_captcha' in steps_txt:
448
+ login_passed = True
449
+ break
450
+ if login_passed:
451
+ base, _ = resolve_base_url(args.env, args.platform)
452
+ if base:
453
+ mark_logged_in(args.env, args.platform, base)
454
+ # 🧠 自动沉淀锚点 (测完就学会, 下次跳过LLM):
455
+ # ① 若传了 --page-json (read_page 结果) → 沉淀该页元素锚点
456
+ # ② 从通过的 intent 式用例里提取 goto URL + anchor → 沉淀 (用例自带锚点也存)
457
+ learned_pages = []
458
+ # page-json 方式: 每个 pj {"url":"...", "elements":[...]} 或元素数组
459
+ for pj_raw in (args.page_json or []):
460
+ try:
461
+ parsed = json.loads(pj_raw)
462
+ except Exception:
463
+ continue
464
+ if isinstance(parsed, dict) and 'url' in parsed:
465
+ url_pattern = _normalize_url_pattern(parsed.get('url', ''))
466
+ els = parsed.get('elements', parsed.get('elements', []))
467
+ elif isinstance(parsed, list):
468
+ url_pattern = '/auto'
469
+ els = parsed
470
+ else:
471
+ continue
472
+ new_n, _ = learn_page_anchors(url_pattern, els if isinstance(els, list) else [])
473
+ if new_n:
474
+ learned_pages.append('%s (+%d)' % (url_pattern, new_n))
475
+ # ② 从通过的 intent 用例提取 goto URL + anchor 自动沉淀
476
+ for c in cases:
477
+ cid = c.get('case_id', '')
478
+ if str(results.get(cid, '')).lower() not in ('pass', 'passed', 'ok', 'true', '1'):
479
+ continue # 只沉淀通过用例的锚点 (失败的锚点可能不准)
480
+ current_url = '/auto'
481
+ for s in c.get('steps') or []:
482
+ if not is_intent_step(s):
483
+ continue
484
+ it = s.get('intent', '')
485
+ if it == 'goto' and s.get('value'):
486
+ current_url = _normalize_url_pattern(s['value'])
487
+ elif it in ('click', 'fill') and s.get('anchor'):
488
+ # 用例自带的 anchor → 直接沉淀(执行时验证过有效)
489
+ desc = s.get('desc', '')
490
+ im = {desc: {'intent': it, 'anchor': s['anchor']}} if desc else None
491
+ new_n, _ = learn_page_anchors(current_url, [s], intent_map=im)
492
+ if new_n and current_url not in [p.split(' ')[0] for p in learned_pages]:
493
+ learned_pages.append('%s (+%d)' % (current_url, new_n))
494
+ print('=' * 60)
495
+ print('快速测试结果')
496
+ print('=' * 60)
497
+ print('总计 %d · 通过 %d · 失败 %d' % (total, passed, len(failed_ids)))
498
+ if failed_ids:
499
+ print('失败: %s' % ', '.join(failed_ids))
500
+ if login_passed:
501
+ print('✓ 登录用例通过 → 已标记 %s/%s 为已登录' % (args.env, args.platform))
502
+ print(' 后续测新功能: 直接 goto 目标页, 不用重复登录(验证码慢)。')
503
+ if learned_pages:
504
+ print('🧠 自动沉淀锚点: %s' % ', '.join(learned_pages))
505
+ print(' 下次测同页 recall 命中 跳过 LLM → 秒级。')
506
+ print('=' * 60)
507
+ return 0 if not failed_ids else 1
508
+
509
+ # 3. 解析环境
510
+ base, src = _resolve_env_platform(args)
511
+ if base is None:
512
+ return 1
513
+ args._base = base # _inject_and_check_data 用
514
+
515
+ # 4. 注入数据 + 校验
516
+ cases, ok = _inject_and_check_data(cases, args)
517
+ if not ok:
518
+ return 1
519
+
520
+ # 5. 打印执行计划 (登录态提示)
521
+ logged, login_time = is_logged_in(args.env, args.platform)
522
+ title = args.desc or ('快速测试 (%d 条)' % len(cases))
523
+ record_hint = ('python .qoder/scripts/autotest.py quick '
524
+ '--env %s --record \'{"%s":"pass"}\''
525
+ % (args.env, cases[0].get('case_id', 'Q-1')))
526
+ if logged:
527
+ print('=' * 60)
528
+ print('💡 已登录 %s/%s (%s) — QoderWork 会话保持中' % (args.env, args.platform, login_time))
529
+ print(' 浏览器 cookie 有效, 直接 goto 目标页即可, 不用重复登录!')
530
+ print(' (若已过期/被踢, login-state clear 再重新登录)')
531
+ print('=' * 60)
532
+ _print_execution_plan(cases, base, src, args.env, args.platform, title, record_hint)
533
+ return 0
534
+
535
+
536
+ def cmd_run(args):
537
+ task_dir = resolve_task_dir(args.task)
538
+ if not task_dir.is_dir():
539
+ print('任务不存在: %s' % args.task)
540
+ return 1
541
+ task = load_task_json(task_dir) or {}
542
+
543
+ # 1. 域名解析 (--url > --env > config > 报错)
544
+ base, src = resolve_base_url(args.env, args.platform, override=args.url)
545
+ if base is None:
546
+ print('✗ %s 环境未配 base_url, 无法执行测试。' % args.env)
547
+ print(' 解决方式 (任选其一):')
548
+ print(' 1. 命令行临时指定: --url https://你的地址')
549
+ print(' 2. 配 config.yaml: autotest.environments.%s.%s.base_url'
550
+ % (args.env, args.platform))
551
+ return 1
552
+
553
+ # 2. 取用例 (无则自动生成骨架)
554
+ cases = read_cases(task_dir)
555
+ if not cases:
556
+ print('未找到用例, 自动生成骨架...')
557
+ cases = generate_skeleton(task_dir, args.env, args.platform)
558
+ cases = read_cases(task_dir)
559
+
560
+ # 3. 指定 case_id 过滤
561
+ if args.case:
562
+ want = set(args.case)
563
+ cases = [c for c in cases if c.get('case_id') in want]
564
+ if not cases:
565
+ print('未匹配到指定 case_id: %s' % ', '.join(args.case))
566
+ print(' 已有用例: %s'
567
+ % ', '.join(c.get('case_id', '?')
568
+ for c in read_cases(task_dir)))
569
+ return 1
570
+
571
+ # 4. 占位符注入: 先 base_url, 再测试数据
572
+ cases = _inject_base_url(cases, base)
573
+ # 测试数据 = 个人文档(members/{dev}/autotest-data.yaml, gitignored 不共享)
574
+ # + 命令行 --data(覆盖)。按 env/platform 取最精确的值。
575
+ # ⚠️ 绝不从 config.yaml 读测试账号 —— config 是团队共用会 push, 放账号=泄漏。
576
+ local_data = load_local_test_data(env=args.env, platform=args.platform)
577
+ try:
578
+ cli_data = json.loads(args.data) if args.data else {}
579
+ except json.JSONDecodeError as e:
580
+ print('✗ --data 不是合法 JSON: %s' % e)
581
+ print(' 示例: --data \'{"登录账号":"test","登录密码":"123456"}\'')
582
+ return 1
583
+ merged_data = dict(local_data)
584
+ merged_data.update(cli_data) # 命令行覆盖个人预置
585
+ cases, missing = inject_data(cases, merged_data)
586
+
587
+ # 测试数据完整性检查: 有未注入的 {{ask:...}} → 列清单让 AI 问用户
588
+ if missing:
589
+ print('=' * 60)
590
+ print('⚠️ 需要测试数据才能执行 (%d 项)' % len(missing))
591
+ print('=' * 60)
592
+ print('以下用例引用了真实测试数据(账号/密码/手机号等), 不能编造:')
593
+ for i, desc in enumerate(missing, 1):
594
+ print(' %d. %s' % (i, desc))
595
+ print('-' * 60)
596
+ print('请向用户收集后, --data 传入:')
597
+ print(' --data \'{"%s":"<值>", "%s":"<值>"}\''
598
+ % (missing[0], missing[1] if len(missing) > 1 else missing[0]))
599
+ print()
600
+ print('常用账号预置到个人文档(不进 git, 团队看不到):')
601
+ print(' 先生成: python .qoder/scripts/autotest.py init-data')
602
+ print(' 再填: python .qoder/scripts/autotest.py set-data 登录账号 test')
603
+ return 1
604
+
605
+ # 5. 校验
606
+ ok, warns = validate_cases(cases)
607
+ has_skeleton = any('待补充' in (c.get('title', '') + c.get('expected', ''))
608
+ for c in cases)
609
+
610
+ # 6. 如果调用方已传入执行结果 (--record), 落历史并汇总
611
+ if args.record:
612
+ return _record_and_report(task_dir, args, base, src, args.record)
613
+
614
+ # 7. 探测 QoderWork, 输出执行指令 (核心: 增强层)
615
+ is_qw, qw_hint = _detect_qoderwork()
616
+
617
+ cases_path = _cases_path(task_dir)
618
+ print('=' * 60)
619
+ print('浏览器自动化测试 · 执行编排')
620
+ print('=' * 60)
621
+ print('任务: %s' % task.get('title', Path(task_dir).name))
622
+ print('环境: %s 平台: %s' % (args.env, args.platform))
623
+ print('目标: %s (来源: %s)' % (base, src))
624
+ print('用例: %d 条 → %s' % (len(cases), cases_path))
625
+ if has_skeleton:
626
+ print('⚠️ 含骨架占位用例 (标题/预期标"待补充"), 建议先补全再执行。')
627
+ if warns:
628
+ print('校验提醒:')
629
+ for w in warns[:10]:
630
+ print(' - ' + w)
631
+ print('-' * 60)
632
+
633
+ if is_qw:
634
+ print()
635
+ print('🧩 QoderWork 浏览器执行 (已检测到: %s)' % qw_hint)
636
+ print('请用 Browser Use / Computer 控制连接器, 按下列要求执行:')
637
+ print()
638
+ print('1. 打开浏览器, 访问: %s' % base)
639
+ print('2. 逐条执行 %s 中的用例 (共 %d 条):' % (CASES_FILENAME, len(cases)))
640
+ for c in cases:
641
+ print(' ▸ %s %s' % (c.get('case_id', '?'), c.get('title', '')))
642
+ for s in (c.get('steps') or [])[:6]:
643
+ print(' %s' % _fmt_step(s))
644
+ print(' 预期: %s' % c.get('expected', ''))
645
+ print()
646
+ print('3. 每条用例记录 pass/fail, 全部跑完后回传结果:')
647
+ print(' python .qoder/scripts/autotest.py run %s \\'
648
+ % Path(task_dir).name)
649
+ print(' --env %s --record \'{"AT-...-001":"pass",'
650
+ '"AT-...-002":"fail"}\'' % args.env)
651
+ print(' (或直接把结果贴给我, 我帮你落历史 + 出报告)')
652
+ else:
653
+ print()
654
+ print('[非 QoderWork 或未授权浏览器连接器 降级为人工核对]')
655
+ print('用例已生成, 请人工按下方清单逐项检查 (或复制到 QoderWork 执行):')
656
+ print()
657
+ print('目标地址: %s' % base)
658
+ print()
659
+ for c in cases:
660
+ print('□ %s %s' % (c.get('case_id', '?'), c.get('title', '')))
661
+ for s in (c.get('steps') or [])[:4]:
662
+ print(' %s' % _fmt_step(s))
663
+ print(' 预期: %s' % c.get('expected', ''))
664
+ print()
665
+ print('在 QoderWork 桌面端 (Settings Connectors 授权浏览器/Computer) ')
666
+ print('可让 Agent 自动执行; 结果用 --record 回传出报告。')
667
+ print('-' * 60)
668
+ return 0
669
+
670
+
671
+ def _record_and_report(task_dir, args, base, src, record_json):
672
+ """把执行结果落历史 jsonl 并打印汇总。"""
673
+ try:
674
+ results = json.loads(record_json)
675
+ except json.JSONDecodeError as e:
676
+ print('✗ --record 不是合法 JSON: %s' % e)
677
+ return 1
678
+ if not isinstance(results, dict):
679
+ print('✗ --record 需为 {case_id: "pass"|"fail"} 形式')
680
+ return 1
681
+
682
+ total = len(results)
683
+ passed = sum(1 for v in results.values()
684
+ if str(v).lower() in ('pass', 'passed', 'ok', 'true', '1'))
685
+ failed_ids = [k for k, v in results.items()
686
+ if str(v).lower() not in ('pass', 'passed', 'ok', 'true', '1')]
687
+ status = 'pass' if (total > 0 and not failed_ids) else (
688
+ 'fail' if failed_ids else 'none')
689
+
690
+ task = load_task_json(task_dir) or {}
691
+ record = {
692
+ 'time': datetime.now().isoformat(),
693
+ 'task': Path(task_dir).name,
694
+ 'task_title': task.get('title', ''),
695
+ 'env': args.env,
696
+ 'platform': args.platform,
697
+ 'base_url': base,
698
+ 'url_source': src,
699
+ 'total': total,
700
+ 'passed': passed,
701
+ 'failed': len(failed_ids),
702
+ 'status': status,
703
+ 'failed_cases': failed_ids,
704
+ 'runner': 'qoderwork-browser',
705
+ }
706
+
707
+ try:
708
+ from common.atomicio import atomic_append_jsonl
709
+ os.makedirs(os.path.dirname(HISTORY_PATH), exist_ok=True)
710
+ atomic_append_jsonl(HISTORY_PATH, record, timeout=10)
711
+ except Exception as e:
712
+ print('⚠ 历史记录写入失败 (不影响本次结果): %s' % e, file=sys.stderr)
713
+
714
+ print('=' * 60)
715
+ print('测试结果汇总')
716
+ print('=' * 60)
717
+ print('任务: %s' % record['task_title'])
718
+ print('环境: %s 目标: %s' % (args.env, base))
719
+ print('总计 %d · 通过 %d · 失败 %d · 状态: %s'
720
+ % (total, passed, len(failed_ids), status.upper()))
721
+ if failed_ids:
722
+ print('失败用例:')
723
+ for cid in failed_ids:
724
+ print(' ✗ %s' % cid)
725
+ print('-' * 60)
726
+ print('已记录到 %s' % HISTORY_PATH)
727
+ if status == 'fail':
728
+ print('建议: 查失败用例, 修复后重跑 /wl-test run %s --env %s'
729
+ % (Path(task_dir).name, args.env))
730
+ return 1
731
+ print('全部通过! 可 /wl-commit 提交, 或 /wl-report 出报告。')
732
+ return 0
733
+
734
+
735
+ # ---------------------------------------------------------------------------
736
+ # 子命令: list
737
+ # ---------------------------------------------------------------------------
738
+
739
+ def cmd_list(args):
740
+ task_dir = resolve_task_dir(args.task)
741
+ if not task_dir.is_dir():
742
+ print('任务不存在: %s' % args.task)
743
+ return 1
744
+ cases = read_cases(task_dir)
745
+ if not cases:
746
+ print('任务 %s 暂无用例。' % Path(task_dir).name)
747
+ print('生成: python .qoder/scripts/autotest.py generate %s'
748
+ % Path(task_dir).name)
749
+ return 0
750
+ task = load_task_json(task_dir) or {}
751
+ print('任务: %s' % task.get('title', Path(task_dir).name))
752
+ print('用例文件: %s' % _cases_path(task_dir))
753
+ print()
754
+ print('%-18s %-8s %-6s %s' % ('CASE_ID', 'ENV', 'PLAT', '标题'))
755
+ print('-' * 60)
756
+ for c in cases:
757
+ print('%-18s %-8s %-6s %s' % (
758
+ c.get('case_id', '?'),
759
+ c.get('env', '?'),
760
+ c.get('platform', '?'),
761
+ c.get('title', '')))
762
+ print('-' * 60)
763
+ print('共 %d ' % len(cases))
764
+ return 0
765
+
766
+
767
+ # ---------------------------------------------------------------------------
768
+ # 子命令: set-data / list-data (个人测试数据, 本地 gitignored)
769
+ # ---------------------------------------------------------------------------
770
+
771
+ def cmd_set_data(args):
772
+ """预置一条个人测试数据到本地文档(不进 git)。"""
773
+ env = getattr(args, 'env', None)
774
+ platform = getattr(args, 'platform', None)
775
+ save_local_test_data(args.key, args.value, env=env, platform=platform)
776
+ loc = ('%s.%s' % (env, platform)) if (env and platform) else (
777
+ env or platform or '通用(顶层)')
778
+ print('已保存到个人文档: %s' % local_data_path())
779
+ print(' %s = %s [位置: %s]' % (args.key, args.value, loc))
780
+ print()
781
+ print('🔒 此文档已被 .gitignore 覆盖 (workspace/members/*/autotest-data.yaml),')
782
+ print(' team_sync 不会 push, 团队/npm 包都看不到。')
783
+ print(' run 时自动注入用例的 {{ask:%s}} 占位。' % args.key)
784
+ if any(s in args.key for s in ('密码', '密', 'secret', 'token', '码')):
785
+ print(' (敏感数据, 存个人文档比每次对话明文输入更安全)')
786
+ return 0
787
+
788
+
789
+ def cmd_list_data(args):
790
+ """列出本地已预置的测试数据(可按 env/platform 过滤)。"""
791
+ env = getattr(args, 'env', None)
792
+ platform = getattr(args, 'platform', None)
793
+ p = local_data_path()
794
+ if not p.is_file():
795
+ print('个人测试数据文档不存在: %s' % p)
796
+ print()
797
+ print('生成模板: python .qoder/scripts/autotest.py init-data')
798
+ print('或直接预置: python .qoder/scripts/autotest.py set-data 登录账号 test')
799
+ return 0
800
+ data = load_local_test_data(env=env, platform=platform)
801
+ if not data:
802
+ scope = ('%s/%s' % (env, platform)) if (env and platform) else (
803
+ env or platform or '全部')
804
+ print('文档 %s %s 范围内无数据。' % (p, scope))
805
+ return 0
806
+ print('个人测试数据 (%d 项)' % len(data))
807
+ print('文档: %s' % p)
808
+ print('-' * 50)
809
+ for k, v in data.items():
810
+ show = v
811
+ if any(s in k for s in ('密码', '密', 'secret', 'token')):
812
+ show = '*' * min(len(str(v)), 8) if str(v) else '(空)'
813
+ print(' %-16s = %s' % (k, show))
814
+ print('-' * 50)
815
+ print('🔒 个人文档, 已 gitignore, 不会 push/共享。run 时自动注入 {{ask:描述}}。')
816
+ return 0
817
+
818
+
819
+ def cmd_init_data(args):
820
+ """生成带注释的个人测试数据模板文档(若不存在)。"""
821
+ path, created = init_local_data_doc()
822
+ if created:
823
+ print('已生成模板: %s' % path)
824
+ print()
825
+ print('打开它, 填入你的测试账号密码(例子已给)。每人只填自己的 members/{自己}/。')
826
+ print('🔒 gitignore, 不会 push/共享给团队。')
827
+ else:
828
+ print('文档已存在(不覆盖): %s' % path)
829
+ print('直接编辑它, 或用 set-data 命令追加。')
830
+ return 0
831
+
832
+
833
+ # ---------------------------------------------------------------------------
834
+ # 子命令: login-state (登录态: 已登录就别重复登录, 尤其验证码慢)
835
+ # ---------------------------------------------------------------------------
836
+
837
+ def cmd_login_state(args):
838
+ """查看/标记/清除 登录态。
839
+
840
+ QoderWork 保持同一个浏览器会话 登录一次后 cookie 不丢。
841
+ 后续测新功能直接 goto 目标页, 不用重复登录(数学题验证码很慢)。
842
+ """
843
+ action = args.action # show / mark / clear
844
+ env = getattr(args, 'env', None) or _default_env()
845
+ platform = getattr(args, 'platform', None) or 'web'
846
+
847
+ if action == 'mark':
848
+ base, src = resolve_base_url(env, platform)
849
+ if not base:
850
+ print('✗ %s/%s 未配域名, 无法标记登录。' % (env, platform))
851
+ return 1
852
+ mark_logged_in(env, platform, base)
853
+ print('已标记 %s/%s 为已登录 (浏览器会话保持中, cookie 有效)。' % (env, platform))
854
+ print('🔒 存个人文档 (gitignored), 不会 push。')
855
+ print()
856
+ print('后续 quick/browser 测新功能: 直接 goto 目标页, 不用再登录。')
857
+ return 0
858
+
859
+ if action == 'clear':
860
+ clear_login_state(env if env and env != 'all' else None,
861
+ platform if env != 'all' else None)
862
+ print('已清除登录态: %s/%s。' % (env, platform))
863
+ print('下次测试会重新走登录流程。')
864
+ return 0
865
+
866
+ # show (默认)
867
+ state = load_login_state()
868
+ if not state:
869
+ print('未记录任何登录态。')
870
+ print('登录用例通过后自动标记, 或手动: login-state mark --env %s --platform %s'
871
+ % (env, platform))
872
+ return 0
873
+ print('已记录登录态 (QoderWork 会话保持中, 后续测新功能可直接 goto 目标页):')
874
+ print('-' * 50)
875
+ for e, plats in state.items():
876
+ for pf, info in (plats or {}).items():
877
+ t = (info or {}).get('time', '?')[:19].replace('T', ' ')
878
+ url = (info or {}).get('url', '')
879
+ print(' %s/%s 已登录 (%s) %s' % (e, pf, t, url))
880
+ print('-' * 50)
881
+ print('🔒 个人文档, gitignored, 不 push。清除: login-state clear --env <环境>')
882
+ return 0
883
+
884
+
885
+ # ---------------------------------------------------------------------------
886
+ # 子命令: learn / recall (页面锚点沉淀 + 查询, A方案核心)
887
+ # ---------------------------------------------------------------------------
888
+
889
+ def cmd_learn(args):
890
+ """把 read_page 返回的页面元素沉淀成稳定锚点 → data/index/test-pages.json。
891
+
892
+ AI 执行 observe(read_page) 后, 把元素 JSON 回传, 本命令提取稳定锚点存盘。
893
+ 下次 recall 同页直接命中锚点, 跳过 LLM 匹配(Stagehand 式 action 缓存)。
894
+ """
895
+ url_pattern = _normalize_url_pattern(args.url, getattr(args, '_base', None))
896
+ # 解析 page-json (read_page 返回的元素数组, 或 {elements:[...]})
897
+ try:
898
+ parsed = json.loads(args.page_json)
899
+ except json.JSONDecodeError as e:
900
+ print('✗ --page-json 不是合法 JSON: %s' % e)
901
+ return 1
902
+ if isinstance(parsed, dict):
903
+ elements = parsed.get('elements') or parsed.get('elements') or []
904
+ elif isinstance(parsed, list):
905
+ elements = parsed
906
+ else:
907
+ print('✗ --page-json 应为元素数组或含 elements 的对象')
908
+ return 1
909
+ # 可选 intent-map
910
+ intent_map = None
911
+ if args.intent_map:
912
+ try:
913
+ intent_map = json.loads(args.intent_map)
914
+ except json.JSONDecodeError as e:
915
+ print('⚠ --intent-map 不是合法 JSON, 忽略: %s' % e)
916
+ new_n, total_n = learn_page_anchors(url_pattern, elements, intent_map=intent_map)
917
+ print('=' * 56)
918
+ print('锚点沉淀 data/index/test-pages.json')
919
+ print('=' * 56)
920
+ print('页面: %s' % url_pattern)
921
+ print('本次新增 %d 个锚点, 该页累计 %d 个' % (new_n, total_n))
922
+ print('🔒 团队共享(随 team_sync 分发), 只存 role/name/placeholder, 不含密码/ref。')
923
+ print()
924
+ print('下次 recall %s 即可直接用锚点生成用例, 跳过 LLM 匹配。' % url_pattern)
925
+ return 0
926
+
927
+
928
+ def cmd_recall(args):
929
+ """查某页已沉淀的锚点。AI 生成用例前查它, 有锚点就直接填进 step。"""
930
+ url_pattern = _normalize_url_pattern(args.url)
931
+ anchors = recall_page_anchors(url_pattern)
932
+ print('=' * 56)
933
+ print('已沉淀锚点: %s' % url_pattern)
934
+ print('=' * 56)
935
+ if not anchors:
936
+ print('该页暂无锚点。执行 observe(read_page) 后用 learn 沉淀:')
937
+ print(' python .qoder/scripts/autotest.py learn --url %s --page-json \'<read_page结果>\'' % url_pattern)
938
+ return 0
939
+ print('%-10s %-22s %s' % ('INTENT', 'ANCHOR', '描述'))
940
+ print('-' * 56)
941
+ for a in anchors:
942
+ print('%-10s %-22s %s' % (
943
+ a.get('intent', '?'),
944
+ _fmt_anchor(a.get('anchor')),
945
+ a.get('desc', '')[:30]))
946
+ print('-' * 56)
947
+ print('共 %d 个锚点。生成用例时可直接填入 step.anchor, 跳过 LLM 匹配。' % len(anchors))
948
+ return 0
949
+
950
+
951
+ # ---------------------------------------------------------------------------
952
+ # 子命令: page (页面按钮+接口画像, 开发→测试交接物)
953
+ # ---------------------------------------------------------------------------
954
+
955
+ def cmd_page(args):
956
+ """查页面按钮+接口画像。薄入口, 实际逻辑在 page_probe.py。
957
+ 数据来自知识图谱 traces 表, 无图谱则静默提示。
958
+ """
959
+ try:
960
+ import page_probe
961
+ except ImportError:
962
+ # 直接当脚本跑时, 补一下 sys.path
963
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
964
+ import page_probe
965
+
966
+ profile = page_probe.build_page_profile(keyword=getattr(args, 'kw', None),
967
+ url=getattr(args, 'url', None))
968
+ label = args.kw or args.url
969
+ print('## 页面画像: %s' % label)
970
+ print()
971
+ print(page_probe.render_profile_md(profile))
972
+ if profile.get('ok'):
973
+ n_pages = len(profile['pages'])
974
+ n_btns = sum(len(pg['buttons']) for pg in profile['pages'])
975
+ print()
976
+ print('[page] %d 个页面, %d 个交互点' % (n_pages, n_btns))
977
+ return 0
978
+
979
+
980
+ # ---------------------------------------------------------------------------
981
+ # 子命令: report
982
+ # ---------------------------------------------------------------------------
983
+
984
+ def cmd_report(args):
985
+ task_dir = resolve_task_dir(args.task)
986
+ task_name = Path(task_dir).name
987
+ task = load_task_json(task_dir) or {}
988
+
989
+ if not os.path.isfile(HISTORY_PATH):
990
+ print('暂无测试执行历史 (%s)' % HISTORY_PATH)
991
+ print('先跑: python .qoder/scripts/autotest.py run %s --env test'
992
+ % task_name)
993
+ return 0
994
+
995
+ # 读该任务的全部历史 (倒序)
996
+ records = []
997
+ with open(HISTORY_PATH, encoding='utf-8') as f:
998
+ for line in f:
999
+ line = line.strip()
1000
+ if not line:
1001
+ continue
1002
+ try:
1003
+ r = json.loads(line)
1004
+ except json.JSONDecodeError:
1005
+ continue
1006
+ if r.get('task') == task_name:
1007
+ records.append(r)
1008
+ if not records:
1009
+ print('任务 %s 无执行记录。' % task_name)
1010
+ return 0
1011
+
1012
+ records.sort(key=lambda r: r.get('time', ''), reverse=True)
1013
+ print('=' * 60)
1014
+ print('测试历史 · %s' % task.get('title', task_name))
1015
+ print('=' * 60)
1016
+ print('%-20s %-6s %-7s %-5s %s' % (
1017
+ '时间', 'ENV', '状态', 'P/F', '目标'))
1018
+ print('-' * 60)
1019
+ for r in records[:20]:
1020
+ t = r.get('time', '')[:19].replace('T', ' ')
1021
+ print('%-20s %-6s %-7s %d/%d %s' % (
1022
+ t,
1023
+ r.get('env', '?'),
1024
+ r.get('status', '?').upper(),
1025
+ r.get('passed', 0),
1026
+ r.get('total', 0),
1027
+ r.get('base_url', '')))
1028
+ print('-' * 60)
1029
+ total_runs = len(records)
1030
+ pass_runs = sum(1 for r in records if r.get('status') == 'pass')
1031
+ print('历史 %d 次执行 · 通过率 %.0f%% (最近 %d 次)'
1032
+ % (total_runs, 100 * pass_runs / total_runs if total_runs else 0,
1033
+ min(total_runs, 20)))
1034
+ last = records[0]
1035
+ if last.get('failed_cases'):
1036
+ print('最近一次失败用例: %s' % ', '.join(last['failed_cases']))
1037
+ return 0
1038
+
1039
+
1040
+ # ---------------------------------------------------------------------------
1041
+ # argparse
1042
+ # ---------------------------------------------------------------------------
1043
+
1044
+ def build_parser():
1045
+ p = argparse.ArgumentParser(
1046
+ prog='autotest.py',
1047
+ description='浏览器自动化测试: 用例生成 + 运行编排 (QoderWork 浏览器执行, 纯 Qoder 降级人工)',
1048
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1049
+ )
1050
+ sub = p.add_subparsers(dest='cmd')
1051
+
1052
+ def add_env_platform(sp):
1053
+ sp.add_argument('--env', default=_default_env(),
1054
+ choices=SUPPORTED_ENVS,
1055
+ help='环境 (test/pre/prod), 默认 %(default)s')
1056
+ sp.add_argument('--platform', default='web',
1057
+ choices=SUPPORTED_PLATFORMS,
1058
+ help='平台 (web=管理端 / app=移动端), 默认 %(default)s')
1059
+
1060
+ pg = sub.add_parser('generate', help='从任务生成测试用例骨架')
1061
+ pg.add_argument('task', help='任务名/路径 (如 06-14-login)')
1062
+ add_env_platform(pg)
1063
+ pg.set_defaults(func=cmd_generate)
1064
+
1065
+ pr = sub.add_parser('run', help='生成(若无)+输出浏览器执行指令/人工清单')
1066
+ pr.add_argument('task', help='任务名/路径')
1067
+ add_env_platform(pr)
1068
+ pr.add_argument('--url', default=None,
1069
+ help='临时覆盖目标域名 (优先于 --env 配置)')
1070
+ pr.add_argument('--case', nargs='*', default=None,
1071
+ help='只执行指定 case_id (可多个)')
1072
+ pr.add_argument('--data', default=None,
1073
+ help='测试数据 JSON, 注入 {{ask:描述}} 占位, '
1074
+ ' \'{"登录账号":"test","登录密码":"123456"}\'')
1075
+ pr.add_argument('--record', default=None,
1076
+ help='回传执行结果 JSON, \'{"AT-x-001":"pass"}\'')
1077
+ pr.set_defaults(func=cmd_run)
1078
+
1079
+ pq = sub.add_parser('quick', help='快速测试: 不需任务/PRD, AI 直接据描述生成并执行')
1080
+ pq.add_argument('--cases', default=None,
1081
+ help='JSON 用例 (单条对象或数组), AI 据用户描述生成后传入')
1082
+ pq.add_argument('--cases-file', dest='cases_file', default=None,
1083
+ help='用例 JSON 文件路径(推荐, 不截断)。AI 写文件后传路径')
1084
+ pq.add_argument('--desc', default=None,
1085
+ help='本次快速测试的描述/标题 (可选)')
1086
+ add_env_platform(pq)
1087
+ pq.add_argument('--url', default=None, help='临时覆盖目标域名')
1088
+ pq.add_argument('--data', default=None, help='测试数据 JSON, 注入 {{ask:描述}}')
1089
+ pq.add_argument('--record', default=None, help='回传执行结果 JSON')
1090
+ pq.add_argument('--page-json', nargs='*', default=None,
1091
+ help='read_page 返回的页面元素 JSON(多个), --record 时自动沉淀锚点')
1092
+ pq.set_defaults(func=cmd_quick)
1093
+
1094
+ pl = sub.add_parser('list', help='列出任务已有用例')
1095
+ pl.add_argument('task', help='任务名/路径')
1096
+ pl.set_defaults(func=cmd_list)
1097
+
1098
+ pr2 = sub.add_parser('report', help='查看任务测试执行历史')
1099
+ pr2.add_argument('task', help='任务名/路径')
1100
+ pr2.set_defaults(func=cmd_report)
1101
+
1102
+ psd = sub.add_parser('set-data', help='预置个人测试数据到个人文档(gitignored, 不共享)')
1103
+ psd.add_argument('key', help='数据名(对应用例 {{ask:数据名}} 占位), 如 登录账号')
1104
+ psd.add_argument('value', help='数据值, test')
1105
+ psd.add_argument('--env', default=None, choices=SUPPORTED_ENVS,
1106
+ help='只对该环境生效( test/pre/prod)')
1107
+ psd.add_argument('--platform', default=None, choices=SUPPORTED_PLATFORMS,
1108
+ help='只对该平台生效(web/app)')
1109
+ psd.set_defaults(func=cmd_set_data)
1110
+
1111
+ pld = sub.add_parser('list-data', help='列出个人已预置的测试数据(可按 env/platform 过滤)')
1112
+ pld.add_argument('--env', default=None, choices=SUPPORTED_ENVS)
1113
+ pld.add_argument('--platform', default=None, choices=SUPPORTED_PLATFORMS)
1114
+ pld.set_defaults(func=cmd_list_data)
1115
+
1116
+ pid = sub.add_parser('init-data', help='生成个人测试数据模板文档(members/{dev}/autotest-data.yaml)')
1117
+ pid.set_defaults(func=cmd_init_data)
1118
+
1119
+ pls = sub.add_parser('login-state', help='登录态: 已登录就别重复登录(QoderWork 会话保持)')
1120
+ pls.add_argument('action', nargs='?', default='show',
1121
+ choices=['show', 'mark', 'clear'],
1122
+ help='show=查看 / mark=标记已登录 / clear=清除(强制重新登录)')
1123
+ add_env_platform(pls)
1124
+ pls.set_defaults(func=cmd_login_state)
1125
+
1126
+ ple = sub.add_parser('learn', help='沉淀页面锚点到 test-pages.json(observe后调, 团队共享)')
1127
+ ple.add_argument('--url', required=True, help='页面 URL 或路径(如 /auth/login 或完整URL)')
1128
+ ple.add_argument('--page-json', required=True,
1129
+ help="read_page 返回的元素 JSON (数组 或 {elements:[...]})")
1130
+ ple.add_argument('--intent-map', default=None,
1131
+ help='可选: {元素描述:{intent,anchor}} 标注意图')
1132
+ ple.set_defaults(func=cmd_learn)
1133
+
1134
+ prc = sub.add_parser('recall', help='查某页已沉淀的锚点(生成用例前查, 跳过LLM匹配)')
1135
+ prc.add_argument('--url', required=True, help='页面 URL 或路径(如 /auth/login)')
1136
+ prc.set_defaults(func=cmd_recall)
1137
+
1138
+ # page: 查页面按钮+接口画像 (开发→测试交接物, 数据来自知识图谱 traces 表)
1139
+ ppg = sub.add_parser('page', help='查页面按钮+接口画像(开发交接给测试,照着写用例)')
1140
+ ppg_grp = ppg.add_mutually_exclusive_group(required=True)
1141
+ ppg_grp.add_argument('--kw', help='业务关键词(如 保险 / 排班)')
1142
+ ppg_grp.add_argument('--url', help='页面 URL 或路径关键词(如 /veh/insurance)')
1143
+ ppg.set_defaults(func=cmd_page)
1144
+
1145
+ return p
1146
+
1147
+
1148
+ def _default_env():
1149
+ """config.yaml autotest.default_env, 缺失回退 test。"""
1150
+ try:
1151
+ return load_config_section('autotest').get('default_env') or 'test'
1152
+ except Exception:
1153
+ return 'test'
1154
+
1155
+
1156
+ def main(argv=None):
1157
+ parser = build_parser()
1158
+ args = parser.parse_args(argv)
1159
+ if not getattr(args, 'cmd', None):
1160
+ parser.print_help()
1161
+ return 1
1162
+ try:
1163
+ return args.func(args)
1164
+ except RuntimeError as e:
1165
+ # 身份缺失等 (多人共用 workspace 时的隔离保障)
1166
+ print('✗ %s' % e)
1167
+ return 1
1168
+ except (ValueError, FileNotFoundError) as e:
1169
+ print(' %s' % e)
1170
+ return 1
1171
+
1172
+
1173
+ if __name__ == '__main__':
1174
+ sys.exit(main())