Files
Genarrative/scripts/check-game-distribution-web-a11y-e2e.mjs
T
kdletters 76cdb96c55
Project CI / AI game creator shell Rust crates (push) Successful in 1m35s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m55s
Project CI / Backend tests (push) Successful in 4m15s
Project CI / Frontend tests (push) Successful in 2m8s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m5s
Project CI / Native shell tests (push) Successful in 6m25s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m15s
Project CI / AI game creator shell web tests (push) Successful in 1m43s
Project CI / Repository checks (push) Successful in 2m11s
补齐游戏分发网页侧导航可达性与视觉约定取证
- 新增 scripts/check-game-distribution-web-a11y-e2e.mjs:桌面/移动导航切换、键盘焦点一路进到开始游戏、全屏按钮生效、安全区内边距、移动端发布面板无横向溢出,共 11 项 PASS
- 游戏分发里程碑阶段 C 第 8 条改为已勾选,并记录移动端首次进入的阻断提示这一既有门槛
- 第 9 条按共享组件、warm token、无假统计/演示兜底、iframe 未放宽四条约定取证后勾选
2026-09-28 20:52:54 +08:00

439 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 游戏分发网页侧「导航 / 键盘可达 / 全屏 / 安全区 / 移动发布面板」检查。
//
// 需要:完整本地 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',
'<!doctype html><html><head><meta charset="utf-8"><title>a11y</title>' +
'<script src="assets/app.js"></script></head><body><h1>A11Y-OK</h1></body></html>',
);
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);
});