补齐游戏分发网页侧链路取证并修掉返回目录丢关键词
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 发行入口代理两条经验
This commit is contained in:
kdletters
2026-09-28 20:07:20 +08:00
parent eb7c00658a
commit f0ae3ce52c
6 changed files with 546 additions and 8 deletions
+496
View File
@@ -0,0 +1,496 @@
// 游戏分发网页侧真实链路检查:真实 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);
});