300 lines
17 KiB
Python
300 lines
17 KiB
Python
"""数据中枢控制台浏览器自测:真的打开控制台,点一遍新页面。
|
|
|
|
跑法:python tools/verify_datahub_console_ui.py [--shots 目录]
|
|
覆盖门禁、凭证区、模型池(拉取失败→手动录入)、会员与邀请码、日夜主题、1030 窄屏。
|
|
与 verify_datahub_console.py 共用沙箱:临时端口 + 临时库,跑完把仓库状态原样放回。
|
|
需要 Playwright 与本地 Chromium。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import verify_datahub_console as backend
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
FAILURES: list[str] = []
|
|
|
|
|
|
def chrome_path() -> str | None:
|
|
"""Playwright 默认渠道是 msedge;这里优先用它自带的 chromium。
|
|
|
|
CHROMIUM_PATH 可显式指定;否则在 Playwright 缓存里找一份(版本号会随
|
|
Playwright 升级变化,所以按目录名匹配而不写死)。缺 GTK 系 so 时,用
|
|
LD_LIBRARY_PATH 指向本地补齐的库目录再跑本脚本。
|
|
"""
|
|
explicit = os.environ.get("CHROMIUM_PATH")
|
|
if explicit:
|
|
return explicit
|
|
cache = Path.home() / ".cache/ms-playwright"
|
|
builds = sorted(cache.glob("chromium-*/chrome-linux*/chrome"), reverse=True)
|
|
return str(builds[0]) if builds else None
|
|
|
|
|
|
|
|
def check(label: str, ok: bool, detail: str = "") -> None:
|
|
print(f" {'PASS' if ok else 'FAIL'} {label}{(' — ' + detail) if detail else ''}")
|
|
if not ok:
|
|
FAILURES.append(label)
|
|
|
|
|
|
def main() -> int:
|
|
if any(argument in {"-h", "--help"} for argument in sys.argv[1:]):
|
|
print(__doc__.strip())
|
|
return 0
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
shots = Path(sys.argv[sys.argv.index("--shots") + 1]) if "--shots" in sys.argv else None
|
|
if shots:
|
|
shots.mkdir(parents=True, exist_ok=True)
|
|
workdir = Path(backend.tempfile.mkdtemp(prefix="hel560-ui-"))
|
|
review_port, hub_port = backend.free_port(), backend.free_port()
|
|
review, hub = f"http://127.0.0.1:{review_port}", f"http://127.0.0.1:{hub_port}"
|
|
with backend.RepoSandbox():
|
|
backend.start_review(workdir, review_port)
|
|
backend.wait_for(f"{review}/api/session")
|
|
backend.start_hub(workdir, hub_port, review_port)
|
|
backend.wait_for(f"{hub}/livez")
|
|
|
|
status, _, cookie_header = backend.request(
|
|
f"{review}/api/auth/register", {"username": "boss", "password": backend.PASSWORD}, "POST"
|
|
)
|
|
admin_cookie = backend.session_cookie(cookie_header).split("=", 1)[1]
|
|
|
|
with sync_playwright() as play:
|
|
browser = play.chromium.launch(executable_path=chrome_path())
|
|
errors: list[str] = []
|
|
|
|
def new_page(width: int, logged_in: bool):
|
|
context = browser.new_context(viewport={"width": width, "height": 900})
|
|
if logged_in:
|
|
context.add_cookies([
|
|
{"name": "xiaobai_session", "value": admin_cookie, "domain": "127.0.0.1", "path": "/"}
|
|
])
|
|
page = context.new_page()
|
|
page.on("pageerror", lambda exc: errors.append(f"{width}px pageerror: {exc}"))
|
|
page.on("console", lambda msg: errors.append(f"{width}px console.{msg.type}: {msg.text}")
|
|
if msg.type == "error" else None)
|
|
page.on("response", lambda res: errors.append(f"{width}px HTTP {res.status} {res.url}")
|
|
if res.status >= 400 else None)
|
|
return context, page
|
|
|
|
print("\n[UI-1] 未登录时只看到门禁,不再有独立登录表单")
|
|
context, page = new_page(1440, logged_in=False)
|
|
page.goto(f"{hub}/admin/", wait_until="networkidle")
|
|
check("门禁面板可见", page.is_visible("#gate-view"))
|
|
check("控制台外壳隐藏", page.is_hidden("#appRoot"))
|
|
check("提示去主站登录", "登录" in page.inner_text("#gate-desc"), page.inner_text("#gate-desc")[:40])
|
|
check("给出主站登录链接", "8765" in (page.get_attribute("#gate-login", "href") or "") or
|
|
str(review_port) in (page.get_attribute("#gate-login", "href") or ""))
|
|
check("页面里没有独立账号输入框", page.locator("#login-form, #change-form").count() == 0)
|
|
if shots:
|
|
page.screenshot(path=str(shots / "ui-gate.png"), full_page=True)
|
|
context.close()
|
|
|
|
print("\n[UI-2] 带主站管理员会话直接进控制台")
|
|
context, page = new_page(1440, logged_in=True)
|
|
page.goto(f"{hub}/admin/", wait_until="networkidle")
|
|
page.wait_for_selector("#appRoot:not([hidden])", timeout=15000)
|
|
check("控制台外壳渲染", page.is_visible("#appRoot"))
|
|
check("右上角显示主站用户名", page.inner_text("#who").strip() == "boss", page.inner_text("#who"))
|
|
check("导航含模型池与会员管理", page.locator('[data-nav="models"]').count() == 1
|
|
and page.locator('[data-nav="members"]').count() == 1)
|
|
|
|
print("\n[UI-3] 数据源页带可编辑凭证区")
|
|
page.click('[data-nav="sources"]')
|
|
page.wait_for_selector('[data-cred-form="tushare"]', timeout=10000)
|
|
check("Tushare 卡出现凭证输入框", page.locator('[data-cred-input="tushare_token"]').count() == 1)
|
|
check("iFinD 卡也能在线填凭证", page.locator('[data-cred-input="ifind_refresh_token"]').count() == 1)
|
|
check("凭证输入是密码框(不回显明文)",
|
|
page.get_attribute('[data-cred-input="tushare_token"]', "type") == "password")
|
|
check("原有接口清单没被删掉", page.locator("table.dtable").count() >= 1)
|
|
if shots:
|
|
page.screenshot(path=str(shots / "ui-sources-night.png"), full_page=True)
|
|
|
|
print("\n[UI-4] 模型池按供应商组织,可拉取/手填")
|
|
page.click('[data-nav="models"]')
|
|
page.wait_for_selector("#addVendor", timeout=10000)
|
|
check("有新增供应商入口", page.is_visible("#addVendor"))
|
|
check("有调用编排(主/辅模型)", page.locator("#primaryModel").count() == 1 and page.locator("#fallbackModel").count() == 1)
|
|
check("顶部条给出供应商与模型数", "供应商" in page.inner_text(".strip") and "主模型" in page.inner_text(".strip"))
|
|
page.select_option("#newVendorPreset", "https://api.openai.com/v1")
|
|
page.click("#addVendor")
|
|
page.wait_for_selector('[data-vend-fetch="https://api.openai.com/v1"]', timeout=10000)
|
|
check("供应商卡有获取模型列表按钮", page.is_visible('[data-vend-fetch="https://api.openai.com/v1"]'))
|
|
check("供应商卡有 BASE URL 与 API KEY 两个字段",
|
|
page.locator('[data-vend-url="https://api.openai.com/v1"]').count() == 1
|
|
and page.locator('[data-vend-key="https://api.openai.com/v1"]').count() == 1)
|
|
check("供应商卡可单独保存", page.is_visible('[data-vend-save="https://api.openai.com/v1"]'))
|
|
|
|
print("\n[UI-4b] 拉取失败后退回卡内手动录入(不弹系统对话框)")
|
|
page.fill('[data-vend-key="https://api.openai.com/v1"]', "sk-invalid-for-smoke")
|
|
with page.expect_response(lambda res: "/models/fetch" in res.url, timeout=20000):
|
|
page.click('[data-vend-fetch="https://api.openai.com/v1"]')
|
|
page.wait_for_selector('[data-vend-manual-input="https://api.openai.com/v1"]', timeout=15000)
|
|
check("拉取失败给出失败提示", "拉取失败" in page.inner_text(".vend-note.bad"),
|
|
page.inner_text(".vend-note.bad")[:80])
|
|
check("失败后出现手动录入输入框", page.is_visible('[data-vend-manual-input="https://api.openai.com/v1"]'))
|
|
page.fill('[data-vend-manual-input="https://api.openai.com/v1"]', "gpt-4o")
|
|
with page.expect_response(lambda res: "/models/save" in res.url, timeout=20000) as saved:
|
|
page.click('[data-vend-manual-add="https://api.openai.com/v1"]')
|
|
check("手动录入的模型保存成功", saved.value.status == 200, str(saved.value.status))
|
|
page.wait_for_selector(".model-row", timeout=15000)
|
|
row = page.inner_text(".model-row")
|
|
check("模型行显示名称/供应商/测试与删除", "gpt-4o" in row and "OpenAI" in row
|
|
and page.locator("[data-model-test]").count() >= 1
|
|
and page.locator("[data-model-remove]").count() >= 1, row.replace("\n", " | ")[:100])
|
|
check("首个模型自动成为主模型", "主模型" in row, row.replace("\n", " | ")[:80])
|
|
if shots:
|
|
page.screenshot(path=str(shots / "ui-models-night.png"), full_page=True)
|
|
|
|
print("\n[UI-5] 会员管理 + 邀请码:生成、复制、作废")
|
|
page.click('[data-nav="members"]')
|
|
page.wait_for_selector("#createInvites", timeout=10000)
|
|
check("会员表渲染出主站账号", "boss" in page.inner_text("table.dtable"))
|
|
check("有会员额度输入与保存", page.locator("#memberQuota").count() == 1 and page.locator("#saveQuota").count() == 1)
|
|
page.click("#createInvites")
|
|
page.wait_for_selector("[data-invite-copy]", timeout=10000)
|
|
code_text = page.inner_text("td.invite-code")
|
|
check("生成后当次显示完整邀请码", code_text.count("-") >= 3 and "•" not in code_text, code_text)
|
|
check("未使用的码可复制可作废",
|
|
page.locator("[data-invite-copy]").count() >= 1 and page.locator("[data-invite-revoke]").count() >= 1)
|
|
page.once("dialog", lambda dialog: dialog.accept())
|
|
revoke_response = None
|
|
with page.expect_response(lambda res: "/invites/revoke" in res.url, timeout=10000) as caught:
|
|
page.click("[data-invite-revoke]")
|
|
revoke_response = caught.value
|
|
page.wait_for_timeout(600)
|
|
check("作废接口返回 200", revoke_response.status == 200,
|
|
f"{revoke_response.status} {revoke_response.text()[:120]}")
|
|
invite_row = page.inner_text("tr:has(td.invite-code)")
|
|
check("作废后该行状态变为作废", "作废" in invite_row, invite_row.replace("\n", " | ")[:120])
|
|
check("作废后不再显示完整码,只留掩码", "•" in page.inner_text("td.invite-code"),
|
|
page.inner_text("td.invite-code"))
|
|
check("作废后复制与作废按钮都收起",
|
|
page.locator("[data-invite-copy]").count() == 0 and page.locator("[data-invite-revoke]").count() == 0)
|
|
if shots:
|
|
page.screenshot(path=str(shots / "ui-members-night.png"), full_page=True)
|
|
|
|
print("\n[UI-6] 日间 / 夜间主题切换")
|
|
check("默认夜间", page.get_attribute("html", "data-theme") == "night")
|
|
page.click("#themeBtn")
|
|
page.wait_for_timeout(300)
|
|
check("切到日间后 data-theme=day", page.get_attribute("html", "data-theme") == "day")
|
|
body_bg = page.evaluate("getComputedStyle(document.body).backgroundColor")
|
|
check("日间底色是浅色", _is_light(body_bg), body_bg)
|
|
check("按钮文案回切为夜间", page.inner_text("#themeBtn").strip() == "夜间", page.inner_text("#themeBtn"))
|
|
if shots:
|
|
page.screenshot(path=str(shots / "ui-members-day.png"), full_page=True)
|
|
page.click('[data-nav="sources"]')
|
|
page.wait_for_timeout(600)
|
|
page.screenshot(path=str(shots / "ui-sources-day.png"), full_page=True)
|
|
page.click('[data-nav="models"]')
|
|
page.wait_for_timeout(600)
|
|
page.screenshot(path=str(shots / "ui-models-day.png"), full_page=True)
|
|
page.click("#themeBtn")
|
|
page.reload(wait_until="networkidle")
|
|
page.wait_for_selector("#appRoot:not([hidden])", timeout=15000)
|
|
check("主题选择刷新后保持", page.get_attribute("html", "data-theme") == "night")
|
|
context.close()
|
|
|
|
print("\n[UI-7] 窄屏 1030px:输入框与按钮竖排不重叠")
|
|
context, page = new_page(1030, logged_in=True)
|
|
page.goto(f"{hub}/admin/#sources", wait_until="networkidle")
|
|
page.wait_for_selector('[data-cred-form="tushare"]', timeout=15000)
|
|
# 窄屏下"输入框一行、按钮整排落到下一行"是样图 1030 的硬要求
|
|
stacked = page.evaluate(
|
|
"""() => {
|
|
const bad = [];
|
|
document.querySelectorAll('.cred-box').forEach((box) => {
|
|
const input = box.querySelector('input');
|
|
const button = box.querySelector('.pbtn');
|
|
if (!input || !button) return;
|
|
const a = input.getBoundingClientRect();
|
|
const b = button.getBoundingClientRect();
|
|
const overlap = a.right > b.left && a.left < b.right && a.bottom > b.top && a.top < b.bottom;
|
|
if (overlap) bad.push(box.dataset.credForm + ':重叠');
|
|
if (b.top < a.bottom - 1) bad.push(box.dataset.credForm + ':同行');
|
|
});
|
|
return bad;
|
|
}"""
|
|
)
|
|
check("凭证输入框与按钮竖排不重叠", stacked == [], str(stacked))
|
|
clipped = page.evaluate(
|
|
"() => [...document.querySelectorAll('input, select, .pbtn, .tbtn')]"
|
|
".filter((el) => el.getBoundingClientRect().right > window.innerWidth + 1).length"
|
|
)
|
|
check("窄屏没有控件溢出视口", clipped == 0, str(clipped))
|
|
if shots:
|
|
page.screenshot(path=str(shots / "ui-sources-1030.png"), full_page=True)
|
|
page.goto(f"{hub}/admin/#models", wait_until="networkidle")
|
|
page.wait_for_selector(".vend-line", timeout=15000)
|
|
# 供应商卡的地址/Key/按钮在宽屏并排,窄屏必须整列竖排
|
|
model_stacked = page.evaluate(
|
|
"""() => {
|
|
const line = document.querySelector('.vend-line');
|
|
const kids = [...line.children];
|
|
const bad = [];
|
|
for (let i = 1; i < kids.length; i += 1) {
|
|
const prev = kids[i - 1].getBoundingClientRect();
|
|
const cur = kids[i].getBoundingClientRect();
|
|
if (cur.top < prev.bottom - 1) bad.push(i);
|
|
}
|
|
return bad;
|
|
}"""
|
|
)
|
|
check("供应商卡地址/Key/按钮窄屏竖排", model_stacked == [], str(model_stacked))
|
|
model_clipped = page.evaluate(
|
|
"() => [...document.querySelectorAll('input, select, .pbtn, .tbtn, .model-row')]"
|
|
".filter((el) => el.getBoundingClientRect().right > window.innerWidth + 1).length"
|
|
)
|
|
check("模型页窄屏没有控件溢出视口", model_clipped == 0, str(model_clipped))
|
|
if shots:
|
|
page.screenshot(path=str(shots / "ui-models-1030.png"), full_page=True)
|
|
page.goto(f"{hub}/admin/#members", wait_until="networkidle")
|
|
page.wait_for_timeout(800)
|
|
page.screenshot(path=str(shots / "ui-members-1030.png"), full_page=True)
|
|
context.close()
|
|
|
|
print("\n[UI-8] 全程无 JS 异常与服务端错误")
|
|
# 预期噪音:未登录时门禁本来就会拿到 401;favicon 本项目没提供。
|
|
def expected(line: str) -> bool:
|
|
if "favicon" in line:
|
|
return True
|
|
if "401" in line and "/admin/api/session" in line:
|
|
return True
|
|
if "400" in line and "/admin/api/models/fetch" in line:
|
|
return True # UI-4b 故意用错 Key 拉取,400 是本轮要验的正确行为
|
|
if "console.error: Failed to load resource" in line:
|
|
return True # 上面两类的浏览器侧复述,URL 已单独判过
|
|
return False
|
|
|
|
crashes = [line for line in errors if "pageerror" in line]
|
|
server_errors = [line for line in errors if "HTTP 5" in line]
|
|
unexpected = [line for line in errors if not expected(line) and "pageerror" not in line
|
|
and "HTTP 5" not in line]
|
|
check("没有 JS 未捕获异常", crashes == [], "; ".join(crashes[:3]))
|
|
check("没有 5xx 服务端错误", server_errors == [], "; ".join(server_errors[:3]))
|
|
check("没有其它意外失败请求", unexpected == [], "; ".join(unexpected[:3]))
|
|
browser.close()
|
|
backend.shutil.rmtree(workdir, ignore_errors=True)
|
|
|
|
print("\n" + "=" * 60)
|
|
if FAILURES:
|
|
print(f"FAILED {len(FAILURES)} 项:")
|
|
for item in FAILURES:
|
|
print(" - " + item)
|
|
return 1
|
|
print("数据中枢控制台浏览器自测全部通过")
|
|
return 0
|
|
|
|
|
|
def _is_light(colour: str) -> bool:
|
|
numbers = [int(part) for part in colour.replace("rgba", "").replace("rgb", "").strip("() ").split(",")[:3]]
|
|
return sum(numbers) / 3 > 160
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|