Files
Genarrative/scripts/check-game-distribution-package-limits-e2e.mjs
T
k88936 1f239a96ce 游戏分发 E2E 抽出共享 multipart 发布 helper
- 新增 scripts/game-distribution-e2e-helpers.mjs:COVER_PNG、imagePart、coverImagePart、publishFormData、resolveApiRequest
- 11 个 E2E 脚本删除各自的重复实现,改为 import;api() 的 body 分支收敛到 resolveApiRequest
- 各脚本的封面文件名前缀、envelope 与鉴权头合并顺序、返回形状保持不变
2026-10-07 11:42:45 +08:00

507 lines
16 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.
// 游戏分发发行包边界复跑:现行上限 200 MiB 档的成功链路 + 文件数 / 单文件 / 展开量 /
// 压缩比四个边界失败关闭,并记录可复核的耗时与 api-server 工作集变化。
//
// 需要:本地 dev 栈(`npm run dev` 或 `dev:spacetime` + `dev:api-server`)+ 管理员账号。
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-package-limits-e2e
// E2E_API_BASE 默认 http://127.0.0.1:8082;E2E_SKIP_BIG_PACKAGE=1 可跳过 200 MiB 成功档。
import { execSync } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import JSZip from 'jszip';
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
const API_PORT = Number(new URL(API).port || '80');
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!';
const MIB = 1024 * 1024;
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,
formData,
headers = {},
binary,
} = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
const request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: request.headers,
body: request.body,
});
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,
};
}
/** api-server 进程当前工作集(MB);Windows 本地栈用 PowerShell 读监听进程。 */
function apiServerWorkingSetMb() {
try {
const output = execSync(
`powershell -NoProfile -Command "$c = Get-NetTCPConnection -LocalPort ${API_PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c) { (Get-Process -Id $c.OwningProcess).WorkingSet64 }"`,
{ encoding: 'utf8', timeout: 30_000 },
);
const value = Number(output.trim());
return Number.isFinite(value) && value > 0 ? Math.round(value / MIB) : null;
} catch {
return null;
}
}
const coverImagePart = (id) =>
imagePart('cover', id, { fileName: `limits-${id}.png` });
async function createGameWithVersion(
author,
cover,
title,
suffix,
declaration,
) {
const metadata = {
title,
summary: '边界复跑临时游戏',
description: '',
category: '休闲',
tags: ['limits'],
coverObjectKey: null,
screenshots: [],
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': `limits-game-${suffix}` },
formData: publishFormData(metadata, { cover }),
});
const gameId = created.data?.id;
if (created.status !== 200 || !gameId) {
throw new Error(
`创建游戏失败 ${created.status} ${created.text.slice(0, 160)}`,
);
}
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `limits-version-${suffix}` },
formData: publishFormData({
packageSha256: declaration.packageSha256 ?? 'a'.repeat(64),
packageBytes: declaration.packageBytes,
packageFileCount: declaration.packageFileCount,
packageEntryPath: 'index.html',
// 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。
gameMetadata: {
...metadata,
coverObjectKey: created.data.coverObjectKey,
},
}),
});
return { gameId, metadata, version };
}
async function uploadAndConfirm(author, versionId, bytes, suffix) {
const uploaded = await api(
`/api/game-distribution/versions/${versionId}/package`,
{
method: 'PUT',
token: author,
headers: {
'Idempotency-Key': `limits-upload-${suffix}`,
'Content-Type': 'application/zip',
},
binary: bytes,
},
);
const confirmed = await api(
`/api/game-distribution/versions/${versionId}/package/complete`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `limits-confirm-${suffix}` },
},
);
return { uploaded, confirmed };
}
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);
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 stamp = Date.now();
const register = await api('/api/auth/entry', {
method: 'POST',
body: {
purePhoneNumber: `138${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);
const cover = coverImagePart(stamp);
// A. 声明超过 200 MiB:创建版本阶段就该失败关闭,不必真的传包。
const declaredOver = await createGameWithVersion(
author,
cover,
`边界 声明超限 ${String(stamp).slice(-5)}`,
`${stamp}-declared`,
{ packageBytes: 200 * MIB + 1, packageFileCount: 1 },
);
check(
'声明超过 200 MiB 的版本在创建阶段被拒(413)',
declaredOver.version.status === 413,
`status=${declaredOver.version.status} msg=${declaredOver.version.error?.message ?? ''}`,
);
// B. 文件数边界:10,001 个条目必须失败关闭。
const manyFiles = new JSZip();
manyFiles.file('index.html', '<!doctype html><html><body>many</body></html>');
for (let index = 0; index < 10_000; index += 1) {
manyFiles.file(`f/${index}.txt`, 'x');
}
const manyBytes = Buffer.from(
await manyFiles.generateAsync({
type: 'uint8array',
compression: 'DEFLATE',
}),
);
const manyCase = await createGameWithVersion(
author,
cover,
`边界 文件数 ${String(stamp).slice(-5)}`,
`${stamp}-many`,
{
packageBytes: manyBytes.length,
packageFileCount: 10_001,
packageSha256: createHash('sha256').update(manyBytes).digest('hex'),
},
);
const manyResult = await uploadAndConfirm(
author,
manyCase.version.data?.versionId,
manyBytes,
`${stamp}-many`,
);
const manyVersion = await api(
`/api/game-distribution/versions/${manyCase.version.data?.versionId}`,
{ token: author },
);
check(
'10,001 个条目的包在上传校验时按 TooManyFiles 失败关闭',
manyResult.uploaded.status === 422 &&
manyResult.uploaded.error?.details?.reason === 'TooManyFiles' &&
manyVersion.data?.version?.status === 'upload_failed',
`upload=${manyResult.uploaded.status} reason=${manyResult.uploaded.error?.details?.reason ?? ''} versionStatus=${manyVersion.data?.version?.status ?? ''}`,
);
// C. 单文件边界:声明 65 MiB 的文件超过 64 MiB 上限。
const bigFile = new JSZip();
bigFile.file(
'index.html',
'<!doctype html><html><body>big file</body></html>',
);
bigFile.file('assets/huge.bin', Buffer.alloc(65 * MIB, 0));
const bigFileBytes = Buffer.from(
await bigFile.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }),
);
const bigFileCase = await createGameWithVersion(
author,
cover,
`边界 单文件 ${String(stamp).slice(-5)}`,
`${stamp}-bigfile`,
{
packageBytes: bigFileBytes.length,
packageFileCount: 2,
packageSha256: createHash('sha256').update(bigFileBytes).digest('hex'),
},
);
const bigFileResult = await uploadAndConfirm(
author,
bigFileCase.version.data?.versionId,
bigFileBytes,
`${stamp}-bigfile`,
);
const bigFileVersion = await api(
`/api/game-distribution/versions/${bigFileCase.version.data?.versionId}`,
{ token: author },
);
check(
'单个文件超过 64 MiB 按 FileTooLarge 失败关闭',
bigFileResult.uploaded.status === 422 &&
bigFileResult.uploaded.error?.details?.reason === 'FileTooLarge' &&
bigFileVersion.data?.version?.status === 'upload_failed',
`upload=${bigFileResult.uploaded.status} reason=${bigFileResult.uploaded.error?.details?.reason ?? ''} versionStatus=${bigFileVersion.data?.version?.status ?? ''}`,
);
// D. 展开量边界:9 × 60 MiB = 540 MiB,超过 500 MiB 上限。
const expanded = new JSZip();
expanded.file(
'index.html',
'<!doctype html><html><body>expanded</body></html>',
);
for (let index = 0; index < 9; index += 1) {
// 半可压缩内容:每 6 字节放 1 个随机字节,整包约 145 MiB(在 200 MiB 上限内),
// 但 9 × 60 MiB = 540 MiB 的展开总量会越过 500 MiB 上限。
const chunk = Buffer.alloc(60 * MIB, 0);
const noise = randomBytes((60 * MIB) / 6);
for (let offset = 0; offset < noise.length; offset += 1) {
chunk[offset * 6] = noise[offset];
}
expanded.file(`assets/blob-${index}.bin`, chunk);
}
const expandedBytes = Buffer.from(
await expanded.generateAsync({
type: 'uint8array',
compression: 'DEFLATE',
}),
);
const expandedCase = await createGameWithVersion(
author,
cover,
`边界 展开量 ${String(stamp).slice(-5)}`,
`${stamp}-expanded`,
{
packageBytes: expandedBytes.length,
packageFileCount: 10,
packageSha256: createHash('sha256').update(expandedBytes).digest('hex'),
},
);
const expandedResult = await uploadAndConfirm(
author,
expandedCase.version.data?.versionId,
expandedBytes,
`${stamp}-expanded`,
);
const expandedVersion = await api(
`/api/game-distribution/versions/${expandedCase.version.data?.versionId}`,
{ token: author },
);
check(
'展开总量超过 500 MiB 按 ExpandedPackageTooLarge 失败关闭',
expandedResult.uploaded.status === 422 &&
expandedResult.uploaded.error?.details?.reason ===
'ExpandedPackageTooLarge' &&
expandedVersion.data?.version?.status === 'upload_failed',
`upload=${expandedResult.uploaded.status} reason=${expandedResult.uploaded.error?.details?.reason ?? ''} versionStatus=${expandedVersion.data?.version?.status ?? ''}`,
);
// E. 压缩比边界:10 MiB 全零内容压成十几 KB,比例超过 100:1。
const ratio = new JSZip();
ratio.file('index.html', '<!doctype html><html><body>ratio</body></html>');
ratio.file('assets/zeros.bin', Buffer.alloc(10 * MIB, 0));
const ratioBytes = Buffer.from(
await ratio.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }),
);
const ratioCase = await createGameWithVersion(
author,
cover,
`边界 压缩比 ${String(stamp).slice(-5)}`,
`${stamp}-ratio`,
{
packageBytes: ratioBytes.length,
packageFileCount: 2,
packageSha256: createHash('sha256').update(ratioBytes).digest('hex'),
},
);
const ratioResult = await uploadAndConfirm(
author,
ratioCase.version.data?.versionId,
ratioBytes,
`${stamp}-ratio`,
);
const ratioVersion = await api(
`/api/game-distribution/versions/${ratioCase.version.data?.versionId}`,
{ token: author },
);
check(
'压缩比超过 100:1 按 CompressionRatioTooHigh 失败关闭',
ratioResult.uploaded.status === 422 &&
ratioResult.uploaded.error?.details?.reason ===
'CompressionRatioTooHigh' &&
ratioVersion.data?.version?.status === 'upload_failed',
`upload=${ratioResult.uploaded.status} reason=${ratioResult.uploaded.error?.details?.reason ?? ''} versionStatus=${ratioVersion.data?.version?.status ?? ''} zip=${ratioBytes.length}`,
);
// F. 200 MiB 档成功链路(默认执行;E2E_SKIP_BIG_PACKAGE=1 可跳过)。
if ((process.env.E2E_SKIP_BIG_PACKAGE ?? '').trim() === '1') {
console.log('SKIP 200 MiB 档成功链路(E2E_SKIP_BIG_PACKAGE=1)');
} else {
const big = new JSZip();
big.file(
'index.html',
'<!doctype html><html><head><script>' +
"fetch('/api/game-distribution/games');window.__packageExecuted=true;" +
'</script></head><body><h1>limits-big</h1></body></html>',
);
for (let index = 0; index < 4; index += 1) {
big.file(`assets/blob-${index}.bin`, randomBytes(49 * MIB));
}
const bigBytes = Buffer.from(
await big.generateAsync({ type: 'uint8array', compression: 'STORE' }),
);
const bigSha = createHash('sha256').update(bigBytes).digest('hex');
check(
'构造的合法包在 200 MiB 上限以内',
bigBytes.length < 200 * MIB,
`packageBytes=${bigBytes.length} (${(bigBytes.length / MIB).toFixed(1)} MiB)`,
);
const bigCase = await createGameWithVersion(
author,
cover,
`边界 200MiB 档 ${String(stamp).slice(-5)}`,
`${stamp}-big`,
{
packageBytes: bigBytes.length,
packageFileCount: 5,
packageSha256: bigSha,
},
);
const rssBefore = apiServerWorkingSetMb();
const uploadStarted = Date.now();
const bigUploaded = await api(
`/api/game-distribution/versions/${bigCase.version.data?.versionId}/package`,
{
method: 'PUT',
token: author,
headers: {
'Idempotency-Key': `limits-big-upload-${stamp}`,
'Content-Type': 'application/zip',
},
binary: bigBytes,
},
);
const uploadMs = Date.now() - uploadStarted;
const confirmStarted = Date.now();
const bigConfirmed = await api(
`/api/game-distribution/versions/${bigCase.version.data?.versionId}/package/complete`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `limits-big-confirm-${stamp}` },
},
);
const confirmMs = Date.now() - confirmStarted;
const rssAfter = apiServerWorkingSetMb();
check(
'200 MiB 档包上传并被校验通过',
bigUploaded.status === 200 &&
bigConfirmed.status === 200 &&
bigConfirmed.data?.status === 'uploaded',
`upload=${bigUploaded.status}/${uploadMs}ms confirm=${bigConfirmed.status}/${confirmMs}ms versionStatus=${bigConfirmed.data?.status ?? ''}`,
);
console.log(
`INFO 200 MiB 档耗时:上传 ${uploadMs}ms,确认(含回读与校验) ${confirmMs}ms;api-server 工作集 ${rssBefore ?? 'n/a'}MB → ${rssAfter ?? 'n/a'}MB`,
);
const submitted = await api(
`/api/game-distribution/versions/${bigCase.version.data?.versionId}/submit`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `limits-big-submit-${stamp}` },
body: { expectedPublicationRevision: 0 },
},
);
check(
'200 MiB 档包能正常送审',
submitted.status === 202,
`status=${submitted.status}`,
);
}
// G. 校验不执行上传代码:校验路径是纯 Rust 解析,没有拉起任何进程。
const moduleSource = await import('node:fs').then((fs) =>
['package.rs', 'release.rs']
.map((file) =>
fs.readFileSync(
`server-rs/crates/module-game-distribution/src/${file}`,
'utf8',
),
)
.join('\n'),
);
check(
'校验实现里没有进程拉起 / 代码执行入口',
!/Command::new|std::process::Command|spawn\(/u.test(moduleSource),
'module-game-distribution 的 package.rs / release.rs',
);
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((error) => {
console.error(
`[check-game-distribution-package-limits-e2e] 运行失败:${error}`,
);
process.exit(1);
});