Files
Genarrative/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts
T
kdletters 4a2b270714 补齐游戏分发封面与截图的冻结、上传与展示
- 服务端:游戏表追加可空 cover_object_key / screenshots_json,版本表追加可空 metadata_json,创建版本时校验必需封面、最多 6 张截图与图片素材归属,并从素材记录派生对象键生成冻结资料。
- 服务端:审核通过时把冻结资料整体生效到游戏行;只有已公开且存在有效活动版本的游戏,其封面/截图素材才在 /api/assets/read-url 获得匿名读授权。
- 服务端:作者与管理员回读版本时追加 frozenMetadata,带回封面与截图素材 ID 供续发复用,公开投影仍只暴露对象键。
- 网页:/games/publish 新增「封面与截图」区,复用平台图片直传与 confirm 通道,缺封面或截图超量在发请求前拦截,更新版本时按冻结资料回填且不要求重新上传。
- 网页:游戏广场卡片与详情页 hero 换签展示真实封面,详情页新增可点击的截图缩略图条,无素材或换签失败时静默回退原有渐变占位。
- AGC:发布面板支持封面必选与截图最多 6 张,新增素材直传服务并在创建游戏前拦截缺封面,同一文件重复提交复用素材 ID。
- AGC:http 能力作用域新增阿里云 OSS 直传地址,客户端同时校验直传目标必须是平台素材存储。
- 契约与文档:同步 shared-contracts、packages/shared 契约,更新 SpacetimeDB 迁移注释、后端架构、玩法链路与实施计划文档,并补齐网页端与 AGC 定向测试。
2026-09-20 22:19:50 +08:00

247 lines
8.9 KiB
TypeScript

// @vitest-environment jsdom
/**
* AGC「一键发布」的真实链路验证(默认跳过,按需开启)。
*
* 运行方式:
* GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL=http://127.0.0.1:10001 \
* npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts
*
* 开启后测试会注册一个临时作者,并通过真实的 `clientApi` / `clientHttp`(而不是
* mock 请求层)调用 AGC 的发布函数,覆盖:本地导出包读取、创建游戏、同
* `localProjectId` 复用游戏身份、真实 ZIP 上传、送审与版本回读。
*/
import { createHash } from 'node:crypto';
import JSZip from 'jszip';
import { expect, test, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { LocalProjectExportPackagePayload } from '../src/app/types';
import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload';
import { setStoredAuthAccessToken } from '../src/services/clientAuth';
import { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
/** 1x1 透明 PNG:真实上传一张合法图片作为封面,避免依赖本地素材文件。 */
const LIVE_COVER_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
function buildLiveCoverFile() {
const bytes = Buffer.from(LIVE_COVER_PNG_BASE64, 'base64');
return new File([new Uint8Array(bytes)], 'agc-live-cover.png', {
type: 'image/png',
});
}
const liveBaseUrl = (process.env.GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL ?? '')
.trim()
.replace(/\/+$/u, '');
const liveTest = liveBaseUrl ? test : test.skip;
const realFetch = globalThis.fetch.bind(globalThis);
const ENVELOPE_HEADERS = { 'x-genarrative-response-envelope': 'v1' };
function apiUrl(path: string) {
return new URL(path, `${liveBaseUrl}/`).toString();
}
/** 把 AGC 的相对请求改写到本地栈;其余语义(头部、超时包装)保持真实实现。 */
function installFetchBridge() {
vi.stubGlobal(
'fetch',
async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string'
? apiUrl(input)
: input instanceof URL
? input.toString()
: input;
const body = init?.body;
if (typeof Blob !== 'undefined' && body instanceof Blob) {
// jsdom 的 Blob/ArrayBuffer 属于另一个 realm,且旧版 jsdom 没有
// Blob.arrayBuffer;统一读成字节后复制为 Node 侧 Buffer 再转发。
const bytes = await readBlobBytes(body);
return realFetch(url as string, {
...init,
body: Buffer.from(bytes),
});
}
return realFetch(url as string, init);
},
);
}
async function readBlobBytes(blob: Blob): Promise<Uint8Array> {
const maybeArrayBuffer = (
blob as Blob & { arrayBuffer?: () => Promise<ArrayBuffer> }
).arrayBuffer;
if (typeof maybeArrayBuffer === 'function') {
return new Uint8Array(await maybeArrayBuffer.call(blob));
}
return await new Promise<Uint8Array>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
resolve(
result instanceof ArrayBuffer
? new Uint8Array(result)
: new Uint8Array(0),
);
};
reader.onerror = () =>
reject(reader.error ?? new Error('读取发行包字节失败'));
reader.readAsArrayBuffer(blob);
});
}
async function unwrap<T>(response: Response): Promise<T> {
const text = await response.text();
const parsed: unknown = JSON.parse(text);
if (
!parsed ||
typeof parsed !== 'object' ||
(parsed as { ok?: boolean }).ok !== true
) {
throw new Error(`后端返回失败:${text.slice(0, 400)}`);
}
return (parsed as { data: T }).data;
}
async function registerAuthor(): Promise<string> {
const phone = `136${String(Date.now()).slice(-8)}`;
const response = await realFetch(apiUrl('/api/auth/entry'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...ENVELOPE_HEADERS },
body: JSON.stringify({
purePhoneNumber: phone,
password: 'GenTest123!',
}),
});
const data = await unwrap<{ token: string }>(response);
return data.token;
}
async function buildExportPayload(): Promise<LocalProjectExportPackagePayload> {
const zip = new JSZip();
const indexHtml =
'<!doctype html><html><head><meta charset="utf-8"><title>AGC Live</title>' +
'<script src="assets/app.js"></script></head><body><h1>AGC-LIVE</h1></body></html>';
const appJs =
'window.__agcLive=1;document.documentElement.dataset.booted="agc";';
zip.file('index.html', indexHtml);
zip.file('assets/app.js', appJs);
const bytes = await zip.generateAsync({ type: 'uint8array' });
const sha256 = createHash('sha256').update(bytes).digest('hex');
return {
packageRelativePath: 'dist/game.zip',
packageBytes: Array.from(bytes),
packageSha256: sha256,
packageSizeBytes: bytes.length,
files: [
{
path: 'index.html',
sizeBytes: Buffer.byteLength(indexHtml),
sha256: createHash('sha256').update(indexHtml).digest('hex'),
},
{
path: 'assets/app.js',
sizeBytes: Buffer.byteLength(appJs),
sha256: createHash('sha256').update(appJs).digest('hex'),
},
],
};
}
liveTest(
'AGC 发布函数在真实后端完成创建、上传、送审并在重复发布时复用游戏身份',
async () => {
installFetchBridge();
const token = await registerAuthor();
setStoredAccessToken(token);
const payload = await buildExportPayload();
const stamp = String(Date.now());
const manifest = {
projectId: `agc-live-${stamp}`,
name: `AGC 真实发布${stamp.slice(-4)}`,
goal: '验证 AGC 一键发布链路',
} as unknown as GameCreationAppManifest;
const invoke = vi.fn(async () => payload);
// 服务端要求发布必须带封面:真实走一遍凭证 → 直传 → confirm。
const uploadedCover = await uploadPlatformMediaAsset({
file: buildLiveCoverFile(),
assetKind: 'game_distribution_cover',
pathSegments: ['game-distribution', 'cover', stamp],
entityId: 'game-distribution-cover',
// jsdom 里没有 Tauri HTTP 插件,复用测试注入的 fetch bridge 直连 dev OSS。
fetchImpl: (input, init) => realFetch(apiUrl(input), init),
});
expect(uploadedCover.assetObjectId).toMatch(/\S/u);
const metadata = {
summary: '由 AGC 发布函数真实提交',
description:
'集成验证:AGC 发布函数 → 本地 api-server → SpacetimeDB / 私有 OSS',
category: '益智' as const,
tags: ['集成验证'],
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'] as const,
orientation: 'landscape' as const,
coverAssetId: uploadedCover.assetObjectId,
screenshots: [] as string[],
};
const first = await publishLocalProjectGame({
invoke,
projectPath: '/data/dsk/Genarrative/tmp-agc-live-project',
packageRelativePath: 'dist/game.zip',
manifest,
metadata: { ...metadata, inputModes: [...metadata.inputModes] },
});
expect(first.status).toBe('pending_review');
expect(first.versionNumber).toBe(1);
expect(first.packageSha256).toBe(payload.packageSha256);
const readResult = await unwrap<{
version: { versionId: string; status: string; recoveryAction: string };
game: { id: string };
}>(
await realFetch(
apiUrl(`/api/game-distribution/versions/${first.versionId}`),
{ headers: { Authorization: `Bearer ${token}`, ...ENVELOPE_HEADERS } },
),
);
expect(readResult.version.status).toBe('pending_review');
expect(readResult.version.recoveryAction).toBe('wait');
expect(readResult.game.id).toBe(first.gameId);
// 第二次发布不带旧幂等键:同 localProjectId 必须复用同一 gameId 并新增版本。
const second = await publishLocalProjectGame({
invoke,
projectPath: '/data/dsk/Genarrative/tmp-agc-live-project',
packageRelativePath: 'dist/game.zip',
manifest,
metadata: { ...metadata, inputModes: [...metadata.inputModes] },
});
expect(second.gameId).toBe(first.gameId);
expect(second.versionNumber).toBe(first.versionNumber + 1);
const myGames = await unwrap<{
games: Array<{
id: string;
latestVersion: { versionId: string; status: string } | null;
}>;
}>(
await realFetch(apiUrl('/api/game-distribution/my-games'), {
headers: { Authorization: `Bearer ${token}`, ...ENVELOPE_HEADERS },
}),
);
const published = myGames.games.find((game) => game.id === first.gameId);
expect(published?.latestVersion?.versionId).toBe(second.versionId);
expect(published?.latestVersion?.status).toBe('pending_review');
},
120_000,
);
function setStoredAccessToken(token: string) {
setStoredAuthAccessToken(token);
}