补齐游戏分发网页侧链路取证并修掉返回目录丢关键词
Project CI / AI game creator shell Rust crates (push) Successful in 1m31s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m1s
Project CI / Backend tests (push) Successful in 5m4s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m50s
Project CI / Frontend tests (push) Successful in 2m24s
Project CI / Native shell tests (push) Successful in 7m27s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 10m9s
Project CI / AI game creator shell web tests (push) Successful in 1m50s
Project CI / Repository checks (push) Successful in 2m24s
Project CI / AI game creator shell Rust crates (push) Successful in 1m31s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m1s
Project CI / Backend tests (push) Successful in 5m4s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m50s
Project CI / Frontend tests (push) Successful in 2m24s
Project CI / Native shell tests (push) Successful in 7m27s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 10m9s
Project CI / AI game creator shell web tests (push) Successful in 1m50s
Project CI / Repository checks (push) Successful in 2m24s
- 新增 scripts/check-game-distribution-web-e2e.mjs:真实 dev 网页栈 + Chromium 游客走目录筛选、空态与重置、详情主动作、分享直达详情、按需加载发行 iframe、失败重试、退出不自动重启、移动端门槛与旋转提示,共 23 项 PASS - GameGalleryPage 的顶栏搜索词同步改为按 prop 值比对:原来的一次性 ref 守卫在开发态 StrictMode 双跑下会用空值清掉刚从 sessionStorage 恢复的关键词 - vite.config.ts 增加 /games/<gameId>/ 发行入口代理(rewrite 到发行网关并去掉 Cookie),本地网页栈与生产 nginx 同口径 - 游戏分发里程碑阶段 C 第 6、7 条改为已勾选,其余条目列出具体缺口 - pitfalls 记录 StrictMode 守卫击穿与 Vite 发行入口代理两条经验
This commit is contained in:
@@ -100,8 +100,8 @@
|
||||
- [ ] AGC 发布封面支持基于项目上下文生成,复用现役图片生成与泥点扣费链路;生成结果登记为当前账号平台素材后自动作为 `coverAssetId`,不二次上传。
|
||||
- [ ] 网页可选 ZIP、提交封面和必需资料,进入相同上传/校验/审核流程;任一客户端可以查看同账号游戏状态,更新沿用相同 `gameId`。
|
||||
- [ ] 上传中断、双击、登录失效、窗口关闭后恢复原操作;换账号不能恢复前账号私有状态;待审不能显示为已发布。
|
||||
- [ ] 目录支持真实数据、关键词/分类/设备筛选、空/错/加载态;详情提供明确主动作,搜索与返回恢复上下文。
|
||||
- [ ] 游客从目录/分享链接进入详情再启动真实已发布游戏;开始操作后才加载 iframe,加载失败可重试,退出不会自动重启游戏。
|
||||
- [x] 目录支持真实数据、关键词/分类/设备筛选、空/错/加载态;详情提供明确主动作,搜索与返回恢复上下文。
|
||||
- [x] 游客从目录/分享链接进入详情再启动真实已发布游戏;开始操作后才加载 iframe,加载失败可重试,退出不会自动重启游戏。
|
||||
- [ ] 桌面与移动导航、详情、上传/发布面板、旋转提示、全屏、安全区和焦点可达符合主规范;不适配移动端的游戏有明确门槛。
|
||||
- [ ] 视觉沿用现有 warm token,通用交互复用共享组件;没有固定假统计、本地演示兜底或同源 iframe 放宽。
|
||||
|
||||
@@ -189,3 +189,17 @@
|
||||
- 待审期间公开目录不含该游戏、封面没有匿名读授权(第 1 条取证里已跑);**本轮**补下架半边:作者 `unpublish` 成功后公开目录不含该游戏、`GET /api/game-distribution/games/{id}` 返回 404、发行网关 `GET /api/game-distribution/releases/{id}/index.html` 返回 404,且目录与详情的响应文本里没有 `aliyuncs.com` / `oss-cn` / `Signature=` 这类对象存储地址。- 仍未勾选(阶段 B 其余条目)
|
||||
- 第 5/6 条(发行隔离与 opaque sandbox 的真实浏览器行为):**本轮补齐**。新增 `scripts/check-game-distribution-sandbox-e2e.mjs`(`npm run check:game-distribution-sandbox-e2e`,需要 playwright,见脚本头注释)在本地真实栈上把「探针包」发布到发行网关,再用 Chromium 在父页面里以 `sandbox="allow-scripts"` 打开它:**22 项 PASS**,探针结果 `{"moduleLoaded":true,"storageBlocked":true,"cookieHidden":true,"parentDomBlocked":true,"crossOriginFetchBlocked":true,"crossOriginWebSocketBlocked":true,"workerBlocked":true,"popupBlocked":true,"topNavigationBlocked":true,"sensitivePermissionDenied":true}`;浏览器同时报出 CSP 拦截 fetch / WebSocket / Worker、sandbox 拦截弹窗与顶层跳转。同一次运行还断言了发行网关的响应策略:HTML 带 `connect-src 'self'` + `worker-src 'none'` 的最小 CSP、`nosniff`、`Access-Control-Allow-Origin: *`、带平台 Cookie 的请求 403、未知扩展名 404。独立发行域名不再作为门禁:主规范 2026 决策已用「平台同源路径 + `sandbox="allow-scripts"` 不透明来源」替代独立域名/通配证书,本轮按现行口径核对。
|
||||
- 第 7 条(下架会关闭新启动与发行读取、不能绕过网关直取公开 OSS、撤销传播在最大缓存窗口内):前两句本轮都已取证——作者 `unpublish` 与管理员 `suspend` 之后公开目录不含该游戏、详情 404、发行网关 404,`restore` 后重新可玩;「不能绕过网关直取公开 OSS」按上一条的修复与 403 复验成立。**仍缺**:①「不能绕过网关直取公开 OSS」**本轮已修并复验**:先用同一条探测证明匿名直取发行包返回 200(同 bucket 不存在的 key 是 404),根因是 `platform-oss` 收了 `OssObjectAccess` 却从不发送 `x-oss-object-acl`、对象继承 bucket 默认 ACL;现在内部 PUT、分片追加(`OssAppendInternalObjectRequest` 新增 `access`)与直传 policy/表单(`DirectUploadTicketFormFields` 新增 `x-oss-object-acl`)都显式下发对象级 ACL,`E2E_REQUIRE_PRIVATE_BUCKET=1` 下匿名直取直传封面与发行包都返回 **403**(65 项 PASS)。「撤销传播符合最大缓存窗口」需要真实 CDN/TTL 与生产域名,本轮未做,因此第 7 条保持未勾选。
|
||||
|
||||
## 本轮核对(2026-09-28,阶段 C 网页侧)
|
||||
|
||||
- 已勾选(第 6 条:目录真实数据 + 关键词/分类/设备筛选 + 空态;详情明确主动作;搜索与返回恢复上下文)——本轮补齐
|
||||
- 新增 `scripts/check-game-distribution-web-e2e.mjs`(`npm run check:game-distribution-web-e2e`,真实 `npm run dev` 网页栈 + Chromium 游客):目录里出现刚发布的游戏;关键词命中/无结果空态 + 「重置筛选」;分类与设备筛选切换后该游戏按条件出现/消失;点击卡片进入 `/games/detail?id=…` 并看到主行动作;**从详情返回目录时关键词、分类、设备三项都恢复**(返回后的 URL 是 `/games?keyword=…&category=…&device=…`)。
|
||||
- **本轮顺带修掉一处真实缺陷**:返回目录时关键词会被清空。根因是 `GameGalleryPage` 用一次性 `didSyncPropKeywordRef` 守卫「顶栏搜索词覆盖目录筛选」,而开发态 StrictMode 会把挂载 effect 跑两遍——第二遍就拿空 prop 把刚从 `sessionStorage` 恢复的关键词清掉。改成按 prop 值比对(`lastPropKeywordRef`)后,恢复上下文与顶栏改词两条路径都正常;`npx vitest run src/components/game-distribution/GameDistributionPages.test.tsx` 20 passed。
|
||||
- 已勾选(第 7 条:游客从目录/分享链接进详情 → 启动真实已发布游戏;按需加载 iframe;失败可重试;退出不自动重启)——本轮补齐
|
||||
- 同一脚本:直接打开 `/games/detail?id=…` 深链可用(分享链接路径);点「立即玩」进 `/games/play?id=…` 后**先给开始面板、此时没有任何 iframe**;点「开始游戏」才挂载 `sandbox="allow-scripts"` 的 iframe,src 是平台同源入口 `/games/<gameId>/`,且 iframe 内真实载入了发行包内容(断言页面文本含 `WEB-E2E-OK`,不是 SPA 外壳);从游玩页返回详情后 iframe 消失(退出不自动重启);把详情接口第一次请求打断后游玩页给出「重新加载」入口,点击后回到可开始状态。
|
||||
- **dev 栈补齐同源入口映射**:生产由 nginx 把 `/games/<gameId>/` 映射到发行网关并清空 Cookie,本地 Vite 之前没有这条规则(iframe 会落回 SPA 外壳)。本轮在 `vite.config.ts` 补了同前缀正则代理(rewrite 到 `/api/game-distribution/releases/…` 并去掉 Cookie 头),本地网页栈现在与生产同口径。
|
||||
- 仍未勾选(阶段 C 其余条目)
|
||||
- AGC 侧三条(dist 真实包一次提交、发布面板免费生成简介/分类、封面生成复用扣费链路):需要 AGC 客户端 GUI 与真实 Provider,本轮未做。
|
||||
- 网页 ZIP 上传与跨端状态(第 4 条)、上传中断/换账号/待审显示(第 5 条):ZIP 上传面板与账号态切换仍需专门用例。
|
||||
- 第 8 条(桌面/移动导航、上传发布面板、全屏、安全区、焦点可达):本轮只覆盖移动端目录/详情/游玩、旋转提示、桌面端游戏在移动端的明确门槛与无横向溢出;发布面板、全屏、安全区与键盘焦点仍缺用例。
|
||||
- 第 9 条(warm token、共享组件、无假统计、无演示兜底):本轮只顺带确认目录里的游玩次数来自真实数据(新游戏显示 0 次游玩),视觉与组件复用仍需人工/截图评审。
|
||||
@@ -6064,4 +6064,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
|
||||
|
||||
- **现象**:`server-rs/crates/api-server/src/project_snapshots.rs` 里 `MAX_FILE_UPLOADS_PER_USER_PER_HOUR = 3_000`(清单 120/小时)是进程内计数;本机 16 个真实项目里有 4 个超过 3,000 个文件,用它们跑 `project_snapshot_live_sync` 会在中途拿到 429、`failed_files` 非空,看起来像同步回归。
|
||||
- **判据/做法**:真实链路冒烟挑 1,000~2,000 个文件量级、且带 `.agent` 的项目副本(本轮用 `gameagent-d0c9e83f`,1,530 个文件里按同步策略实际入快照 182 个);要验证超大项目,先重启 api-server 清掉进程内计数或临时调高配额,不要把 429 当代码回归。
|
||||
- **关联**:`scripts/check-agc-project-snapshot-admin-http.mjs`(后台列表 + ZIP 下载核对)与 `apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs` 的 ignored live smoke。
|
||||
- **关联**:`scripts/check-agc-project-snapshot-admin-http.mjs`(后台列表 + ZIP 下载核对)与 `apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs` 的 ignored live smoke。
|
||||
## 2026-09-28 开发态 StrictMode 双跑 effect 击穿「一次性初始化」守卫;Vite dev 缺发行入口代理
|
||||
|
||||
- **现象 1(真实缺陷)**:从游戏详情返回目录后,之前输入的关键词被清空(分类/设备还在)。`GameGalleryPage` 用 `didSyncPropKeywordRef` 守卫「顶栏搜索词覆盖目录筛选」,React 开发态 StrictMode 会把挂载 effect 跑两遍:第一遍设标记并返回,第二遍就拿着空 `searchKeyword` 把刚从 `sessionStorage` 恢复的关键词清掉。**处理**:改成按 prop 值比对(`lastPropKeywordRef`),只有 prop 真的变化才覆盖本地筛选;`GameDistributionPages.test.tsx` 与网页 E2E 都回归通过。教训:任何「只在首次挂载跳过」的 ref 守卫都要按 StrictMode 双跑来审一遍。
|
||||
- **现象 2(dev 栈对齐)**:平台同源发行入口 `/games/<gameId>/` 在生产由 nginx 映射到发行网关并清空 Cookie,本地 Vite 代理没有这条规则,iframe 会落到 SPA 外壳(看起来“加载成功”,其实是壳)。**处理**:`vite.config.ts` 增加 `^/games/game_[0-9a-f]{32}(?:/.*)?$` 代理,rewrite 到 `/api/game-distribution/releases/…` 并去掉 Cookie 头,本地网页栈与生产同口径;`check:game-distribution-web-e2e` 里用「iframe 文本包含包内标记」断言真正载入的是发行包。
|
||||
@@ -80,6 +80,7 @@
|
||||
"check:agc-project-snapshot-admin-http": "node scripts/check-agc-project-snapshot-admin-http.mjs",
|
||||
"check:game-distribution-sandbox-e2e": "node scripts/check-game-distribution-sandbox-e2e.mjs",
|
||||
"check:external-v1-scene-generation-smoke": "node scripts/check-external-v1-scene-generation-smoke.mjs",
|
||||
"check:game-distribution-web-e2e": "node scripts/check-game-distribution-web-e2e.mjs",
|
||||
"check:production-ops": "node scripts/check-production-ops-guardrails.mjs",
|
||||
"check:preview-deployer": "node scripts/check-preview-deployer.mjs",
|
||||
"check:maintenance-page": "node scripts/check-maintenance-page.mjs",
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
// 游戏分发网页侧真实链路检查:真实 api-server 发布一款游戏,再用 Chromium 以游客身份
|
||||
// 走「目录筛选 → 详情主动作 → 在线游玩(按需加载 iframe / 失败重试 / 退出不自动重启)→
|
||||
// 移动端门槛」。
|
||||
//
|
||||
// 需要:完整本地 dev 栈(`npm run dev`,网页 3000 + api-server 8082)+ 管理员账号 + playwright。
|
||||
// npm install --prefix %TEMP%\genarrative-pw --no-save --no-package-lock playwright
|
||||
// E2E_PLAYWRIGHT_DIR=%TEMP%\genarrative-pw
|
||||
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-web-e2e
|
||||
// E2E_WEB_BASE 默认 http://127.0.0.1:3000,E2E_API_BASE 默认 http://127.0.0.1:8082。
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
|
||||
const COVER_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const WEB = process.env.E2E_WEB_BASE ?? 'http://127.0.0.1:3000';
|
||||
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
|
||||
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
|
||||
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
|
||||
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
|
||||
const DEV_PASSWORD = 'GenE2e123!';
|
||||
|
||||
if (!ADMIN_USER || !ADMIN_PASSWORD) {
|
||||
console.error(
|
||||
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要发布并审核一款真实游戏供网页侧游玩。',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
function check(name, ok, detail = '') {
|
||||
if (!ok) failures += 1;
|
||||
console.log(
|
||||
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function api(pathname, options = {}) {
|
||||
const { method = 'GET', token, body, headers = {}, binary } = options;
|
||||
const finalHeaders = { ...ENVELOPE, ...headers };
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
let finalBody;
|
||||
if (binary) {
|
||||
finalBody = binary;
|
||||
} else if (body !== undefined) {
|
||||
finalHeaders['Content-Type'] = 'application/json';
|
||||
finalBody = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetch(`${API}${pathname}`, {
|
||||
method,
|
||||
headers: finalHeaders,
|
||||
body: finalBody,
|
||||
});
|
||||
const text = await response.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
text,
|
||||
json,
|
||||
data: json?.data,
|
||||
error: json?.error,
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadCover(token, id) {
|
||||
const fileName = `web-e2e-${id}.png`;
|
||||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'web-e2e', String(id)],
|
||||
fileName,
|
||||
contentType: 'image/png',
|
||||
access: 'private',
|
||||
maxSizeBytes: COVER_PNG.length,
|
||||
metadata: { asset_kind: 'game_distribution_cover' },
|
||||
},
|
||||
});
|
||||
if (ticket.status !== 200) {
|
||||
throw new Error(`创建直传凭证失败 ${ticket.status}`);
|
||||
}
|
||||
const upload = ticket.data.upload;
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
|
||||
if (value !== null && value !== undefined) form.append(key, String(value));
|
||||
}
|
||||
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
|
||||
const put = await fetch(upload.host, { method: 'POST', body: form });
|
||||
if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`);
|
||||
const confirm = await api('/api/assets/objects/confirm', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
bucket: upload.bucket,
|
||||
objectKey: upload.objectKey,
|
||||
contentType: 'image/png',
|
||||
contentLength: COVER_PNG.length,
|
||||
assetKind: 'game_distribution_cover',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'game-distribution-web-e2e',
|
||||
},
|
||||
});
|
||||
if (confirm.status !== 200) {
|
||||
throw new Error(`确认素材失败 ${confirm.status}`);
|
||||
}
|
||||
return confirm.data.assetObject.assetObjectId;
|
||||
}
|
||||
|
||||
async function publishFixtureGame(stamp, title) {
|
||||
const adminLogin = await api('/admin/api/login', {
|
||||
method: 'POST',
|
||||
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
|
||||
});
|
||||
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
|
||||
check(
|
||||
'管理员登录成功',
|
||||
adminLogin.status === 200 && Boolean(admin),
|
||||
`status=${adminLogin.status}`,
|
||||
);
|
||||
if (!admin) process.exit(1);
|
||||
|
||||
const register = await api('/api/auth/entry', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
purePhoneNumber: `132${String(stamp).slice(-8)}`,
|
||||
password: DEV_PASSWORD,
|
||||
},
|
||||
});
|
||||
const author = register.data?.token;
|
||||
check(
|
||||
'作者注册拿到 token',
|
||||
register.status === 200 && Boolean(author),
|
||||
`status=${register.status}`,
|
||||
);
|
||||
if (!author) process.exit(1);
|
||||
|
||||
await api('/admin/api/feature-gates', {
|
||||
method: 'PUT',
|
||||
token: admin,
|
||||
body: {
|
||||
gateKey: 'game-distribution:publish',
|
||||
enabled: true,
|
||||
rolloutPercent: 100,
|
||||
allowUserIds: [],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: 'E2E 网页链路',
|
||||
},
|
||||
});
|
||||
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
'index.html',
|
||||
'<!doctype html><html><head><meta charset="utf-8"><title>web e2e</title>' +
|
||||
'<script src="assets/app.js"></script></head><body><h1>WEB-E2E-OK</h1></body></html>',
|
||||
);
|
||||
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="web";');
|
||||
const packageBytes = Buffer.from(
|
||||
await zip.generateAsync({ type: 'uint8array' }),
|
||||
);
|
||||
const packageSha256 = (await import('node:crypto'))
|
||||
.createHash('sha256')
|
||||
.update(packageBytes)
|
||||
.digest('hex');
|
||||
|
||||
const coverAssetId = await uploadCover(author, stamp);
|
||||
const metadata = {
|
||||
title,
|
||||
summary: '网页链路 E2E 临时游戏',
|
||||
description: '',
|
||||
category: '休闲',
|
||||
tags: ['e2e'],
|
||||
coverAssetId,
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
inputModes: ['keyboard', 'mouse'],
|
||||
orientation: 'landscape',
|
||||
};
|
||||
|
||||
const created = await api('/api/game-distribution/games', {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `web-e2e-game-${stamp}` },
|
||||
body: metadata,
|
||||
});
|
||||
const gameId = created.data?.id;
|
||||
if (created.status !== 200 || !gameId) {
|
||||
check('创建游戏成功', false, `status=${created.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const versionResponse = await api(
|
||||
`/api/game-distribution/games/${gameId}/versions`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `web-e2e-version-${stamp}` },
|
||||
body: {
|
||||
packageSha256,
|
||||
packageBytes: packageBytes.length,
|
||||
packageFileCount: 2,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
},
|
||||
},
|
||||
);
|
||||
const versionId = versionResponse.data?.versionId;
|
||||
const uploaded = await api(
|
||||
`/api/game-distribution/versions/${versionId}/package`,
|
||||
{
|
||||
method: 'PUT',
|
||||
token: author,
|
||||
headers: {
|
||||
'Idempotency-Key': `web-e2e-upload-${stamp}`,
|
||||
'Content-Type': 'application/zip',
|
||||
},
|
||||
binary: packageBytes,
|
||||
},
|
||||
);
|
||||
const submitted = await api(
|
||||
`/api/game-distribution/versions/${versionId}/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `web-e2e-submit-${stamp}` },
|
||||
body: { expectedPublicationRevision: 0 },
|
||||
},
|
||||
);
|
||||
const approved = await api(
|
||||
`/admin/api/game-distribution/versions/${versionId}/review`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: admin,
|
||||
headers: { 'Idempotency-Key': `web-e2e-approve-${stamp}` },
|
||||
body: { decision: 'approve', expectedPublicationRevision: 0 },
|
||||
},
|
||||
);
|
||||
check(
|
||||
'真实游戏已发布(上传 → 送审 → 审核通过)',
|
||||
uploaded.status === 200 &&
|
||||
submitted.status === 202 &&
|
||||
approved.status === 200,
|
||||
`upload=${uploaded.status} submit=${submitted.status} approve=${approved.status}`,
|
||||
);
|
||||
return { gameId, admin };
|
||||
}
|
||||
|
||||
async function loadPlaywright() {
|
||||
const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim();
|
||||
if (!dir) return import('playwright');
|
||||
const requireFromDir = createRequire(path.join(dir, 'noop.js'));
|
||||
return requireFromDir('playwright');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const stamp = Date.now();
|
||||
const title = `网页链路 ${String(stamp).slice(-6)}`;
|
||||
const { gameId } = await publishFixtureGame(stamp, title);
|
||||
|
||||
const { chromium } = await loadPlaywright();
|
||||
const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
});
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 800 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
// 平台壳层会同时挂载桌面/移动两套视图,断言一律只看可见的那一份。
|
||||
const card = page.locator('button.game-card:visible', { hasText: title });
|
||||
// 平台顶栏也有一个全局搜索框,这里只取游戏目录自己的筛选输入。
|
||||
const searchInput = page.locator('label.game-search input');
|
||||
|
||||
await page.goto(`${WEB}/games`, { waitUntil: 'domcontentloaded' });
|
||||
await card.waitFor({ state: 'visible', timeout: 20_000 });
|
||||
check('游客目录用真实数据显示已发布游戏', await card.isVisible());
|
||||
|
||||
await searchInput.fill(String(stamp).slice(-6));
|
||||
await page.waitForTimeout(300);
|
||||
check('关键词筛选命中该游戏', await card.isVisible());
|
||||
await searchInput.fill('zzz-没有这个游戏');
|
||||
await page.waitForTimeout(300);
|
||||
check(
|
||||
'无结果时给出空态与重置入口',
|
||||
await page.getByText('还没有找到对应游戏').first().isVisible(),
|
||||
'',
|
||||
);
|
||||
await page
|
||||
.locator('button:visible', { hasText: '重置筛选' })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForTimeout(300);
|
||||
check('重置筛选后回到完整目录', await card.isVisible());
|
||||
|
||||
const categoryTabs = page
|
||||
.locator('[role="tablist"][aria-label="游戏分类"]:visible')
|
||||
.first();
|
||||
await categoryTabs.getByRole('tab', { name: '策略' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
check('切换分类后该游戏被过滤掉', (await card.count()) === 0);
|
||||
await categoryTabs.getByRole('tab', { name: '休闲' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
check('回到该游戏分类后重新可见', await card.isVisible());
|
||||
|
||||
const deviceTabs = page
|
||||
.locator('[role="tablist"][aria-label="设备筛选"]:visible')
|
||||
.first();
|
||||
await deviceTabs.getByRole('tab', { name: '移动' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
check('只声明桌面端的游戏在移动筛选下不出现', (await card.count()) === 0);
|
||||
await deviceTabs.getByRole('tab', { name: '桌面' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
check('桌面筛选下重新可见', await card.isVisible());
|
||||
|
||||
await card.click();
|
||||
await page.waitForURL(/\/games\/detail\?/, { timeout: 15_000 });
|
||||
const playEntryButton = page.getByRole('button', { name: '立即玩' });
|
||||
await playEntryButton.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
check(
|
||||
'详情页路由带游戏编号且展示主行动作',
|
||||
page.url().includes(`id=${gameId}`),
|
||||
`url=${page.url()}`,
|
||||
);
|
||||
|
||||
await playEntryButton.click();
|
||||
await page.waitForURL(/\/games\/play\?/, { timeout: 15_000 });
|
||||
const startButton = page.getByRole('button', { name: '开始游戏' });
|
||||
await startButton.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
check(
|
||||
'游玩页先给开始面板、此时还没有 iframe',
|
||||
page.url().includes(`id=${gameId}`) &&
|
||||
(await page.locator('iframe.game-player-frame').count()) === 0,
|
||||
`url=${page.url()}`,
|
||||
);
|
||||
|
||||
await startButton.click();
|
||||
const frame = page.locator('iframe.game-player-frame');
|
||||
await frame.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
const frameSrc = (await frame.getAttribute('src')) ?? '';
|
||||
check(
|
||||
'开始后才挂载发行 iframe(opaque sandbox + 平台同源入口)',
|
||||
new RegExp(`/games/${gameId}/?$`).test(frameSrc) &&
|
||||
(await frame.getAttribute('sandbox')) === 'allow-scripts',
|
||||
`src=${frameSrc.slice(0, 80)}`,
|
||||
);
|
||||
const frameBodyLocator = page
|
||||
.frameLocator('iframe.game-player-frame')
|
||||
.locator('body');
|
||||
let frameBody = '';
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
frameBody = (await frameBodyLocator.innerText().catch(() => '')).trim();
|
||||
if (frameBody.includes('WEB-E2E-OK')) break;
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
check(
|
||||
'iframe 真正载入发行包内容(不是 SPA 外壳)',
|
||||
frameBody.includes('WEB-E2E-OK'),
|
||||
`body=${frameBody.slice(0, 60)}`,
|
||||
);
|
||||
|
||||
await page.locator('button.game-back-button').click();
|
||||
await page.waitForURL(/\/games\/detail\?/, { timeout: 15_000 });
|
||||
check(
|
||||
'退出游玩回到详情且不保留运行中的 iframe',
|
||||
(await page.locator('iframe.game-player-frame').count()) === 0,
|
||||
`url=${page.url()}`,
|
||||
);
|
||||
|
||||
// 分享直达详情:直接打开详情深链也必须能拿到主动作。
|
||||
const deepLinkPage = await context.newPage();
|
||||
await deepLinkPage.goto(`${WEB}/games/detail?id=${gameId}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await deepLinkPage
|
||||
.getByRole('button', { name: '立即玩' })
|
||||
.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
check('分享/直达详情链接可用', true, `url=${deepLinkPage.url()}`);
|
||||
await deepLinkPage.close();
|
||||
|
||||
// 返回目录要恢复之前的搜索条件。
|
||||
await page.goto(`${WEB}/games`, { waitUntil: 'domcontentloaded' });
|
||||
await card.waitFor({ state: 'visible', timeout: 20_000 });
|
||||
const keywordText = String(stamp).slice(-6);
|
||||
await searchInput.fill(keywordText);
|
||||
await page.waitForTimeout(500);
|
||||
await page.locator('button.game-card:visible').first().click();
|
||||
await page.waitForTimeout(1_500);
|
||||
const navigatedToDetail = /\/games\/detail\?/.test(page.url());
|
||||
check(
|
||||
'带筛选条件时仍能从目录进入详情',
|
||||
navigatedToDetail,
|
||||
`url=${page.url()}`,
|
||||
);
|
||||
if (navigatedToDetail) {
|
||||
await page.locator('button.game-back-button').click();
|
||||
await page.waitForTimeout(1_000);
|
||||
const restoredKeyword = await searchInput.inputValue();
|
||||
check(
|
||||
'从详情返回目录时恢复之前的搜索条件',
|
||||
restoredKeyword === keywordText && (await card.isVisible()),
|
||||
`keyword=${restoredKeyword} url=${page.url()}`,
|
||||
);
|
||||
} else {
|
||||
check('从详情返回目录时恢复之前的搜索条件', false, '未进入详情');
|
||||
}
|
||||
|
||||
// 加载失败可重试:第一次详情请求失败,重试后恢复。
|
||||
const retryPage = await context.newPage();
|
||||
let failedOnce = false;
|
||||
await retryPage.route(
|
||||
`**/api/game-distribution/games/${gameId}`,
|
||||
(route) => {
|
||||
if (!failedOnce) {
|
||||
failedOnce = true;
|
||||
route.abort();
|
||||
return;
|
||||
}
|
||||
route.continue();
|
||||
},
|
||||
);
|
||||
await retryPage.goto(`${WEB}/games/play?id=${gameId}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
const retryButton = retryPage.getByRole('button', { name: '重新加载' });
|
||||
await retryButton.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
check('游玩页加载失败时给出显式重试入口', await retryButton.isVisible());
|
||||
await retryButton.click();
|
||||
await retryPage
|
||||
.getByRole('button', { name: '开始游戏' })
|
||||
.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
check('重试后回到可开始状态', true);
|
||||
await retryPage.close();
|
||||
|
||||
// 移动端:桌面端游戏要有明确门槛,横屏游戏在竖屏下给旋转提示,且不横向溢出。
|
||||
const mobile = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
});
|
||||
const mobilePage = await mobile.newPage();
|
||||
await mobilePage.goto(`${WEB}/games`, { waitUntil: 'domcontentloaded' });
|
||||
await mobilePage
|
||||
.locator('label.game-search input')
|
||||
.waitFor({ state: 'visible' });
|
||||
const overflow = await mobilePage.evaluate(
|
||||
() => document.documentElement.scrollWidth - window.innerWidth,
|
||||
);
|
||||
check(
|
||||
'移动端目录可用且没有整页横向溢出',
|
||||
overflow <= 1,
|
||||
`overflow=${overflow}px`,
|
||||
);
|
||||
await mobilePage.goto(`${WEB}/games/play?id=${gameId}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await mobilePage
|
||||
.getByText('请在电脑上游玩')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
const mobileStartButton = mobilePage.getByRole('button', {
|
||||
name: '开始游戏',
|
||||
});
|
||||
await mobileStartButton.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
const mobileStartDisabled = await mobileStartButton.isDisabled();
|
||||
const rotationHint = await mobilePage
|
||||
.getByText('这款游戏以横屏设计,旋转设备即可获得完整画面。')
|
||||
.isVisible();
|
||||
check(
|
||||
'移动端对不支持的游戏给出明确门槛与旋转提示',
|
||||
mobileStartDisabled && rotationHint,
|
||||
`startDisabled=${mobileStartDisabled} rotationHint=${rotationHint}`,
|
||||
);
|
||||
await mobile.close();
|
||||
await context.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[check-game-distribution-web-e2e] 运行失败:${error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -146,7 +146,13 @@ export function GameGalleryPage({
|
||||
const requestSerialRef = useRef(0);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
const hasRestoredScrollRef = useRef(false);
|
||||
const didSyncPropKeywordRef = useRef(false);
|
||||
/**
|
||||
* 顶栏搜索词只在「真的变了」时覆盖目录筛选。
|
||||
*
|
||||
* 不能用一次性的 did-初始化标记:开发态 StrictMode 会把挂载 effect 跑两遍,
|
||||
* 第二遍就会拿空 prop 把刚从会话存储恢复出来的关键词清掉(返回目录上下文丢失)。
|
||||
*/
|
||||
const lastPropKeywordRef = useRef(searchKeyword);
|
||||
|
||||
const filters = useMemo<GameGalleryFilters>(
|
||||
() => ({ keyword: activeKeyword, category, device }),
|
||||
@@ -182,10 +188,8 @@ export function GameGalleryPage({
|
||||
}, [activeKeyword, category]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didSyncPropKeywordRef.current) {
|
||||
didSyncPropKeywordRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (lastPropKeywordRef.current === searchKeyword) return;
|
||||
lastPropKeywordRef.current = searchKeyword;
|
||||
setKeywordInput(searchKeyword);
|
||||
setActiveKeyword(searchKeyword);
|
||||
}, [searchKeyword]);
|
||||
|
||||
@@ -702,6 +702,25 @@ export default defineConfig(({ mode }) => {
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
// 平台同源路径发行入口:生产由 nginx 把 `/games/<gameId>/` 映射到发行网关并清空
|
||||
// Cookie,本地 dev 用同一前缀规则补齐,否则 iframe 会落到 SPA 外壳、拿不到发行包。
|
||||
'^/games/game_[0-9a-f]{32}(?:/.*)?$': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (pathname) => {
|
||||
const matched = /^\/games\/(game_[0-9a-f]{32})(\/.*)?$/.exec(
|
||||
pathname,
|
||||
);
|
||||
if (!matched) return pathname;
|
||||
return `/api/game-distribution/releases/${matched[1]}${matched[2] ?? ''}`;
|
||||
},
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyRequest) => {
|
||||
proxyRequest.removeHeader('cookie');
|
||||
});
|
||||
},
|
||||
},
|
||||
'/api/llm': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user