// 游戏分发网页侧真实链路检查:真实 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 是按需编译的:某条路由的模块图要等**浏览器**真的加载它时才现编译,实测冷启动 // 可以超过 20 秒,超过单步断言超时就会被当成"页面打不开"。纯 HTTP 预热只取到 SPA 外壳 // (HTML 里没有路由模块),挡不住这类慢——所以预热必须在浏览器里把每条路由真的走一遍, // 让 Vite 先把模块编译好。预热失败不致命:断言阶段的导航还会再试一次,失败信息照旧可见。 async function warmUpWebRoutesInBrowser(browser, urls) { const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, }); try { const page = await context.newPage(); for (const url of urls) { try { const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 120_000, }); // 模块请求是 DOMContentLoaded 之后才陆续完成的,留一点时间让 Vite 编译落缓存。 await page.waitForTimeout(2_000); console.log( `[warmup] ${url} -> ${response?.status() ?? 'no-response'}`, ); } catch (error) { console.warn(`[warmup] ${url} 预热失败,断言阶段会再试一次:${error}`); } } } finally { await context.close(); } } 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', 'web e2e' + '

WEB-E2E-OK

', ); 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 { // 先把这次要用到的三条网页路由在浏览器里预热一遍,避免 dev 冷启动把它们误伤成"打不开"。 await warmUpWebRoutesInBrowser( browser, ['/games', `/games/detail?id=${gameId}`, `/games/play?id=${gameId}`].map( (route) => `${WEB}${route}`, ), ); 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}`, ); // 游玩页是固定画布:面板必须是 overflow:hidden 且不溢出,整页也不能出现滚动条。 // 面板自身 `overflow-x: hidden`,横向溢出只会被静默裁掉、不会出滚动条,所以两个方向都要量: // 只量 scrollHeight 会漏掉「卡片被裁一半」这类横向回归。 const playScroll = await mobilePage.evaluate(() => { const panel = document.querySelector('.platform-tab-panel'); return { documentOverflowY: document.documentElement.scrollHeight - window.innerHeight, documentOverflowX: document.documentElement.scrollWidth - window.innerWidth, panelOverflowY: panel ? panel.scrollHeight - panel.clientHeight : 0, panelOverflowX: panel ? panel.scrollWidth - panel.clientWidth : 0, panelOverflowStyle: panel ? getComputedStyle(panel).overflowY : 'missing', }; }); check( '移动端游玩页不可滚动(面板与整页都不溢出)', playScroll.documentOverflowY <= 1 && playScroll.documentOverflowX <= 1 && playScroll.panelOverflowY <= 1 && playScroll.panelOverflowX <= 1 && playScroll.panelOverflowStyle === 'hidden', JSON.stringify(playScroll), ); 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); });