Files
cma-management/backend/scripts/verify_budget_versions_ui_browser.py
T

225 lines
7.9 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""2026-09-07 预算版本管理UI修复 - 浏览器页面实测
流程: 登录博海(entity2) → /budget 版本管理 → 提取行DOM断言 → switch-entity 到酣客(entity1) → 再提取断言
验收: 博海 v2026H2 行: 状态草稿/条目数25/总额462/最后更新非空/提交审批按钮可见
酣客 active版本有提交审批; 2026H2 approved 显示已批准 无提交按钮
"""
import json
import sys
import time
import urllib.request
import websocket
CDP_HTTP = "http://127.0.0.1:9224"
BASE = "https://cma.sxbh.ltd/api/cma"
def http_get(path):
with urllib.request.urlopen(CDP_HTTP + path, timeout=5) as r:
return json.loads(r.read().decode())
class CDP:
def __init__(self, ws_url):
self.ws = websocket.create_connection(ws_url, timeout=40)
self.msg_id = 0
def call(self, method, params=None):
self.msg_id += 1
mid = self.msg_id
self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
while True:
resp = json.loads(self.ws.recv())
if resp.get("id") == mid:
if "error" in resp:
raise RuntimeError(f"{method}: {resp['error']}")
return resp.get("result", {})
def eval(self, expr):
r = self.call("Runtime.evaluate", {"expression": expr, "returnByValue": True, "awaitPromise": True})
if r.get("exceptionDetails"):
return "EXC: " + json.dumps(r["exceptionDetails"], ensure_ascii=False)[:300]
return r.get("result", {}).get("value")
def close(self):
try:
self.ws.close()
except Exception:
pass
def open_page(url):
ver = http_get("/json/version")
bws = CDP(ver["webSocketDebuggerUrl"])
t = bws.call("Target.createTarget", {"url": url})
tid = t["targetId"]
bws.close()
time.sleep(2.5)
for p in http_get("/json"):
if p["id"] == tid:
return tid, p["webSocketDebuggerUrl"]
return tid, None
def wait_for(c, expr, timeout=25, interval=1.0):
"""轮询直到 expr 为 truthy,返回其值"""
for _ in range(int(timeout / interval)):
v = c.eval(expr)
if v:
return v
time.sleep(interval)
return None
def login_via_fetch(c, entity_id):
"""同源 fetch 登录并写 localStorage,返回状态文本"""
expr = f"""
(async () => {{
const r = await fetch('{BASE}/auth/login', {{
method: 'POST', headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{username:'admin', password:'admin123', entity_id:{entity_id}}})
}});
const j = await r.json();
if (!r.ok) return 'LOGIN_FAIL:' + JSON.stringify(j);
localStorage.setItem('cma_token', j.token);
localStorage.setItem('cma_entity_id', String(j.entity_id));
localStorage.setItem('cma_entity_name', j.entity_name || '');
localStorage.setItem('cma_user', JSON.stringify(j.user || {{}}));
return 'LOGIN_OK:' + j.entity_name + ':' + j.token.slice(0, 8);
}})()
"""
return c.eval(expr)
JS_GO_BUDGET = """
(() => {
location.href = '/budget';
return 'NAV';
})()
"""
JS_EXTRACT = r"""
(() => {
// 等表格渲染完
const rows = Array.from(document.querySelectorAll('.el-table__body-wrapper tbody tr'));
if (!rows.length) return 'NO_ROWS';
const out = [];
for (const tr of rows) {
const tds = Array.from(tr.querySelectorAll('td'));
if (tds.length < 6) continue;
const getTxt = (td) => (td.innerText || '').trim().replace(/\s+/g, ' ');
const version = getTxt(tds[0]);
const status = getTxt(tds[1]);
const planCount = getTxt(tds[2]);
const total = getTxt(tds[3]);
const updated = getTxt(tds[4]);
const opTxt = (tds[5] ? tds[5].innerText || '' : '').trim().replace(/\s+/g, ' ');
const btns = Array.from(tds[5].querySelectorAll('button')).map(b => (b.innerText || '').trim().replace(/\s+/g, ''));
const checkboxCount = tds[5].querySelectorAll('.el-checkbox').length;
out.push({ version, status, planCount, total, updated, opTxt, btns, checkboxCount });
}
return JSON.stringify(out);
})()
"""
JS_CLICK_VERSION_TAB = """
(() => {
const tabs = Array.from(document.querySelectorAll('[role="tab"]'));
const t = tabs.find(e => (e.innerText || '').replace(/\\s/g, '') === '版本管理');
if (!t) return 'NO_TAB:' + tabs.map(x => x.innerText).join('|');
t.click();
return 'TAB_CLICKED';
})()
"""
def main():
tid, ws = open_page("https://cma.sxbh.ltd/login")
if not ws:
print("FAIL: no ws for new page")
sys.exit(1)
c = CDP(ws)
c.call("Page.enable")
c.call("Runtime.enable")
# 等待页面导航到 cma 域完成(约 blank → login 竞态修复,2026-09-07 复跑实测:
# Target.createTarget 后偶发停留在 about:blank → 访问 localStorage 抛 SecurityError)
nav = wait_for(c, "location.hostname === 'cma.sxbh.ltd'", timeout=25)
if not nav:
print("FAIL: 页面未导航到 cma.sxbh.ltd")
sys.exit(1)
time.sleep(3)
# 1) 登录博海 entity=2
r = login_via_fetch(c, 2)
print("LOGIN_E2:", r)
if not (isinstance(r, str) and r.startswith("LOGIN_OK")):
print("FAIL login entity2")
sys.exit(1)
c.eval(JS_GO_BUDGET)
time.sleep(5)
print("URL1:", c.eval("location.href"))
# 确认当前账套
print("ENTITY1:", c.eval("localStorage.getItem('cma_entity_id')"))
tab = wait_for(c, JS_CLICK_VERSION_TAB, timeout=15)
print("TAB1:", tab)
time.sleep(4)
rows_json = wait_for(c, JS_EXTRACT, timeout=20)
print("ROWS_E2_RAW:", rows_json)
rows_e2 = json.loads(rows_json) if isinstance(rows_json, str) and rows_json.startswith("[") else []
for row in rows_e2:
print(" E2", json.dumps(row, ensure_ascii=False))
# 断言博海
ok = True
target = [r for r in rows_e2 if r.get("version", "").strip().startswith("v2026H2")]
if not target:
print("FAIL: 博海未见 v2026H2")
ok = False
else:
r = target[0]
assert "草稿" in r["status"], f"状态应含草稿, got {r['status']}"
assert r["planCount"].replace(" ", "") == "25", f"条目数应25, got {r['planCount']}"
assert "462" in r["total"].replace(",", ""), f"总额应462, got {r['total']}"
assert r["updated"], "最后更新应非空"
assert "提交审批" in r["opTxt"], f"应有提交审批按钮, got {r['btns']}"
print("E2_ASSERT_OK:", json.dumps(r, ensure_ascii=False))
# 2) 切换酣客 entity=1(同页面 fetch switch-entity 或直接重新登录)
r = login_via_fetch(c, 1)
print("LOGIN_E1:", r)
c.eval(JS_GO_BUDGET)
time.sleep(5)
print("ENTITY2:", c.eval("localStorage.getItem('cma_entity_id')"))
tab = wait_for(c, JS_CLICK_VERSION_TAB, timeout=15)
print("TAB2:", tab)
time.sleep(4)
rows_json = wait_for(c, JS_EXTRACT, timeout=20)
print("ROWS_E1_RAW:", rows_json)
rows_e1 = json.loads(rows_json) if isinstance(rows_json, str) and rows_json.startswith("[") else []
for row in rows_e1:
print(" E1", json.dumps(row, ensure_ascii=False))
ok2 = True
for row in rows_e1:
ver = row.get("version", "").strip()
if ver == "2026H2":
if "已批准" not in row["status"] or "提交审批" in row["opTxt"]:
print(f"FAIL: 酣客2026H2 approved 应只读已批准无提交, got {json.dumps(row, ensure_ascii=False)}")
ok2 = False
elif row["status"].replace(" ", "").startswith("草稿"):
if "提交审批" not in row["opTxt"]:
print(f"FAIL: 酣客{ver} active/草稿 应有提交审批, got {json.dumps(row, ensure_ascii=False)}")
ok2 = False
if not rows_e1:
ok2 = False
print("FAIL: 酣客无行")
print("\n" + ("ALL_PASS" if (ok and ok2) else "HAS_FAIL"))
c.close()
sys.exit(0 if (ok and ok2) else 1)
if __name__ == "__main__":
main()