// 游戏分发「校验执行器重启可恢复 + 失败可观测 + 不误删公开版本」检查。 // // 两段式:prepare 段上传一个大包并在确认(校验)进行中杀掉 api-server;调用方重启 // api-server 后用同一 E2E_STATE_FILE 跑 resume 段,确认仍能续做、失败可见、公开版本仍在。 // // 需要:本地 dev 栈 + 管理员账号 + playwright 无关。 // E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... E2E_STAGE=prepare npm run check:game-distribution-validation-restart-e2e // <重启 api-server> // E2E_STAGE=resume ... npm run check:game-distribution-validation-restart-e2e import { execSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; 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 STAGE = (process.env.E2E_STAGE ?? 'prepare').trim(); const STATE_FILE = (process.env.E2E_STATE_FILE ?? '').trim() || path.join(tmpdir(), 'genarrative-validation-restart.json'); const DEV_PASSWORD = 'GenE2e123!'; const MIB = 1024 * 1024; if (!ADMIN_USER || !ADMIN_PASSWORD) { console.error('缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD。'); process.exit(2); } if (STAGE !== 'prepare' && STAGE !== 'resume') { console.error(`E2E_STAGE 只支持 prepare / resume,收到:${STAGE}`); 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, }; } async function adminToken() { const login = await api('/admin/api/login', { method: 'POST', body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, }); const token = login.data?.token ?? login.data?.accessToken; check( '管理员登录成功', login.status === 200 && Boolean(token), `status=${login.status}`, ); return token; } async function uploadCover(token, id) { const fileName = `restart-${id}.png`; const ticket = await api('/api/assets/direct-upload-tickets', { method: 'POST', token, body: { legacyPrefix: 'generated-character-drafts', pathSegments: ['game-distribution', 'restart', 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-restart', }, }); if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`); return confirm.data.assetObject.assetObjectId; } function gameMetadata(title, coverAssetId) { return { title, summary: '校验重启恢复 E2E', description: '', category: '休闲', tags: ['restart'], coverAssetId, deviceSupport: { desktop: true, mobile: false, touch: false }, inputModes: ['keyboard', 'mouse'], orientation: 'landscape', }; } async function createVersion( token, gameId, metadata, bytes, suffix, fileCount, ) { return api(`/api/game-distribution/games/${gameId}/versions`, { method: 'POST', token, headers: { 'Idempotency-Key': `restart-version-${suffix}` }, body: { packageSha256: createHash('sha256').update(bytes).digest('hex'), packageBytes: bytes.length, packageFileCount: fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, }, }); } function buildPackage(sizeMiB, marker) { const zip = new JSZip(); zip.file( 'index.html', `

${marker}

`, ); // 单文件上限 64 MiB,这里按 48 MiB 切片,确保大包用例卡在"包大小"而不是"单文件"。 let remaining = sizeMiB; let index = 0; while (remaining > 0) { const chunk = Math.min(remaining, 48); zip.file(`assets/blob-${index}.bin`, randomBytes(chunk * MIB)); remaining -= chunk; index += 1; } return zip .generateAsync({ type: 'uint8array', compression: 'STORE' }) .then((bytes) => ({ bytes: Buffer.from(bytes), fileCount: index + 1 })); } function killApiServer() { execSync( `powershell -NoProfile -Command "$c = Get-NetTCPConnection -LocalPort ${API_PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c) { Stop-Process -Id $c.OwningProcess -Force }"`, { stdio: 'ignore', timeout: 30_000 }, ); } async function runPrepare() { const admin = await adminToken(); 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 phone = `135${String(stamp).slice(-8)}`; const register = await api('/api/auth/entry', { method: 'POST', body: { purePhoneNumber: phone, password: DEV_PASSWORD }, }); const author = register.data?.token; check('作者注册拿到 token', register.status === 200 && Boolean(author)); if (!author) process.exit(1); const coverAssetId = await uploadCover(author, stamp); const title = `校验重启 ${String(stamp).slice(-6)}`; const metadata = gameMetadata(title, coverAssetId); // 先发布 v1(公开版本),用来验证后续失败/重试不会误删公开内容。 const created = await api('/api/game-distribution/games', { method: 'POST', token: author, headers: { 'Idempotency-Key': `restart-game-${stamp}` }, body: metadata, }); const gameId = created.data?.id; check('创建游戏成功', created.status === 200 && Boolean(gameId)); const v1Package = await buildPackage(1, 'V1-OK'); const v1Bytes = v1Package.bytes; const v1 = await createVersion( author, gameId, metadata, v1Bytes, `${stamp}-v1`, v1Package.fileCount, ); await api(`/api/game-distribution/versions/${v1.data?.versionId}/package`, { method: 'PUT', token: author, headers: { 'Idempotency-Key': `restart-upload-v1-${stamp}`, 'Content-Type': 'application/zip', }, binary: v1Bytes, }); await api(`/api/game-distribution/versions/${v1.data?.versionId}/submit`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `restart-submit-v1-${stamp}` }, body: { expectedPublicationRevision: 0 }, }); const approved = await api( `/admin/api/game-distribution/versions/${v1.data?.versionId}/review`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `restart-approve-v1-${stamp}` }, body: { decision: 'approve', expectedPublicationRevision: 0 }, }, ); check('v1 已公开', approved.status === 200, `status=${approved.status}`); // v2:上传一个大包,然后在确认(校验)进行中杀掉 api-server。 const v2Package = await buildPackage(196, 'V2-OK'); const v2Bytes = v2Package.bytes; const v2 = await createVersion( author, gameId, metadata, v2Bytes, `${stamp}-v2`, v2Package.fileCount, ); const versionId = v2.data?.versionId; const uploaded = await api( `/api/game-distribution/versions/${versionId}/package`, { method: 'PUT', token: author, headers: { 'Idempotency-Key': `restart-upload-v2-${stamp}`, 'Content-Type': 'application/zip', }, binary: v2Bytes, }, ); check( 'v2 整包上传完成(等待确认校验)', uploaded.status === 200, `status=${uploaded.status} bytes=${v2Bytes.length}`, ); const confirmPromise = api( `/api/game-distribution/versions/${versionId}/package/complete`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `restart-confirm-v2-${stamp}` }, }, ).catch((error) => ({ error: String(error) })); // 请求已经发出就立刻杀掉校验进程:确认要回读整包并算清单,进程退出必然让这次 // 请求失败,同时版本状态不应被写成 uploaded / validation_failed。 killApiServer(); const interrupted = await confirmPromise; check( '确认校验进行中杀掉 api-server 会让请求失败而不是静默成功', Boolean(interrupted?.error) || interrupted.status >= 500, `result=${interrupted?.error ?? interrupted?.status ?? 'n/a'}`, ); writeFileSync( STATE_FILE, JSON.stringify( { apiBase: API, phone, password: DEV_PASSWORD, gameId, title, coverAssetId, versionId, v1VersionId: v1.data?.versionId, v2Bytes: v2Bytes.length, stamp, }, null, 2, ), ); console.log( `\nPREPARED gameId=${gameId} v2VersionId=${versionId}(api-server 已杀,重启后用 E2E_STAGE=resume 继续)`, ); process.exit(failures === 0 ? 0 : 1); } async function runResume() { const state = JSON.parse(readFileSync(STATE_FILE, 'utf8')); const admin = await adminToken(); const login = await api('/api/auth/entry', { method: 'POST', body: { purePhoneNumber: state.phone, password: state.password }, }); const author = login.data?.token; check( '重启后作者仍能登录', login.status === 200 && Boolean(author), `status=${login.status}`, ); if (!author) process.exit(1); const uploadState = await api( `/api/game-distribution/versions/${state.versionId}/package/upload-state`, { token: author }, ); check( '重启后已收字节仍在(暂存对象没因进程退出丢失)', uploadState.status === 200 && uploadState.data?.receivedBytes === state.v2Bytes, `receivedBytes=${uploadState.data?.receivedBytes ?? 'n/a'} expected=${state.v2Bytes}`, ); const confirm = await api( `/api/game-distribution/versions/${state.versionId}/package/complete`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `restart-confirm-retry-${state.stamp}` }, }, ); check( '重启后重新确认能完成校验(可恢复)', confirm.status === 200 && confirm.data?.status === 'uploaded', `status=${confirm.status} versionStatus=${confirm.data?.status ?? ''}`, ); const submitted = await api( `/api/game-distribution/versions/${state.versionId}/submit`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `restart-submit-v2-${state.stamp}` }, body: { expectedPublicationRevision: 1 }, }, ); check( 'v2 送审成功(200/202)', submitted.status === 202, `status=${submitted.status}`, ); const reviews = await api('/admin/api/game-distribution/reviews', { token: admin, }); const backlog = (reviews.data?.entries ?? []).filter( (entry) => entry.status === 'pending_review', ); check( '审核积压可观测(待审队列能查到该版本)', backlog.some((entry) => entry.versionId === state.versionId), `pending=${backlog.length}`, ); const adminGames = await api('/admin/api/game-distribution/games', { token: admin, }); const adminGame = (adminGames.data?.games ?? []).find( (game) => game.gameId === state.gameId, ); check( '后台游戏列表能看到同一游戏的两个版本状态', (adminGame?.versions ?? []).length === 2 && adminGame.versions.some((version) => version.status === 'pending_review'), `versions=${adminGame?.versions?.length ?? 'n/a'}`, ); // 失败可观测:上传一个结构损坏的包,作者当场能拿到失败码与原因,版本落 // upload_failed(原因同时落库 last_error_code/last_error_message),后台能看到状态。 const brokenBytes = Buffer.concat([ Buffer.from('PK\x03\x04broken', 'binary'), randomBytes(1024), ]); const metadata = gameMetadata(state.title, state.coverAssetId); const v3 = await api( `/api/game-distribution/games/${state.gameId}/versions`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `restart-version-v3-${state.stamp}` }, body: { packageSha256: createHash('sha256').update(brokenBytes).digest('hex'), packageBytes: brokenBytes.length, packageFileCount: 1, packageEntryPath: 'index.html', gameMetadata: metadata, }, }, ); const v3Id = v3.data?.versionId; const brokenUpload = await api( `/api/game-distribution/versions/${v3Id}/package`, { method: 'PUT', token: author, headers: { 'Idempotency-Key': `restart-upload-v3-${state.stamp}`, 'Content-Type': 'application/zip', }, binary: brokenBytes, }, ); const v3Readback = await api(`/api/game-distribution/versions/${v3Id}`, { token: author, }); check( '坏包校验失败可诊断(422 + 失败码 + 原因)', brokenUpload.status === 422 && brokenUpload.error?.code === 'PACKAGE_VALIDATION_FAILED' && Boolean(brokenUpload.error?.details?.reason), `upload=${brokenUpload.status} code=${brokenUpload.error?.code ?? ''} reason=${brokenUpload.error?.details?.reason ?? ''}`, ); check( '失败版本落 upload_failed 且可回读', v3Readback.data?.version?.status === 'upload_failed', `versionStatus=${v3Readback.data?.version?.status ?? ''}`, ); const v3Admin = await api('/admin/api/game-distribution/games', { token: admin, }); const v3AdminGame = (v3Admin.data?.games ?? []).find( (game) => game.gameId === state.gameId, ); check( '失败版本在后台列表可见(可观测)', (v3AdminGame?.versions ?? []).some( (version) => version.versionId === v3Id && version.status === 'upload_failed', ), `statuses=${(v3AdminGame?.versions ?? []).map((version) => version.status).join(',')}`, ); // 清理/误删防护:公开版本 v1 的发行内容在 v2 待审、v3 失败之后仍可读。 const release = await fetch( `${API}/api/game-distribution/releases/${state.gameId}/index.html`, ); const releaseBody = await release.text(); check( '公开版本内容在后续待审/失败之后仍然可读(没有被清理误删)', release.status === 200 && releaseBody.includes('V1-OK'), `status=${release.status} marker=${releaseBody.includes('V1-OK')}`, ); console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`); process.exit(failures === 0 ? 0 : 1); } if (STAGE === 'prepare') { await runPrepare(); } else { await runResume(); }