// 游戏分发网页侧「导航 / 键盘可达 / 全屏 / 安全区 / 移动发布面板」检查。 // // 需要:完整本地 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', 'a11y' + '

A11Y-OK

', ); zip.file('assets/app.js', 'document.documentElement.dataset.e2e="a11y";'); const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' })); const sha256 = (await import('node:crypto')) .createHash('sha256') .update(bytes) .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': `a11y-game-${stamp}` }, body: metadata, }); const gameId = created.data?.id; const version = await api(`/api/game-distribution/games/${gameId}/versions`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `a11y-version-${stamp}` }, body: { packageSha256: sha256, packageBytes: bytes.length, packageFileCount: 2, packageEntryPath: 'index.html', gameMetadata: metadata, }, }); const versionId = version.data?.versionId; const uploaded = await api( `/api/game-distribution/versions/${versionId}/package`, { method: 'PUT', token: author, headers: { 'Idempotency-Key': `a11y-upload-${stamp}`, 'Content-Type': 'application/zip', }, binary: bytes, }, ); const submitted = await api( `/api/game-distribution/versions/${versionId}/submit`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `a11y-submit-${stamp}` }, body: { expectedPublicationRevision: 0 }, }, ); const approved = await api( `/admin/api/game-distribution/versions/${versionId}/review`, { method: 'POST', token: adminToken, headers: { 'Idempotency-Key': `a11y-approve-${stamp}` }, body: { decision: 'approve', expectedPublicationRevision: 0 }, }, ); if ( created.status !== 200 || uploaded.status !== 200 || submitted.status !== 202 || approved.status !== 200 ) { throw new Error( `发布夹具失败 created=${created.status} uploaded=${uploaded.status} submitted=${submitted.status} approved=${approved.status}`, ); } return { gameId, author, metadata }; } async function main() { 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 stamp = Date.now(); const title = `可达性 ${String(stamp).slice(-6)}`; const { gameId } = await publishFixtureGame(admin, stamp, title); check('测试游戏已发布', Boolean(gameId), `gameId=${gameId ?? ''}`); 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: 900 }, }); const page = await context.newPage(); // 桌面左侧导航:点「游戏」「我的」都能切换页面。 await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000, }); await page .locator('button.platform-desktop-rail__button', { hasText: '游戏' }) .first() .waitFor({ state: 'visible', timeout: 60_000 }); await page .locator('button.platform-desktop-rail__button', { hasText: '我的' }) .first() .click(); await page.waitForURL(/\/profile/, { timeout: 30_000 }); check('桌面导航切到我的', true, `url=${page.url()}`); await page .locator('button.platform-desktop-rail__button', { hasText: '游戏' }) .first() .click(); await page.waitForURL(/\/games$/, { timeout: 30_000 }); check('桌面导航切回游戏广场', true, `url=${page.url()}`); // 键盘可达:只用 Tab + Enter 从目录进入详情、进游玩页并开始游戏。 await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 }); const card = page.locator('button.game-card:visible', { hasText: title }); await card.waitFor({ state: 'visible', timeout: 60_000 }); await card.focus(); await page.keyboard.press('Enter'); await page.waitForURL(/\/games\/detail\?id=/, { timeout: 30_000 }); check('键盘在目录里能用 Enter 打开焦点卡片', true, `url=${page.url()}`); const playButton = page.getByRole('button', { name: '立即玩' }); await playButton.waitFor({ state: 'visible', timeout: 30_000 }); await playButton.focus(); await page.keyboard.press('Enter'); await page.waitForURL(/\/games\/play\?id=/, { timeout: 30_000 }); const startButton = page.getByRole('button', { name: '开始游戏' }); await startButton.waitFor({ state: 'visible', timeout: 30_000 }); await startButton.focus(); await page.keyboard.press('Enter'); await page .locator('iframe.game-player-frame') .waitFor({ state: 'visible', timeout: 30_000 }); check('键盘可以从详情一路进到开始游戏', true); // 全屏:开始前禁用,开始后可用(headless 下不强制断言真的进入全屏)。 const fullscreenButton = page.getByRole('button', { name: '全屏' }); check( '开始游戏后全屏按钮可用', !(await fullscreenButton.isDisabled()), 'hasStarted=true', ); await fullscreenButton.click().catch(() => {}); const enteredFullscreen = await page.evaluate( () => document.fullscreenElement !== null, ); console.log( `INFO 全屏尝试结果:fullscreenElement=${enteredFullscreen}(headless 环境可能不授予)`, ); // 安全区:移动壳层与底部导航都用 env(safe-area-inset-*),静态核对样式来源。 const indexCss = await readFile('src/index.css', 'utf8'); check( '移动壳层与底部导航使用安全区内边距', indexCss.includes('--platform-bottom-nav-height') && indexCss.includes('env(safe-area-inset-bottom'), 'src/index.css', ); // 移动端导航 + 发布面板:底部 dock 能切页,发布表单在窄屏可用且不横向溢出。 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: 'commit', timeout: 120_000, }); await dismissMobileNotice(mobilePage); const mobileNav = mobilePage.getByRole('navigation', { name: '移动平台导航', }); await mobileNav.waitFor({ state: 'visible', timeout: 60_000 }); await mobileNav.getByRole('button', { name: '我的' }).click(); await mobilePage.waitForURL(/\/profile/, { timeout: 30_000 }); check('移动端底部导航切到我的', true, `url=${mobilePage.url()}`); await dismissMobileNotice(mobilePage); await mobileNav.getByRole('button', { name: '游戏' }).click(); await mobilePage.waitForURL(/\/games$/, { timeout: 30_000 }); check('移动端底部导航切回游戏', true, `url=${mobilePage.url()}`); const mobilePhone = `136${String(stamp).slice(-8)}`; await dismissMobileNotice(mobilePage); await loginThroughWebUi(mobilePage, mobilePhone); await mobilePage.goto(`${WEB}/games/publish`, { waitUntil: 'commit', timeout: 120_000, }); await dismissMobileNotice(mobilePage); await mobilePage .getByLabel('游戏名称') .waitFor({ state: 'visible', timeout: 60_000 }); const overflow = await mobilePage.evaluate( () => document.documentElement.scrollWidth - window.innerWidth, ); check( '移动端发布面板可用且没有整页横向溢出', overflow <= 1, `overflow=${overflow}px`, ); await mobile.close(); await context.close(); } finally { await browser.close(); await api('/admin/api/feature-gates', { method: 'PUT', token: admin, body: { gateKey: 'game-distribution:publish', enabled: false, rolloutPercent: 0, allowUserIds: [], allowUserTags: [], denyUserIds: [], description: 'E2E 可达性(收尾关闭)', }, }); } console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`); process.exit(failures === 0 ? 0 : 1); } main().catch((error) => { console.error(`[check-game-distribution-web-a11y-e2e] 运行失败:${error}`); process.exit(1); });