// 游戏分发网页发布链路检查:真实 dev 网页栈 + Chromium,作者在网页里登录、选封面、选发行包 // ZIP、填资料并提交,随后核对「同一账号在网页里看到待审状态」「审核通过后出现在广场」。 // // 需要:完整本地 dev 栈(`npm run dev`)+ 管理员账号 + 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-publish-e2e // E2E_WEB_BASE 默认 http://127.0.0.1:3000,E2E_API_BASE 默认 http://127.0.0.1:8082。 import { createHash } from 'node:crypto'; import { mkdtemp, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; 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 = {} } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; 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 }; } 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 loginThroughWebUi(page, phone) { await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 }); await page.waitForSelector('.platform-account-entry', { timeout: 120_000 }); await page.locator('.platform-account-entry').first().click(); await page.getByRole('tab', { name: '密码登录' }).first().click(); await page.waitForTimeout(600); await page .getByLabel('同意法律协议') .first() .check({ force: true }) .catch(() => {}); await page .locator('input[placeholder="13800000000"]:visible') .first() .fill(phone); await page .locator('input[placeholder="输入密码"]:visible') .first() .fill(DEV_PASSWORD); await page.getByRole('button', { name: '登录', exact: true }).first().click(); await page .locator('.platform-account-entry') .first() .filter({ hasText: phone.slice(-4) }) .waitFor({ state: 'visible', timeout: 30_000 }); } async function main() { 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 gate = await api('/admin/api/feature-gates', { method: 'PUT', token: admin, body: { gateKey: 'game-distribution:publish', enabled: true, rolloutPercent: 100, allowUserIds: [], allowUserTags: [], denyUserIds: [], description: 'E2E 网页发布链路', }, }); check('发布灰度已开启', gate.status === 200, `status=${gate.status}`); const stamp = Date.now(); const phone = `131${String(stamp).slice(-8)}`; const title = `网页发布 ${String(stamp).slice(-6)}`; const workDir = await mkdtemp( path.join(tmpdir(), 'genarrative-web-publish-'), ); const coverPath = path.join(workDir, 'cover.png'); const zipPath = path.join(workDir, 'package.zip'); await writeFile(coverPath, COVER_PNG); const zip = new JSZip(); zip.file( 'index.html', 'web publish' + '

WEB-PUBLISH-OK

', ); zip.file('assets/app.js', 'document.documentElement.dataset.e2e="publish";'); const zipBytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' })); await writeFile(zipPath, zipBytes); const zipSha256 = createHash('sha256').update(zipBytes).digest('hex'); const { chromium } = await loadPlaywright(); const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim(); const browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}), }); let gameId = ''; let versionId = ''; try { const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, }); await loginThroughWebUi(page, phone); check('网页里用密码登录/注册成功', true, `phone=${phone}`); await page.goto(`${WEB}/games/publish`, { waitUntil: 'commit', timeout: 120_000, }); await page .getByLabel('游戏名称') .waitFor({ state: 'visible', timeout: 60_000 }); await page.getByLabel('游戏名称').fill(title); await page.getByLabel('一句话简介').fill('网页发布链路 E2E'); await page .getByLabel('详细介绍') .fill('由 Playwright 在真实网页里提交的发行包。'); await page.getByLabel('分类').selectOption('休闲'); await page.getByLabel('标签').fill('e2e'); await page.getByLabel('游戏封面(必需)').setInputFiles(coverPath); await page .locator('img[alt="游戏封面预览"]') .waitFor({ state: 'visible', timeout: 60_000 }); await page.getByLabel('发行包 ZIP').setInputFiles(zipPath); await page.waitForTimeout(500); await page.getByRole('button', { name: '提交审核' }).first().click(); await page .getByText('已提交审核,审核通过后会自动出现在游戏广场。') .first() .waitFor({ state: 'visible', timeout: 60_000 }); check('网页提交发行包 ZIP 后进入待审', true); const authorLogin = await api('/api/auth/entry', { method: 'POST', body: { purePhoneNumber: phone, password: DEV_PASSWORD }, }); const author = authorLogin.data?.token; const mine = await api('/api/game-distribution/my-games', { token: author, }); const mineGame = (mine.data?.games ?? []).find( (item) => item.title === title, ); gameId = mineGame?.id ?? ''; versionId = mineGame?.versions?.[0]?.versionId ?? ''; check( '同一账号在服务端看到该游戏为审核中', mineGame?.latestVersion?.status === 'pending_review' && mineGame?.status === 'unpublished', `gameId=${gameId} versionStatus=${mineGame?.latestVersion?.status ?? ''} visibility=${mineGame?.status ?? ''}`, ); const catalogBefore = await api('/api/game-distribution/games?limit=100'); check( '待审期间广场看不到该游戏', !(catalogBefore.data?.games ?? []).some((item) => item.id === gameId), ); await page.goto(`${WEB}/games/mine`, { waitUntil: 'commit', timeout: 120_000, }); await page .getByText(title) .first() .waitFor({ state: 'visible', timeout: 60_000 }); check( '网页「我的游戏」显示审核中', await page.getByText('审核中').first().isVisible(), ); const approved = await api( `/admin/api/game-distribution/versions/${versionId}/review`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `web-publish-approve-${stamp}` }, body: { decision: 'approve', expectedPublicationRevision: 0 }, }, ); check( '管理员审核通过网页提交的版本', approved.status === 200, `status=${approved.status}`, ); const catalogAfter = await api('/api/game-distribution/games?limit=100'); const publishedGame = (catalogAfter.data?.games ?? []).find( (item) => item.id === gameId, ); check( '审核通过后广场出现该游戏且入口由服务端派生', Boolean(publishedGame) && publishedGame.currentVersion?.entryUrl === `/games/${gameId}/`, `entryUrl=${publishedGame?.currentVersion?.entryUrl ?? ''}`, ); await page.goto(`${WEB}/games/mine`, { waitUntil: 'commit', timeout: 120_000, }); await page .getByText(title) .first() .waitFor({ state: 'visible', timeout: 60_000 }); check( '网页「我的游戏」同步显示为已发布', await page.getByText('已发布').first().isVisible(), ); const mineAfter = await api('/api/game-distribution/my-games', { token: author, }); const sameGame = (mineAfter.data?.games ?? []).find( (item) => item.id === gameId, ); check( '同一 gameId 的账号状态在两端读同一份后端数据', Boolean(sameGame) && sameGame.status === 'published' && sameGame.latestVersion?.packageSha256 === zipSha256, `status=${sameGame?.status ?? ''} sha=${String(sameGame?.latestVersion?.packageSha256).slice(0, 12)} expected=${zipSha256.slice(0, 12)}`, ); // 更新沿用同一 gameId:网页发布页带 ?game= 回填旧版,提交新版本后仍是同一个游戏。 const updateZip = new JSZip(); updateZip.file( 'index.html', 'web publish v2' + '

WEB-PUBLISH-V2

', ); updateZip.file( 'assets/app.js', 'document.documentElement.dataset.e2e="publish-v2";', ); const updateBytes = Buffer.from( await updateZip.generateAsync({ type: 'uint8array' }), ); const updateZipPath = path.join(workDir, 'package-v2.zip'); await writeFile(updateZipPath, updateBytes); await page.goto(`${WEB}/games/publish?game=${gameId}`, { waitUntil: 'commit', timeout: 120_000, }); await page .getByText('提交新版本审核') .first() .waitFor({ state: 'visible', timeout: 60_000 }); await page.getByLabel('发行包 ZIP').setInputFiles(updateZipPath); await page.waitForTimeout(500); await page.getByRole('button', { name: '提交新版本审核' }).first().click(); await page .getByText('已提交审核,审核通过后会自动出现在游戏广场。') .first() .waitFor({ state: 'visible', timeout: 60_000 }); const mineAfterUpdate = await api('/api/game-distribution/my-games', { token: author, }); const updatedGame = (mineAfterUpdate.data?.games ?? []).find( (item) => item.id === gameId, ); check( '网页提交新版本沿用同一 gameId 且旧版仍在线', Boolean(updatedGame) && updatedGame.versions?.length === 2 && updatedGame.latestVersion?.status === 'pending_review' && updatedGame.status === 'published', `versions=${updatedGame?.versions?.length ?? 'n/a'} latest=${updatedGame?.latestVersion?.status ?? ''} status=${updatedGame?.status ?? ''}`, ); await page.close(); } finally { await browser.close(); await api('/admin/api/feature-gates', { method: 'PUT', token: admin, body: { gateKey: 'game-distribution:publish', enabled: false, rolloutPercent: 0, allowUserIds: [], allowUserTags: [], denyUserIds: [], description: 'E2E 网页发布链路(收尾关闭)', }, }); } console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`); process.exit(failures === 0 ? 0 : 1); } main().catch((error) => { console.error(`[check-game-distribution-web-publish-e2e] 运行失败:${error}`); process.exit(1); });