Files
Genarrative/scripts/check-game-distribution-media-e2e.mjs
T
kdletters 57a72f652c
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Phaser 项目一键发布:发布前自动构建与打包
- AGC:export_local_project_package 改为发布前构建——已有可玩入口直接打包;否则解析 game/(Phaser 4 + Vite 脚手架)或项目根的 npm build 脚本,缺 game/node_modules 时先跑 project.bootstrap(npm install),再走 project.verify 的受控 npm 运行器执行 npm run build,最后校验入口并打包;安装/构建失败返回带日志尾部的可操作错误。
- 新增 resolve_publish_build_plan 纯函数与用例:game 子工程优先并要求先装依赖、根 npm 工程回退、没有可构建工程时失败关闭。
- 后端:发行网关根路径(含尾斜杠)等价于 index.html,路由级用例覆盖 Cookie 拒绝门与根路径;生产仍由每游戏 origin 把根路径映射到该游戏入口。
- 脚本:check:game-distribution-media-e2e 支持 E2E_PACKAGE_ZIP 直接发布真实构建产物,断言从包内派生入口/资源,本地审核入口改为发行网关路径。
- 文档:玩法链路与实施计划记录「作者不构建、不打 ZIP」的 Phaser 发布口径与验证证据。
- 验证:真实 Phaser 4.2.1 + Vite 7 构建产物(相对引用)经发布链路后网关 index.html 200 / assets 1,388,719 B 200,播放页在 allow-scripts 沙箱 iframe 内渲染 PHASER-PUBLISH-OK 且点击交互生效。
2026-09-22 20:38:00 +08:00

558 lines
18 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.
// 游戏分发「封面 + 截图」真实链路检查(需要本地 dev 栈 + 真实 OSS 配置)。
//
// 用法:
// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \
// npm run check:game-distribution-media-e2e
// E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:12401)。
// E2E_PACKAGE_ZIP 指向一个已经构建好的发行包(根目录含 index.html),例如真实
// Phaser/Vite 工程 `game/dist/**` 打成的 ZIP;不传时使用脚本内置的最小 fixture。
// E2E_GAME_TITLE 可覆盖游戏标题,便于在广场里认出这次验证。
//
// 覆盖:真实素材直传 OSS → 创建游戏(素材归属校验)→ 创建版本(资料冻结)→ 送审 →
// 作者回读 frozenMetadata → 待审期间匿名不可见/不可读 → 管理员审核通过 → 公开投影
// 暴露对象键且不泄露素材 ID → 匿名换签读封面与截图 → 发行网关可直接游玩。
import { readFile } from 'node:fs/promises';
import JSZip from 'jszip';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:12401';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error(
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:请用已配置管理员账号的环境变量运行,' +
'本地栈可先以 GENARRATIVE_ADMIN_USERNAME / GENARRATIVE_ADMIN_PASSWORD 启动 api-server。',
);
process.exit(2);
}
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
async function api(path, 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}${path}`, {
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,
json,
text,
data: json?.data,
error: json?.error,
};
}
function stamp() {
return `${Date.now()}${Math.floor(Math.random() * 1000)}`;
}
async function uploadImage(token, kind, id) {
const bytes = COVER_PNG;
const fileName = `${kind}-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', kind, id],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: bytes.length,
metadata: { asset_kind: `game_distribution_${kind}` },
},
});
if (ticket.status !== 200) {
throw new Error(
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
);
}
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([bytes], { 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: bytes.length,
assetKind: `game_distribution_${kind}`,
accessPolicy: 'private',
entityId: `game-distribution-${kind}`,
},
});
if (confirm.status !== 200) {
throw new Error(
`confirm 失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return {
assetObjectId: confirm.data.assetObject.assetObjectId,
objectKey: confirm.data.assetObject.objectKey,
};
}
function gameMetadata(overrides = {}) {
return {
title: `分发媒体验证 ${stamp().slice(-6)}`,
summary: '真实链路验证封面与截图冻结',
description: 'E2E:真实素材直传 + 冻结 + 审核生效',
category: '益智',
tags: ['E2E'],
deviceSupport: { desktop: true, mobile: true, touch: true },
inputModes: ['keyboard', 'mouse', 'touch'],
orientation: 'responsive',
...overrides,
};
}
const externalPackageZip = (process.env.E2E_PACKAGE_ZIP ?? '').trim();
const gameTitleOverride = (process.env.E2E_GAME_TITLE ?? '').trim();
/** 返回待发布的发行包字节与条目数:优先使用调用方真实构建产物,否则用内置 fixture。 */
async function buildZip() {
if (externalPackageZip) {
const bytes = await readFile(externalPackageZip);
const archive = new JSZip();
const parsed = await archive.loadAsync(bytes);
const entryNames = Object.keys(parsed.files).filter(
(name) => !parsed.files[name].dir,
);
if (!entryNames.includes('index.html')) {
throw new Error(
`E2E_PACKAGE_ZIP 根目录缺少 index.html${externalPackageZip}`,
);
}
// 真实构建产物(Phaser/Vite 等)资源名带哈希:从包内派生一个资源路径做网关断言。
const assetPath =
entryNames.find((name) => /^assets\/.+\.js$/u.test(name)) ??
entryNames.find((name) => name.endsWith('.js'));
if (!assetPath) {
throw new Error(
`E2E_PACKAGE_ZIP 内没有可断言的 JS 资源:${externalPackageZip}`,
);
}
return {
bytes: Buffer.from(bytes),
fileCount: entryNames.length,
assetPath,
entryMarker: null,
};
}
const zip = new JSZip();
zip.file(
'index.html',
'<!doctype html><html><head><meta charset="utf-8"><title>E2E 媒体验证</title><script src="assets/app.js"></script></head><body><h1>E2E-MEDIA-OK</h1></body></html>',
);
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="media";');
const bytes = await zip.generateAsync({ type: 'uint8array' });
return {
bytes: Buffer.from(bytes),
fileCount: 2,
assetPath: 'assets/app.js',
entryMarker: 'E2E-MEDIA-OK',
};
}
async function main() {
// 1. 作者注册
const phone = `137${String(Date.now()).slice(-8)}`;
const entry = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: 'GenE2e123!' },
});
check(
'作者注册拿到 token',
entry.status === 200 && Boolean(entry.data?.token),
`status=${entry.status}`,
);
const author = entry.data.token;
const otherEntry = await api('/api/auth/entry', {
method: 'POST',
body: {
purePhoneNumber: `138${String(Date.now() + 7).slice(-8)}`,
password: 'GenE2e123!',
},
});
const another = otherEntry.data.token;
// 2. 真实素材直传
const id = stamp();
const cover = await uploadImage(author, 'cover', id);
const shot1 = await uploadImage(author, 'screenshot', `${id}-1`);
const shot2 = await uploadImage(author, 'screenshot', `${id}-2`);
check(
'封面素材直传并 confirm',
Boolean(cover.assetObjectId) &&
cover.objectKey.includes('game-distribution/cover'),
cover.objectKey,
);
check(
'截图素材直传并 confirm',
Boolean(shot1.assetObjectId) && Boolean(shot2.assetObjectId),
);
// 3. 服务端校验:缺封面 / 超 6 张 / 素材不存在
const noCover = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-nocover-${id}` },
body: gameMetadata({ title: '缺封面验证' }),
});
check(
'缺少封面被拒(400',
noCover.status === 400,
`status=${noCover.status} msg=${noCover.error?.message ?? ''}`,
);
const tooMany = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-many-${id}` },
body: gameMetadata({
coverAssetId: cover.assetObjectId,
screenshots: Array.from({ length: 7 }, () => shot1.assetObjectId),
}),
});
check(
'截图超过 6 张被拒(400',
tooMany.status === 400,
`status=${tooMany.status} msg=${tooMany.error?.message ?? ''}`,
);
const ghost = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-ghost-${id}` },
body: gameMetadata({ coverAssetId: 'asset_not_exists', screenshots: [] }),
});
check(
'不存在的封面素材被拒',
ghost.status === 400,
`status=${ghost.status} msg=${ghost.error?.message ?? ''}`,
);
// 4. 创建游戏 + 版本(冻结资料)
const metadata = gameMetadata({
title: gameTitleOverride || `分发媒体验证 ${id.slice(-6)}`,
coverAssetId: cover.assetObjectId,
screenshots: [shot1.assetObjectId, shot2.assetObjectId],
});
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-game-${id}` },
body: metadata,
});
check(
'创建游戏成功',
created.status === 200 && Boolean(created.data?.id),
`status=${created.status} ${created.text.slice(0, 200)}`,
);
const gameId = created.data.id;
// 版本级校验用例需要真实的游戏行:用合法素材建一个只用于负面校验的游戏。
const ghostGame = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-ghostgame-${id}` },
body: gameMetadata({
title: `版本级素材校验 ${id.slice(-6)}`,
coverAssetId: cover.assetObjectId,
screenshots: [],
}),
});
const gameIdForGhost = ghostGame.data?.id ?? gameId;
const ghostVersion = await api(
`/api/game-distribution/games/${gameIdForGhost}/versions`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-ghost-version-${id}` },
body: {
packageSha256: 'a'.repeat(64),
packageBytes: 1024,
packageFileCount: 1,
packageEntryPath: 'index.html',
gameMetadata: gameMetadata({
coverAssetId: 'asset_not_exists',
screenshots: [],
}),
},
},
);
check(
'版本冻结时同样拒绝不存在的素材',
ghostVersion.status >= 400,
`status=${ghostVersion.status}`,
);
const built = await buildZip();
const zipBytes = built.bytes;
const crypto = await import('node:crypto');
const sha256 = crypto.createHash('sha256').update(zipBytes).digest('hex');
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-version-${id}` },
body: {
packageSha256: sha256,
packageBytes: zipBytes.length,
packageFileCount: built.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
});
check(
'创建版本成功',
version.status === 200 && Boolean(version.data?.versionId),
`status=${version.status} ${version.text.slice(0, 200)}`,
);
const versionId = version.data.versionId;
const uploadPackage = await api(
`/api/game-distribution/versions/${versionId}/package`,
{
method: 'PUT',
token: author,
headers: {
'Idempotency-Key': `e2e-upload-${id}`,
'Content-Type': 'application/zip',
},
binary: zipBytes,
},
);
check(
'上传发行包成功',
uploadPackage.status === 200 && uploadPackage.data?.status === 'uploaded',
`status=${uploadPackage.status}`,
);
const submitted = await api(
`/api/game-distribution/versions/${versionId}/submit`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-submit-${id}` },
body: {
expectedPublicationRevision: created.data.publicationRevision ?? 0,
},
},
);
check(
'送审成功(202 + pending_review',
submitted.status === 202 &&
submitted.data?.version?.status === 'pending_review',
`status=${submitted.status} ${submitted.text.slice(0, 200)}`,
);
// 版本冻结前仍要复核素材归属:换成别人的封面素材必须被拒。
const foreign = await api('/api/game-distribution/games', {
method: 'POST',
token: another,
headers: { 'Idempotency-Key': `e2e-foreign-game-${id}` },
body: gameMetadata({
title: `他人素材验证 ${id.slice(-6)}`,
coverAssetId: cover.assetObjectId,
screenshots: [],
}),
});
check(
'借用他人封面素材创建游戏被拒',
foreign.status === 403 || foreign.status === 400,
`status=${foreign.status} msg=${foreign.error?.message ?? ''}`,
);
// 5. 作者回读版本:冻结资料带回素材 ID
const readback = await api(`/api/game-distribution/versions/${versionId}`, {
token: author,
});
const frozen = readback.data?.version?.frozenMetadata;
check(
'作者回读拿到 frozenMetadata',
Boolean(frozen),
`status=${readback.status}`,
);
check(
'冻结资料保留封面素材 ID',
frozen?.coverAssetId === cover.assetObjectId,
String(frozen?.coverAssetId),
);
check(
'冻结资料保留截图素材 ID 顺序',
Array.isArray(frozen?.screenshots) &&
frozen.screenshots[0]?.assetId === shot1.assetObjectId &&
frozen.screenshots[1]?.assetId === shot2.assetObjectId,
);
check(
'冻结资料对象键由服务端派生',
frozen?.coverObjectKey === cover.objectKey,
String(frozen?.coverObjectKey),
);
// 6. 待审期间:公开目录不可见,匿名读封面被拒
const catalogBefore = await api('/api/game-distribution/games');
check(
'待审期间公开目录不含该游戏',
!(catalogBefore.data?.games ?? []).some((game) => game.id === gameId),
);
const readBefore = await api(
`/api/assets/read-url?objectKey=${encodeURIComponent(cover.objectKey)}`,
);
check(
'未公开游戏的封面没有匿名读授权',
readBefore.status >= 400,
`status=${readBefore.status}`,
);
// 7. 管理员审核通过(本地非生产允许回环 http 入口)
const adminLogin = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
check(
'管理员登录成功',
adminLogin.status === 200 &&
Boolean(adminLogin.data?.token ?? adminLogin.data?.accessToken),
`status=${adminLogin.status}`,
);
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
const approved = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `e2e-approve-${id}` },
body: {
decision: 'approve',
expectedPublicationRevision: readback.data.version.publicationRevision,
// 本地用发行网关路径当入口,让「审核通过 → 游玩」在本地也走真实网关。
entryUrl: `${API}/api/game-distribution/releases/${gameId}/`,
},
},
);
check(
'管理员审核通过',
approved.status === 200,
`status=${approved.status} ${approved.text.slice(0, 250)}`,
);
// 8. 公开目录:封面/截图对象键生效
const catalogAfter = await api('/api/game-distribution/games');
const publishedGame = (catalogAfter.data?.games ?? []).find(
(game) => game.id === gameId,
);
check('公开目录返回该游戏', Boolean(publishedGame));
check(
'公开投影带封面对象键',
publishedGame?.coverObjectKey === cover.objectKey,
String(publishedGame?.coverObjectKey),
);
check(
'公开投影带截图对象键',
Array.isArray(publishedGame?.screenshots) &&
publishedGame.screenshots[0] === shot1.objectKey,
JSON.stringify(publishedGame?.screenshots ?? []),
);
check(
'公开投影不泄露素材 ID',
!JSON.stringify(publishedGame ?? {}).includes(cover.assetObjectId),
);
// 9. 匿名读授权:封面与截图都能换签名地址
const coverRead = await api(
`/api/assets/read-url?objectKey=${encodeURIComponent(cover.objectKey)}`,
);
check(
'匿名可读已公开游戏封面',
coverRead.status === 200 &&
Boolean(coverRead.data?.read?.signedUrl ?? coverRead.data?.signedUrl),
`status=${coverRead.status}`,
);
const shotRead = await api(
`/api/assets/read-url?objectKey=${encodeURIComponent(shot1.objectKey)}`,
);
check(
'匿名可读已公开游戏截图',
shotRead.status === 200 &&
Boolean(shotRead.data?.read?.signedUrl ?? shotRead.data?.signedUrl),
`status=${shotRead.status}`,
);
// 10. 发行网关可直接玩
const release = await fetch(
`${API}/api/game-distribution/releases/${gameId}/index.html`,
);
const releaseBody = await release.text();
const entryOk =
release.status === 200 &&
/<html|<!doctype html/iu.test(releaseBody) &&
(built.entryMarker === null || releaseBody.includes(built.entryMarker));
check(
'发行网关返回游戏入口',
entryOk,
`status=${release.status} bytes=${releaseBody.length}`,
);
check(
'发行入口带 nosniff',
release.headers.get('x-content-type-options') === 'nosniff',
);
const releaseAsset = await fetch(
`${API}/api/game-distribution/releases/${gameId}/${built.assetPath}`,
);
const assetBody = await releaseAsset.arrayBuffer();
check(
'发行网关返回包内资源',
releaseAsset.status === 200 && assetBody.byteLength > 0,
`status=${releaseAsset.status} path=${built.assetPath} bytes=${assetBody.byteLength}`,
);
console.log(`\n结果:${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exitCode = failures === 0 ? 0 : 1;
}
main().catch((error) => {
console.error('E2E 脚本异常:', error);
process.exitCode = 1;
});