Files
Genarrative/scripts/check-game-distribution-web-publish-e2e.mjs
T
kdletters 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」的经验
2026-09-28 20:21:34 +08:00

350 lines
12 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.
// 游戏分发网页发布链路检查:真实 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);
});