feat(release): wire dogfood staging product flow
This commit is contained in:
parent
af15ee1a32
commit
3bff0eefc3
@ -1,182 +1,487 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 造梦/绘境AI · staging 五链路冒烟门(deploy smoke gate)
|
||||
# -----------------------------------------------------------------------------
|
||||
# 用途:每次 staging 部署后跑一遍,**秒级**确认 5 条端到端闭环的关键 API 在岗、
|
||||
# 鉴权/CORS/质量分回路结构正常。这是「部署后是否健康」的就绪检查(liveness/
|
||||
# readiness),**不是**深度 e2e(深度 e2e 走 agent-loop orchestrator 批跑)。
|
||||
#
|
||||
# 设计原则:
|
||||
# - 默认 **以读为主**(health/列表/feed/钱包/包体),可随时重复跑;唯一写=studio/draft 建一条
|
||||
# 草稿(status 0,不发布、无下游副作用、可忽略),用于验证创作入口+鉴权+gameId 分配在岗;
|
||||
# - `--deep` 才走重写路径(generate 入队触发真实生成 + 遥测事件 + 互动),会落库,按需开启;
|
||||
# - 每条链路逐项 PASS/FAIL,末尾总判 + 退出码(0=全过,1=有 FAIL)——可挂 CI/部署脚本。
|
||||
#
|
||||
# 用法:
|
||||
# bash deploy/smoke-test.sh # 默认 staging 只读冒烟
|
||||
# BASE=http://100.64.0.7:48080 TOKEN=test1 bash deploy/smoke-test.sh
|
||||
# bash deploy/smoke-test.sh --deep # 含写路径(生成入队+遥测事件)
|
||||
#
|
||||
# 约定:内网地址/测试 token 可入仓(内网非密);**真实密钥严禁写入本文件**。
|
||||
# =============================================================================
|
||||
# 绘境AI staging 冒烟门。
|
||||
# 默认模式只做公开读 readiness;--dogfood-e2e 才执行真实创作、审核、试玩会话和遥测写链。
|
||||
set -uo pipefail
|
||||
|
||||
BASE="${BASE:-http://100.64.0.7:48080}" # staging 后端(mini-desktop)
|
||||
TOKEN="${TOKEN:-test1}" # staging 批跑/冒烟测试态 token(种子创作者)
|
||||
BASE="${BASE:-http://100.64.0.7:48080}"
|
||||
STUDIO_BASE="${STUDIO_BASE:-http://100.64.0.7:4173}"
|
||||
ORIGIN="${ORIGIN:-$STUDIO_BASE}"
|
||||
TENANT="${TENANT:-1}"
|
||||
ORIGIN="${ORIGIN:-http://100.64.0.7:4173}" # 前端 origin(验 CORS 同源放行)
|
||||
DEEP=0
|
||||
[ "${1:-}" = "--deep" ] && DEEP=1
|
||||
MODE="readiness"
|
||||
if [ "${1:-}" = "--dogfood-e2e" ]; then
|
||||
MODE="dogfood-e2e"
|
||||
elif [ -n "${1:-}" ]; then
|
||||
printf '未知参数:%s\n用法:%s [--dogfood-e2e]\n' "$1" "$0" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
PASS=0; FAIL=0; FAILED_ITEMS=()
|
||||
PASS=0
|
||||
FAIL=0
|
||||
FAILED_ITEMS=()
|
||||
LAST_HTTP=""
|
||||
LAST_BODY=""
|
||||
BODY_FILE="$(mktemp)"
|
||||
RUN_ID="${RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
|
||||
EVIDENCE_OUT="${EVIDENCE_OUT:-/tmp/dogfood-e2e-${RUN_ID}.json}"
|
||||
TASK_CHAIN_ID=""
|
||||
AIGC_TASK_ID=""
|
||||
GAME_ID=""
|
||||
VERSION_ID=""
|
||||
SESSION_ID=""
|
||||
TELEMETRY_TRACE_ID=""
|
||||
ARTIFACT_HASH=""
|
||||
E2E_STATUS="not-started"
|
||||
FAILURE_ITEM=""
|
||||
|
||||
# 颜色(非 TTY 自动降级为纯文本)
|
||||
if [ -t 1 ]; then G='\033[0;32m'; R='\033[0;31m'; Y='\033[0;33m'; B='\033[0;34m'; N='\033[0m'; else G=''; R=''; Y=''; B=''; N=''; fi
|
||||
cleanup() {
|
||||
rm -f "$BODY_FILE"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
_log() { printf "%b\n" "$*"; }
|
||||
_head() { printf "\n%b== %s ==%b\n" "$B" "$1" "$N"; }
|
||||
if [ -t 1 ]; then
|
||||
G='\033[0;32m'; R='\033[0;31m'; B='\033[0;34m'; N='\033[0m'
|
||||
else
|
||||
G=''; R=''; B=''; N=''
|
||||
fi
|
||||
|
||||
# api METHOD PATH [DATA] —— 返回 "HTTP_CODE\nBODY";鉴权头正确引用(防 'Bearer test1' 空格被拆)
|
||||
api() {
|
||||
local method="$1" path="$2" data="${3:-}"
|
||||
local url="$path"; [[ "$path" == http* ]] || url="$BASE$path"
|
||||
log() { printf "%b\n" "$*"; }
|
||||
head_line() { printf "\n%b== %s ==%b\n" "$B" "$1" "$N"; }
|
||||
|
||||
# 请求响应分别放在全局 LAST_HTTP/LAST_BODY,避免 token 或响应体进入日志。
|
||||
request() {
|
||||
local token="$1" method="$2" path="$3" data="${4:-}"
|
||||
local url="$path"
|
||||
local -a args
|
||||
[[ "$path" == http://* || "$path" == https://* ]] || url="$BASE$path"
|
||||
args=(-sS --connect-timeout 5 --max-time 30 -o "$BODY_FILE" -w '%{http_code}' -X "$method"
|
||||
-H "tenant-id: $TENANT" -H "Origin: $ORIGIN")
|
||||
[ -n "$token" ] && args+=(-H "Authorization: Bearer $token")
|
||||
if [ -n "$data" ]; then
|
||||
curl -s -w $'\n%{http_code}' -X "$method" "$url" \
|
||||
-H "Authorization: Bearer $TOKEN" -H "tenant-id: $TENANT" \
|
||||
-H "Origin: $ORIGIN" -H "Content-Type: application/json" -d "$data" 2>/dev/null
|
||||
args+=(-H 'Content-Type: application/json' --data "$data")
|
||||
fi
|
||||
LAST_HTTP="$(curl "${args[@]}" "$url" 2>/dev/null || printf '000')"
|
||||
LAST_BODY="$(<"$BODY_FILE")"
|
||||
}
|
||||
|
||||
# 从 stdin JSON 按点路径读取标量;缺字段或非 JSON 返回空串。
|
||||
json_path() {
|
||||
local path="$1"
|
||||
python3 -c '
|
||||
import json,sys
|
||||
path=sys.argv[1].split(".") if sys.argv[1] else []
|
||||
try:
|
||||
value=json.load(sys.stdin)
|
||||
for part in path:
|
||||
value=value[int(part)] if isinstance(value,list) else value.get(part)
|
||||
if value is None: break
|
||||
if value is None: print("")
|
||||
elif isinstance(value,bool): print("true" if value else "false")
|
||||
elif isinstance(value,(dict,list)): print(json.dumps(value,separators=(",",":"),ensure_ascii=False))
|
||||
else: print(value)
|
||||
except Exception:
|
||||
print("")
|
||||
' "$path"
|
||||
}
|
||||
|
||||
body_value() {
|
||||
printf '%s' "$LAST_BODY" | json_path "$1"
|
||||
}
|
||||
|
||||
envelope_ok() {
|
||||
[ "$LAST_HTTP" = "200" ] && [ "$(body_value code)" = "0" ]
|
||||
}
|
||||
|
||||
mark_pass() {
|
||||
PASS=$((PASS + 1))
|
||||
log " ${G}PASS${N} $1"
|
||||
}
|
||||
|
||||
mark_fail() {
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_ITEMS+=("$1")
|
||||
log " ${R}FAIL${N} $1"
|
||||
}
|
||||
|
||||
# 狗粮安全边界业务码:逐字对账后端各模块 ErrorCodeConstants,禁止用“任意失败”冒充安全门生效。
|
||||
CODE_PASSWORD_REGISTER_DISABLED=1002090007 # system PASSWORD_REGISTER_DISABLED = 1_002_090_007
|
||||
CODE_DOGFOOD_SMS_DISABLED=1002090008 # system DOGFOOD_SMS_DISABLED = 1_002_090_008
|
||||
CODE_AD_DOGFOOD_DISABLED=1111002006 # ad AD_DOGFOOD_DISABLED = 1_111_002_006
|
||||
CODE_TRADE_DOGFOOD_DISABLED=1106008000 # trade TRADE_DOGFOOD_MONETIZATION_DISABLED = 1_106_008_000
|
||||
|
||||
expect_business_code() {
|
||||
local name="$1" expected="$2" token="$3" method="$4" path="$5" data="${6:-}"
|
||||
request "$token" "$method" "$path" "$data"
|
||||
local actual
|
||||
actual="$(body_value code)"
|
||||
if [ "$LAST_HTTP" = "200" ] && [ "$actual" = "$expected" ]; then
|
||||
mark_pass "${name}(业务码=${expected})"
|
||||
else
|
||||
curl -s -w $'\n%{http_code}' -X "$method" "$url" \
|
||||
-H "Authorization: Bearer $TOKEN" -H "tenant-id: $TENANT" \
|
||||
-H "Origin: $ORIGIN" 2>/dev/null
|
||||
e2e_fail "$name 未返回预期业务码(expected=$expected actual=${actual:-none} http=${LAST_HTTP})"
|
||||
fi
|
||||
}
|
||||
|
||||
# check NAME METHOD PATH MODE [EXTRA] [DATA]
|
||||
# MODE=health → HTTP 200 即过(actuator)
|
||||
# MODE=envelope → HTTP 200 ∧ huijing body code==0 即过
|
||||
# MODE=envelope_nonempty → 再要求 data 非空(list 长度≥1 或 data 非 null)
|
||||
# EXTRA:envelope_nonempty 下可传 jq 风格 python 取数表达式(默认探 data.list 或 data)
|
||||
check() {
|
||||
local name="$1" method="$2" path="$3" mode="$4" data="${5:-}"
|
||||
local out code body
|
||||
out="$(api "$method" "$path" "$data")"
|
||||
code="$(printf '%s' "$out" | tail -n1)"
|
||||
body="$(printf '%s' "$out" | sed '$d')"
|
||||
local ok=0 detail=""
|
||||
case "$mode" in
|
||||
health)
|
||||
[ "$code" = "200" ] && ok=1; detail="http=$code" ;;
|
||||
envelope|envelope_nonempty)
|
||||
if [ "$code" = "200" ]; then
|
||||
# 解析 huijing 信封 {code,data};envelope_nonempty 再判 data 是否非空
|
||||
local pyout
|
||||
pyout="$(printf '%s' "$body" | python3 -c "
|
||||
import sys,json
|
||||
try: d=json.load(sys.stdin)
|
||||
except Exception as e: print('ERR|parse'); sys.exit()
|
||||
c=d.get('code')
|
||||
data=d.get('data')
|
||||
if isinstance(data,dict): n=len(data.get('list')) if isinstance(data.get('list'),list) else (1 if data else 0)
|
||||
elif isinstance(data,list): n=len(data)
|
||||
elif data is None: n=0
|
||||
else: n=1
|
||||
print('%s|%s'%(c,n))
|
||||
" 2>/dev/null)"
|
||||
local ecode="${pyout%%|*}" ecount="${pyout##*|}"
|
||||
if [ "$ecode" = "0" ]; then
|
||||
if [ "$mode" = "envelope_nonempty" ]; then
|
||||
[ "${ecount:-0}" -ge 1 ] 2>/dev/null && ok=1 || ok=0
|
||||
detail="code=0 count=$ecount"
|
||||
else ok=1; detail="code=0"; fi
|
||||
else detail="http=$code code=$ecode"; fi
|
||||
else detail="http=$code"; fi ;;
|
||||
esac
|
||||
if [ "$ok" = "1" ]; then PASS=$((PASS+1)); _log " ${G}✓${N} $name ($detail)"; else FAIL=$((FAIL+1)); FAILED_ITEMS+=("$name [$detail]"); _log " ${R}✗${N} $name ($detail)"; fi
|
||||
read_check() {
|
||||
local name="$1" path="$2" require_data="${3:-0}"
|
||||
request "" GET "$path"
|
||||
if envelope_ok; then
|
||||
if [ "$require_data" = "1" ] && ! printf '%s' "$LAST_BODY" | python3 -c '
|
||||
import json,sys
|
||||
value=json.load(sys.stdin).get("data")
|
||||
assert value is not None and value != [] and value != {} and value != ""
|
||||
' >/dev/null 2>&1; then
|
||||
mark_fail "${name}(data 为空)"
|
||||
else
|
||||
mark_pass "$name"
|
||||
fi
|
||||
else
|
||||
mark_fail "${name}(http=${LAST_HTTP} code=$(body_value code))"
|
||||
fi
|
||||
}
|
||||
|
||||
_log "${B}绘境AI staging 五链路冒烟门${N} BASE=$BASE TOKEN=${TOKEN:0:2}*** deep=$DEEP"
|
||||
require_env() {
|
||||
local name
|
||||
for name in "$@"; do
|
||||
if [ -z "${!name:-}" ]; then
|
||||
log "${R}缺少必填环境变量:$name${N}" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ---------------- 基础设施 + 鉴权 ----------------
|
||||
_head "基础设施 / 鉴权"
|
||||
check "infra: actuator/health" GET /actuator/health health
|
||||
check "auth: passport/me(token 有效)" GET /app-api/passport/me envelope
|
||||
write_evidence() {
|
||||
local status="$1"
|
||||
mkdir -p "$(dirname "$EVIDENCE_OUT")"
|
||||
E2E_STATUS="$status" RUN_ID="$RUN_ID" TASK_CHAIN_ID="$TASK_CHAIN_ID" AIGC_TASK_ID="$AIGC_TASK_ID" \
|
||||
GAME_ID="$GAME_ID" VERSION_ID="$VERSION_ID" SESSION_ID="$SESSION_ID" \
|
||||
TELEMETRY_TRACE_ID="$TELEMETRY_TRACE_ID" ARTIFACT_HASH="$ARTIFACT_HASH" FAILURE_ITEM="$FAILURE_ITEM" \
|
||||
python3 - "$EVIDENCE_OUT" <<'PY'
|
||||
import json,os,sys
|
||||
def number(name):
|
||||
raw=os.environ.get(name,"")
|
||||
return int(raw) if raw.isdigit() else None
|
||||
doc={
|
||||
"runId":os.environ["RUN_ID"], "status":os.environ["E2E_STATUS"],
|
||||
"taskChainId":number("TASK_CHAIN_ID"), "aigcTaskId":number("AIGC_TASK_ID"),
|
||||
"gameId":number("GAME_ID"), "versionId":number("VERSION_ID"),
|
||||
"sessionId":number("SESSION_ID"), "telemetryTraceId":os.environ.get("TELEMETRY_TRACE_ID") or None,
|
||||
"artifactHash":os.environ.get("ARTIFACT_HASH") or None,
|
||||
"result":{"verdict":os.environ["E2E_STATUS"],"failedItem":os.environ.get("FAILURE_ITEM") or None},
|
||||
"browserQaResult":"pending",
|
||||
}
|
||||
with open(sys.argv[1],"w",encoding="utf-8") as f:
|
||||
json.dump(doc,f,ensure_ascii=False,indent=2)
|
||||
PY
|
||||
chmod 600 "$EVIDENCE_OUT"
|
||||
}
|
||||
|
||||
# ---------------- 链路① 创作 → 生成 → 预览试玩 ----------------
|
||||
_head "链路① 创作→生成→预览"
|
||||
# 四模板就绪(执行器 banner 同源:getTemplateList 应返 clicker/merge/idle/tycoon)
|
||||
out="$(api GET /app-api/aigc/template/list)"
|
||||
tcount="$(printf '%s' "$out" | sed '$d' | python3 -c "import sys,json
|
||||
try: d=json.load(sys.stdin); data=d.get('data') or []; print(len(data) if isinstance(data,list) else 0)
|
||||
except: print(-1)" 2>/dev/null)"
|
||||
if [ "${tcount:-0}" -ge 4 ] 2>/dev/null; then PASS=$((PASS+1)); _log " ${G}✓${N} aigc/template/list(四模板就绪 count=${tcount})"; else FAIL=$((FAIL+1)); FAILED_ITEMS+=("template/list count=$tcount"); _log " ${R}✗${N} aigc/template/list(期望≥4,实=${tcount})"; fi
|
||||
check "studio/draft(创建草稿拿 gameId)" POST /app-api/studio/draft envelope_nonempty '{"title":"冒烟门-clicker","templateId":"clicker"}'
|
||||
e2e_fail() {
|
||||
local message="$1"
|
||||
E2E_STATUS="failed"
|
||||
FAILURE_ITEM="$message"
|
||||
write_evidence "$E2E_STATUS"
|
||||
log "${R}DOGFOOD_E2E_FAIL${N} $message"
|
||||
log "证据:$EVIDENCE_OUT"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---------------- 链路② 发布 → 审核 → 游戏流 ----------------
|
||||
_head "链路② 发布→审核→游戏流"
|
||||
check "feed/stream(内容池非空)" GET "/app-api/feed/stream?size=5" envelope_nonempty
|
||||
check "feed/zones(分区结构)" GET /app-api/feed/zones envelope
|
||||
check "project/my(创作者项目区)" GET "/app-api/project/my?pageNo=1&pageSize=5" envelope
|
||||
e2e_require_ok() {
|
||||
local name="$1"
|
||||
if envelope_ok; then
|
||||
mark_pass "$name"
|
||||
else
|
||||
e2e_fail "${name}(http=${LAST_HTTP} code=$(body_value code))"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------- 链路③ 游戏流 → 试玩 → 互动 → 分享 ----------------
|
||||
_head "链路③ 试玩→互动→分享"
|
||||
# 真实取包走 GET /app-api/runtime/package/{versionId}(descriptor 含 entry/manifest/sandbox);
|
||||
# 注意:feed item 的 packageUrl 字段恒为 null,**不是**取包入口(runtime.ts:25 实证)。
|
||||
VID="$(api GET "/app-api/feed/stream?size=1" | sed '$d' | python3 -c "
|
||||
import sys,json
|
||||
try:
|
||||
d=json.load(sys.stdin); lst=(d.get('data') or {}).get('list') or []
|
||||
print(lst[0].get('versionId','') if lst else '')
|
||||
except: print('')" 2>/dev/null)"
|
||||
if [ -n "$VID" ] && [ "$VID" != "None" ]; then
|
||||
check "runtime/package/{versionId}(试玩取包 vid=${VID})" GET "/app-api/runtime/package/$VID" envelope
|
||||
else FAIL=$((FAIL+1)); FAILED_ITEMS+=("feed 首条无 versionId"); _log " ${R}✗${N} feed 首条无 versionId(无法验取包)"; fi
|
||||
|
||||
# ---------------- 链路④ 广告 → 收益 → 钱包 ----------------
|
||||
_head "链路④ 广告→收益→钱包"
|
||||
check "ad/slot/list-enabled(广告位)" GET /app-api/ad/slot/list-enabled envelope
|
||||
check "trade/account/mine(钱包)" GET /app-api/trade/account/mine envelope
|
||||
check "trade/income/page(收益流水)" GET "/app-api/trade/income/page?pageNo=1&pageSize=5" envelope
|
||||
|
||||
# ---------------- 链路⑤ 数据回路(遥测→质量分→feed 重排)----------------
|
||||
_head "链路⑤ 数据回路(质量分回灌)"
|
||||
# 结构断言:feed 首条携带 qualityScore(feed 排序由质量分驱动 = 回路已回灌)
|
||||
QSCORE="$(api GET "/app-api/feed/stream?size=1" | sed '$d' | python3 -c "
|
||||
import sys,json
|
||||
try:
|
||||
d=json.load(sys.stdin); lst=(d.get('data') or {}).get('list') or []
|
||||
print(lst[0].get('qualityScore') if lst and lst[0].get('qualityScore') is not None else 'NONE')
|
||||
except: print('NONE')" 2>/dev/null)"
|
||||
if [ "$QSCORE" != "NONE" ] && [ -n "$QSCORE" ]; then PASS=$((PASS+1)); _log " ${G}✓${N} feed 携带 qualityScore=${QSCORE}(质量分回路已回灌)"; else FAIL=$((FAIL+1)); FAILED_ITEMS+=("feed 无 qualityScore"); _log " ${R}✗${N} feed 首条无 qualityScore"; fi
|
||||
|
||||
# ---------------- --deep:写路径(生成入队 + 遥测事件)----------------
|
||||
if [ "$DEEP" = "1" ]; then
|
||||
_head "深度(--deep)写路径:创作→生成入队"
|
||||
# 真实创作链写路径:create_draft → studio_generate(契约 backend_gw.py:123/130)。
|
||||
# generate body = {sessionId: draft.id, prompt}(sessionId=draft 的 id 非 gameId)。
|
||||
# draft 不设 templateId:便宜档真实流「一句话不选模板」→ 后端归一 generic → 分派闸(templateId=='generic')投外置 worker :9501;
|
||||
# 旧四模板名(idle/clicker 等)会撞硬相等分派闸、fall-through 到进程内 LLM 旧路、到不了 worker(见 staging-ops §8)。
|
||||
# 入队触发 executor 派发 worker(异步产包经回调落版本),本冒烟**只验入队 200/code=0**,不等生成完成。
|
||||
# 不验 telemetry/interact 写:① 遥测回路产出已由链路⑤(feed 携 qualityScore)结构性验证;
|
||||
# ② 完整深度 e2e(play→judge→publish)是 agent-loop orchestrator 批跑的职责,不与冒烟门重叠。
|
||||
SID="$(api POST /app-api/studio/draft '{"title":"冒烟门-deep-generic"}' | sed '$d' | python3 -c "import sys,json
|
||||
try: print((json.load(sys.stdin).get('data') or {}).get('id',''))
|
||||
except: print('')" 2>/dev/null)"
|
||||
if [ -n "$SID" ] && [ "$SID" != "None" ]; then
|
||||
check "studio/generate(入队 sessionId=${SID})" POST /app-api/studio/generate envelope \
|
||||
"{\"sessionId\":$SID,\"prompt\":\"一个挂机种树攒果实的放置小游戏\"}"
|
||||
else FAIL=$((FAIL+1)); FAILED_ITEMS+=("deep: draft 无 id"); _log " ${R}✗${N} deep: draft 未拿到 sessionId(id)"; fi
|
||||
# 写模式具有明确副作用,账号凭据与人工确认必须在任何网络请求前齐备。
|
||||
if [ "$MODE" = "dogfood-e2e" ]; then
|
||||
require_env DOGFOOD_CREATOR_USERNAME DOGFOOD_CREATOR_PASSWORD \
|
||||
DOGFOOD_REVIEWER_USERNAME DOGFOOD_REVIEWER_PASSWORD \
|
||||
DOGFOOD_READONLY_USERNAME DOGFOOD_READONLY_PASSWORD \
|
||||
DOGFOOD_E2E_ACK
|
||||
if [ "$DOGFOOD_E2E_ACK" != "I_ACKNOWLEDGE_DOGFOOD_WRITES" ]; then
|
||||
log "${R}DOGFOOD_E2E_ACK 无效;必须显式设置为 I_ACKNOWLEDGE_DOGFOOD_WRITES${N}" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------- 总判 ----------------
|
||||
_head "总判"
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ "$FAIL" = "0" ]; then
|
||||
_log "${G}SMOKE_PASS${N} $PASS/$TOTAL 全过"
|
||||
echo "SMOKE_VERDICT=PASS"
|
||||
exit 0
|
||||
head_line "公开只读 readiness"
|
||||
request "" GET /actuator/health
|
||||
if [ "$LAST_HTTP" = "200" ]; then mark_pass "backend health"; else mark_fail "backend health(http=${LAST_HTTP})"; fi
|
||||
read_check "feed zones" /app-api/feed/zones 1
|
||||
read_check "feed stream" "/app-api/feed/stream?size=1" 1
|
||||
|
||||
FIRST_VERSION_ID=""
|
||||
request "" GET "/app-api/feed/stream?size=1"
|
||||
if envelope_ok; then
|
||||
FIRST_VERSION_ID="$(body_value data.list.0.versionId)"
|
||||
fi
|
||||
if [ -n "$FIRST_VERSION_ID" ]; then
|
||||
read_check "published runtime package(versionId=${FIRST_VERSION_ID})" "/app-api/runtime/package/${FIRST_VERSION_ID}?scene=play" 1
|
||||
else
|
||||
_log "${R}SMOKE_FAIL${N} $PASS/$TOTAL 过,$FAIL 项 FAIL:"
|
||||
for it in "${FAILED_ITEMS[@]}"; do _log " ${R}-${N} $it"; done
|
||||
echo "SMOKE_VERDICT=FAIL"
|
||||
mark_fail "feed 首条缺 versionId,无法校验公开运行包"
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "readiness" ]; then
|
||||
head_line "总判"
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
log "${G}SMOKE_PASS${N} $PASS/$((PASS + FAIL)) 全过(默认模式无写请求)"
|
||||
log "SMOKE_VERDICT=PASS"
|
||||
exit 0
|
||||
fi
|
||||
log "${R}SMOKE_FAIL${N} $PASS/$((PASS + FAIL)) 通过"
|
||||
printf ' - %s\n' "${FAILED_ITEMS[@]}"
|
||||
log "SMOKE_VERDICT=FAIL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 以下模式会创建并发布一款带 RUN_ID 的游戏、写试玩会话和遥测事件,不做隐式清理。
|
||||
E2E_TIMEOUT_SEC="${E2E_TIMEOUT_SEC:-1200}"
|
||||
POLL_INTERVAL_SEC="${POLL_INTERVAL_SEC:-5}"
|
||||
E2E_PROMPT="${E2E_PROMPT:-做一个躲避陨石、可以计分并有明确结束状态的小游戏}"
|
||||
E2E_TITLE="${E2E_TITLE:-dogfood-${RUN_ID}}"
|
||||
export DOGFOOD_CREATOR_USERNAME DOGFOOD_CREATOR_PASSWORD DOGFOOD_REVIEWER_USERNAME \
|
||||
DOGFOOD_REVIEWER_PASSWORD DOGFOOD_READONLY_USERNAME DOGFOOD_READONLY_PASSWORD \
|
||||
E2E_PROMPT E2E_TITLE RUN_ID
|
||||
|
||||
head_line "真实账号登录(凭据不落日志)"
|
||||
CREATOR_LOGIN_BODY="$(python3 - <<'PY'
|
||||
import json,os
|
||||
print(json.dumps({"username":os.environ["DOGFOOD_CREATOR_USERNAME"],"password":os.environ["DOGFOOD_CREATOR_PASSWORD"]}))
|
||||
PY
|
||||
)"
|
||||
request "" POST /app-api/passport/password-login "$CREATOR_LOGIN_BODY"
|
||||
e2e_require_ok "creator password login"
|
||||
CREATOR_TOKEN="$(body_value data.accessToken)"
|
||||
CREATOR_USER_ID="$(body_value data.userId)"
|
||||
CREATOR_FLAG="$(body_value data.creatorFlag)"
|
||||
[ -n "$CREATOR_TOKEN" ] || e2e_fail "creator 登录响应缺 accessToken"
|
||||
[ "$CREATOR_FLAG" = "1" ] || e2e_fail "creatorFlag=${CREATOR_FLAG},不是预置创作者"
|
||||
|
||||
admin_login() {
|
||||
local role="$1" user_var="$2" pass_var="$3"
|
||||
local payload
|
||||
export DOGFOOD_LOGIN_USERNAME="${!user_var}" DOGFOOD_LOGIN_PASSWORD="${!pass_var}"
|
||||
payload="$(python3 - <<'PY'
|
||||
import json,os
|
||||
print(json.dumps({"username":os.environ["DOGFOOD_LOGIN_USERNAME"],"password":os.environ["DOGFOOD_LOGIN_PASSWORD"]}))
|
||||
PY
|
||||
)"
|
||||
request "" POST /admin-api/system/auth/login "$payload"
|
||||
envelope_ok || e2e_fail "$role 登录失败(http=$LAST_HTTP code=$(body_value code))"
|
||||
ADMIN_LOGIN_TOKEN="$(body_value data.accessToken)"
|
||||
[ -n "$ADMIN_LOGIN_TOKEN" ] || e2e_fail "$role 登录响应缺 accessToken"
|
||||
}
|
||||
ADMIN_LOGIN_TOKEN=""
|
||||
admin_login reviewer DOGFOOD_REVIEWER_USERNAME DOGFOOD_REVIEWER_PASSWORD
|
||||
REVIEWER_TOKEN="$ADMIN_LOGIN_TOKEN"
|
||||
admin_login readonly DOGFOOD_READONLY_USERNAME DOGFOOD_READONLY_PASSWORD
|
||||
READONLY_TOKEN="$ADMIN_LOGIN_TOKEN"
|
||||
mark_pass "reviewer / readonly password login"
|
||||
|
||||
head_line "狗粮安全边界精确负例"
|
||||
# 用户名最长 30 位,保留 dogreg + RUN_ID 数字前 24 位。
|
||||
REGISTER_NEGATIVE_BODY="$(REGISTER_NAME="dogreg${RUN_ID//[^0-9]/}" python3 - <<'PY'
|
||||
import json,os
|
||||
print(json.dumps({"username":os.environ["REGISTER_NAME"][:30],"password":"DogfoodNoRegister1"}))
|
||||
PY
|
||||
)"
|
||||
expect_business_code "password register disabled" "$CODE_PASSWORD_REGISTER_DISABLED" "" POST \
|
||||
/app-api/passport/password-register "$REGISTER_NEGATIVE_BODY"
|
||||
expect_business_code "SMS send disabled" "$CODE_DOGFOOD_SMS_DISABLED" "" POST \
|
||||
/app-api/passport/send-sms-code '{"mobile":"13800000000"}'
|
||||
expect_business_code "ad slot disabled" "$CODE_AD_DOGFOOD_DISABLED" "$CREATOR_TOKEN" GET \
|
||||
/app-api/ad/slot/list-enabled
|
||||
expect_business_code "charge balance disabled" "$CODE_TRADE_DOGFOOD_DISABLED" "$CREATOR_TOKEN" GET \
|
||||
/app-api/trade/charge/points-balance
|
||||
expect_business_code "withdraw page disabled" "$CODE_TRADE_DOGFOOD_DISABLED" "$CREATOR_TOKEN" GET \
|
||||
'/app-api/trade/withdraw/page?pageNo=1&pageSize=1'
|
||||
|
||||
head_line "创建并等待生成终态"
|
||||
CREATE_BODY="$(python3 - <<'PY'
|
||||
import json,os
|
||||
print(json.dumps({"title":os.environ["E2E_TITLE"],"prompt":os.environ["E2E_PROMPT"],
|
||||
"templateId":"generic","idempotencyKey":"dogfood-"+os.environ["RUN_ID"]},ensure_ascii=False))
|
||||
PY
|
||||
)"
|
||||
request "$CREATOR_TOKEN" POST /app-api/studio/create "$CREATE_BODY"
|
||||
e2e_require_ok "studio one-step create"
|
||||
TASK_CHAIN_ID="$(body_value data.id)"
|
||||
AIGC_TASK_ID="$(body_value data.aigcTaskId)"
|
||||
GAME_ID="$(body_value data.gameId)"
|
||||
[ -n "$TASK_CHAIN_ID" ] && [ -n "$GAME_ID" ] || e2e_fail "create 响应缺 taskChainId/gameId"
|
||||
|
||||
deadline=$((SECONDS + E2E_TIMEOUT_SEC))
|
||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||
request "$CREATOR_TOKEN" GET "/app-api/studio/task/$TASK_CHAIN_ID"
|
||||
envelope_ok || e2e_fail "轮询任务链失败(http=$LAST_HTTP code=$(body_value code))"
|
||||
task_status="$(body_value data.status)"
|
||||
AIGC_TASK_ID="$(body_value data.aigcTaskId)"
|
||||
GAME_ID="$(body_value data.gameId)"
|
||||
VERSION_ID="$(body_value data.versionId)"
|
||||
if [ "$task_status" = "2" ]; then break; fi
|
||||
[ "$task_status" != "3" ] || e2e_fail "生成进入失败终态(taskChainId=${TASK_CHAIN_ID} aigcTaskId=${AIGC_TASK_ID})"
|
||||
sleep "$POLL_INTERVAL_SEC"
|
||||
done
|
||||
[ "${task_status:-}" = "2" ] || e2e_fail "等待生成终态超时 ${E2E_TIMEOUT_SEC}s"
|
||||
[ -n "$VERSION_ID" ] || e2e_fail "生成完成但 versionId 为空"
|
||||
mark_pass "generation terminal success(taskChainId=${TASK_CHAIN_ID} versionId=${VERSION_ID})"
|
||||
|
||||
head_line "预览包与发布门禁"
|
||||
request "$CREATOR_TOKEN" GET "/app-api/runtime/package/$VERSION_ID?scene=preview"
|
||||
e2e_require_ok "creator preview package descriptor"
|
||||
ARTIFACT_HASH="$(body_value data.checksum)"
|
||||
if ! [[ "$ARTIFACT_HASH" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
e2e_fail "预览运行包缺少合法 SHA-256 checksum"
|
||||
fi
|
||||
request "$CREATOR_TOKEN" GET "/app-api/runtime/package/$VERSION_ID/manifest?scene=preview"
|
||||
if [ "$LAST_HTTP" = "200" ] && printf '%s' "$LAST_BODY" | python3 -m json.tool >/dev/null 2>&1; then
|
||||
mark_pass "creator preview manifest"
|
||||
else
|
||||
e2e_fail "预览 manifest 不可读(http=${LAST_HTTP})"
|
||||
fi
|
||||
|
||||
LAUNCH_ZONE_ID="${DOGFOOD_ZONE_ID:-}"
|
||||
if [ -z "$LAUNCH_ZONE_ID" ]; then
|
||||
request "" GET /app-api/feed/zones
|
||||
envelope_ok || e2e_fail "无法读取发布专区"
|
||||
LAUNCH_ZONE_ID="$(body_value data.0.id)"
|
||||
fi
|
||||
[ -n "$LAUNCH_ZONE_ID" ] || e2e_fail "没有可用发布专区,请设置 DOGFOOD_ZONE_ID"
|
||||
PUBLISH_BODY="{\"versionId\":$VERSION_ID,\"launchZoneId\":$LAUNCH_ZONE_ID}"
|
||||
request "$CREATOR_TOKEN" POST "/app-api/project/$GAME_ID/publish" "$PUBLISH_BODY"
|
||||
e2e_require_ok "submit publish"
|
||||
[ "$(body_value data.admitted)" = "true" ] || e2e_fail "发布门禁未全通过:$(body_value data.gates)"
|
||||
|
||||
# 扫完整个主 feed:0=找到并输出 versionId,1=确认到底仍不存在,2=请求/游标异常导致无法下结论。
|
||||
scan_main_feed() {
|
||||
local cursor="" page encoded found
|
||||
for page in $(seq 1 20); do
|
||||
if [ -n "$cursor" ]; then
|
||||
encoded="$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1],safe=""))' "$cursor")"
|
||||
request "" GET "/app-api/feed/stream?size=30&cursor=$encoded"
|
||||
else
|
||||
request "" GET "/app-api/feed/stream?size=30"
|
||||
fi
|
||||
envelope_ok || return 2
|
||||
found="$(printf '%s' "$LAST_BODY" | python3 -c 'import json,sys; gid=int(sys.argv[1]); d=json.load(sys.stdin); xs=(d.get("data") or {}).get("list",[]); print(next((x.get("versionId","") for x in xs if int(x.get("gameId",-1))==gid),""))' "$GAME_ID")"
|
||||
if [ -n "$found" ]; then printf '%s' "$found"; return 0; fi
|
||||
[ "$(body_value data.hasMore)" = "true" ] || return 1
|
||||
cursor="$(body_value data.nextCursor)"
|
||||
[ -n "$cursor" ] || return 2
|
||||
done
|
||||
return 2
|
||||
}
|
||||
|
||||
head_line "审核队列与 RBAC 负验"
|
||||
request "$REVIEWER_TOKEN" GET "/admin-api/project/review/page?pageNo=1&pageSize=100&status=1"
|
||||
e2e_require_ok "reviewer review queue"
|
||||
if ! printf '%s' "$LAST_BODY" | python3 -c 'import json,sys; gid=int(sys.argv[1]); d=json.load(sys.stdin); assert any(int(x.get("id",-1))==gid for x in (d.get("data") or {}).get("list",[]))' "$GAME_ID"; then
|
||||
e2e_fail "新游戏未进入审核队列(gameId=${GAME_ID})"
|
||||
fi
|
||||
REVIEW_BODY="{\"gameId\":$GAME_ID,\"versionId\":$VERSION_ID,\"decision\":1,\"reason\":\"dogfood $RUN_ID\"}"
|
||||
request "$READONLY_TOKEN" POST /admin-api/project/review "$REVIEW_BODY"
|
||||
if envelope_ok; then
|
||||
e2e_fail "readonly 账号竟可审核,RBAC 负验失败"
|
||||
fi
|
||||
request "$REVIEWER_TOKEN" GET "/admin-api/project/review/page?pageNo=1&pageSize=100&status=1"
|
||||
if ! envelope_ok || ! printf '%s' "$LAST_BODY" | python3 -c 'import json,sys; gid=int(sys.argv[1]); d=json.load(sys.stdin); assert any(int(x.get("id",-1))==gid for x in (d.get("data") or {}).get("list",[]))' "$GAME_ID"; then
|
||||
e2e_fail "readonly 拒绝后审核对象状态发生变化"
|
||||
fi
|
||||
mark_pass "readonly 审核写权限被拒绝且状态未变"
|
||||
|
||||
PRE_APPROVE_VERSION="$(scan_main_feed)"
|
||||
PRE_APPROVE_SCAN_RC=$?
|
||||
if [ "$PRE_APPROVE_SCAN_RC" -eq 0 ]; then
|
||||
e2e_fail "审核通过前 gameId=$GAME_ID 已进入主 feed(versionId=${PRE_APPROVE_VERSION})"
|
||||
elif [ "$PRE_APPROVE_SCAN_RC" -eq 1 ]; then
|
||||
mark_pass "pre-approve game absent from main feed"
|
||||
else
|
||||
e2e_fail "审核通过前无法完成主 feed 全量缺席证明"
|
||||
fi
|
||||
|
||||
request "$REVIEWER_TOKEN" POST /admin-api/project/review "$REVIEW_BODY"
|
||||
e2e_require_ok "reviewer approve"
|
||||
|
||||
head_line "发布态、feed 与公开运行包"
|
||||
deadline=$((SECONDS + 90))
|
||||
project_status=""
|
||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||
request "$CREATOR_TOKEN" GET "/app-api/project/$GAME_ID"
|
||||
if envelope_ok; then project_status="$(body_value data.status)"; fi
|
||||
[ "$project_status" = "4" ] && break
|
||||
sleep 2
|
||||
done
|
||||
[ "$project_status" = "4" ] || e2e_fail "审核通过后项目未进入 published(4)"
|
||||
mark_pass "project published status=4"
|
||||
|
||||
FEED_VERSION_ID="$(scan_main_feed)"
|
||||
POST_APPROVE_SCAN_RC=$?
|
||||
[ "$POST_APPROVE_SCAN_RC" -eq 0 ] || e2e_fail "审核通过后未能在主 feed 找到 gameId=${GAME_ID}(scanRc=${POST_APPROVE_SCAN_RC})"
|
||||
[ "$FEED_VERSION_ID" = "$VERSION_ID" ] || e2e_fail "feed versionId=$FEED_VERSION_ID 与生成版本 $VERSION_ID 不一致"
|
||||
mark_pass "exact game appears in main feed"
|
||||
request "" GET "/app-api/runtime/package/$VERSION_ID?scene=play"
|
||||
e2e_require_ok "public play package descriptor"
|
||||
request "" GET "/app-api/runtime/package/$VERSION_ID/manifest?scene=play"
|
||||
if [ "$LAST_HTTP" = "200" ] && printf '%s' "$LAST_BODY" | python3 -m json.tool >/dev/null 2>&1; then
|
||||
mark_pass "public play manifest"
|
||||
else
|
||||
e2e_fail "公开试玩 manifest 不可读(http=${LAST_HTTP})"
|
||||
fi
|
||||
|
||||
head_line "试玩会话与遥测回流"
|
||||
request "$READONLY_TOKEN" GET "/admin-api/telemetry/game-stat/$GAME_ID"
|
||||
e2e_require_ok "readonly telemetry query"
|
||||
BASE_PLAY_COUNT="$(body_value data.playCount)"; BASE_PLAY_COUNT="${BASE_PLAY_COUNT:-0}"
|
||||
BASE_END_COUNT="$(body_value data.playEndCount)"; BASE_END_COUNT="${BASE_END_COUNT:-0}"
|
||||
TELEMETRY_TRACE_ID="$(python3 -c 'import uuid; print(uuid.uuid4())')"
|
||||
CLIENT_PLAY_TOKEN="ptk-${RUN_ID:0:36}"
|
||||
SESSION_BODY="{\"gameId\":$GAME_ID,\"versionId\":$VERSION_ID,\"clientPlayToken\":\"$CLIENT_PLAY_TOKEN\",\"scene\":\"play\",\"traceId\":\"$TELEMETRY_TRACE_ID\"}"
|
||||
request "$CREATOR_TOKEN" POST /app-api/runtime/session/start "$SESSION_BODY"
|
||||
e2e_require_ok "runtime session start"
|
||||
SESSION_ID="$(body_value data.sessionId)"
|
||||
[ -n "$SESSION_ID" ] || e2e_fail "session start 未返回 sessionId"
|
||||
request "$CREATOR_TOKEN" POST /app-api/runtime/session/end "{\"sessionId\":$SESSION_ID,\"durationMs\":1200,\"endReason\":\"game_end\"}"
|
||||
e2e_require_ok "runtime session end"
|
||||
|
||||
export GAME_ID VERSION_ID SESSION_ID TELEMETRY_TRACE_ID CREATOR_USER_ID
|
||||
TELEMETRY_BODY="$(python3 - <<'PY'
|
||||
import json,os,time,uuid
|
||||
now=int(time.time()*1000)
|
||||
base={"schemaVersion":"v1","traceId":os.environ["TELEMETRY_TRACE_ID"],
|
||||
"sessionId":os.environ["SESSION_ID"],"user":{"userId":os.environ["CREATOR_USER_ID"]},
|
||||
"context":{"gameId":os.environ["GAME_ID"],"versionId":os.environ["VERSION_ID"],"deviceType":"desktop"}}
|
||||
start={**base,"eventId":str(uuid.uuid4()),"event":"game_play_start","ts":now,"props":{}}
|
||||
end={**base,"eventId":str(uuid.uuid4()),"event":"game_play_end","ts":now+1200,
|
||||
"props":{"duration_ms":1200,"completed":True}}
|
||||
print(json.dumps({"batch":[start,end]},separators=(",",":")))
|
||||
PY
|
||||
)"
|
||||
request "$CREATOR_TOKEN" POST /app-api/telemetry/events/batch "$TELEMETRY_BODY"
|
||||
e2e_require_ok "telemetry batch admission"
|
||||
[ "$(body_value data.accepted)" = "2" ] && [ "$(body_value data.rejected)" = "0" ] || \
|
||||
e2e_fail "遥测批量未全部受理(accepted=$(body_value data.accepted) rejected=$(body_value data.rejected))"
|
||||
|
||||
deadline=$((SECONDS + 120))
|
||||
PLAY_COUNT="$BASE_PLAY_COUNT"; END_COUNT="$BASE_END_COUNT"
|
||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||
request "$READONLY_TOKEN" GET "/admin-api/telemetry/game-stat/$GAME_ID"
|
||||
if envelope_ok; then
|
||||
PLAY_COUNT="$(body_value data.playCount)"; PLAY_COUNT="${PLAY_COUNT:-0}"
|
||||
END_COUNT="$(body_value data.playEndCount)"; END_COUNT="${END_COUNT:-0}"
|
||||
if [ "$PLAY_COUNT" -gt "$BASE_PLAY_COUNT" ] 2>/dev/null && [ "$END_COUNT" -gt "$BASE_END_COUNT" ] 2>/dev/null; then break; fi
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
if [ "$PLAY_COUNT" -le "$BASE_PLAY_COUNT" ] 2>/dev/null || [ "$END_COUNT" -le "$BASE_END_COUNT" ] 2>/dev/null; then
|
||||
e2e_fail "遥测 MQ 聚合在 120s 内未回流(play ${BASE_PLAY_COUNT}->${PLAY_COUNT}, end ${BASE_END_COUNT}->${END_COUNT})"
|
||||
fi
|
||||
mark_pass "telemetry persisted and aggregated(play=${PLAY_COUNT} end=${END_COUNT})"
|
||||
|
||||
E2E_STATUS="passed"
|
||||
write_evidence "$E2E_STATUS"
|
||||
head_line "总判"
|
||||
log "${G}DOGFOOD_E2E_PASS${N} gameId=$GAME_ID versionId=$VERSION_ID sessionId=$SESSION_ID"
|
||||
log "浏览器真玩仍是独立硬门:$STUDIO_BASE/play/$GAME_ID/$VERSION_ID"
|
||||
log "证据:$EVIDENCE_OUT"
|
||||
log "DOGFOOD_E2E_VERDICT=PASS"
|
||||
|
||||
113
deploy/tests/test-dogfood-smoke.sh
Executable file
113
deploy/tests/test-dogfood-smoke.sh
Executable file
@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# 内部狗粮 smoke 回归:用假 HTTP 服务锁住默认无副作用与写模式 fail-closed 前置门。
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
SMOKE="$ROOT_DIR/deploy/smoke-test.sh"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
REQUEST_LOG="$TMP_DIR/requests.log"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local file="$1" expected="$2"
|
||||
grep -Fq "$expected" "$file" || fail "$file 缺少:$expected"
|
||||
}
|
||||
|
||||
assert_exit_code() {
|
||||
local expected="$1" actual="$2" message="$3"
|
||||
[ "$actual" -eq "$expected" ] || fail "$message(expected=$expected actual=$actual)"
|
||||
}
|
||||
|
||||
# 假 curl 只实现 readiness 所需响应,并记录方法与 URL;测试不访问任何真实服务。
|
||||
mkdir -p "$TMP_DIR/bin"
|
||||
cat >"$TMP_DIR/bin/curl" <<'FAKE_CURL'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
method="GET"
|
||||
output=""
|
||||
url=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-X) method="$2"; shift 2 ;;
|
||||
-o) output="$2"; shift 2 ;;
|
||||
-w|-H|--connect-timeout|--max-time|--data) shift 2 ;;
|
||||
-sS) shift ;;
|
||||
http://*|https://*) url="$1"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf '%s %s\n' "$method" "$url" >>"$REQUEST_LOG"
|
||||
case "$url" in
|
||||
*/actuator/health)
|
||||
body='{"status":"UP"}' ;;
|
||||
*/app-api/feed/zones)
|
||||
body='{"code":0,"data":[{"id":1}]}' ;;
|
||||
*/app-api/feed/stream*)
|
||||
body='{"code":0,"data":{"list":[{"gameId":11,"versionId":22}],"hasMore":false}}' ;;
|
||||
*/app-api/runtime/package/22*)
|
||||
body='{"code":0,"data":{"gameId":11,"versionId":22,"checksum":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}' ;;
|
||||
*)
|
||||
body='{"code":404,"data":null}' ;;
|
||||
esac
|
||||
printf '%s' "$body" >"$output"
|
||||
printf '200'
|
||||
FAKE_CURL
|
||||
chmod +x "$TMP_DIR/bin/curl"
|
||||
|
||||
export PATH="$TMP_DIR/bin:$PATH"
|
||||
export REQUEST_LOG
|
||||
|
||||
bash -n "$SMOKE"
|
||||
|
||||
# 构建配置只能包含内网地址和模式开关,不能烘焙登录 token。
|
||||
assert_contains "$ROOT_DIR/game-studio/.env.staging" 'VITE_STUDIO_TOKEN='
|
||||
assert_contains "$ROOT_DIR/game-studio/.env.staging" 'VITE_DOGFOOD_MODE=true'
|
||||
if grep -Eq '^VITE_STUDIO_TOKEN=.+$' "$ROOT_DIR/game-studio/.env.staging"; then
|
||||
fail 'game-studio staging 构建烘焙了 token'
|
||||
fi
|
||||
assert_contains "$ROOT_DIR/game-admin/.env.stage" "VITE_BASE_URL='http://100.64.0.7:48080'"
|
||||
if find "$ROOT_DIR/game-studio/public/mock-manifests" -type f -print -quit 2>/dev/null | grep -q .; then
|
||||
fail 'staging public 目录仍包含退役 mock/CDP 走查资产'
|
||||
fi
|
||||
|
||||
# 写模式必须显式确认副作用,且在任何 HTTP 请求前 fail-closed。
|
||||
assert_contains "$SMOKE" 'DOGFOOD_E2E_ACK'
|
||||
set +e
|
||||
PATH="$TMP_DIR/bin:$PATH" "$SMOKE" --dogfood-e2e >"$TMP_DIR/missing-env.out" 2>&1
|
||||
missing_env_rc=$?
|
||||
set -e
|
||||
assert_exit_code 2 "$missing_env_rc" '缺凭据时没有 fail-closed'
|
||||
[ ! -s "$REQUEST_LOG" ] || fail '缺凭据时已发出网络请求'
|
||||
|
||||
set +e
|
||||
DOGFOOD_CREATOR_USERNAME=creator DOGFOOD_CREATOR_PASSWORD=creator-pass \
|
||||
DOGFOOD_REVIEWER_USERNAME=reviewer DOGFOOD_REVIEWER_PASSWORD=reviewer-pass \
|
||||
DOGFOOD_READONLY_USERNAME=readonly DOGFOOD_READONLY_PASSWORD=readonly-pass \
|
||||
PATH="$TMP_DIR/bin:$PATH" "$SMOKE" --dogfood-e2e >"$TMP_DIR/missing-ack.out" 2>&1
|
||||
missing_ack_rc=$?
|
||||
set -e
|
||||
assert_exit_code 2 "$missing_ack_rc" '缺 ACK 时没有 fail-closed'
|
||||
[ ! -s "$REQUEST_LOG" ] || fail '缺 ACK 时已发出网络请求'
|
||||
|
||||
# 默认 readiness 只能发送 GET,且假服务完整返回时应通过。
|
||||
PATH="$TMP_DIR/bin:$PATH" "$SMOKE" >"$TMP_DIR/readiness.out" 2>&1
|
||||
assert_contains "$TMP_DIR/readiness.out" 'SMOKE_VERDICT=PASS'
|
||||
if grep -Ev '^GET ' "$REQUEST_LOG" | grep -q .; then
|
||||
fail '默认 readiness 发出了非 GET 请求'
|
||||
fi
|
||||
|
||||
# 未知参数同样必须在发请求前拒绝。
|
||||
: >"$REQUEST_LOG"
|
||||
set +e
|
||||
PATH="$TMP_DIR/bin:$PATH" "$SMOKE" --deep >"$TMP_DIR/unknown.out" 2>&1
|
||||
unknown_rc=$?
|
||||
set -e
|
||||
assert_exit_code 2 "$unknown_rc" '未知参数未拒绝'
|
||||
[ ! -s "$REQUEST_LOG" ] || fail '未知参数触发了网络请求'
|
||||
|
||||
printf 'PASS: dogfood smoke fail-closed regression\n'
|
||||
@ -3,8 +3,8 @@ NODE_ENV=production
|
||||
|
||||
VITE_DEV=false
|
||||
|
||||
# 请求路径
|
||||
VITE_BASE_URL='http://api-dashboard.huijing.iocoder.cn'
|
||||
# staging 后端固定走 mini-desktop 内网地址,禁止误连上游演示环境。
|
||||
VITE_BASE_URL='http://100.64.0.7:48080'
|
||||
|
||||
# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
|
||||
VITE_UPLOAD_TYPE=server
|
||||
@ -21,14 +21,16 @@ VITE_DROP_CONSOLE=true
|
||||
# 是否sourcemap
|
||||
VITE_SOURCEMAP=false
|
||||
|
||||
# 打包路径
|
||||
VITE_BASE_PATH='http://static-vue3.huijing.iocoder.cn/'
|
||||
# staging 由独立 4174 端口提供静态站点。
|
||||
VITE_BASE_PATH='/'
|
||||
|
||||
# 输出路径
|
||||
VITE_OUT_DIR=dist-stage
|
||||
|
||||
# 商城H5会员端域名
|
||||
VITE_MALL_H5_DOMAIN='http://mall.huijing.iocoder.cn'
|
||||
# staging 后端未启用租户、验证码与 API 加密,前端必须逐项对齐。
|
||||
VITE_APP_TENANT_ENABLE=false
|
||||
VITE_APP_CAPTCHA_ENABLE=false
|
||||
VITE_APP_API_ENCRYPT_ENABLE=false
|
||||
|
||||
# GoView域名
|
||||
VITE_GOVIEW_URL='http://127.0.0.1:3000'
|
||||
# 绘境业务页面必须走真实 staging API,不启用本地业务 mock。
|
||||
VITE_APP_WANXIANG_MOCK=false
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
# game-studio 联调 env(vite --mode staging)。staging 后端在 mini-desktop。
|
||||
# 非密钥:仅后端地址 + studio 登录态 token(后端可识别的固定测试态),故入库供 mini-desktop 克隆联调
|
||||
# (根 .gitignore 已显式豁免 !game-studio/.env.staging)。
|
||||
# staging 只烘焙公开地址与狗粮模式,不烘焙任何账号 token。
|
||||
# 用户必须走真实用户名密码登录,token 仅落浏览器 localStorage。
|
||||
VITE_API_BASE=http://100.64.0.7:48080
|
||||
VITE_STUDIO_TOKEN=test1
|
||||
VITE_STUDIO_TOKEN=
|
||||
VITE_DOGFOOD_MODE=true
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# U5+U2 真 UI 走查 runbook(mini-desktop 本地执行)。
|
||||
# chrome 生命周期:起 headless chrome(:9222) → 探活 → 跑 python 走查 → 杀 chrome 净场。
|
||||
# 自身输出重定向到 /tmp/u5u2_out.txt(CDP §2 坑5:避免继承 ssh fd 被断连杀;本次一次性同步跑,输出兜底落文件)。
|
||||
set -u
|
||||
exec >/tmp/u5u2_out.txt 2>&1
|
||||
echo "=== RUN_U5U2 START $(date -Is) ==="
|
||||
|
||||
SHOT=/tmp/u5u2
|
||||
mkdir -p "$SHOT"
|
||||
CHROME=/usr/bin/google-chrome
|
||||
PROF=/tmp/u5u2_chrome_prof
|
||||
rm -rf "$PROF"; mkdir -p "$PROF"
|
||||
|
||||
# 先清掉可能残留的 9222 chrome
|
||||
pkill -f "remote-debugging-port=9222" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# 起 headless chrome(CDP §2:no-sandbox + no-zygote + remote-allow-origins=*)
|
||||
"$CHROME" \
|
||||
--headless=new --no-sandbox --no-zygote \
|
||||
--remote-debugging-port=9222 --remote-allow-origins=* \
|
||||
--user-data-dir="$PROF" \
|
||||
--window-size=420,900 \
|
||||
--autoplay-policy=no-user-gesture-required \
|
||||
about:blank >/tmp/u5u2_chrome.log 2>&1 &
|
||||
CHROME_PID=$!
|
||||
echo "chrome pid=$CHROME_PID"
|
||||
|
||||
# 探活:起后必 curl /json/version 验活再用(最多 ~10s)
|
||||
UP=0
|
||||
for i in $(seq 1 20); do
|
||||
if curl -s --max-time 2 http://localhost:9222/json/version >/dev/null 2>&1; then
|
||||
UP=1; echo "chrome :9222 UP after ${i} tries"; break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
if [ "$UP" != "1" ]; then
|
||||
echo "FATAL: chrome :9222 not up"; cat /tmp/u5u2_chrome.log; kill "$CHROME_PID" 2>/dev/null; exit 1
|
||||
fi
|
||||
curl -s --max-time 3 http://localhost:9222/json/version | head -c 200; echo
|
||||
|
||||
# 跑走查
|
||||
echo "=== PYTHON WALK ==="
|
||||
python3 /tmp/u5u2_walk.py
|
||||
RC=$?
|
||||
echo "=== PYTHON RC=$RC ==="
|
||||
|
||||
# 净场
|
||||
kill "$CHROME_PID" 2>/dev/null || true
|
||||
pkill -f "remote-debugging-port=9222" 2>/dev/null || true
|
||||
echo "=== screenshots ==="
|
||||
ls -la "$SHOT"
|
||||
echo "=== RUN_U5U2 DONE $(date -Is) rc=$RC ==="
|
||||
@ -1,392 +0,0 @@
|
||||
#!/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()
|
||||
@ -35,6 +35,9 @@ import { gateStorageKey, gateStorageWrite } from './storageGate';
|
||||
import { buildIframeSrcdoc, buildDemoPackage, fetchAndVerifyManifest } from './inject';
|
||||
import { getRuntimePackage } from './runtimeApi';
|
||||
|
||||
/** 狗粮阶段不展示任何假广告/假支付 UI;请求仍须显式失败回包,避免游戏悬挂等待。 */
|
||||
const dogfoodMode = import.meta.env.VITE_DOGFOOD_MODE === 'true';
|
||||
|
||||
// —— props(spec §3.4)——
|
||||
const props = withDefaults(defineProps<GamePlayerProps>(), {
|
||||
traceId: '',
|
||||
@ -403,6 +406,17 @@ function handleStorage(payload: unknown, direction: MessageDirection, requestId?
|
||||
/** 广告请求 → 抛 adRequest + 弹桩层 */
|
||||
function handleAdRequest(p: AdRequestPayload, requestId?: string): void {
|
||||
emit('adRequest', { slotId: p.slotId, type: p.adType });
|
||||
if (dogfoodMode) {
|
||||
if (requestId) {
|
||||
bridge?.post(
|
||||
'ad',
|
||||
p.adType === 'rewarded' ? { rewarded: false } : { shown: false },
|
||||
traceId.value,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
overlay.kind = 'ad';
|
||||
overlay.adType = p.adType;
|
||||
overlay.slotId = p.slotId;
|
||||
@ -413,6 +427,10 @@ function handleAdRequest(p: AdRequestPayload, requestId?: string): void {
|
||||
/** 支付请求 → 抛 payRequest + 弹桩层 */
|
||||
function handlePayRequest(p: PayRequestPayload, requestId?: string): void {
|
||||
emit('payRequest', { orderId: p.orderId, amount: p.amount });
|
||||
if (dogfoodMode) {
|
||||
if (requestId) bridge?.post('pay', { paid: false }, traceId.value, requestId);
|
||||
return;
|
||||
}
|
||||
overlay.kind = 'pay';
|
||||
overlay.orderId = p.orderId;
|
||||
overlay.amount = p.amount;
|
||||
@ -558,7 +576,7 @@ function onIframeLoad(): void {
|
||||
</div>
|
||||
|
||||
<!-- ad/pay 桩弹层 -->
|
||||
<div v-if="overlay.visible" class="gp-overlay" @click.self="cancelOverlay">
|
||||
<div v-if="!dogfoodMode && overlay.visible" class="gp-overlay" @click.self="cancelOverlay">
|
||||
<div class="gp-sheet">
|
||||
<!-- 广告桩 -->
|
||||
<template v-if="overlay.kind === 'ad'">
|
||||
|
||||
@ -5,9 +5,9 @@
|
||||
* 鉴权改造(spec §9.2):
|
||||
* - setLogin:真实登录成功后调用,新增 token/userId/nickname/creatorFlag 落 localStorage 持久化(刷新不丢);
|
||||
* - logout:同步清 localStorage;
|
||||
* - token 初值优先级:localStorage 真 token > VITE_STUDIO_TOKEN(env,staging 批跑=test1) > 'mock-studio-token' 占位;
|
||||
* - token 初值优先级:localStorage 真 token > VITE_STUDIO_TOKEN;本地 mock 才回退 mock token,狗粮态保持空 token;
|
||||
* - isRealLogin():仅「localStorage 真 token」视为真实登录——守卫/互动弹窗一律用它(isLogin() 因 token 永有兜底值恒 true,不可用作守卫,保留不动)。
|
||||
* staging 批跑/agent 走 env token 直连 API,不写 localStorage,故 isRealLogin()=false,不触发任何 UI 守卫,零中断。
|
||||
* 狗粮态必须走真实登录,未登录时不伪造 Authorization 头。
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
@ -65,13 +65,14 @@ function ensureAnonId(): string {
|
||||
|
||||
/**
|
||||
* token 初值解析(优先级:localStorage 真 token > env VITE_STUDIO_TOKEN > mock 占位)。
|
||||
* 真 token 来自上次真实登录的持久化;env token 服务 staging 批跑/agent(=test1);mock 占位保证全链路可跑。
|
||||
* 真 token 来自上次真实登录的持久化;env token 只供本地自动化;mock 占位只在非狗粮模式维持本地体验。
|
||||
*/
|
||||
function resolveInitToken(): string {
|
||||
const real = lsGet(LS_TOKEN)
|
||||
if (real) return real
|
||||
const envToken = import.meta.env.VITE_STUDIO_TOKEN as string | undefined
|
||||
if (envToken) return envToken
|
||||
if (import.meta.env.VITE_DOGFOOD_MODE === 'true') return ''
|
||||
return 'mock-studio-token'
|
||||
}
|
||||
|
||||
@ -81,7 +82,7 @@ function resolveInitUserId(): string {
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
/** 登录 Token:localStorage 真 token > env VITE_STUDIO_TOKEN(=test1) > 'mock-studio-token' 占位(§9.2) */
|
||||
/** 登录 Token:真实持久化 > 可选自动化 token;狗粮未登录态为空,本地 mock 才使用占位 token。 */
|
||||
const token = ref<string>(resolveInitToken())
|
||||
/** 用户 ID:真实登录持久化优先,否则 mock 固定 '1001' */
|
||||
const userId = ref<string>(resolveInitUserId())
|
||||
@ -105,7 +106,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
|
||||
/**
|
||||
* 是否「真实登录」:仅 localStorage 存在真 token 才算(§9.2 守卫/互动唯一判据)。
|
||||
* staging 批跑/agent 走 env token,不写 localStorage,故此处 false,不触发 UI 守卫(零中断)。
|
||||
* 只有真实登录写入 localStorage 后才返回 true;狗粮态不会靠构建时 token 绕过守卫。
|
||||
*/
|
||||
function isRealLogin(): boolean {
|
||||
return !!lsGet(LS_TOKEN)
|
||||
|
||||
@ -50,6 +50,9 @@ const { t } = useI18n({
|
||||
messages: { 'zh-CN': hubZh, 'en-US': hubEn },
|
||||
})
|
||||
|
||||
/** 狗粮阶段不展示尚未接真实联盟账单的广告收益。 */
|
||||
const dogfoodMode = import.meta.env.VITE_DOGFOOD_MODE === 'true'
|
||||
|
||||
/** 我的项目全量(聚合累计播放/点赞用;一次性拉取,分页上限内足够看板聚合) */
|
||||
const projects = ref<Project[]>([])
|
||||
/** 创作者等级(已发布作品数权威源;失败保持 null,回退项目内 status=4 计数) */
|
||||
@ -221,17 +224,19 @@ async function fetchAll() {
|
||||
summaryLoaded.value = false
|
||||
}),
|
||||
// 我的收益汇总:广告收益数据源(trade /my/income-summary,单位分)
|
||||
tradeApi
|
||||
.getMyIncomeSummary()
|
||||
.then((inc) => {
|
||||
income.value = inc
|
||||
incomeLoaded.value = true
|
||||
})
|
||||
.catch(() => {
|
||||
// 失败:广告收益显「--」
|
||||
income.value = null
|
||||
incomeLoaded.value = false
|
||||
}),
|
||||
dogfoodMode
|
||||
? Promise.resolve()
|
||||
: tradeApi
|
||||
.getMyIncomeSummary()
|
||||
.then((inc) => {
|
||||
income.value = inc
|
||||
incomeLoaded.value = true
|
||||
})
|
||||
.catch(() => {
|
||||
// 失败:广告收益显「--」
|
||||
income.value = null
|
||||
incomeLoaded.value = false
|
||||
}),
|
||||
// 七日趋势:每日播放/完成(telemetry /my/game-stat/trend?days=7)
|
||||
telemetryApi
|
||||
.getMyTrend(7)
|
||||
@ -319,7 +324,7 @@ onMounted(() => {
|
||||
<span class="pending-row__value">{{ completionRateText }}</span>
|
||||
</div>
|
||||
<!-- 广告收益:trade /my/income-summary totalNet(已结算实得净额,分→元) -->
|
||||
<div class="pending-row">
|
||||
<div v-if="!dogfoodMode" class="pending-row">
|
||||
<span class="pending-row__label">{{ t('dashboard.adIncome') }}</span>
|
||||
<span class="pending-row__value">{{ incomeYuanText }}</span>
|
||||
</div>
|
||||
|
||||
@ -46,8 +46,11 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
/** 内部狗粮只允许预置账号密码登录,不暴露短信、邀请注册或密码注册入口。 */
|
||||
const dogfoodMode = import.meta.env.VITE_DOGFOOD_MODE === 'true'
|
||||
|
||||
/** 当前 Tab:'sms'=验证码登录(默认主路径) / 'invite'=邀请码注册 / 'password'=用户名密码 */
|
||||
const activeTab = ref<'sms' | 'invite' | 'password'>('sms')
|
||||
const activeTab = ref<'sms' | 'invite' | 'password'>(dogfoodMode ? 'password' : 'sms')
|
||||
|
||||
/** 手机号(sms/invite 两 Tab 共用;password Tab 不用) */
|
||||
const mobile = ref<string>('')
|
||||
@ -217,7 +220,7 @@ async function onSubmit(): Promise<void> {
|
||||
// 用户名密码路:username 去空白后提交;password 原样(不 trim);子模式决定注册/登录端点
|
||||
const uname = username.value.trim()
|
||||
resp =
|
||||
passwordMode.value === 'register'
|
||||
!dogfoodMode && passwordMode.value === 'register'
|
||||
? await passwordRegister({ username: uname, password: password.value, anonId })
|
||||
: await passwordLogin({ username: uname, password: password.value, anonId })
|
||||
}
|
||||
@ -239,7 +242,7 @@ async function onSubmit(): Promise<void> {
|
||||
</header>
|
||||
|
||||
<!-- Tab 切换:验证码登录 / 邀请码注册 / 用户名密码 -->
|
||||
<div class="login-tabs">
|
||||
<div v-if="!dogfoodMode" class="login-tabs">
|
||||
<button
|
||||
class="login-tab"
|
||||
:class="{ 'is-active': activeTab === 'sms' }"
|
||||
@ -316,7 +319,7 @@ async function onSubmit(): Promise<void> {
|
||||
<!-- 用户名密码:子模式(登录/注册)切换 + 用户名 + 密码(内测种子用户,WU1 §3.5) -->
|
||||
<template v-else>
|
||||
<!-- 登录/注册子模式切换(注册走 password-register 建号即登录,登录走 password-login) -->
|
||||
<div class="pwd-mode">
|
||||
<div v-if="!dogfoodMode" class="pwd-mode">
|
||||
<button
|
||||
class="pwd-mode-btn"
|
||||
:class="{ 'is-active': passwordMode === 'login' }"
|
||||
|
||||
4
game-studio/src/vite-env.d.ts
vendored
4
game-studio/src/vite-env.d.ts
vendored
@ -7,8 +7,10 @@
|
||||
interface ImportMetaEnv {
|
||||
/** 后端 API base(联调指向 staging,如 http://100.64.0.7:48080;空串=同源/mock) */
|
||||
readonly VITE_API_BASE?: string
|
||||
/** studio 登录态 token(staging=test1,后端可识别的固定测试态;缺省回退 mock 占位) */
|
||||
/** studio 登录态 token(只供本地 mock/自动化;staging 不得烘焙真实 token) */
|
||||
readonly VITE_STUDIO_TOKEN?: string
|
||||
/** 内部狗粮模式:只开放密码登录,并隐藏未接真实渠道的广告、支付与收益入口 */
|
||||
readonly VITE_DOGFOOD_MODE?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user