合并 Phaser 一键发布自动构建与打包
Project CI / AI game creator shell Rust crates (push) Successful in 1m36s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m9s
Project CI / Backend tests (push) Successful in 5m41s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m33s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m53s
Project CI / Native shell tests (push) Successful in 7m28s
Project CI / Frontend tests (push) Successful in 3m38s
Project CI / Repository checks (push) Successful in 3m36s
Project CI / AI game creator shell web tests (push) Successful in 2m55s

This commit was merged in pull request #481.
This commit is contained in:
2026-09-22 21:03:57 +08:00
7 changed files with 426 additions and 15 deletions
+59 -10
View File
@@ -4,10 +4,15 @@
// 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';
@@ -139,7 +144,39 @@ function gameMetadata(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',
@@ -147,7 +184,12 @@ async function buildZip() {
);
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="media";');
const bytes = await zip.generateAsync({ type: 'uint8array' });
return Buffer.from(bytes);
return {
bytes: Buffer.from(bytes),
fileCount: 2,
assetPath: 'assets/app.js',
entryMarker: 'E2E-MEDIA-OK',
};
}
async function main() {
@@ -230,7 +272,7 @@ async function main() {
// 4. 创建游戏 + 版本(冻结资料)
const metadata = gameMetadata({
title: `分发媒体验证 ${id.slice(-6)}`,
title: gameTitleOverride || `分发媒体验证 ${id.slice(-6)}`,
coverAssetId: cover.assetObjectId,
screenshots: [shot1.assetObjectId, shot2.assetObjectId],
});
@@ -284,7 +326,8 @@ async function main() {
`status=${ghostVersion.status}`,
);
const zipBytes = await buildZip();
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`, {
@@ -294,7 +337,7 @@ async function main() {
body: {
packageSha256: sha256,
packageBytes: zipBytes.length,
packageFileCount: 2,
packageFileCount: built.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
@@ -423,7 +466,8 @@ async function main() {
body: {
decision: 'approve',
expectedPublicationRevision: readback.data.version.publicationRevision,
entryUrl: `${API}`,
// 本地用发行网关路径当入口,让「审核通过 → 游玩」在本地也走真实网关。
entryUrl: `${API}/api/game-distribution/releases/${gameId}/`,
},
},
);
@@ -480,22 +524,27 @@ async function main() {
`${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(
'发行网关返回游戏入口',
release.status === 200 && releaseBody.includes('E2E-MEDIA-OK'),
`status=${release.status}`,
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}/assets/app.js`,
`${API}/api/game-distribution/releases/${gameId}/${built.assetPath}`,
);
const assetBody = await releaseAsset.arrayBuffer();
check(
'发行网关返回包内资源',
releaseAsset.status === 200,
`status=${releaseAsset.status}`,
releaseAsset.status === 200 && assetBody.byteLength > 0,
`status=${releaseAsset.status} path=${built.assetPath} bytes=${assetBody.byteLength}`,
);
console.log(`\n结果:${failures === 0 ? '全部通过' : `${failures} 项失败`}`);