@hupan56/wlkj 2.6.0 → 2.7.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 (125) hide show
  1. package/bin/cli.js +261 -11
  2. package/package.json +1 -1
  3. package/templates/qoder/agents/insight-planning.md +67 -0
  4. package/templates/qoder/agents/insight-research.md +61 -0
  5. package/templates/qoder/agents/prd-reference.md +8 -2
  6. package/templates/qoder/commands/optional/wl-insight.md +275 -0
  7. package/templates/qoder/commands/{wl-report.md → optional/wl-report.md} +13 -5
  8. package/templates/qoder/commands/{wl-spec.md → optional/wl-spec.md} +1 -1
  9. package/templates/qoder/commands/{wl-status.md → optional/wl-status.md} +28 -2
  10. package/templates/qoder/commands/wl-code.md +10 -2
  11. package/templates/qoder/commands/wl-commit.md +1 -1
  12. package/templates/qoder/commands/wl-design-draw.md +78 -0
  13. package/templates/qoder/commands/wl-design-scan.md +108 -0
  14. package/templates/qoder/commands/wl-design-spec.md +154 -0
  15. package/templates/qoder/commands/wl-design.md +32 -0
  16. package/templates/qoder/commands/wl-init.md +24 -3
  17. package/templates/qoder/commands/wl-prd-full.md +226 -0
  18. package/templates/qoder/commands/wl-prd-quick.md +134 -0
  19. package/templates/qoder/commands/wl-prd-review.md +104 -0
  20. package/templates/qoder/commands/wl-prd.md +17 -311
  21. package/templates/qoder/commands/wl-search.md +66 -30
  22. package/templates/qoder/commands/wl-task.md +290 -59
  23. package/templates/qoder/commands/wl-test.md +92 -24
  24. package/templates/qoder/config.yaml +51 -15
  25. package/templates/qoder/hooks/inject-workflow-state.py +2 -2
  26. package/templates/qoder/hooks/session-start.py +82 -51
  27. package/templates/qoder/rules/wl-pipeline.md +216 -105
  28. package/templates/qoder/scripts/__pycache__/search_index.cpython-39.pyc +0 -0
  29. package/templates/qoder/scripts/archive_prd.py +377 -0
  30. package/templates/qoder/scripts/autotest.py +1715 -0
  31. package/templates/qoder/scripts/autotest_batch.py +224 -0
  32. package/templates/qoder/scripts/autotest_run.py +297 -0
  33. package/templates/qoder/scripts/benchmark.py +210 -209
  34. package/templates/qoder/scripts/build_style_index.py +444 -4
  35. package/templates/qoder/scripts/check_carriers.py +238 -0
  36. package/templates/qoder/scripts/check_mcp.py +298 -0
  37. package/templates/qoder/scripts/check_qoderwork_consistency.py +166 -0
  38. package/templates/qoder/scripts/common/events.py +46 -0
  39. package/templates/qoder/scripts/common/extract.py +419 -0
  40. package/templates/qoder/scripts/common/graph_traverse.py +533 -0
  41. package/templates/qoder/scripts/common/paths.py +89 -0
  42. package/templates/qoder/scripts/common/pip_install.py +144 -0
  43. package/templates/qoder/scripts/common/platform_guard.py +61 -0
  44. package/templates/qoder/scripts/common/search_engine.py +205 -205
  45. package/templates/qoder/scripts/common/terms.py +57 -0
  46. package/templates/qoder/scripts/common/ts_extract.py +536 -0
  47. package/templates/qoder/scripts/context_pack.py +73 -13
  48. package/templates/qoder/scripts/enrich_prompt.py +226 -0
  49. package/templates/qoder/scripts/eval_prd.py +318 -231
  50. package/templates/qoder/scripts/export.py +487 -487
  51. package/templates/qoder/scripts/extract_api_params.py +246 -0
  52. package/templates/qoder/scripts/extract_routes.py +54 -0
  53. package/templates/qoder/scripts/extract_routes_tree.py +78 -0
  54. package/templates/qoder/scripts/fill_prototype.py +707 -0
  55. package/templates/qoder/scripts/gen_design_doc.py +394 -0
  56. package/templates/qoder/scripts/git_sync.py +27 -15
  57. package/templates/qoder/scripts/init_doctor.py +292 -40
  58. package/templates/qoder/scripts/install_qoderwork.py +366 -9
  59. package/templates/qoder/scripts/kg.py +708 -0
  60. package/templates/qoder/scripts/kg_auto_login.py +196 -0
  61. package/templates/qoder/scripts/kg_build.py +612 -0
  62. package/templates/qoder/scripts/kg_build_db.py +327 -0
  63. package/templates/qoder/scripts/kg_duckdb.py +549 -0
  64. package/templates/qoder/scripts/kg_incremental.py +393 -0
  65. package/templates/qoder/scripts/kg_link_db.py +224 -0
  66. package/templates/qoder/scripts/kg_mcp_server.py +801 -0
  67. package/templates/qoder/scripts/kg_semantic.py +150 -0
  68. package/templates/qoder/scripts/kg_test_runner.py +241 -0
  69. package/templates/qoder/scripts/lanhu_stdio_wrapper.py +119 -0
  70. package/templates/qoder/scripts/learn.py +118 -39
  71. package/templates/qoder/scripts/learn_aggregate.py +201 -0
  72. package/templates/qoder/scripts/mcp_launcher.py +359 -0
  73. package/templates/qoder/scripts/mysql_mcp_server.py +396 -0
  74. package/templates/qoder/scripts/repo_root.py +106 -0
  75. package/templates/qoder/scripts/role.py +12 -0
  76. package/templates/qoder/scripts/run_weekly_update.bat +5 -0
  77. package/templates/qoder/scripts/run_weekly_update.sh +5 -0
  78. package/templates/qoder/scripts/search_index.py +307 -60
  79. package/templates/qoder/scripts/secure-ls.js +5640 -0
  80. package/templates/qoder/scripts/setup.py +706 -641
  81. package/templates/qoder/scripts/setup_lanhu.py +963 -0
  82. package/templates/qoder/scripts/status.py +250 -11
  83. package/templates/qoder/scripts/sync_carriers.py +259 -0
  84. package/templates/qoder/scripts/syncgate.py +2 -2
  85. package/templates/qoder/scripts/task.py +75 -0
  86. package/templates/qoder/scripts/team_sync.py +60 -4
  87. package/templates/qoder/scripts/workspace_init.py +1 -1
  88. package/templates/qoder/skills/design-import/SKILL.md +226 -0
  89. package/templates/qoder/skills/design-import/figma-workflow.md +81 -0
  90. package/templates/qoder/skills/design-review/SKILL.md +82 -25
  91. package/templates/qoder/skills/prd-generator/SKILL.md +184 -60
  92. package/templates/qoder/skills/prd-review/SKILL.md +18 -1
  93. package/templates/qoder/skills/prompt-enrich/SKILL.md +90 -0
  94. package/templates/qoder/skills/prototype-generator/SKILL.md +256 -141
  95. package/templates/qoder/skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp +0 -0
  96. package/templates/qoder/skills/spec-coder/SKILL.md +18 -1
  97. package/templates/qoder/skills/spec-generator/SKILL.md +18 -1
  98. package/templates/qoder/skills/test-generator/SKILL.md +15 -2
  99. package/templates/qoder/skills/wl-code/SKILL.md +55 -36
  100. package/templates/qoder/skills/wl-commit/SKILL.md +89 -76
  101. package/templates/qoder/skills/wl-design/SKILL.md +55 -0
  102. package/templates/qoder/skills/wl-init/SKILL.md +76 -67
  103. package/templates/qoder/skills/wl-insight/SKILL.md +201 -81
  104. package/templates/qoder/skills/wl-prd-full/SKILL.md +69 -0
  105. package/templates/qoder/skills/wl-prd-quick/SKILL.md +49 -0
  106. package/templates/qoder/skills/wl-prd-review/SKILL.md +34 -0
  107. package/templates/qoder/skills/wl-report/SKILL.md +131 -107
  108. package/templates/qoder/skills/wl-search/SKILL.md +141 -75
  109. package/templates/qoder/skills/wl-spec/SKILL.md +49 -39
  110. package/templates/qoder/skills/wl-status/SKILL.md +83 -61
  111. package/templates/qoder/skills/wl-task/SKILL.md +132 -58
  112. package/templates/qoder/skills/wl-test/SKILL.md +406 -40
  113. package/templates/qoder/templates/prototype-app.html +13 -8
  114. package/templates/qoder/templates/prototype-web.html +376 -93
  115. package/templates/root/AGENTS.md +89 -34
  116. package/templates/root/requirements.txt +21 -0
  117. package/templates/root//344/275/277/347/224/250/350/257/264/346/230/216.md +259 -259
  118. package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +186 -186
  119. package/templates/qoder/agents/prd-planning.md +0 -57
  120. package/templates/qoder/agents/prd-research.md +0 -34
  121. package/templates/qoder/commands/wl-insight.md +0 -51
  122. package/templates/qoder/scripts/__pycache__/init_doctor.cpython-39.pyc +0 -0
  123. package/templates/qoder/scripts/__pycache__/setup.cpython-39.pyc +0 -0
  124. package/templates/qoder/scripts/common/__pycache__/developer.cpython-39.pyc +0 -0
  125. package/templates/qoder/skills/wl-prd/SKILL.md +0 -92
@@ -0,0 +1,224 @@
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()
@@ -0,0 +1,297 @@
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()