bbbd91db88
Project CI / AI game creator shell Rust crates (push) Successful in 1m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m54s
Project CI / Backend tests (push) Successful in 4m9s
Project CI / Frontend tests (push) Successful in 2m6s
Project CI / Native shell tests (push) Successful in 6m22s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m32s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m31s
Project CI / AI game creator shell web tests (push) Successful in 1m37s
Project CI / Repository checks (push) Successful in 2m17s
- 新增 scripts/check-game-distribution-web-publish-e2e.mjs:网页登录、选封面、选发行包 ZIP、填资料提交,待审不可见、审核后公开、更新沿用同一 gameId 且旧版在线,共 12 项 PASS - frontendRuntimeConfigService 去掉 skipAuth:发布灰度按作者判定,匿名请求恒为 false,之前只要显式配置过灰度网页端就永远没有发布入口;单测同步更新 - 游戏分发里程碑阶段 C 第 4 条改为已勾选,第 5 条缺口收窄到上传中断/双击/登录失效/窗口关闭与换账号 - pitfalls 记录「按用户灰度配置不能走 skipAuth」的经验
350 lines
12 KiB
JavaScript
350 lines
12 KiB
JavaScript
// 游戏分发网页发布链路检查:真实 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',
|
||
'<!doctype html><html><head><meta charset="utf-8"><title>web publish</title>' +
|
||
'<script src="assets/app.js"></script></head><body><h1>WEB-PUBLISH-OK</h1></body></html>',
|
||
);
|
||
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',
|
||
'<!doctype html><html><head><meta charset="utf-8"><title>web publish v2</title>' +
|
||
'<script src="assets/app.js"></script></head><body><h1>WEB-PUBLISH-V2</h1></body></html>',
|
||
);
|
||
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);
|
||
});
|