1951a88fba
Project CI / AI game creator shell Rust crates (push) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m45s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
- check-game-distribution-web-e2e:导航前先 fetch 预热 /games、/games/detail、/games/play 三条路由(120s 上限,失败只告警),首个 goto 超时提到 120s,避免 Vite dev 冷启动(实测 /games 首请求 20.2s、warm 后 14ms)被当成页面故障 - 复跑合并 Web 预检改动之后的网页侧四条 E2E:web-e2e 24/24 PASS、web-publish 全过、web-publish-recovery 全过、web-a11y 全过 - 里程碑补「网页侧证据复跑(2026-09-29)」小节;pitfalls 记录冷启动判据与修法
527 lines
18 KiB
JavaScript
527 lines
18 KiB
JavaScript
// 游戏分发网页侧真实链路检查:真实 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}` : ''}`,
|
||
);
|
||
}
|
||
|
||
// Vite dev 是按需编译的:某条路由第一次被访问时现编译模块图,实测 /games 冷启动可以到
|
||
// 20 秒以上,超过导航默认超时就会被当成"页面打不开"。先预热一遍路由,把冷启动从断言
|
||
// 语义里摘出去(预热失败不致命:真正导航时还会再试一次,失败信息照旧可见)。
|
||
async function warmUpWebRoute(url, timeoutMs = 120_000) {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||
try {
|
||
const response = await fetch(url, { signal: controller.signal });
|
||
await response.arrayBuffer();
|
||
console.log(`[warmup] ${url} -> ${response.status}`);
|
||
} catch (error) {
|
||
console.warn(`[warmup] ${url} 预热失败,导航阶段会再试一次:${error}`);
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
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',
|
||
'<!doctype html><html><head><meta charset="utf-8"><title>web e2e</title>' +
|
||
'<script src="assets/app.js"></script></head><body><h1>WEB-E2E-OK</h1></body></html>',
|
||
);
|
||
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);
|
||
|
||
// 先把这次要用到的三条网页路由预热一遍,避免 dev 冷启动把它们误伤成"打不开"。
|
||
for (const route of [
|
||
'/games',
|
||
`/games/detail?id=${gameId}`,
|
||
`/games/play?id=${gameId}`,
|
||
]) {
|
||
await warmUpWebRoute(`${WEB}${route}`);
|
||
}
|
||
|
||
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',
|
||
timeout: 120_000,
|
||
});
|
||
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);
|
||
});
|