Files
Genarrative/scripts/check-game-distribution-web-e2e.mjs
T
kdletters f0ae3ce52c
Project CI / AI game creator shell Rust crates (push) Successful in 1m31s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m1s
Project CI / Backend tests (push) Successful in 5m4s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m50s
Project CI / Frontend tests (push) Successful in 2m24s
Project CI / Native shell tests (push) Successful in 7m27s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 10m9s
Project CI / AI game creator shell web tests (push) Successful in 1m50s
Project CI / Repository checks (push) Successful in 2m24s
补齐游戏分发网页侧链路取证并修掉返回目录丢关键词
- 新增 scripts/check-game-distribution-web-e2e.mjs:真实 dev 网页栈 + Chromium 游客走目录筛选、空态与重置、详情主动作、分享直达详情、按需加载发行 iframe、失败重试、退出不自动重启、移动端门槛与旋转提示,共 23 项 PASS
- GameGalleryPage 的顶栏搜索词同步改为按 prop 值比对:原来的一次性 ref 守卫在开发态 StrictMode 双跑下会用空值清掉刚从 sessionStorage 恢复的关键词
- vite.config.ts 增加 /games/<gameId>/ 发行入口代理(rewrite 到发行网关并去掉 Cookie),本地网页栈与生产 nginx 同口径
- 游戏分发里程碑阶段 C 第 6、7 条改为已勾选,其余条目列出具体缺口
- pitfalls 记录 StrictMode 守卫击穿与 Vite 发行入口代理两条经验
2026-09-28 20:07:20 +08:00

497 lines
17 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}` : ''}`,
);
}
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);
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' });
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);
});