From 76cdb96c55852f75d328d165021926cc0cc4dc66 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:52:54 +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=E5=AF=BC=E8=88=AA=E5=8F=AF?= =?UTF-8?q?=E8=BE=BE=E6=80=A7=E4=B8=8E=E8=A7=86=E8=A7=89=E7=BA=A6=E5=AE=9A?= =?UTF-8?q?=E5=8F=96=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/check-game-distribution-web-a11y-e2e.mjs:桌面/移动导航切换、键盘焦点一路进到开始游戏、全屏按钮生效、安全区内边距、移动端发布面板无横向溢出,共 11 项 PASS - 游戏分发里程碑阶段 C 第 8 条改为已勾选,并记录移动端首次进入的阻断提示这一既有门槛 - 第 9 条按共享组件、warm token、无假统计/演示兜底、iframe 未放宽四条约定取证后勾选 --- ...‹碑】游戏分发目录详情与在线游玩-2026-09-18.md | 16 +- package.json | 1 + .../check-game-distribution-web-a11y-e2e.mjs | 438 ++++++++++++++++++ 3 files changed, 452 insertions(+), 3 deletions(-) create mode 100644 scripts/check-game-distribution-web-a11y-e2e.mjs diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index a15a7fb2e..f49cd17fd 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -102,8 +102,8 @@ - [x] 上传中断、双击、登录失效、窗口关闭后恢复原操作;换账号不能恢复前账号私有状态;待审不能显示为已发布。 - [x] 目录支持真实数据、关键词/分类/设备筛选、空/错/加载态;详情提供明确主动作,搜索与返回恢复上下文。 - [x] 游客从目录/分享链接进入详情再启动真实已发布游戏;开始操作后才加载 iframe,加载失败可重试,退出不会自动重启游戏。 -- [ ] 桌面与移动导航、详情、上传/发布面板、旋转提示、全屏、安全区和焦点可达符合主规范;不适配移动端的游戏有明确门槛。 -- [ ] 视觉沿用现有 warm token,通用交互复用共享组件;没有固定假统计、本地演示兜底或同源 iframe 放宽。 +- [x] 桌面与移动导航、详情、上传/发布面板、旋转提示、全屏、安全区和焦点可达符合主规范;不适配移动端的游戏有明确门槛。 +- [x] 视觉沿用现有 warm token,通用交互复用共享组件;没有固定假统计、本地演示兜底或同源 iframe 放宽。 ### 证据要求 @@ -210,4 +210,14 @@ - **本轮顺带修掉一处真实缺陷**:`loadFrontendRuntimeConfig()` 之前用 `skipAuth: true` 调 `/api/runtime/frontend-config`,而发布灰度是**按作者**判定的(匿名恒为 false)。结果是:只要运营显式配置过 `game-distribution:publish`,登录作者在网页端也永远看不到发布入口(页面一直显示「发布功能正在灰度中」)。改为带登录态请求后,登录后壳层重读配置即可拿到发布入口,无需刷新页面;`frontendRuntimeConfigService.test.ts` 同步更新。 - 已勾选(第 5 条:上传中断、双击、登录失效、窗口关闭后恢复原操作;换账号不能恢复前账号私有状态;待审不能显示为已发布)——本轮补齐 - 新增 `scripts/check-game-distribution-web-publish-recovery-e2e.mjs`(`npm run check:game-distribution-web-publish-recovery-e2e`,真实网页栈 + Chromium,**10 项 PASS**):拦截一次发行包上传让页面失败 → 本地留下恢复草稿且服务端只有一份游戏/一个版本;清掉 access token 与 refresh cookie 后发布页要求重新登录,重登同一账号后恢复提示仍在;关窗后用新页面打开 `/games/publish` 能看到「上次发布未完成(版本 v1)/ 继续上传」,续传沿用**原 versionId** 进入待审并清掉草稿;把前一账号的草稿留在同一浏览器里换成另一账号打开时不再显示该草稿。待审不冒充已发布由发布链路脚本的更新场景覆盖(游戏仍 `published`,新版本单独 `pending_review`)。 - - **本轮顺带修掉一处真实缺陷**:连点两次「提交审核」会创建两份游戏(`isSubmitting` 是 React 状态,双击落在同一次渲染窗口里还没生效,再叠上 `prepareGamePackage` 的压缩耗时)。修复:加同步的 `submitInFlightRef` 在途守卫,并让同一次发布(含失败后重试)复用同一组 Idempotency-Key(`publishKeyRef`),服务端按幂等重放;E2E 里「双击只落一份游戏与一个版本(games=1 versions=1)」就是这条的回归证据,`GamePublishPage.test.tsx` 15 passed。 - 「待审不能显示为已发布」已随本轮取证(更新待审时游戏仍显示已发布、新版单独处于审核中);**仍缺**:上传中断、双击提交、登录失效、窗口关闭后恢复原操作,以及换账号不能恢复前账号私有状态的用例。 \ No newline at end of file + - **本轮顺带修掉一处真实缺陷**:连点两次「提交审核」会创建两份游戏(`isSubmitting` 是 React 状态,双击落在同一次渲染窗口里还没生效,再叠上 `prepareGamePackage` 的压缩耗时)。修复:加同步的 `submitInFlightRef` 在途守卫,并让同一次发布(含失败后重试)复用同一组 Idempotency-Key(`publishKeyRef`),服务端按幂等重放;E2E 里「双击只落一份游戏与一个版本(games=1 versions=1)」就是这条的回归证据,`GamePublishPage.test.tsx` 15 passed。 - 「待审不能显示为已发布」已随本轮取证(更新待审时游戏仍显示已发布、新版单独处于审核中);**仍缺**:上传中断、双击提交、登录失效、窗口关闭后恢复原操作,以及换账号不能恢复前账号私有状态的用例。 +## 本轮核对(2026-09-28,阶段 C 可达性与视觉约定) + +- 已勾选(第 8 条:桌面与移动导航、详情、上传/发布面板、旋转提示、全屏、安全区、焦点可达;不适配移动端的游戏有明确门槛)——本轮补齐 + - 新增 `scripts/check-game-distribution-web-a11y-e2e.mjs`(`npm run check:game-distribution-web-a11y-e2e`,真实网页栈 + Chromium,**11 项 PASS**):桌面左侧栏在「游戏 / 我的」之间切换;移动端底部导航(`aria-label=移动平台导航`)在「游戏 / 我的」之间切换;键盘只用焦点 + Enter 就能从目录打开卡片 → 进详情 → 点「立即玩」→ 进游玩页并「开始游戏」挂载发行 iframe;全屏按钮在开始后可用,实点后 `document.fullscreenElement` 生效(headless 也授予);移动壳层与底部导航使用 `env(safe-area-inset-*)`;移动端发布面板在 390×844 下可用且 `scrollWidth - innerWidth = 0`。旋转提示与「不支持移动端的游戏给出明确门槛」由上一轮的游玩链路脚本覆盖。 + - 顺带记录的交互事实:移动端首次进入会弹「移动端仅支持作品展示体验、创作工具请使用电脑端」的阻断提示(`platform-overlay`),它会盖住底部导航;E2E 在每次进入移动端页面后先关掉它,这也说明该提示是产品内既有的移动端门槛。 +- 已勾选(第 9 条:视觉沿用 warm token、通用交互复用共享组件、无固定假统计/演示兜底、无同源 iframe 放宽)——本轮补齐 + - 共享组件:目录/详情/游玩/发布四个页面都直接用 `PlatformActionButton` / `PlatformStatusMessage` / `PlatformEmptyState`(`src/components/common/`),没有另起一套按钮与状态条。 + - warm token:`src/components/game-distribution/gameDistribution.css` 的颜色/边框/背景全部走 `var(--platform-*)` 变量,没有独立色板。 + - 无假统计与演示兜底:目录卡片的游玩次数直接取服务端 `playCount`(新游戏显示 0 次游玩,发布链路脚本里验证过广场数据来自真实接口);四个页面里检索 `Math.random` / `demo` / `mock` / `假数据` 无命中。 + - iframe 未放宽:游玩页只用 `sandbox="allow-scripts"`(没有 `allow-same-origin`),由发行沙箱脚本在真实浏览器里逐条断言过(storage/cookie/父文档 DOM/跨源请求/Worker/弹窗/顶层跳转都被挡)。 \ No newline at end of file diff --git a/package.json b/package.json index 2e2e7102c..2acadfcbd 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "check:game-distribution-web-e2e": "node scripts/check-game-distribution-web-e2e.mjs", "check:game-distribution-web-publish-e2e": "node scripts/check-game-distribution-web-publish-e2e.mjs", "check:game-distribution-web-publish-recovery-e2e": "node scripts/check-game-distribution-web-publish-recovery-e2e.mjs", + "check:game-distribution-web-a11y-e2e": "node scripts/check-game-distribution-web-a11y-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-a11y-e2e.mjs b/scripts/check-game-distribution-web-a11y-e2e.mjs new file mode 100644 index 000000000..a971b8502 --- /dev/null +++ b/scripts/check-game-distribution-web-a11y-e2e.mjs @@ -0,0 +1,438 @@ +// 游戏分发网页侧「导航 / 键盘可达 / 全屏 / 安全区 / 移动发布面板」检查。 +// +// 需要:完整本地 dev 栈(`npm run dev`)+ 管理员账号 + 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-a11y-e2e +import { readFile } from 'node:fs/promises'; +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 }; +} + +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 uploadCover(token, id) { + const fileName = `a11y-${id}.png`; + const ticket = await api('/api/assets/direct-upload-tickets', { + method: 'POST', + token, + body: { + legacyPrefix: 'generated-character-drafts', + pathSegments: ['game-distribution', 'a11y', 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-a11y', + }, + }); + if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`); + return confirm.data.assetObject.assetObjectId; +} + +async function loginThroughWebUi(page, phone) { + await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 }); + // 移动端每次进入都可能重新弹「创作用电脑端」提示,它会盖住账号入口。 + await dismissMobileNotice(page); + await page.waitForSelector('.platform-account-entry', { timeout: 120_000 }); + await page.locator('.platform-account-entry').first().click(); + await page.getByRole('tab', { name: '密码登录' }).first().click(); + await page.waitForTimeout(600); + await page + .getByLabel('同意法律协议') + .first() + .check({ force: true }) + .catch(() => {}); + await page + .locator('input[placeholder="13800000000"]:visible') + .first() + .fill(phone); + await page + .locator('input[placeholder="输入密码"]:visible') + .first() + .fill(DEV_PASSWORD); + await page.getByRole('button', { name: '登录', exact: true }).first().click(); + await page + .locator('.platform-account-entry') + .first() + .filter({ hasText: phone.slice(-4) }) + .waitFor({ state: 'visible', timeout: 30_000 }); +} + +/** + * 移动端首次进入(以及每次重新进入)会弹「移动端仅支持展示体验、创作用电脑端」的阻断提示, + * 它会盖住底部导航,交互前先关掉。 + */ +async function dismissMobileNotice(page) { + const notice = page.locator('.platform-overlay'); + // 提示可能在首帧之后才挂上,先等一小会儿再判断,避免"看起来没有"却被它盖住。 + await notice + .first() + .waitFor({ state: 'visible', timeout: 10_000 }) + .catch(() => {}); + if ((await notice.count()) === 0) return; + await notice + .getByRole('button') + .first() + .click({ timeout: 5_000 }) + .catch(() => {}); + await page.waitForTimeout(600); +} + +async function publishFixtureGame(adminToken, stamp, title) { + await api('/admin/api/feature-gates', { + method: 'PUT', + token: adminToken, + body: { + gateKey: 'game-distribution:publish', + enabled: true, + rolloutPercent: 100, + allowUserIds: [], + allowUserTags: [], + denyUserIds: [], + description: 'E2E 可达性', + }, + }); + const register = await api('/api/auth/entry', { + method: 'POST', + body: { + purePhoneNumber: `130${String(stamp).slice(-8)}`, + password: DEV_PASSWORD, + }, + }); + const author = register.data?.token; + const zip = new JSZip(); + zip.file( + 'index.html', + '