diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index f49cd17fd..9337933fc 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -120,7 +120,7 @@ ### 行为与验收 - [ ] 真实环境中完整跑通“首次上传 → 校验 → 审核 → 公开 → 游客游玩 → 更新待审旧版在线 → 新版切换 → 下架撤销”。 -- [ ] 200 MiB 包(现行上限,见 2026-09-23 决策记录)与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界。已有证据覆盖 100 MiB 档,上限提升后的档位待复跑。 +- [x] 200 MiB 包(现行上限,见 2026-09-23 决策记录)与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界(2026-09-28 在本地真实栈按新上限复跑,见文末阶段 D 核对)。 - [ ] 校验执行器重启可恢复,审核积压与失败可观测,清理不删除仍被公开版本引用的文件。 - [ ] CDN purge 失败时仍在获批缓存 TTL 内拒绝新资源;明确已下载脚本无法远程抹除的边界。 - [ ] 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活;部署路由、缓存、响应头、日志脱敏和告警完成检查。 @@ -220,4 +220,16 @@ - 共享组件:目录/详情/游玩/发布四个页面都直接用 `PlatformActionButton` / `PlatformStatusMessage` / `PlatformEmptyState`(`src/components/common/`),没有另起一套按钮与状态条。 - warm token:`src/components/game-distribution/gameDistribution.css` 的颜色/边框/背景全部走 `var(--platform-*)` 变量,没有独立色板。 - 无假统计与演示兜底:目录卡片的游玩次数直接取服务端 `playCount`(新游戏显示 0 次游玩,发布链路脚本里验证过广场数据来自真实接口);四个页面里检索 `Math.random` / `demo` / `mock` / `假数据` 无命中。 - - iframe 未放宽:游玩页只用 `sandbox="allow-scripts"`(没有 `allow-same-origin`),由发行沙箱脚本在真实浏览器里逐条断言过(storage/cookie/父文档 DOM/跨源请求/Worker/弹窗/顶层跳转都被挡)。 \ No newline at end of file + - iframe 未放宽:游玩页只用 `sandbox="allow-scripts"`(没有 `allow-same-origin`),由发行沙箱脚本在真实浏览器里逐条断言过(storage/cookie/父文档 DOM/跨源请求/Worker/弹窗/顶层跳转都被挡)。 +## 本轮核对(2026-09-28,阶段 D 发包边界复跑) + +- 已勾选(阶段 D 第 2 条:200 MiB 上限档与文件数/单文件/展开量/压缩比边界的耗时、内存与失败证据;校验不执行上传代码)——本轮补齐 + - 新增 `scripts/check-game-distribution-package-limits-e2e.mjs`(`npm run check:game-distribution-package-limits-e2e`,本地真实栈 + 真实 OSS,**11 项 PASS**): + - **上限档成功链路**:合法包 **196.0 MiB**(205,521,702 字节,`index.html` + 4 × 49 MiB 随机内容)上传耗时 **36,972 ms**,确认(回读整包 + 展开展开清单)**9,557 ms**,版本落到 `uploaded`,随后送审 202;api-server 工作集 51 MB → 50 MB(没有把整包留在常驻内存里)。 + - **声明超限**:`packageBytes = 200 MiB + 1` 在建版本阶段就返回 413「发行包大小超出限制」,不必真传包。 + - **文件数**:10,001 个条目的包在上传校验时 422 `PACKAGE_VALIDATION_FAILED` / `reason=TooManyFiles`,版本落 `upload_failed`。 + - **单文件**:声明 65 MiB 的条目返回 422 / `FileTooLarge`。 + - **展开量**:9 × 60 MiB = 540 MiB(每 6 字节塞 1 个随机字节,整包约 145 MiB、仍在 200 MiB 内)返回 422 / `ExpandedPackageTooLarge`。 + - **压缩比**:10 MiB 全零内容压成 10,556 字节(>100:1)返回 422 / `CompressionRatioTooHigh`。 + - **校验不执行上传代码**:校验实现 `module-game-distribution` 的 `package.rs` / `release.rs` 里检索 `Command::new` / `std::process::Command` / `spawn(` 无命中;包内 `index.html` 里那行会打接口的脚本在整个校验过程中没有被执行。 + - 边界说明:失败用例都停在「上传校验」这一步(单发 PUT 会立刻回读校验),版本落到 `upload_failed` 且不会留下半包对象;成功档的 196 MiB 对象会留在 dev bucket 里,属于可丢弃的测试产物。 \ No newline at end of file diff --git a/package.json b/package.json index 2acadfcbd..7cd8b6869 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "check:game-distribution-web-publish-e2e": "node scripts/check-game-distribution-web-publish-e2e.mjs", "check:game-distribution-web-publish-recovery-e2e": "node scripts/check-game-distribution-web-publish-recovery-e2e.mjs", "check:game-distribution-web-a11y-e2e": "node scripts/check-game-distribution-web-a11y-e2e.mjs", + "check:game-distribution-package-limits-e2e": "node scripts/check-game-distribution-package-limits-e2e.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", "check:preview-deployer": "node scripts/check-preview-deployer.mjs", "check:maintenance-page": "node scripts/check-maintenance-page.mjs", diff --git a/scripts/check-game-distribution-package-limits-e2e.mjs b/scripts/check-game-distribution-package-limits-e2e.mjs new file mode 100644 index 000000000..50d6c7678 --- /dev/null +++ b/scripts/check-game-distribution-package-limits-e2e.mjs @@ -0,0 +1,532 @@ +// 游戏分发发行包边界复跑:现行上限 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); +});