Files
Genarrative/scripts/check-game-distribution-web-e2e.mjs
T
kdletters 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
网页侧证据复跑并修掉 Vite 冷启动造成的假失败
- 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 记录冷启动判据与修法
2026-09-29 00:21:43 +08:00

527 lines
18 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.
// 游戏分发网页侧真实链路检查:真实 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);
});