From f0ae3ce52c2293dd87955af8f06e5506bfc3d69c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:07:20 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=B8=B8=E6=88=8F=E5=88=86?= =?UTF-8?q?=E5=8F=91=E7=BD=91=E9=A1=B5=E4=BE=A7=E9=93=BE=E8=B7=AF=E5=8F=96?= =?UTF-8?q?=E8=AF=81=E5=B9=B6=E4=BF=AE=E6=8E=89=E8=BF=94=E5=9B=9E=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E4=B8=A2=E5=85=B3=E9=94=AE=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/check-game-distribution-web-e2e.mjs:真实 dev 网页栈 + Chromium 游客走目录筛选、空态与重置、详情主动作、分享直达详情、按需加载发行 iframe、失败重试、退出不自动重启、移动端门槛与旋转提示,共 23 项 PASS - GameGalleryPage 的顶栏搜索词同步改为按 prop 值比对:原来的一次性 ref 守卫在开发态 StrictMode 双跑下会用空值清掉刚从 sessionStorage 恢复的关键词 - vite.config.ts 增加 /games// 发行入口代理(rewrite 到发行网关并去掉 Cookie),本地网页栈与生产 nginx 同口径 - 游戏分发里程碑阶段 C 第 6、7 条改为已勾选,其余条目列出具体缺口 - pitfalls 记录 StrictMode 守卫击穿与 Vite 发行入口代理两条经验 --- ...‹碑】游戏分发目录详情与在线游玩-2026-09-18.md | 18 +- docs/project-memory/shared-memory/pitfalls.md | 6 +- package.json | 1 + scripts/check-game-distribution-web-e2e.mjs | 496 ++++++++++++++++++ .../game-distribution/GameGalleryPage.tsx | 14 +- vite.config.ts | 19 + 6 files changed, 546 insertions(+), 8 deletions(-) create mode 100644 scripts/check-game-distribution-web-e2e.mjs diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index 32e7262e8..e65685b30 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -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//`,且 iframe 内真实载入了发行包内容(断言页面文本含 `WEB-E2E-OK`,不是 SPA 外壳);从游玩页返回详情后 iframe 消失(退出不自动重启);把详情接口第一次请求打断后游玩页给出「重新加载」入口,点击后回到可开始状态。 + - **dev 栈补齐同源入口映射**:生产由 nginx 把 `/games//` 映射到发行网关并清空 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 次游玩),视觉与组件复用仍需人工/截图评审。 \ No newline at end of file diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 02c2fecd4..9147932e0 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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。 \ No newline at end of file +- **关联**:`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//` 在生产由 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 文本包含包内标记」断言真正载入的是发行包。 \ No newline at end of file diff --git a/package.json b/package.json index 0953c042a..65da868a7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-game-distribution-web-e2e.mjs b/scripts/check-game-distribution-web-e2e.mjs new file mode 100644 index 000000000..be36078fb --- /dev/null +++ b/scripts/check-game-distribution-web-e2e.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', + 'web e2e' + + '

WEB-E2E-OK

', + ); + 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); +}); diff --git a/src/components/game-distribution/GameGalleryPage.tsx b/src/components/game-distribution/GameGalleryPage.tsx index 4b12ba5a4..11636ccfc 100644 --- a/src/components/game-distribution/GameGalleryPage.tsx +++ b/src/components/game-distribution/GameGalleryPage.tsx @@ -146,7 +146,13 @@ export function GameGalleryPage({ const requestSerialRef = useRef(0); const scrollContainerRef = useRef(null); const hasRestoredScrollRef = useRef(false); - const didSyncPropKeywordRef = useRef(false); + /** + * 顶栏搜索词只在「真的变了」时覆盖目录筛选。 + * + * 不能用一次性的 did-初始化标记:开发态 StrictMode 会把挂载 effect 跑两遍, + * 第二遍就会拿空 prop 把刚从会话存储恢复出来的关键词清掉(返回目录上下文丢失)。 + */ + const lastPropKeywordRef = useRef(searchKeyword); const filters = useMemo( () => ({ 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]); diff --git a/vite.config.ts b/vite.config.ts index d3d92d118..1210009a3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -702,6 +702,25 @@ export default defineConfig(({ mode }) => { changeOrigin: true, secure: false, }, + // 平台同源路径发行入口:生产由 nginx 把 `/games//` 映射到发行网关并清空 + // 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,