Files
Genarrative/scripts/check-game-distribution-validation-restart-e2e.mjs
T
k88936 1f239a96ce 游戏分发 E2E 抽出共享 multipart 发布 helper
- 新增 scripts/game-distribution-e2e-helpers.mjs:COVER_PNG、imagePart、coverImagePart、publishFormData、resolveApiRequest
- 11 个 E2E 脚本删除各自的重复实现,改为 import;api() 的 body 分支收敛到 resolveApiRequest
- 各脚本的封面文件名前缀、envelope 与鉴权头合并顺序、返回形状保持不变
2026-10-07 11:42:45 +08:00

495 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';
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
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,
formData,
headers = {},
binary,
} = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
const request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: request.headers,
body: request.body,
});
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;
}
const coverImagePart = (id) =>
imagePart('cover', id, { fileName: `restart-${id}.png` });
function gameMetadata(title, coverObjectKey) {
return {
title,
summary: '校验重启恢复 E2E',
description: '',
category: '休闲',
tags: ['restart'],
coverObjectKey,
screenshots: [],
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}` },
formData: publishFormData({
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 cover = coverImagePart(stamp);
const title = `校验重启 ${String(stamp).slice(-6)}`;
const metadata = gameMetadata(title, null);
// 先发布 v1(公开版本),用来验证后续失败/重试不会误删公开内容。
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `restart-game-${stamp}` },
formData: publishFormData(metadata, { cover }),
});
const gameId = created.data?.id;
check('创建游戏成功', created.status === 200 && Boolean(gameId));
// `check` 只记失败不中断:创建失败时 created.data 为空,继续解引用会抛未捕获 TypeError
// 把脚本直接打崩,拿不到后面成组的 check 结论。
if (!gameId) process.exit(1);
// 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。
const resolvedMetadata = {
...metadata,
coverObjectKey: created.data.coverObjectKey,
};
const v1Package = await buildPackage(1, 'V1-OK');
const v1Bytes = v1Package.bytes;
const v1 = await createVersion(
author,
gameId,
resolvedMetadata,
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,
resolvedMetadata,
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,
coverObjectKey: resolvedMetadata.coverObjectKey,
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),
]);
// 兼容旧版脚本写下的 STATE_FILE:当时只存 coverAssetId,直接读 coverObjectKey 会得到
// undefined 并被 JSON.stringify 丢掉,v3 就会没有封面。
const metadata = gameMetadata(
state.title,
state.coverObjectKey ?? 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}` },
formData: publishFormData({
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();
}