- .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>
393 lines
18 KiB
Python
393 lines
18 KiB
Python
#!/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()
|