Files
Genarrative/scripts/check-game-distribution-package-limits-e2e.mjs
T
kdletters b58fab7b52
Project CI / AI game creator shell Rust crates (push) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m5s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 5m53s
Project CI / Backend tests (push) Successful in 4m47s
Project CI / Frontend tests (push) Successful in 2m6s
Project CI / Native shell tests (push) Successful in 6m46s
Project CI / Repository checks (push) Successful in 2m49s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m24s
Project CI / AI game creator shell web tests (push) Successful in 1m50s
补齐游戏分发 200 MiB 上限档与发包边界复跑
- 新增 scripts/check-game-distribution-package-limits-e2e.mjs:196 MiB 合法包走通上传+校验+送审并记录耗时与工作集,声明超限 413,文件数/单文件/展开量/压缩比四类边界各自 422 失败关闭并落 upload_failed
- 校验实现无进程拉起,包内脚本未被执行
- 游戏分发里程碑阶段 D 第 2 条改为已勾选,并写明本轮可复核数据
2026-09-28 21:13:50 +08:00

533 lines
18 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';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
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, 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,
};
}
/** 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;
}
}
async function uploadCover(token, id) {
const fileName = `limits-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'limits', 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-limits',
},
});
if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`);
return confirm.data.assetObject.assetObjectId;
}
async function createGameWithVersion(
author,
coverAssetId,
title,
suffix,
declaration,
) {
const metadata = {
title,
summary: '边界复跑临时游戏',
description: '',
category: '休闲',
tags: ['limits'],
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': `limits-game-${suffix}` },
body: metadata,
});
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}` },
body: {
packageSha256: declaration.packageSha256 ?? 'a'.repeat(64),
packageBytes: declaration.packageBytes,
packageFileCount: declaration.packageFileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
});
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 coverAssetId = await uploadCover(author, stamp);
// A. 声明超过 200 MiB:创建版本阶段就该失败关闭,不必真的传包。
const declaredOver = await createGameWithVersion(
author,
coverAssetId,
`边界 声明超限 ${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,
coverAssetId,
`边界 文件数 ${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,
coverAssetId,
`边界 单文件 ${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,
coverAssetId,
`边界 展开量 ${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,
coverAssetId,
`边界 压缩比 ${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,
coverAssetId,
`边界 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);
});