#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ U5(8 页走查) + U2(审核→发布) 真 UI 走查 —— 单文件 CDP 客户端(websocket-client 直驱)。 在 mini-desktop 本地驱动 headless chrome(:9222) 走 localhost 前端(staging-ops §1 认可的 e2e 门): U5 studio(:4173) 6 页:feed流 / 试玩(play) / 创作(create,draft) / 创作者主页(creator) / 素材中心(material) / 玩法中心(template) U5 admin (:4174) 2 页:经营看板(operation-dashboard) / 审核队列(review) U2 admin 审核→发布:审核队列点「通过」approve 真实待审游戏(已 seed 9324 saa-e2e-breakout) → 验转发布态。 逐页断言(每页落 PAGE_VERDICT): - 关键元素渲染(DOM 实查 selector) - 控制台零未捕获异常(Runtime.exceptionThrown / console.error 累计) - 零 5xx(Network.responseReceived 累计状态码 >=500) - 真数据(非空壳:列表/卡片/指标计数 > 0) 鉴权:studio 受保护页用 localStorage 注入 test1 token(key=wanxiang_token,见 store/user.ts)过 requiresAuth 守卫(只读走查口径); tenant-id=1 由前端请求拦截器无条件注入,无需手动设。admin 走真实登录(芋道源码/admin/admin123)。 不改后端/DB(seed 已在脚本外经真实 publish 路径完成),仅 UI 走查取证。 """ import json, base64, time, sys, urllib.request import websocket # websocket-client CDP_HTTP = "http://localhost:9222" STUDIO = "http://localhost:4173" ADMIN = "http://localhost:4174" SHOT_DIR = "/tmp/u5u2" STUDIO_TOKEN = "test1" # U2 seed: 经真实 publish 路径已置 9324(saa-e2e-breakout) 为 status=1 审核中 U2_TARGET_NAME = "saa-e2e-breakout" U2_TARGET_ID = "9324" import os os.makedirs(SHOT_DIR, exist_ok=True) def new_target(url="about:blank"): """Chrome 111+ 必须用 PUT /json/new 建 target。""" req = urllib.request.Request(f"{CDP_HTTP}/json/new?{url}", method="PUT") d = json.load(urllib.request.urlopen(req, timeout=10)) return d["webSocketDebuggerUrl"], d["id"] class CDP: def __init__(self, ws_url): self.ws = websocket.create_connection(ws_url, max_size=None, timeout=45, suppress_origin=True) self._id = 0 self.console = [] # 累积 console error / exception self.http5xx = [] # 累积 5xx 响应 self.http4xx = [] # 累积 4xx 响应(参考,不计入 pass) def _collect(self, msg): m = msg.get("method") if m == "Log.entryAdded": e = msg["params"]["entry"] if e.get("level") == "error": self.console.append({"src": "log", "level": "error", "text": (e.get("text") or "")[:240]}) elif m == "Runtime.consoleAPICalled": t = msg["params"].get("type") if t == "error": args = msg["params"].get("args", []) txt = " ".join(str(a.get("value", a.get("description", ""))) for a in args)[:240] self.console.append({"src": "console", "level": "error", "text": txt}) elif m == "Runtime.exceptionThrown": d = msg["params"].get("exceptionDetails", {}) self.console.append({"src": "exception", "level": "error", "text": (d.get("text") or json.dumps(d.get("exception", {}), ensure_ascii=False))[:240]}) elif m == "Network.responseReceived": r = msg["params"].get("response", {}) st = r.get("status", 0) url = (r.get("url") or "")[:160] if st >= 500: self.http5xx.append({"status": st, "url": url}) elif 400 <= st < 500: self.http4xx.append({"status": st, "url": url}) def send(self, method, params=None, timeout=45): self._id += 1 mid = self._id self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}})) end = time.time() + timeout while time.time() < end: self.ws.settimeout(max(0.1, end - time.time())) try: msg = json.loads(self.ws.recv()) except Exception: continue self._collect(msg) if msg.get("id") == mid: if "error" in msg: raise RuntimeError(f"{method}: {msg['error']}") return msg.get("result", {}) raise TimeoutError(method) def drain(self, secs=1.0): end = time.time() + secs while time.time() < end: self.ws.settimeout(max(0.05, end - time.time())) try: msg = json.loads(self.ws.recv()) except Exception: continue self._collect(msg) def evaljs(self, expr, timeout=45): r = self.send("Runtime.evaluate", {"expression": expr, "returnByValue": True, "awaitPromise": True}, timeout) if "exceptionDetails" in r: return {"__js_error__": json.dumps(r.get("exceptionDetails"), ensure_ascii=False)[:300]} return r.get("result", {}).get("value") def nav(self, url, settle=5): self.send("Page.navigate", {"url": url}) time.sleep(settle) self.drain(0.8) def shot(self, name): r = self.send("Page.captureScreenshot", {"format": "png"}) path = f"{SHOT_DIR}/{name}" with open(path, "wb") as f: f.write(base64.b64decode(r["data"])) return path def reset_net_log(self): """每页走查前清网络/console 累计,使断言只针对当前页。""" self.console = [] self.http5xx = [] self.http4xx = [] def close(self): try: self.ws.close() except Exception: pass # ---------- JS 片段 ---------- # 注入 studio 登录态(test1):必须在 :4173 同 origin 已开 target 内 setItem(localStorage 按 origin 隔离) def js_inject_studio_token(): return ("(()=>{try{" f"localStorage.setItem('wanxiang_token','{STUDIO_TOKEN}');" f"localStorage.setItem('wanxiang_user_id','1');" "localStorage.setItem('wanxiang_creator_flag','1');" "localStorage.setItem('wanxiang_nickname','走查创作者');" "return 'token-set:'+localStorage.getItem('wanxiang_token');" "}catch(e){return 'err:'+e.message}})()") # 通用页面信息 dump JS_DUMP = r"""(()=>{ const btns=[...document.querySelectorAll('button')].map(e=>(e.innerText||'').replace(/\s+/g,'')).filter(Boolean).slice(0,30); return {href:location.href, title:document.title, buttons:btns, bodyText:(document.body.innerText||'').replace(/\s+/g,' ').slice(0,400)}; })()""" # 计数若干 selector 命中数(真数据/渲染断言用);返回 {sel:count} def js_count(selectors): arr = json.dumps(selectors) return (r"""(()=>{const sels=%s;const o={}; for(const s of sels){try{o[s]=document.querySelectorAll(s).length}catch(e){o[s]=-1}} o.__href=location.href; o.__bodyLen=(document.body.innerText||'').replace(/\s+/g,'').length; return o;})()""" % arr) # admin el-table 行 dump(审核队列用) JS_QUEUE = r"""(()=>{ const rows=[...document.querySelectorAll('.el-table__row')].map(r=>{ const cells=[...r.querySelectorAll('td')].map(td=>(td.innerText||'').trim().replace(/\s+/g,' ').slice(0,40)); const btns=[...r.querySelectorAll('button')].map(b=>(b.innerText||'').replace(/\s+/g,'')); return {cells, btns}; }); const empty=document.querySelector('.el-table__empty-text'); return {href:location.href, rowCount:rows.length, rows:rows.slice(0,8), emptyText:empty?(empty.innerText||'').trim():'', bodyText:(document.body.innerText||'').replace(/\s+/g,' ').slice(0,260)}; })()""" JS_DUMP_LOGIN = r"""(()=>{ const inp=[...document.querySelectorAll('input')].map(e=>({type:e.type,ph:e.placeholder||''})); const btn=[...document.querySelectorAll('button')].map(e=>(e.innerText||'').replace(/\s+/g,'')).filter(Boolean); return {href:location.href, inputs:inp, buttons:btn}; })()""" JS_CLICK_LOGIN = r"""(()=>{ const b=[...document.querySelectorAll('button')].find(e=>(e.innerText||'').replace(/\s+/g,'')==='登录'); if(b){b.click();return 'clicked-login';} return 'no-login-btn'; })()""" def js_click_pass(name): return r"""(()=>{ const rows=[...document.querySelectorAll('.el-table__row')]; for(const r of rows){ if((r.innerText||'').includes('%s')){ const p=[...r.querySelectorAll('button')].find(b=>(b.innerText||'').replace(/\s+/g,'')==='通过'); if(p){p.click();return 'clicked-pass';} return 'row-matched-no-pass-btn|btns:'+[...r.querySelectorAll('button')].map(b=>(b.innerText||'').replace(/\s+/g,'')).join(','); } } return 'target-not-in-rows|count:'+rows.length; })()""" % name JS_CONFIRM = r"""(()=>{ const btns=[...document.querySelectorAll('.el-message-box button,.el-overlay button,.el-dialog button')]; const ok=btns.find(b=>{const t=(b.innerText||'').replace(/\s+/g,'');return t==='确定'||t==='确认';}); if(ok){ok.click();return 'confirmed';} return 'no-confirm|btns:'+btns.map(b=>(b.innerText||'').replace(/\s+/g,'')).join(','); })()""" def out(tag, obj): print(f"@@{tag}@@ " + json.dumps(obj, ensure_ascii=False), flush=True) def page_verdict(c, page, counts, render_ok, data_ok, extra=None): """汇总单页断言:渲染 / console 零异常 / 零 5xx / 真数据。""" v = { "page": page, "url": counts.get("__href", ""), "render_ok": render_ok, "console_errors": c.console[:8], "console_error_count": len(c.console), "http5xx": c.http5xx[:6], "http5xx_count": len(c.http5xx), "data_ok": data_ok, "counts": {k: v2 for k, v2 in counts.items() if not k.startswith("__")}, "pass": bool(render_ok and data_ok and len(c.console) == 0 and len(c.http5xx) == 0), } if extra: v["extra"] = extra out("PAGE_VERDICT", v) return v def main(): ws_url, tid = new_target("about:blank") c = CDP(ws_url) c.send("Page.enable") c.send("Runtime.enable") c.send("Log.enable") c.send("Network.enable") verdicts = [] # ====================================================================== # STUDIO 段:先建 :4173 origin 注入 token,再逐页走 # ====================================================================== c.nav(f"{STUDIO}/feed", settle=6) # 先到 feed(无需登录)建立 origin print("INJECT_TOKEN:", c.evaljs(js_inject_studio_token()), flush=True) # ---- U5-1 feed 流 ---- c.reset_net_log() c.nav(f"{STUDIO}/feed", settle=7) c.shot("u5_1_feed.png") counts = c.evaljs(js_count([".feed", ".feed__zone", ".feed__page", ".game-card,[class*=card]"])) out("FEED_DUMP", c.evaljs(JS_DUMP)) render = (counts.get(".feed", 0) > 0) data = (counts.get(".feed__page", 0) > 0) # 至少一张卡片页 verdicts.append(page_verdict(c, "studio/feed", counts, render, data)) # ---- U5-2 试玩 play(已发布游戏 9306/93112) ---- c.reset_net_log() c.nav(f"{STUDIO}/play/9306/93112", settle=9) # 试玩页含 iframe 宿主,留足加载时间 c.shot("u5_2_play.png") counts = c.evaljs(js_count([".play,[class*=play]", "iframe", ".loading-bar,[class*=loading]", "[class*=error]"])) out("PLAY_DUMP", c.evaljs(JS_DUMP)) # 渲染=有 iframe 宿主挂载;真数据=非错误态(无 error 容器主导) + iframe 在 render = (counts.get("iframe", 0) > 0) data = (counts.get("iframe", 0) > 0) verdicts.append(page_verdict(c, "studio/play", counts, render, data, extra={"note": "试玩=GamePlayer iframe 宿主挂载;游戏内真玩另见 game-e2e harness"})) # ---- U5-3 创作 create(draft,requiresAuth) ---- c.reset_net_log() c.nav(f"{STUDIO}/create", settle=7) c.shot("u5_3_create.png") counts = c.evaljs(js_count([".create-page", ".create-head", "textarea", "button"])) out("CREATE_DUMP", c.evaljs(JS_DUMP)) href = counts.get("__href", "") render = (counts.get(".create-page", 0) > 0) and ("/login" not in href) data = (counts.get("textarea", 0) > 0) # 创作输入框在=页面真渲染(非被守卫弹回登录) verdicts.append(page_verdict(c, "studio/create", counts, render, data, extra={"guard_bounced_to_login": "/login" in href})) # ---- U5-4 创作者主页 creator/1(PUBLIC,9 作品) ---- c.reset_net_log() c.nav(f"{STUDIO}/creator/1", settle=7) c.shot("u5_4_creator.png") counts = c.evaljs(js_count([".creator", ".creator__stats", ".creator__stat", ".game-card,[class*=card],[class*=work]", "[class*=empty]"])) out("CREATOR_DUMP", c.evaljs(JS_DUMP)) render = (counts.get(".creator", 0) > 0) data = (counts.get(".creator__stat", 0) > 0) # 统计卡(发布数/播放/点赞)渲染=真数据 verdicts.append(page_verdict(c, "studio/creator", counts, render, data)) # ---- U5-5 素材中心 material(requiresAuth) ---- c.reset_net_log() c.nav(f"{STUDIO}/studio/material", settle=7) c.shot("u5_5_material.png") counts = c.evaljs(js_count([".material", ".material__head", ".material__tabs", ".material__upload", "[class*=empty]", "[class*=asset],[class*=grid] img,[class*=item]"])) out("MATERIAL_DUMP", c.evaljs(JS_DUMP)) href = counts.get("__href", "") render = (counts.get(".material", 0) > 0) and ("/login" not in href) # 素材库可能为空(空态=合法非空壳);真渲染=头部+上传入口在 data = (counts.get(".material__head", 0) > 0 and counts.get(".material__upload", 0) > 0) verdicts.append(page_verdict(c, "studio/material", counts, render, data, extra={"guard_bounced_to_login": "/login" in href, "note": "素材库空态合法;断言取头部+上传入口渲染"})) # ---- U5-6 玩法中心 template(requiresAuth,5 品类) ---- c.reset_net_log() c.nav(f"{STUDIO}/studio/template", settle=7) c.shot("u5_6_template.png") counts = c.evaljs(js_count([".tpl", ".tpl__head", ".tpl__list", ".tpl__card,[class*=card],[class*=item]", "[class*=empty]"])) out("TEMPLATE_DUMP", c.evaljs(JS_DUMP)) href = counts.get("__href", "") render = (counts.get(".tpl", 0) > 0) and ("/login" not in href) data = (counts.get(".tpl__list", 0) > 0) # 模板列表容器在(5 品类) verdicts.append(page_verdict(c, "studio/template", counts, render, data, extra={"guard_bounced_to_login": "/login" in href})) # ====================================================================== # ADMIN 段:真实登录 → 经营看板 + 审核队列(U5) → U2 审核通过 # ====================================================================== c.nav(f"{ADMIN}/", settle=8) c.shot("u5_admin_login.png") out("ADMIN_LOGIN_PAGE", c.evaljs(JS_DUMP_LOGIN)) print("ADMIN_CLICK_LOGIN:", c.evaljs(JS_CLICK_LOGIN), flush=True) time.sleep(7) c.drain(1.0) print("ADMIN_AFTER_LOGIN_HREF:", c.evaljs("location.href"), flush=True) # ---- U5-7 经营看板 operation-dashboard(跨模块 DAU/GMV/生成量/发布量) ---- c.reset_net_log() c.nav(f"{ADMIN}/wanxiang/operation-dashboard", settle=8) c.shot("u5_7_operation_dashboard.png") counts = c.evaljs(js_count(["[class*=card]", "[class*=statistic],[class*=metric]", ".el-card", "canvas,[class*=chart],[class*=echarts]", "[class*=empty]"])) out("OPDASH_DUMP", c.evaljs(JS_DUMP)) href = counts.get("__href", "") render = ("/login" not in href) and (counts.get("__bodyLen", 0) > 20) data = (counts.get(".el-card", 0) > 0 or counts.get("[class*=card]", 0) > 0 or counts.get("[class*=statistic],[class*=metric]", 0) > 0) verdicts.append(page_verdict(c, "admin/operation-dashboard", counts, render, data, extra={"guard_bounced_to_login": "/login" in href})) # ---- U5-8 审核队列 review(含 U2 seed 待审游戏) ---- c.reset_net_log() c.nav(f"{ADMIN}/wanxiang/review", settle=8) c.shot("u5_8_review.png") q = c.evaljs(JS_QUEUE) counts = c.evaljs(js_count([".el-table", ".el-table__row", ".el-table__empty-text"])) out("REVIEW_QUEUE", q) href = counts.get("__href", "") render = (counts.get(".el-table", 0) > 0) and ("/login" not in href) data = (counts.get(".el-table__row", 0) > 0) # 队列有待审行(seed 9324) rv = page_verdict(c, "admin/review", counts, render, data, extra={"queue_rows": q.get("rows", []), "row_count": q.get("rowCount", 0), "guard_bounced_to_login": "/login" in href}) verdicts.append(rv) # ====================================================================== # U2 审核→发布:在审核队列点 9324(saa-e2e-breakout)「通过」 # ====================================================================== u2 = {"target_id": U2_TARGET_ID, "target_name": U2_TARGET_NAME} c.reset_net_log() c.shot("u2_before.png") before_q = c.evaljs(JS_QUEUE) u2["before_queue_rows"] = before_q.get("rowCount", 0) print("U2_CLICK_PASS:", c.evaljs(js_click_pass(U2_TARGET_NAME)), flush=True) time.sleep(2) c.shot("u2_dialog.png") print("U2_CONFIRM:", c.evaljs(JS_CONFIRM), flush=True) time.sleep(5) # 等审核 API(同事务 publish) + 队列刷新 c.drain(1.5) c.shot("u2_after.png") after_q = c.evaljs(JS_QUEUE) u2["after_queue_rows"] = after_q.get("rowCount", 0) u2["queue_decremented"] = (after_q.get("rowCount", 0) < before_q.get("rowCount", 99)) u2["http5xx_during_review"] = c.http5xx[:6] u2["console_errors_during_review"] = c.console[:6] out("U2_REVIEW_RESULT", u2) c.close() out("WALK_SUMMARY", {"verdicts": verdicts, "u2": u2}) print("=== U5U2_WALK_DONE ===", flush=True) if __name__ == "__main__": main()