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 未放宽四条约定取证后勾选
439 lines
15 KiB
JavaScript
439 lines
15 KiB
JavaScript
// 游戏分发网页侧「导航 / 键盘可达 / 全屏 / 安全区 / 移动发布面板」检查。
|
||
//
|
||
// 需要:完整本地 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);
|
||
});
|