Files
Genarrative/scripts/check-game-distribution-validation-restart-e2e.mjs
T
kdletters 6afa63a9de
Project CI / AI game creator shell Rust smoke (push) Successful in 1m34s
Project CI / AI game creator shell Rust crates (push) Successful in 1m25s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
补齐游戏分发校验重启恢复与失败可观测取证
- 新增 scripts/check-game-distribution-validation-restart-e2e.mjs:两段式 prepare/resume,在确认校验中途杀掉 api-server,覆盖暂存字节不丢、重新确认可恢复、待审积压可查、坏包 422 失败可诊断、公开版本不被误删
- package.json 注册 npm run check:game-distribution-validation-restart-e2e
- 里程碑阶段 D 第 3 条勾选,并补「本轮核对(阶段 D 重启恢复与失败可观测)」与阶段 D 现状描述
- pitfalls 记录两段式 E2E 的管理员凭据取决于 api-server 启动环境,以及同一状态文件只能 resume 一次
2026-09-28 21:34:24 +08:00

512 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.
// 游戏分发「校验执行器重启可恢复 + 失败可观测 + 不误删公开版本」检查。
//
// 两段式: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',
`<!doctype html><html><head><script>fetch('/api/game-distribution/games');</script></head><body><h1>${marker}</h1></body></html>`,
);
// 单文件上限 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();
}