// 游戏分发发行包边界复跑:现行上限 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', 'many'); 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', 'big file', ); 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', 'expanded', ); 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', 'ratio'); 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', '

limits-big

', ); 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); });