"""数据中枢控制台浏览器自测:真的打开控制台,点一遍新页面。 跑法: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())