@hupan56/wlkj 2.7.12 → 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 +344 -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} +113 -4
  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,224 +1,242 @@
1
- # -*- coding: utf-8 -*-
2
- """autotest_batch.py 批量页面测试执行器 (一个浏览器会话串测N页).
3
- 用法: python autotest_batch.py <batch_file> [--out result.json]
4
- batch_file: JSON [{page_path, name, component}, ...] (page_path 用准确路由!)
5
-
6
- 与上轮 _batch_test.py 的关键区别:
7
- ① URL 来自 menu-routes-tree.json (准确父子树), 不是 component 猜
8
- 404 判定加强: 识别 "未找到页面/哎呀/404/Not Found/页面不存在" 多种文案
9
- 真验页面加载: 区分 OK(有数据) / LOADED(无表格但非404) / 404 / SESSION_EXPIRED / EMPTY(空白)
10
- """
11
- import json, sys, time, os, re, argparse, subprocess
12
- from playwright.sync_api import sync_playwright
13
-
14
- BASE = 'https://icsqas.inforecloud.com'
15
- ACCESS_KEY = 'ICS-1.3.8-prod-core-access'
16
- SECRET = 'please-replace-me-with-your-own-key'
17
- SCRIPTS = os.path.dirname(os.path.abspath(__file__))
18
- PROJECT = os.path.normpath(os.path.join(SCRIPTS, '..', '..'))
19
- SECURELS_JS = os.path.join(SCRIPTS, 'secure-ls.js')
20
-
21
-
22
- def read_developer():
23
- f = os.path.join(PROJECT, '.qoder', '.developer')
24
- for ln in open(f, encoding='utf-8'):
25
- if ln.strip().startswith('name='):
26
- return ln.strip().split('=', 1)[1].strip()
27
- raise RuntimeError('无 .developer')
28
-
29
-
30
- DEV = read_developer()
31
- AUTH_FILE = os.path.join(PROJECT, 'workspace', 'members', DEV, 'auth-state.json')
32
-
33
-
34
- def login_api():
35
- """调 kg_auto_login.py 重新登录."""
36
- subprocess.run([sys.executable, os.path.join(SCRIPTS, 'kg_auto_login.py')],
37
- capture_output=True, timeout=180)
38
- if os.path.exists(AUTH_FILE):
39
- return json.load(open(AUTH_FILE, encoding='utf-8')).get('access_token')
40
- return None
41
-
42
-
43
- def get_token():
44
- if os.path.exists(AUTH_FILE):
45
- tok = json.load(open(AUTH_FILE, encoding='utf-8')).get('access_token')
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:
51
- return tok
52
- except Exception:
53
- pass
54
- return login_api()
55
-
56
-
57
- def inject_token(page, token):
58
- page.goto(BASE + '/auth/login', wait_until='domcontentloaded'); time.sleep(1.2)
59
- page.add_script_tag(path=SECURELS_JS); time.sleep(0.3)
60
- 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])
61
-
62
-
63
- def wait_menu_ready(page, secs=15):
64
- page.goto(BASE + '/dashboard/workspace', wait_until='domcontentloaded')
65
- for _ in range(secs):
66
- if '加载菜单中' not in page.evaluate('()=>document.body.innerText'):
67
- return True
68
- time.sleep(1)
69
- return False
70
-
71
-
72
- # 404 文案清单 (实测 ICS 的 404 页文案)
73
- NOT_FOUND_MARKERS = ['未找到页面', '哎呀', '404', 'Not Found', '页面不存在', '无法找到', 'page not found', '找不到页面']
74
-
75
-
76
- def is_not_found(body):
77
- b = body[:500]
78
- return any(m in b for m in NOT_FOUND_MARKERS)
79
-
80
-
81
- def collect_anchors(page):
82
- try:
83
- 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);}''')
84
- seen = set(); uniq = []
85
- for el in els:
86
- k = (el['role'], el['name'])
87
- if k not in seen:
88
- seen.add(k); uniq.append(el)
89
- return uniq
90
- except Exception:
91
- return []
92
-
93
-
94
- def test_page(page, item, shot_dir):
95
- """测单页. status: OK/LOADED/404/SESSION_EXPIRED/EMPTY/ERROR
96
- 每页截 1 张图存 shot_dir, 结果带 screenshot 路径 + body 全文(留证据)."""
97
- path = item['page_path']
98
- res = {'page': path, 'name': item.get('name', ''), 'status': 'UNKNOWN', 'rows': 0, 'anchors': [], 'screenshot': '', 'body': ''}
99
- try:
100
- page.goto(BASE + path, wait_until='domcontentloaded', timeout=12000)
101
- time.sleep(2)
102
- cur = page.url
103
- if '/auth/login' in cur:
104
- res['status'] = 'SESSION_EXPIRED'; return res
105
- body = page.evaluate('()=>document.body.innerText.slice(0,1500)')
106
- res['body'] = body
107
- if is_not_found(body):
108
- res['status'] = '404'; res['body_head'] = body[:80].replace('\n', ' ')
109
- res['screenshot'] = _shot(page, shot_dir, path, '404'); return res
110
- # 等表格加载
111
- rows = 0
112
- for _ in range(8):
113
- rows = page.locator('.vxe-body--row').count()
114
- if rows > 0:
115
- break
116
- time.sleep(0.5)
117
- res['rows'] = rows
118
- if rows > 0:
119
- res['status'] = 'OK'
120
- elif len([l for l in body.split('\n') if l.strip()]) > 3:
121
- res['status'] = 'LOADED'
122
- else:
123
- res['status'] = 'EMPTY'
124
- res['anchors'] = collect_anchors(page)
125
- res['screenshot'] = _shot(page, shot_dir, path, res['status'])
126
- except Exception as e:
127
- res['status'] = 'ERROR'; res['error'] = str(e)[:80]
128
- try: res['screenshot'] = _shot(page, shot_dir, path, 'ERROR')
129
- except Exception: pass
130
- return res
131
-
132
-
133
- def _shot(page, shot_dir, path, tag):
134
- """截图存 PNG, 返回文件名. 命名: tag_域名简写.png (去特殊字符)."""
135
- import re as _re
136
- safe = _re.sub(r'[^a-zA-Z0-9\u4e00-\u9fa5]', '_', path).strip('_')[-40:]
137
- fn = '%s_%s.png' % (tag, safe)
138
- fp = os.path.join(shot_dir, fn)
139
- try:
140
- page.screenshot(path=fp, full_page=False)
141
- return fn
142
- except Exception:
143
- return ''
144
-
145
-
146
- def sediment(page_path, anchors):
147
- if not anchors:
148
- return
149
- try:
150
- pj = json.dumps({'url': page_path, 'elements': [
151
- {'role': a['role'], 'name': a['name'], 'desc': a['name']} for a in anchors
152
- ]}, ensure_ascii=False)
153
- subprocess.run([sys.executable, os.path.join(SCRIPTS, 'autotest.py'), 'learn',
154
- '--url', page_path, '--page-json', pj], capture_output=True, timeout=20)
155
- except Exception:
156
- pass
157
-
158
-
159
- def main():
160
- ap = argparse.ArgumentParser()
161
- ap.add_argument('batch_file')
162
- ap.add_argument('--out', default='')
163
- args = ap.parse_args()
164
- batch = json.load(open(args.batch_file, encoding='utf-8'))
165
- print('批量测试 %d 页 (URL来源: 准确路由树)' % len(batch))
166
-
167
- TOKEN = get_token()
168
- if not TOKEN:
169
- print('LOGIN_FAIL'); sys.exit(1)
170
- print('token: ok (dev=%s)' % DEV)
171
-
172
- results = []
173
- shot_dir = os.path.join(os.path.dirname(args.batch_file), 'screenshots')
174
- os.makedirs(shot_dir, exist_ok=True)
175
- with sync_playwright() as pw:
176
- b = pw.chromium.launch(headless=True)
177
- page = b.new_context(locale='zh-CN', viewport={'width': 1440, 'height': 900}).new_page()
178
- page.set_default_timeout(12000)
179
- try:
180
- inject_token(page, TOKEN)
181
- ready = wait_menu_ready(page)
182
- print('SPA初始化:', '菜单就绪' if ready else '菜单未就绪(继续)')
183
- except Exception as e:
184
- print('INIT_FAIL:', str(e)[:80]); b.close(); sys.exit(1)
185
-
186
- for i, item in enumerate(batch):
187
- res = test_page(page, item, shot_dir)
188
- results.append(res)
189
- anc_n = len(res.get('anchors', []))
190
- print(' [%d/%d] %-14s %-50s rows=%-3d anc=%d %s' % (
191
- i + 1, len(batch), res['status'], res['page'][:48], res.get('rows', 0), anc_n,
192
- res.get('body_head', res.get('error', ''))[:30]))
193
- sediment(res['page'], res.get('anchors', []))
194
- if res['status'] == 'SESSION_EXPIRED':
195
- print(' session过期, 重新登录...')
196
- TOKEN = login_api()
197
- if TOKEN:
198
- inject_token(page, TOKEN); wait_menu_ready(page, 8)
199
- b.close()
200
-
201
- from collections import Counter
202
- c = Counter(r['status'] for r in results)
203
- print()
204
- print('=== 汇总 ===')
205
- for s, n in c.most_common():
206
- print(' %-14s %d' % (s, n))
207
- total_anchors = sum(len(r.get('anchors', [])) for r in results)
208
- print(' 沉淀锚点: %d' % total_anchors)
209
- # 列出所有 404/ERROR
210
- problems = [r for r in results if r['status'] in ('404', 'ERROR', 'EMPTY', 'SESSION_EXPIRED')]
211
- if problems:
212
- print()
213
- print('=== 异常页面 (%d) ===' % len(problems))
214
- for r in problems:
215
- print(' [%s] %s %s' % (r['status'], r['page'][:50], r.get('body_head', r.get('error', ''))[:30]))
216
-
217
- out_file = args.out or args.batch_file.replace('.json', '_result.json')
218
- json.dump(results, open(out_file, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
219
- print()
220
- print('结果: %s' % out_file)
221
-
222
-
223
- if __name__ == '__main__':
224
- 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_batch.py 批量页面测试执行器 (一个浏览器会话串测N页).
9
+ 用法: python autotest_batch.py <batch_file> [--out result.json]
10
+ batch_file: JSON [{page_path, name, component}, ...] (page_path 用准确路由!)
11
+
12
+ 与上轮 _batch_test.py 的关键区别:
13
+ ① URL 来自 menu-routes-tree.json (准确父子树), 不是 component 猜
14
+ 404 判定加强: 识别 "未找到页面/哎呀/404/Not Found/页面不存在" 多种文案
15
+ 真验页面加载: 区分 OK(有数据) / LOADED(无表格但非404) / 404 / SESSION_EXPIRED / EMPTY(空白)
16
+ """
17
+ import json, sys, time, os, re, argparse, subprocess
18
+ from playwright.sync_api import sync_playwright
19
+
20
+ BASE = 'https://icsqas.inforecloud.com'
21
+ ACCESS_KEY = 'ICS-1.3.8-prod-core-access'
22
+ SECRET = 'please-replace-me-with-your-own-key'
23
+ SCRIPTS = os.path.dirname(os.path.abspath(__file__))
24
+ PROJECT = os.path.normpath(os.path.join(SCRIPTS, '..', '..'))
25
+ SECURELS_JS = os.path.join(SCRIPTS, 'secure-ls.js')
26
+
27
+
28
+ # v3.0: 登录态从 common.autotest_auth 复用 (单一真源, 消除 3 处重复)
29
+ sys.path.insert(0, SCRIPTS)
30
+ try:
31
+ from common.autotest_auth import get_developer as read_developer, auth_file, get_token as _get_token_shared
32
+
33
+ DEV = read_developer()
34
+ AUTH_FILE = auth_file(DEV)
35
+
36
+ def login_api():
37
+ """调 kg_auto_login.py 重新登录 (转发到 autotest_auth.get_token)."""
38
+ return _get_token_shared(dev=DEV, base_url=BASE)
39
+
40
+ def get_token():
41
+ return _get_token_shared(dev=DEV, base_url=BASE)
42
+ except Exception:
43
+ # 回退: 内联逻辑
44
+ def read_developer():
45
+ f = os.path.join(PROJECT, '.qoder', '.developer')
46
+ for ln in open(f, encoding='utf-8'):
47
+ if ln.strip().startswith('name='):
48
+ return ln.strip().split('=', 1)[1].strip()
49
+ raise RuntimeError(' .developer')
50
+
51
+ DEV = read_developer()
52
+ AUTH_FILE = os.path.join(PROJECT, 'workspace', 'members', DEV, 'auth-state.json')
53
+
54
+ def login_api():
55
+ subprocess.run([sys.executable, os.path.join(SCRIPTS, 'kg_auto_login.py')],
56
+ capture_output=True, timeout=180)
57
+ if os.path.exists(AUTH_FILE):
58
+ return json.load(open(AUTH_FILE, encoding='utf-8')).get('access_token')
59
+ return None
60
+
61
+ def get_token():
62
+ if os.path.exists(AUTH_FILE):
63
+ tok = json.load(open(AUTH_FILE, encoding='utf-8')).get('access_token')
64
+ import requests
65
+ try:
66
+ r = requests.get(BASE + '/test-api/system/user/getInfo',
67
+ headers={'Authorization': 'Bearer ' + tok, 'clientid': 'e5cd7e4891bf95d1d19206ce24a7b32e'}, timeout=8)
68
+ if r.json().get('code') == 200:
69
+ return tok
70
+ except Exception:
71
+ pass
72
+ return login_api()
73
+
74
+
75
+ def inject_token(page, token):
76
+ page.goto(BASE + '/auth/login', wait_until='domcontentloaded'); time.sleep(1.2)
77
+ page.add_script_tag(path=SECURELS_JS); time.sleep(0.3)
78
+ 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])
79
+
80
+
81
+ def wait_menu_ready(page, secs=15):
82
+ page.goto(BASE + '/dashboard/workspace', wait_until='domcontentloaded')
83
+ for _ in range(secs):
84
+ if '加载菜单中' not in page.evaluate('()=>document.body.innerText'):
85
+ return True
86
+ time.sleep(1)
87
+ return False
88
+
89
+
90
+ # 404 文案清单 (实测 ICS 的 404 页文案)
91
+ NOT_FOUND_MARKERS = ['未找到页面', '哎呀', '404', 'Not Found', '页面不存在', '无法找到', 'page not found', '找不到页面']
92
+
93
+
94
+ def is_not_found(body):
95
+ b = body[:500]
96
+ return any(m in b for m in NOT_FOUND_MARKERS)
97
+
98
+
99
+ def collect_anchors(page):
100
+ try:
101
+ 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);}''')
102
+ seen = set(); uniq = []
103
+ for el in els:
104
+ k = (el['role'], el['name'])
105
+ if k not in seen:
106
+ seen.add(k); uniq.append(el)
107
+ return uniq
108
+ except Exception:
109
+ return []
110
+
111
+
112
+ def test_page(page, item, shot_dir):
113
+ """测单页. status: OK/LOADED/404/SESSION_EXPIRED/EMPTY/ERROR
114
+ 每页截 1 张图存 shot_dir, 结果带 screenshot 路径 + body 全文(留证据)."""
115
+ path = item['page_path']
116
+ res = {'page': path, 'name': item.get('name', ''), 'status': 'UNKNOWN', 'rows': 0, 'anchors': [], 'screenshot': '', 'body': ''}
117
+ try:
118
+ page.goto(BASE + path, wait_until='domcontentloaded', timeout=12000)
119
+ time.sleep(2)
120
+ cur = page.url
121
+ if '/auth/login' in cur:
122
+ res['status'] = 'SESSION_EXPIRED'; return res
123
+ body = page.evaluate('()=>document.body.innerText.slice(0,1500)')
124
+ res['body'] = body
125
+ if is_not_found(body):
126
+ res['status'] = '404'; res['body_head'] = body[:80].replace('\n', ' ')
127
+ res['screenshot'] = _shot(page, shot_dir, path, '404'); return res
128
+ # 等表格加载
129
+ rows = 0
130
+ for _ in range(8):
131
+ rows = page.locator('.vxe-body--row').count()
132
+ if rows > 0:
133
+ break
134
+ time.sleep(0.5)
135
+ res['rows'] = rows
136
+ if rows > 0:
137
+ res['status'] = 'OK'
138
+ elif len([l for l in body.split('\n') if l.strip()]) > 3:
139
+ res['status'] = 'LOADED'
140
+ else:
141
+ res['status'] = 'EMPTY'
142
+ res['anchors'] = collect_anchors(page)
143
+ res['screenshot'] = _shot(page, shot_dir, path, res['status'])
144
+ except Exception as e:
145
+ res['status'] = 'ERROR'; res['error'] = str(e)[:80]
146
+ try: res['screenshot'] = _shot(page, shot_dir, path, 'ERROR')
147
+ except Exception: pass
148
+ return res
149
+
150
+
151
+ def _shot(page, shot_dir, path, tag):
152
+ """截图存 PNG, 返回文件名. 命名: tag_域名简写.png (去特殊字符)."""
153
+ import re as _re
154
+ safe = _re.sub(r'[^a-zA-Z0-9\u4e00-\u9fa5]', '_', path).strip('_')[-40:]
155
+ fn = '%s_%s.png' % (tag, safe)
156
+ fp = os.path.join(shot_dir, fn)
157
+ try:
158
+ page.screenshot(path=fp, full_page=False)
159
+ return fn
160
+ except Exception:
161
+ return ''
162
+
163
+
164
+ def sediment(page_path, anchors):
165
+ if not anchors:
166
+ return
167
+ try:
168
+ pj = json.dumps({'url': page_path, 'elements': [
169
+ {'role': a['role'], 'name': a['name'], 'desc': a['name']} for a in anchors
170
+ ]}, ensure_ascii=False)
171
+ subprocess.run([sys.executable, os.path.join(SCRIPTS, 'autotest.py'), 'learn',
172
+ '--url', page_path, '--page-json', pj], capture_output=True, timeout=20)
173
+ except Exception:
174
+ pass
175
+
176
+
177
+ def main():
178
+ ap = argparse.ArgumentParser()
179
+ ap.add_argument('batch_file')
180
+ ap.add_argument('--out', default='')
181
+ args = ap.parse_args()
182
+ batch = json.load(open(args.batch_file, encoding='utf-8'))
183
+ print('批量测试 %d (URL来源: 准确路由树)' % len(batch))
184
+
185
+ TOKEN = get_token()
186
+ if not TOKEN:
187
+ print('LOGIN_FAIL'); sys.exit(1)
188
+ print('token: ok (dev=%s)' % DEV)
189
+
190
+ results = []
191
+ shot_dir = os.path.join(os.path.dirname(args.batch_file), 'screenshots')
192
+ os.makedirs(shot_dir, exist_ok=True)
193
+ with sync_playwright() as pw:
194
+ b = pw.chromium.launch(headless=True)
195
+ page = b.new_context(locale='zh-CN', viewport={'width': 1440, 'height': 900}).new_page()
196
+ page.set_default_timeout(12000)
197
+ try:
198
+ inject_token(page, TOKEN)
199
+ ready = wait_menu_ready(page)
200
+ print('SPA初始化:', '菜单就绪' if ready else '菜单未就绪(继续)')
201
+ except Exception as e:
202
+ print('INIT_FAIL:', str(e)[:80]); b.close(); sys.exit(1)
203
+
204
+ for i, item in enumerate(batch):
205
+ res = test_page(page, item, shot_dir)
206
+ results.append(res)
207
+ anc_n = len(res.get('anchors', []))
208
+ print(' [%d/%d] %-14s %-50s rows=%-3d anc=%d %s' % (
209
+ i + 1, len(batch), res['status'], res['page'][:48], res.get('rows', 0), anc_n,
210
+ res.get('body_head', res.get('error', ''))[:30]))
211
+ sediment(res['page'], res.get('anchors', []))
212
+ if res['status'] == 'SESSION_EXPIRED':
213
+ print(' session过期, 重新登录...')
214
+ TOKEN = login_api()
215
+ if TOKEN:
216
+ inject_token(page, TOKEN); wait_menu_ready(page, 8)
217
+ b.close()
218
+
219
+ from collections import Counter
220
+ c = Counter(r['status'] for r in results)
221
+ print()
222
+ print('=== 汇总 ===')
223
+ for s, n in c.most_common():
224
+ print(' %-14s %d' % (s, n))
225
+ total_anchors = sum(len(r.get('anchors', [])) for r in results)
226
+ print(' 沉淀锚点: %d' % total_anchors)
227
+ # 列出所有 404/ERROR
228
+ problems = [r for r in results if r['status'] in ('404', 'ERROR', 'EMPTY', 'SESSION_EXPIRED')]
229
+ if problems:
230
+ print()
231
+ print('=== 异常页面 (%d) ===' % len(problems))
232
+ for r in problems:
233
+ print(' [%s] %s %s' % (r['status'], r['page'][:50], r.get('body_head', r.get('error', ''))[:30]))
234
+
235
+ out_file = args.out or args.batch_file.replace('.json', '_result.json')
236
+ json.dump(results, open(out_file, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
237
+ print()
238
+ print('结果: %s' % out_file)
239
+
240
+
241
+ if __name__ == '__main__':
242
+ main()