@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,297 +1,309 @@
1
- # -*- coding: utf-8 -*-
2
- """autotest_run.py 真功能测试执行器 (ZCode 直接驱动 Playwright).
3
- 用法: python autotest_run.py --task <任务文件>
4
- 任务文件 JSON: {
5
- "page_path": "/veh/vehicle/vehAffair/insurance",
6
- "page_name": "保险管理",
7
- "cases": [ {case_id, title, steps:[{intent,desc,value,anchor}], expected, assert_fn} ]
8
- }
9
- --task 是必须的 (绕过 MSYS 路径转换; /xxx 会被 bash 转成 Windows 路径)
10
-
11
- 真测: 每步真实驱动浏览器 (goto/fill/click/extract/assert), 按断言判定 pass/fail.
12
- 覆盖与 QoderWork wl-test 相同的工作流 (路由查询→登录态→意图式用例→执行→沉淀).
13
- """
14
- import json, sys, time, os, re, argparse, subprocess
15
- from playwright.sync_api import sync_playwright
16
-
17
- # ===== 配置 (路径自检测) =====
18
- SCRIPTS = os.path.dirname(os.path.abspath(__file__))
19
- PROJECT = os.path.normpath(os.path.join(SCRIPTS, '..', '..'))
20
-
21
-
22
- def _read_developer():
23
- """读 .qoder/.developer 拿当前开发者, 身份隔离用 (绝不 fallback, 防串号)."""
24
- dev_file = os.path.join(PROJECT, '.qoder', '.developer')
25
- if os.path.isfile(dev_file):
26
- for ln in open(dev_file, encoding='utf-8'):
27
- if ln.strip().startswith('name='):
28
- return ln.strip().split('=', 1)[1].strip()
29
- raise RuntimeError('未检测到开发者身份(.qoder/.developer)。请先运行 /wl-init。')
30
-
31
-
32
- DEVELOPER = _read_developer()
33
- MEMBER_DIR = os.path.join(PROJECT, 'workspace', 'members', DEVELOPER)
34
- AUTH_FILE = os.path.join(MEMBER_DIR, 'auth-state.json')
35
- SECURELS_JS = os.path.join(SCRIPTS, 'secure-ls.js')
36
- AUTOLEARN = os.path.join(SCRIPTS, 'autotest.py')
37
- BASE = 'https://icsqas.inforecloud.com'
38
- ACCESS_KEY = 'ICS-1.3.8-prod-core-access'
39
- SECRET = 'please-replace-me-with-your-own-key'
40
-
41
- # ===== 登录态: 读 token, 失效则调 kg_auto_login.py =====
42
- def get_token():
43
- if os.path.exists(AUTH_FILE):
44
- tok = json.load(open(AUTH_FILE, encoding='utf-8')).get('access_token')
45
- # 校验
46
- import requests
47
- try:
48
- r = requests.get(BASE+'/test-api/system/user/getInfo',
49
- headers={'Authorization':'Bearer '+tok,'clientid':'e5cd7e4891bf95d1d19206ce24a7b32e'}, timeout=8)
50
- if r.json().get('code')==200: return tok
51
- except Exception: pass
52
- # 重新登录
53
- subprocess.run([sys.executable, os.path.join(SCRIPTS,'kg_auto_login.py')], capture_output=True, timeout=120)
54
- if os.path.exists(AUTH_FILE):
55
- return json.load(open(AUTH_FILE, encoding='utf-8')).get('access_token')
56
- return None
57
-
58
- # ===== SPA 会话: 注入 token + 初始化菜单 =====
59
- def init_session(page, token):
60
- page.goto(BASE+'/auth/login', wait_until='domcontentloaded'); time.sleep(1.2)
61
- page.add_script_tag(path=SECURELS_JS); time.sleep(0.3)
62
- page.evaluate('''(a)=>{const[t,k,s]=a;const S=window.SecureLS.default||window.SecureLS;const ls=new S({encodingType:'aes',isCompression:true,encryptionSecret:s});ls.set(k,{accessToken:t,refreshToken:t,accessCodes:[],originMenus:[],webMenus:[],accessMenus:[],accessRoutes:[],isAccessChecked:false,loginExpired:false});}''',[token,ACCESS_KEY,SECRET])
63
- page.goto(BASE+'/dashboard/workspace', wait_until='domcontentloaded')
64
- for _ in range(15):
65
- if '加载菜单中' not in page.evaluate('()=>document.body.innerText'): return True
66
- time.sleep(1)
67
- return False
68
-
69
- def _wait_table_stable(page, max_wait=8):
70
- """轮询表格行数, SPA 异步加载完成.
71
- 结束条件: 行数>0 (有数据) 或 行数连续3次为0 (确实无表格/无数据) 或 超时."""
72
- deadline = time.time() + max_wait
73
- zero_streak = 0
74
- last = -1
75
- while time.time() < deadline:
76
- try:
77
- n = page.locator('.vxe-body--row').count()
78
- except Exception:
79
- n = 0
80
- if n > 0:
81
- # 有数据了, 再确认一次稳定 (避免loading中间态)
82
- time.sleep(0.6)
83
- try:
84
- if page.locator('.vxe-body--row').count() >= n:
85
- return n
86
- except Exception:
87
- return n
88
- else:
89
- zero_streak += 1
90
- if zero_streak >= 3:
91
- return 0 # 连续3次0, 认为确实无表格数据
92
- time.sleep(0.5)
93
- return last
94
-
95
- # ===== 锚点定位: 优先 recall, 否则按 anchor 字段匹配 =====
96
- def locate(page, anchor, desc=''):
97
- """根据 anchor {role,name,placeholder,field,text} 定位元素, 返回 locator 或 None."""
98
- if not anchor: return None
99
- role = anchor.get('role','')
100
- name = anchor.get('name','')
101
- ph = anchor.get('placeholder','')
102
- field = anchor.get('field','')
103
- sels = []
104
- if field: sels.append("input[name='%s']" % field)
105
- if ph: sels.append("input[placeholder*='%s']" % ph)
106
- if role == 'tab' and name: sels.append(".ant-tabs-tab:has-text('%s')" % name)
107
- if role == 'button' and name: sels.append("button:has-text('%s')" % name)
108
- if role == 'textbox' and name: sels.append("input[placeholder*='%s']" % name)
109
- for sel in sels:
110
- loc = page.locator(sel).first
111
- try:
112
- if loc.count() > 0: return loc
113
- except Exception: pass
114
- # 表单字段可能异步渲染, 轮询等待最多 5s
115
- deadline = time.time() + 5
116
- while time.time() < deadline:
117
- for sel in sels:
118
- try:
119
- if page.locator(sel).first.count() > 0:
120
- return page.locator(sel).first
121
- except Exception: pass
122
- time.sleep(0.4)
123
- return None
124
-
125
- # ===== 单步执行 =====
126
- def exec_step(page, step, timeout=8000):
127
- """执行一步, 返回 (ok, detail)."""
128
- intent = step.get('intent','')
129
- desc = step.get('desc','')
130
- anchor = step.get('anchor', {}) or {}
131
- val = step.get('value','')
132
- try:
133
- if intent == 'goto':
134
- url = BASE + val if val.startswith('/') else val
135
- page.goto(url, wait_until='domcontentloaded', timeout=12000); time.sleep(2)
136
- # 等待表格数据加载完成 (列表页核心): 轮询直到行数>0 或稳定或超时
137
- _wait_table_stable(page, max_wait=8)
138
- return True, 'navigated'
139
- if intent == 'fill':
140
- loc = locate(page, anchor, desc)
141
- if not loc: return False, '元素未找到(%s)' % desc
142
- # value 可能是 {{ask:xxx}} 占位, 调用方应已替换; 这里直接填
143
- loc.fill(str(val), timeout=timeout); time.sleep(0.3)
144
- return True, 'filled'
145
- if intent == 'click':
146
- loc = locate(page, anchor, desc)
147
- if not loc: return False, '元素未找到(%s)' % desc
148
- loc.click(timeout=timeout); time.sleep(1.5)
149
- return True, 'clicked'
150
- if intent == 'observe':
151
- # 只读页面, 总是成功, 返回关键信息供后续断言
152
- return True, 'observed'
153
- if intent == 'extract':
154
- # 语义断言: body 文本是否含 expected 关键词, 或满足 step.check
155
- body = page.evaluate('()=>document.body.innerText')
156
- check = step.get('check','') # 期望出现的关键词
157
- if check:
158
- return (check in body), ('命中:%s' % check if check in body else '未命中:%s'%check)
159
- return True, 'extracted(no check)'
160
- if intent == 'assert':
161
- # 确定性断言: step.check 是 JS 表达式或条件
162
- check = step.get('check','')
163
- if check.startswith('rows>'):
164
- n = int(check[5:])
165
- _wait_table_stable(page, max_wait=6)
166
- rows = page.locator('.vxe-body--row').count()
167
- return rows>n, ('行数%d>%d'%(rows,n) if rows>n else '行数%d<=%d'%(rows,n))
168
- if check.startswith('rows=='):
169
- n = int(check[6:])
170
- _wait_table_stable(page, max_wait=6)
171
- rows = page.locator('.vxe-body--row').count()
172
- return rows==n, '行数=%d'%rows
173
- if check.startswith('url_contains'):
174
- cur = page.url
175
- kw = check.split(':',1)[1] if ':' in check else ''
176
- return (kw in cur), ('url含%s'%kw if kw in cur else 'url不含%s'%kw)
177
- if check.startswith('input_empty:'):
178
- field = check.split(':',1)[1]
179
- v = page.locator("input[name='%s']"%field).first.input_value()
180
- return v=='', '框值=%r'%v
181
- return True, 'assert(no check)'
182
- except Exception as e:
183
- return False, str(e)[:60]
184
- return False, '未知intent:%s' % intent
185
-
186
- # ===== 跑一个用例 =====
187
- def run_case(page, case):
188
- cid = case.get('case_id','?')
189
- steps = case.get('steps',[])
190
- details = []
191
- all_ok = True
192
- for step in steps:
193
- ok, detail = exec_step(page, step)
194
- details.append('%s:%s(%s)' % (step.get('intent','?'), '✓' if ok else '✗', detail))
195
- if not ok:
196
- all_ok = False
197
- break # 失败即停 (前置步骤失败后续无意义)
198
- return {'case_id': cid, 'title': case.get('title',''), 'pass': all_ok,
199
- 'expected': case.get('expected',''), 'details': details}
200
-
201
- # ===== 沉淀锚点 =====
202
- def sediment(page_path, anchors):
203
- if not anchors: return 0
204
- try:
205
- pj = json.dumps({'url':page_path,'elements':[
206
- {'role':a.get('role'),'name':a.get('name'),'desc':a.get('name')} for a in anchors
207
- ]}, ensure_ascii=False)
208
- subprocess.run([sys.executable, AUTOLEARN, 'learn', '--url', page_path, '--page-json', pj],
209
- capture_output=True, timeout=20)
210
- return len(anchors)
211
- except Exception:
212
- return 0
213
-
214
- def collect_anchors(page):
215
- try:
216
- els = page.evaluate('''()=>{const out=[];document.querySelectorAll('button,.ant-tabs-tab,input[placeholder],input[name],[role=tab]').forEach(el=>{const name=(el.innerText||el.getAttribute('placeholder')||el.getAttribute('name')||'').trim();const role=el.getAttribute('role')||(el.tagName==='INPUT'?'textbox':el.tagName.toLowerCase());if(name&&name.length<25&&!/^[0-9\s]+$/.test(name))out.push({role,name});});return out.slice(0,30);}''')
217
- seen=set(); uniq=[]
218
- for el in els:
219
- k=(el['role'],el['name'])
220
- if k not in seen: seen.add(k); uniq.append(el)
221
- return uniq
222
- except Exception:
223
- return []
224
-
225
- # ===== 主 =====
226
- def main():
227
- ap = argparse.ArgumentParser()
228
- ap.add_argument('--task', required=True, help='任务文件 JSON')
229
- args = ap.parse_args()
230
- task = json.load(open(args.task, encoding='utf-8'))
231
- page_path = task['page_path']
232
- cases = task.get('cases', [])
233
-
234
- result = {'page': page_path, 'page_name': task.get('page_name',''),
235
- 'case_results': [], 'anchors_count': 0, 'error': ''}
236
-
237
- TOKEN = get_token()
238
- if not TOKEN:
239
- result['error'] = 'LOGIN_FAIL'; print(json.dumps(result, ensure_ascii=False)); sys.exit(1)
240
-
241
- # 录屏目录
242
- video_dir = os.path.join(MEMBER_DIR, 'drafts', 'videos')
243
- os.makedirs(video_dir, exist_ok=True)
244
-
245
- with sync_playwright() as pw:
246
- b = pw.chromium.launch(headless=True)
247
- ctx = b.new_context(locale='zh-CN', viewport={'width':1440,'height':900},
248
- record_video_dir=video_dir)
249
- page = ctx.new_page()
250
- page.set_default_timeout(12000)
251
- try:
252
- if not init_session(page, TOKEN):
253
- result['error']='INIT_FAIL'; print(json.dumps(result,ensure_ascii=False)); b.close(); sys.exit(1)
254
- # goto 目标页
255
- page.goto(BASE+page_path, wait_until='domcontentloaded'); time.sleep(2.5)
256
- # 等表格
257
- for _ in range(10):
258
- if page.locator('.vxe-body--row').count()>0: break
259
- time.sleep(0.5)
260
- # 跑每个用例
261
- for case in cases:
262
- # 每个用例前重新 goto 保证干净状态
263
- page.goto(BASE+page_path, wait_until='domcontentloaded'); time.sleep(2)
264
- cr = run_case(page, case)
265
- result['case_results'].append(cr)
266
- tag = '✓' if cr['pass'] else '✗'
267
- print(' %s %s %s' % (tag, cr['case_id'], cr['title'][:30]))
268
- for d in cr['details']: print(' %s' % d)
269
- # 收集沉淀
270
- anc = collect_anchors(page)
271
- result['anchors_count'] = len(anc)
272
- sediment(page_path, anc)
273
- except Exception as e:
274
- result['error'] = str(e)[:100]
275
- # 关 page 触发视频落盘, 再取路径
276
- try:
277
- video_path = page.video.path() if page.video else None
278
- except Exception:
279
- video_path = None
280
- ctx.close()
281
- b.close()
282
- result['video'] = video_path or ''
283
-
284
- passed = sum(1 for c in result['case_results'] if c['pass'])
285
- total = len(result['case_results'])
286
- result['summary'] = '%d/%d 通过' % (passed, total)
287
- print()
288
- print('汇总: %s 锚点%d %s' % (result['summary'], result['anchors_count'], result['error']))
289
- if result.get('video'):
290
- print('录屏: %s' % result['video'])
291
- # 结果写文件
292
- out = args.task.replace('.json','_run.json')
293
- json.dump(result, open(out,'w',encoding='utf-8'), ensure_ascii=False, indent=2)
294
- print('结果: %s' % out)
295
-
296
- if __name__ == '__main__':
297
- main()
1
+ # -*- coding: utf-8 -*-
2
+ # v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
3
+ import os as _o, sys as _s
4
+ _cp = _o.path.join(_o.path.dirname(_o.path.dirname(_o.path.abspath(__file__))), 'common')
5
+ if _cp not in _s.path: _s.path.insert(0, _cp)
6
+ from bootstrap import setup; setup()
7
+
8
+ """autotest_run.py — 真功能测试执行器 (ZCode 直接驱动 Playwright).
9
+ 用法: python autotest_run.py --task <任务文件>
10
+ 任务文件 JSON: {
11
+ "page_path": "/veh/vehicle/vehAffair/insurance",
12
+ "page_name": "保险管理",
13
+ "cases": [ {case_id, title, steps:[{intent,desc,value,anchor}], expected, assert_fn} ]
14
+ }
15
+ --task 是必须的 (绕过 MSYS 路径转换; /xxx 会被 bash 转成 Windows 路径)
16
+
17
+ 真测: 每步真实驱动浏览器 (goto/fill/click/extract/assert), 按断言判定 pass/fail.
18
+ 覆盖与 QoderWork wl-test 相同的工作流 (路由查询→登录态→意图式用例→执行→沉淀).
19
+ """
20
+ import json, sys, time, os, re, argparse, subprocess
21
+ from playwright.sync_api import sync_playwright
22
+
23
+ # ===== 配置 (路径自检测) =====
24
+ SCRIPTS = os.path.dirname(os.path.abspath(__file__))
25
+ PROJECT = os.path.normpath(os.path.join(SCRIPTS, '..', '..'))
26
+
27
+
28
+ def _read_developer():
29
+ """读 .qoder/.developer 拿当前开发者, 身份隔离用 (绝不 fallback, 防串号)."""
30
+ dev_file = os.path.join(PROJECT, '.qoder', '.developer')
31
+ if os.path.isfile(dev_file):
32
+ for ln in open(dev_file, encoding='utf-8'):
33
+ if ln.strip().startswith('name='):
34
+ return ln.strip().split('=', 1)[1].strip()
35
+ raise RuntimeError('未检测到开发者身份(.qoder/.developer)。请先运行 /wl-init。')
36
+
37
+
38
+ DEVELOPER = _read_developer()
39
+ MEMBER_DIR = os.path.join(PROJECT, 'workspace', 'members', DEVELOPER)
40
+ AUTH_FILE = os.path.join(MEMBER_DIR, 'auth-state.json')
41
+ SECURELS_JS = os.path.join(SCRIPTS, 'secure-ls.js')
42
+ AUTOLEARN = os.path.join(SCRIPTS, 'autotest.py')
43
+ BASE = 'https://icsqas.inforecloud.com'
44
+ ACCESS_KEY = 'ICS-1.3.8-prod-core-access'
45
+ SECRET = 'please-replace-me-with-your-own-key'
46
+
47
+ # ===== 登录态: 读 token, 失效则调 kg_auto_login.py =====
48
+ # v3.0: 从 common.autotest_auth 复用 (单一真源, 消除 3 处重复)
49
+ try:
50
+ sys.path.insert(0, SCRIPTS)
51
+ from common.autotest_auth import get_token as _get_token_shared
52
+ def get_token():
53
+ return _get_token_shared(dev=DEVELOPER, base_url=BASE)
54
+ except Exception:
55
+ # 回退: 内联逻辑 (common 不可用时, 与 common/autotest_auth 保持一致)
56
+ def get_token():
57
+ if os.path.exists(AUTH_FILE):
58
+ tok = json.load(open(AUTH_FILE, encoding='utf-8')).get('access_token')
59
+ import requests
60
+ try:
61
+ r = requests.get(BASE+'/test-api/system/user/getInfo',
62
+ headers={'Authorization':'Bearer '+tok,'clientid':'e5cd7e4891bf95d1d19206ce24a7b32e'}, timeout=8)
63
+ if r.json().get('code')==200: return tok
64
+ except Exception: pass
65
+ subprocess.run([sys.executable, os.path.join(SCRIPTS,'kg_auto_login.py')], capture_output=True, timeout=120)
66
+ if os.path.exists(AUTH_FILE):
67
+ return json.load(open(AUTH_FILE, encoding='utf-8')).get('access_token')
68
+ return None
69
+
70
+ # ===== SPA 会话: 注入 token + 初始化菜单 =====
71
+ def init_session(page, token):
72
+ page.goto(BASE+'/auth/login', wait_until='domcontentloaded'); time.sleep(1.2)
73
+ page.add_script_tag(path=SECURELS_JS); time.sleep(0.3)
74
+ page.evaluate('''(a)=>{const[t,k,s]=a;const S=window.SecureLS.default||window.SecureLS;const ls=new S({encodingType:'aes',isCompression:true,encryptionSecret:s});ls.set(k,{accessToken:t,refreshToken:t,accessCodes:[],originMenus:[],webMenus:[],accessMenus:[],accessRoutes:[],isAccessChecked:false,loginExpired:false});}''',[token,ACCESS_KEY,SECRET])
75
+ page.goto(BASE+'/dashboard/workspace', wait_until='domcontentloaded')
76
+ for _ in range(15):
77
+ if '加载菜单中' not in page.evaluate('()=>document.body.innerText'): return True
78
+ time.sleep(1)
79
+ return False
80
+
81
+ def _wait_table_stable(page, max_wait=8):
82
+ """轮询表格行数, 等 SPA 异步加载完成.
83
+ 结束条件: 行数>0 (有数据) 或 行数连续3次为0 (确实无表格/无数据) 或 超时."""
84
+ deadline = time.time() + max_wait
85
+ zero_streak = 0
86
+ last = -1
87
+ while time.time() < deadline:
88
+ try:
89
+ n = page.locator('.vxe-body--row').count()
90
+ except Exception:
91
+ n = 0
92
+ if n > 0:
93
+ # 有数据了, 再确认一次稳定 (避免loading中间态)
94
+ time.sleep(0.6)
95
+ try:
96
+ if page.locator('.vxe-body--row').count() >= n:
97
+ return n
98
+ except Exception:
99
+ return n
100
+ else:
101
+ zero_streak += 1
102
+ if zero_streak >= 3:
103
+ return 0 # 连续3次0, 认为确实无表格数据
104
+ time.sleep(0.5)
105
+ return last
106
+
107
+ # ===== 锚点定位: 优先 recall, 否则按 anchor 字段匹配 =====
108
+ def locate(page, anchor, desc=''):
109
+ """根据 anchor {role,name,placeholder,field,text} 定位元素, 返回 locator 或 None."""
110
+ if not anchor: return None
111
+ role = anchor.get('role','')
112
+ name = anchor.get('name','')
113
+ ph = anchor.get('placeholder','')
114
+ field = anchor.get('field','')
115
+ sels = []
116
+ if field: sels.append("input[name='%s']" % field)
117
+ if ph: sels.append("input[placeholder*='%s']" % ph)
118
+ if role == 'tab' and name: sels.append(".ant-tabs-tab:has-text('%s')" % name)
119
+ if role == 'button' and name: sels.append("button:has-text('%s')" % name)
120
+ if role == 'textbox' and name: sels.append("input[placeholder*='%s']" % name)
121
+ for sel in sels:
122
+ loc = page.locator(sel).first
123
+ try:
124
+ if loc.count() > 0: return loc
125
+ except Exception: pass
126
+ # 表单字段可能异步渲染, 轮询等待最多 5s
127
+ deadline = time.time() + 5
128
+ while time.time() < deadline:
129
+ for sel in sels:
130
+ try:
131
+ if page.locator(sel).first.count() > 0:
132
+ return page.locator(sel).first
133
+ except Exception: pass
134
+ time.sleep(0.4)
135
+ return None
136
+
137
+ # ===== 单步执行 =====
138
+ def exec_step(page, step, timeout=8000):
139
+ """执行一步, 返回 (ok, detail)."""
140
+ intent = step.get('intent','')
141
+ desc = step.get('desc','')
142
+ anchor = step.get('anchor', {}) or {}
143
+ val = step.get('value','')
144
+ try:
145
+ if intent == 'goto':
146
+ url = BASE + val if val.startswith('/') else val
147
+ page.goto(url, wait_until='domcontentloaded', timeout=12000); time.sleep(2)
148
+ # 等待表格数据加载完成 (列表页核心): 轮询直到行数>0 或稳定或超时
149
+ _wait_table_stable(page, max_wait=8)
150
+ return True, 'navigated'
151
+ if intent == 'fill':
152
+ loc = locate(page, anchor, desc)
153
+ if not loc: return False, '元素未找到(%s)' % desc
154
+ # value 可能是 {{ask:xxx}} 占位, 调用方应已替换; 这里直接填
155
+ loc.fill(str(val), timeout=timeout); time.sleep(0.3)
156
+ return True, 'filled'
157
+ if intent == 'click':
158
+ loc = locate(page, anchor, desc)
159
+ if not loc: return False, '元素未找到(%s)' % desc
160
+ loc.click(timeout=timeout); time.sleep(1.5)
161
+ return True, 'clicked'
162
+ if intent == 'observe':
163
+ # 只读页面, 总是成功, 返回关键信息供后续断言
164
+ return True, 'observed'
165
+ if intent == 'extract':
166
+ # 语义断言: body 文本是否含 expected 关键词, 或满足 step.check
167
+ body = page.evaluate('()=>document.body.innerText')
168
+ check = step.get('check','') # 期望出现的关键词
169
+ if check:
170
+ return (check in body), ('命中:%s' % check if check in body else '未命中:%s'%check)
171
+ return True, 'extracted(no check)'
172
+ if intent == 'assert':
173
+ # 确定性断言: step.check 是 JS 表达式或条件
174
+ check = step.get('check','')
175
+ if check.startswith('rows>'):
176
+ n = int(check[5:])
177
+ _wait_table_stable(page, max_wait=6)
178
+ rows = page.locator('.vxe-body--row').count()
179
+ return rows>n, ('行数%d>%d'%(rows,n) if rows>n else '行数%d<=%d'%(rows,n))
180
+ if check.startswith('rows=='):
181
+ n = int(check[6:])
182
+ _wait_table_stable(page, max_wait=6)
183
+ rows = page.locator('.vxe-body--row').count()
184
+ return rows==n, '行数=%d'%rows
185
+ if check.startswith('url_contains'):
186
+ cur = page.url
187
+ kw = check.split(':',1)[1] if ':' in check else ''
188
+ return (kw in cur), ('url含%s'%kw if kw in cur else 'url不含%s'%kw)
189
+ if check.startswith('input_empty:'):
190
+ field = check.split(':',1)[1]
191
+ v = page.locator("input[name='%s']"%field).first.input_value()
192
+ return v=='', '框值=%r'%v
193
+ return True, 'assert(no check)'
194
+ except Exception as e:
195
+ return False, str(e)[:60]
196
+ return False, '未知intent:%s' % intent
197
+
198
+ # ===== 跑一个用例 =====
199
+ def run_case(page, case):
200
+ cid = case.get('case_id','?')
201
+ steps = case.get('steps',[])
202
+ details = []
203
+ all_ok = True
204
+ for step in steps:
205
+ ok, detail = exec_step(page, step)
206
+ details.append('%s:%s(%s)' % (step.get('intent','?'), '' if ok else '✗', detail))
207
+ if not ok:
208
+ all_ok = False
209
+ break # 失败即停 (前置步骤失败后续无意义)
210
+ return {'case_id': cid, 'title': case.get('title',''), 'pass': all_ok,
211
+ 'expected': case.get('expected',''), 'details': details}
212
+
213
+ # ===== 沉淀锚点 =====
214
+ def sediment(page_path, anchors):
215
+ if not anchors: return 0
216
+ try:
217
+ pj = json.dumps({'url':page_path,'elements':[
218
+ {'role':a.get('role'),'name':a.get('name'),'desc':a.get('name')} for a in anchors
219
+ ]}, ensure_ascii=False)
220
+ subprocess.run([sys.executable, AUTOLEARN, 'learn', '--url', page_path, '--page-json', pj],
221
+ capture_output=True, timeout=20)
222
+ return len(anchors)
223
+ except Exception:
224
+ return 0
225
+
226
+ def collect_anchors(page):
227
+ try:
228
+ els = page.evaluate('''()=>{const out=[];document.querySelectorAll('button,.ant-tabs-tab,input[placeholder],input[name],[role=tab]').forEach(el=>{const name=(el.innerText||el.getAttribute('placeholder')||el.getAttribute('name')||'').trim();const role=el.getAttribute('role')||(el.tagName==='INPUT'?'textbox':el.tagName.toLowerCase());if(name&&name.length<25&&!/^[0-9\s]+$/.test(name))out.push({role,name});});return out.slice(0,30);}''')
229
+ seen=set(); uniq=[]
230
+ for el in els:
231
+ k=(el['role'],el['name'])
232
+ if k not in seen: seen.add(k); uniq.append(el)
233
+ return uniq
234
+ except Exception:
235
+ return []
236
+
237
+ # ===== 主 =====
238
+ def main():
239
+ ap = argparse.ArgumentParser()
240
+ ap.add_argument('--task', required=True, help='任务文件 JSON')
241
+ args = ap.parse_args()
242
+ task = json.load(open(args.task, encoding='utf-8'))
243
+ page_path = task['page_path']
244
+ cases = task.get('cases', [])
245
+
246
+ result = {'page': page_path, 'page_name': task.get('page_name',''),
247
+ 'case_results': [], 'anchors_count': 0, 'error': ''}
248
+
249
+ TOKEN = get_token()
250
+ if not TOKEN:
251
+ result['error'] = 'LOGIN_FAIL'; print(json.dumps(result, ensure_ascii=False)); sys.exit(1)
252
+
253
+ # 录屏目录
254
+ video_dir = os.path.join(MEMBER_DIR, 'drafts', 'videos')
255
+ os.makedirs(video_dir, exist_ok=True)
256
+
257
+ with sync_playwright() as pw:
258
+ b = pw.chromium.launch(headless=True)
259
+ ctx = b.new_context(locale='zh-CN', viewport={'width':1440,'height':900},
260
+ record_video_dir=video_dir)
261
+ page = ctx.new_page()
262
+ page.set_default_timeout(12000)
263
+ try:
264
+ if not init_session(page, TOKEN):
265
+ result['error']='INIT_FAIL'; print(json.dumps(result,ensure_ascii=False)); b.close(); sys.exit(1)
266
+ # goto 目标页
267
+ page.goto(BASE+page_path, wait_until='domcontentloaded'); time.sleep(2.5)
268
+ # 等表格
269
+ for _ in range(10):
270
+ if page.locator('.vxe-body--row').count()>0: break
271
+ time.sleep(0.5)
272
+ # 跑每个用例
273
+ for case in cases:
274
+ # 每个用例前重新 goto 保证干净状态
275
+ page.goto(BASE+page_path, wait_until='domcontentloaded'); time.sleep(2)
276
+ cr = run_case(page, case)
277
+ result['case_results'].append(cr)
278
+ tag = '✓' if cr['pass'] else '✗'
279
+ print(' %s %s %s' % (tag, cr['case_id'], cr['title'][:30]))
280
+ for d in cr['details']: print(' %s' % d)
281
+ # 收集沉淀
282
+ anc = collect_anchors(page)
283
+ result['anchors_count'] = len(anc)
284
+ sediment(page_path, anc)
285
+ except Exception as e:
286
+ result['error'] = str(e)[:100]
287
+ # 关 page 触发视频落盘, 再取路径
288
+ try:
289
+ video_path = page.video.path() if page.video else None
290
+ except Exception:
291
+ video_path = None
292
+ ctx.close()
293
+ b.close()
294
+ result['video'] = video_path or ''
295
+
296
+ passed = sum(1 for c in result['case_results'] if c['pass'])
297
+ total = len(result['case_results'])
298
+ result['summary'] = '%d/%d 通过' % (passed, total)
299
+ print()
300
+ print('汇总: %s 锚点%d %s' % (result['summary'], result['anchors_count'], result['error']))
301
+ if result.get('video'):
302
+ print('录屏: %s' % result['video'])
303
+ # 结果写文件
304
+ out = args.task.replace('.json','_run.json')
305
+ json.dump(result, open(out,'w',encoding='utf-8'), ensure_ascii=False, indent=2)
306
+ print('结果: %s' % out)
307
+
308
+ if __name__ == '__main__':
309
+ main()