@hupan56/wlkj 2.6.0 → 2.7.1
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.
- package/bin/cli.js +269 -11
- package/package.json +1 -1
- package/templates/qoder/agents/insight-planning.md +67 -0
- package/templates/qoder/agents/insight-research.md +61 -0
- package/templates/qoder/agents/prd-reference.md +8 -2
- package/templates/qoder/commands/optional/wl-insight.md +275 -0
- package/templates/qoder/commands/{wl-report.md → optional/wl-report.md} +13 -5
- package/templates/qoder/commands/{wl-spec.md → optional/wl-spec.md} +1 -1
- package/templates/qoder/commands/{wl-status.md → optional/wl-status.md} +28 -2
- package/templates/qoder/commands/wl-code.md +10 -2
- package/templates/qoder/commands/wl-commit.md +1 -1
- package/templates/qoder/commands/wl-design-draw.md +78 -0
- package/templates/qoder/commands/wl-design-scan.md +108 -0
- package/templates/qoder/commands/wl-design-spec.md +154 -0
- package/templates/qoder/commands/wl-design.md +32 -0
- package/templates/qoder/commands/wl-init.md +24 -3
- package/templates/qoder/commands/wl-prd-full.md +226 -0
- package/templates/qoder/commands/wl-prd-quick.md +134 -0
- package/templates/qoder/commands/wl-prd-review.md +104 -0
- package/templates/qoder/commands/wl-prd.md +17 -311
- package/templates/qoder/commands/wl-search.md +66 -30
- package/templates/qoder/commands/wl-task.md +290 -59
- package/templates/qoder/commands/wl-test.md +92 -24
- package/templates/qoder/config.yaml +51 -15
- package/templates/qoder/hooks/inject-workflow-state.py +2 -2
- package/templates/qoder/hooks/session-start.py +82 -51
- package/templates/qoder/rules/wl-pipeline.md +216 -105
- package/templates/qoder/scripts/__pycache__/search_index.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/archive_prd.py +377 -0
- package/templates/qoder/scripts/autotest.py +1715 -0
- package/templates/qoder/scripts/autotest_batch.py +224 -0
- package/templates/qoder/scripts/autotest_run.py +297 -0
- package/templates/qoder/scripts/benchmark.py +210 -209
- package/templates/qoder/scripts/build_style_index.py +444 -4
- package/templates/qoder/scripts/check_carriers.py +238 -0
- package/templates/qoder/scripts/check_mcp.py +298 -0
- package/templates/qoder/scripts/check_qoderwork_consistency.py +166 -0
- package/templates/qoder/scripts/common/events.py +46 -0
- package/templates/qoder/scripts/common/extract.py +419 -0
- package/templates/qoder/scripts/common/graph_traverse.py +533 -0
- package/templates/qoder/scripts/common/paths.py +89 -0
- package/templates/qoder/scripts/common/pip_install.py +144 -0
- package/templates/qoder/scripts/common/platform_guard.py +61 -0
- package/templates/qoder/scripts/common/search_engine.py +205 -205
- package/templates/qoder/scripts/common/terms.py +57 -0
- package/templates/qoder/scripts/common/ts_extract.py +536 -0
- package/templates/qoder/scripts/context_pack.py +73 -13
- package/templates/qoder/scripts/enrich_prompt.py +226 -0
- package/templates/qoder/scripts/eval_prd.py +318 -231
- package/templates/qoder/scripts/export.py +487 -487
- package/templates/qoder/scripts/extract_api_params.py +246 -0
- package/templates/qoder/scripts/extract_routes.py +54 -0
- package/templates/qoder/scripts/extract_routes_tree.py +78 -0
- package/templates/qoder/scripts/fill_prototype.py +707 -0
- package/templates/qoder/scripts/gen_design_doc.py +394 -0
- package/templates/qoder/scripts/git_sync.py +27 -15
- package/templates/qoder/scripts/init_doctor.py +292 -40
- package/templates/qoder/scripts/install_qoderwork.py +366 -9
- package/templates/qoder/scripts/kg.py +708 -0
- package/templates/qoder/scripts/kg_auto_login.py +196 -0
- package/templates/qoder/scripts/kg_build.py +612 -0
- package/templates/qoder/scripts/kg_build_db.py +327 -0
- package/templates/qoder/scripts/kg_duckdb.py +549 -0
- package/templates/qoder/scripts/kg_incremental.py +393 -0
- package/templates/qoder/scripts/kg_link_db.py +224 -0
- package/templates/qoder/scripts/kg_mcp_server.py +801 -0
- package/templates/qoder/scripts/kg_semantic.py +150 -0
- package/templates/qoder/scripts/kg_test_runner.py +241 -0
- package/templates/qoder/scripts/lanhu_stdio_wrapper.py +119 -0
- package/templates/qoder/scripts/learn.py +118 -39
- package/templates/qoder/scripts/learn_aggregate.py +201 -0
- package/templates/qoder/scripts/mcp_launcher.py +359 -0
- package/templates/qoder/scripts/mysql_mcp_server.py +396 -0
- package/templates/qoder/scripts/repo_root.py +106 -0
- package/templates/qoder/scripts/role.py +12 -0
- package/templates/qoder/scripts/run_weekly_update.bat +5 -0
- package/templates/qoder/scripts/run_weekly_update.sh +5 -0
- package/templates/qoder/scripts/search_index.py +307 -60
- package/templates/qoder/scripts/secure-ls.js +5640 -0
- package/templates/qoder/scripts/setup.py +706 -641
- package/templates/qoder/scripts/setup_lanhu.py +963 -0
- package/templates/qoder/scripts/status.py +250 -11
- package/templates/qoder/scripts/sync_carriers.py +259 -0
- package/templates/qoder/scripts/syncgate.py +2 -2
- package/templates/qoder/scripts/task.py +75 -0
- package/templates/qoder/scripts/team_sync.py +60 -4
- package/templates/qoder/scripts/workspace_init.py +1 -1
- package/templates/qoder/skills/design-import/SKILL.md +226 -0
- package/templates/qoder/skills/design-import/figma-workflow.md +81 -0
- package/templates/qoder/skills/design-review/SKILL.md +82 -25
- package/templates/qoder/skills/prd-generator/SKILL.md +184 -60
- package/templates/qoder/skills/prd-review/SKILL.md +18 -1
- package/templates/qoder/skills/prompt-enrich/SKILL.md +90 -0
- package/templates/qoder/skills/prototype-generator/SKILL.md +256 -141
- package/templates/qoder/skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp +0 -0
- package/templates/qoder/skills/spec-coder/SKILL.md +18 -1
- package/templates/qoder/skills/spec-generator/SKILL.md +18 -1
- package/templates/qoder/skills/test-generator/SKILL.md +15 -2
- package/templates/qoder/skills/wl-code/SKILL.md +55 -36
- package/templates/qoder/skills/wl-commit/SKILL.md +89 -76
- package/templates/qoder/skills/wl-design/SKILL.md +55 -0
- package/templates/qoder/skills/wl-init/SKILL.md +76 -67
- package/templates/qoder/skills/wl-insight/SKILL.md +201 -81
- package/templates/qoder/skills/wl-prd-full/SKILL.md +69 -0
- package/templates/qoder/skills/wl-prd-quick/SKILL.md +49 -0
- package/templates/qoder/skills/wl-prd-review/SKILL.md +34 -0
- package/templates/qoder/skills/wl-report/SKILL.md +131 -107
- package/templates/qoder/skills/wl-search/SKILL.md +141 -75
- package/templates/qoder/skills/wl-spec/SKILL.md +49 -39
- package/templates/qoder/skills/wl-status/SKILL.md +83 -61
- package/templates/qoder/skills/wl-task/SKILL.md +132 -58
- package/templates/qoder/skills/wl-test/SKILL.md +406 -40
- package/templates/qoder/templates/prototype-app.html +13 -8
- package/templates/qoder/templates/prototype-web.html +376 -93
- package/templates/root/AGENTS.md +89 -34
- package/templates/root/requirements.txt +21 -0
- package/templates/root//344/275/277/347/224/250/350/257/264/346/230/216.md +259 -259
- package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +186 -186
- package/templates/qoder/agents/prd-planning.md +0 -57
- package/templates/qoder/agents/prd-research.md +0 -34
- package/templates/qoder/commands/wl-insight.md +0 -51
- package/templates/qoder/scripts/__pycache__/init_doctor.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/__pycache__/setup.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/common/__pycache__/developer.cpython-39.pyc +0 -0
- 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()
|