Files
cma-management/backend/scripts/verify_phase_c_fixed_entity_e2e.py
T
Hermes CI Fix ebe0962c09 feat(frontend): Phase C 固定企业模式 - VITE_FIXED_ENTITY_ID 支持登录无下拉/隐藏切换器
- 新增 src/utils/entity.ts: isFixedEntity/getFixedEntityId/effectiveEntityId
- Login.vue: 固定企业版隐藏企业下拉显示只读徽标, 登录直接带固定 entity_id
- MainLayout.vue: 切换器加 !isFixedEntity() 守卫, 固定版顶部显示企业 tag, logo 带企业名
- ReportCenter.vue: 页内企业切换下拉加守卫, getEntityId 固定版恒取构建锁定值
- .env.example: VITE_FIXED_ENTITY_ID 示例与说明
- 验证脚本 backend/scripts/verify_phase_c_fixed_entity_e2e.py (22项, 双build E2E全过)
2026-09-08 11:27:01 +08:00

298 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Phase C 固定企业前端 E2E(2026-09-08)
验证:VITE_FIXED_ENTITY_ID 固定版(登录无企业下拉、进入无切换器、数据=固定企业)
vs 非固定超管版(下拉/切换器照旧)。
前置:本机已构建 dist-fixed-1(VITE_FIXED_ENTITY_ID=1)与 dist(无固定),
并分别起 vite preview:
pnpm exec vite preview --port 5199 --outDir dist-fixed-1 --host 127.0.0.1
pnpm exec vite preview --port 5200 --outDir dist --host 127.0.0.1
后端 cma-backend 8010 运行(vite preview 继承 server.proxy 转发 /api → 8010)。
无头 Chrome: --remote-debugging-port=9224 --user-data-dir=/tmp/chrome-cma-phasec
用法:/opt/hermes-agent/bin/python3 scripts/verify_phase_c_fixed_entity_e2e.py
"""
import json
import sys
import time
import urllib.request
import websocket
CDP_HTTP = "http://127.0.0.1:9224"
FIXED_URL = "http://127.0.0.1:5199"
MULTI_URL = "http://127.0.0.1:5200"
USER, PWD = "admin", "admin123"
FAILS = []
PASSES = []
def http_get(path):
with urllib.request.urlopen(CDP_HTTP + path, timeout=5) as r:
return json.loads(r.read().decode())
def ev(ws_url, expr, timeout=25):
"""每次新建短连接执行表达式(长连接易被导航断掉);超时重试一次。"""
last = None
for attempt in range(2):
ws = websocket.create_connection(ws_url, timeout=timeout)
try:
ws.settimeout(timeout)
mid = 1000 + attempt
ws.send(json.dumps({"id": mid, "method": "Runtime.evaluate",
"params": {"expression": expr, "returnByValue": True, "awaitPromise": True}}))
while True:
resp = json.loads(ws.recv())
if resp.get("id") == mid:
res = resp.get("result", {})
if res.get("exceptionDetails"):
return "EXC: " + json.dumps(res["exceptionDetails"], ensure_ascii=False)[:300]
return res.get("result", {}).get("value")
except Exception as e: # noqa: BLE001 网络超时/断连重试一次
last = "WSERR:" + str(e)[:120]
time.sleep(1.0)
finally:
ws.close()
return last
def close_all_tabs():
try:
ver = http_get("/json/version")
ws = websocket.create_connection(ver["webSocketDebuggerUrl"], timeout=15)
for p in http_get("/json"):
if p.get("type") == "page":
ws.send(json.dumps({"id": 999, "method": "Target.closeTarget", "params": {"targetId": p["id"]}}))
ws.close()
time.sleep(2.0)
except Exception as e: # noqa: BLE001
print(" warn close_all_tabs:", str(e)[:120])
def new_page(url):
ver = http_get("/json/version")
ws = websocket.create_connection(ver["webSocketDebuggerUrl"], timeout=30)
mid = 1
ws.send(json.dumps({"id": mid, "method": "Target.createTarget", "params": {"url": url}}))
while True:
resp = json.loads(ws.recv())
if resp.get("id") == mid:
tid = resp.get("result", {}).get("targetId")
break
ws.close()
time.sleep(2.5)
for p in http_get("/json"):
if p["id"] == tid:
return tid, p["webSocketDebuggerUrl"]
return tid, None
def wait_until(ws_url, expr, expect, tries=30, gap=1.0):
for _ in range(tries):
v = ev(ws_url, expr)
if v == expect:
return True
time.sleep(gap)
return False
LOGIN_READY = "document.querySelectorAll('.login-card button').length >= 1"
def ready(ws_url, prefix, tries=40, gap=1.0):
"""确保 tab 真实导航到 prefix(fresh profile 下 createTarget 偶发停留 about:blank)。"""
for _ in range(tries):
h = ev(ws_url, "location.href")
if isinstance(h, str) and h.startswith(prefix):
return True
if isinstance(h, str) and (h == "about:blank" or h.startswith("about:")):
ev(ws_url, "location.href = " + js_str(prefix))
time.sleep(gap)
return False
def check(name, cond, detail=""):
if cond:
PASSES.append(name)
print(f" PASS {name}")
else:
FAILS.append(name)
print(f" FAIL {name} {detail}")
def js_str(s):
return json.dumps(s, ensure_ascii=False)
def main():
# 清掉历史标签(跑多轮后 Chrome 积压标签会拖慢/超时)
close_all_tabs()
# ───────────────────────── 固定企业版(entity=1 酣客)─────────────────────────
print("== [A] 固定企业版 VITE_FIXED_ENTITY_ID=1 ==")
tid, ws = new_page(FIXED_URL + "/login")
if not ws:
print("FATAL: no ws for fixed login"); sys.exit(1)
# 清掉可能持久化的登录态,确保处于未登录登录页
ev(ws, "localStorage.removeItem('cma_token'); localStorage.removeItem('cma_user'); localStorage.removeItem('cma_entity_id'); localStorage.removeItem('cma_entity_name'); 'cleared'")
ev(ws, "location.href = " + js_str(FIXED_URL + "/login"))
ok = wait_until(ws, LOGIN_READY, True, tries=30, gap=1.0)
check("A0 登录页挂载", ok)
# 等固定企业徽标文本就绪(登录企业 + 企业名,需 login-entities 返回)
wait_until(ws, "(() => { const b = document.querySelector('.fixed-entity-badge'); return b && (b.innerText||'').length > 0; })()", True, tries=20, gap=1.0)
time.sleep(0.8)
# 登录页:不应有企业下拉(选择登录公司),应显示固定企业徽标
login_dom = ev(ws, """(() => {
const t = document.body.innerText || '';
const selCount = document.querySelectorAll('.login-card .el-select').length;
const badge = document.querySelector('.fixed-entity-badge');
return { selCount, badgeText: badge ? (badge.innerText || '') : '', hasPlaceholder: t.includes('选择登录公司') };
})()""")
print(" login-dom:", json.dumps(login_dom, ensure_ascii=False))
check("A1 登录页无企业下拉", login_dom and login_dom.get("selCount") == 0, str(login_dom))
check("A2 登录页无'选择登录公司'占位", login_dom and not login_dom.get("hasPlaceholder"), str(login_dom))
check("A3 登录页显示固定企业徽标", login_dom and "登录企业" in (login_dom.get("badgeText") or ""), str(login_dom))
# 填表登录(无需选企业)
ev(ws, """(() => {
const setVal = (sel, v) => {
const el = document.querySelector(sel);
if (!el) return false;
const s = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
s.call(el, v);
el.dispatchEvent(new Event('input', { bubbles: true }));
return true;
};
return setVal('input[placeholder="用户名"]', '""" + USER + """') && setVal('input[placeholder="密码"]', '""" + PWD + """');
})()""")
time.sleep(0.6)
ev(ws, """(() => {
const btns = Array.from(document.querySelectorAll('button'));
const b = btns.find(x => (x.innerText || '').replace(/\\s/g, '') === '登录');
if (b) { b.click(); return 'CLICKED'; }
return 'NOBTN';
})()""")
ok = wait_until(ws, "location.pathname.startsWith('/my-dashboard') || (document.body.innerText||'').includes('我的驾驶舱')", True, tries=30, gap=1.2)
check("A4 固定版登录进入 my-dashboard", ok, "url=" + str(ev(ws, "location.href")))
time.sleep(1.5)
ent = ev(ws, "localStorage.getItem('cma_entity_id')")
check("A5 登录后 localStorage 企业=1(酣客)", ent == "1", "ent=" + str(ent))
# 等主布局(header)渲染完成后再探测切换器/tag
ok = wait_until(ws, "!!document.querySelector('.el-header')", True, tries=20, gap=1.0)
check("A6a 主布局渲染", ok)
time.sleep(0.8)
hdr = ev(ws, """(() => {
const h = document.querySelector('.el-header');
const t = h ? (h.innerText || '') : '';
return {
selCount: h ? h.querySelectorAll('.el-select').length : -1,
entityTag: h ? h.querySelector('.entity-tag') ? (h.querySelector('.entity-tag').innerText || '') : '' : '',
text: t
};
})()""")
print(" header:", json.dumps(hdr, ensure_ascii=False))
check("A6 顶部无企业切换器(selCount=0)", hdr and hdr.get("selCount") == 0, str(hdr))
check("A7 顶部显示固定企业 tag", hdr and bool(hdr.get("entityTag")), str(hdr))
logo = ev(ws, "(() => { const l = document.querySelector('.logo'); return l ? (l.innerText||'') : ''; })()")
check("A8 侧边栏品牌含'管理会计OS'", logo and "管理会计OS" in logo, "logo=" + str(logo))
# 报表中心:页内企业切换下拉应隐藏,数据加载正常
ev(ws, "location.href = " + js_str(FIXED_URL + "/reports"))
ok = wait_until(ws, "(document.body.innerText||'').includes('管理报表')", True, tries=25, gap=1.0)
check("A9 报表中心挂载", ok)
time.sleep(3.5)
rep = ev(ws, """(() => {
const hd = document.querySelector('.report-header');
const t = document.body.innerText || '';
return {
headerSelCount: hd ? hd.querySelectorAll('.el-select').length : -1,
headerText: hd ? (hd.innerText || '') : '',
tableRows: document.querySelectorAll('.el-table__body-wrapper tbody tr').length,
pageTextHas企业: t.includes('选择企业')
};
})()""")
print(" reports:", json.dumps(rep, ensure_ascii=False)[:400])
check("A10 报表中心无页内企业切换下拉", rep and rep.get("headerSelCount") == 0, str(rep))
check("A11 报表中心无'选择企业'下拉", rep and not rep.get("pageTextHas企业"), str(rep))
check("A12 报表数据表有行(entity1 数据)", rep and rep.get("tableRows", 0) > 0, "rows=" + str(rep and rep.get("tableRows")))
# ───────────────────────── 非固定超管版(保持切换)─────────────────────────
print("== [B] 非固定超管版(无 VITE_FIXED_ENTITY_ID)==")
tid2, ws2 = new_page(MULTI_URL + "/login")
if not ws2:
print("FATAL: no ws for multi login"); sys.exit(1)
ev(ws2, "localStorage.removeItem('cma_token'); localStorage.removeItem('cma_user'); localStorage.removeItem('cma_entity_id'); localStorage.removeItem('cma_entity_name'); 'cleared'")
ev(ws2, "location.href = " + js_str(MULTI_URL + "/login"))
ok = wait_until(ws2, LOGIN_READY, True, tries=30, gap=1.0)
check("B0 超管登录页挂载", ok)
time.sleep(3.0) # 等 login-entities 返回、企业下拉渲染
mlogin = ev(ws2, """(() => {
const t = document.body.innerText || '';
const sel = document.querySelector('.login-card .el-select');
return { selCount: document.querySelectorAll('.login-card .el-select').length, badgeCount: document.querySelectorAll('.fixed-entity-badge').length, hasPlaceholder: t.includes('选择登录公司'), selText: sel ? (sel.innerText || '') : '' };
})()""")
print(" multi-login:", json.dumps(mlogin, ensure_ascii=False))
check("B1 超管登录页有企业下拉", mlogin and mlogin.get("selCount") >= 1, str(mlogin))
check("B2 超管登录页为下拉模式(无固定企业徽标)", mlogin and mlogin.get("selCount") >= 1 and mlogin.get("badgeCount") == 0, str(mlogin))
ev(ws2, """(() => {
const setVal = (sel, v) => {
const el = document.querySelector(sel);
if (!el) return false;
const s = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
s.call(el, v);
el.dispatchEvent(new Event('input', { bubbles: true }));
return true;
};
return setVal('input[placeholder="用户名"]', '""" + USER + """') && setVal('input[placeholder="密码"]', '""" + PWD + """');
})()""")
time.sleep(0.6)
ev(ws2, """(() => {
const btns = Array.from(document.querySelectorAll('button'));
const b = btns.find(x => (x.innerText || '').replace(/\\s/g, '') === '登录');
if (b) { b.click(); return 'CLICKED'; }
return 'NOBTN';
})()""")
ok = wait_until(ws2, "location.pathname.startsWith('/my-dashboard') || (document.body.innerText||'').includes('我的驾驶舱')", True, tries=30, gap=1.2)
check("B3 超管版登录进入", ok)
time.sleep(2.5)
mhdr = ev(ws2, """(() => {
const h = document.querySelector('.el-header');
return { selCount: h ? h.querySelectorAll('.el-select').length : -1, entityTagCount: h ? h.querySelectorAll('.entity-tag').length : -1 };
})()""")
print(" multi-header:", json.dumps(mhdr, ensure_ascii=False))
check("B4 超管顶部保留企业切换器", mhdr and mhdr.get("selCount") >= 1, str(mhdr))
opts = ev(ws2, "Array.from(document.querySelectorAll('.el-header .el-select .el-option')).map(o => o.innerText).join('|')")
if not opts:
# el-select 选项在未展开时不渲染,改用 my-entities API 直接验证 admin 授权≥2
entc = ev(ws2, "fetch('/api/cma/auth/my-entities', {headers:{Authorization:'Bearer ' + localStorage.getItem('cma_token')}}).then(r=>r.json()).then(d => Array.isArray(d) ? d.length : ((d.data||[]).length)).catch(e=>'ERR:'+e.message)")
entc = None if entc == "ERR:" else entc
check("B5 超管 my-entities≥2(经API)", isinstance(entc, (int, float)) and entc >= 2, "count=" + str(entc))
else:
check("B5 切换器选项≥2", len(opts.split("|")) >= 2, opts)
ev(ws2, "location.href = " + js_str(MULTI_URL + "/reports"))
ok = wait_until(ws2, "(document.body.innerText||'').includes('管理报表')", True, tries=25, gap=1.0)
check("B6 报表中心挂载", ok)
time.sleep(2.5)
mrep = ev(ws2, """(() => {
const hd = document.querySelector('.report-header');
const t = document.body.innerText || '';
return { headerSelCount: hd ? hd.querySelectorAll('.el-select').length : -1, hasPlaceholder: t.includes('选择企业') };
})()""")
check("B7 报表中心保留企业切换下拉", mrep and mrep.get("headerSelCount") >= 1, str(mrep))
print()
print(f"RESULT: {len(PASSES)} passed, {len(FAILS)} failed")
if FAILS:
print("FAILED:", "; ".join(FAILS))
sys.exit(1)
print("ALL PASS")
if __name__ == "__main__":
main()