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 一次
512 lines
16 KiB
JavaScript
512 lines
16 KiB
JavaScript
// 游戏分发「校验执行器重启可恢复 + 失败可观测 + 不误删公开版本」检查。
|
||
//
|
||
// 两段式: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();
|
||
}
|