lili 8ea972340e chore: 工作树散落活账整理提交(多工作线)
- .gitignore:忽略 AgentScope 2.0.2 源码克隆(.claude/skills/agentscope-skill/agentscope/,本机开发直读参考、~17MB、含自带 .git、非本仓产物、可重克隆)
- .claude/skills/agentscope-skill/SKILL.md:agentscope skill 更新
- contracts/prompts/04-config/cheap-system.md:便宜档 system prompt 加「核心操作非无脑」第9条好玩自检(否决项:⑨不命中则①-⑧全中也只是有元素的无趣游戏)+ 决策层与九门自动验收解耦(基础分保盲驱动器过门 / 技巧分给真人爽感)
- game-runtime/evidence/integration/evidence.json:集成 evidence 更新
- game-studio/public/mock-manifests/:U5+U2 真 UI 走查 runbook + walk 脚本 + mock manifest
- wg1/gen-worker/results/gamedef-quickcheck.json:bake-off 结果(同既有 27 tracked 同类)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:00:42 -07:00

393 lines
18 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 -*-
"""
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(draftrequiresAuth) ----
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(PUBLIC9 作品) ----
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(requiresAuth5 品类) ----
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()