Files
Genarrative/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts
T
kdletters 8701f43f75 AGC 渲染层网络与状态下沉 Rust,并修复画布交互与失败文案
- 删除渲染层传输层:services/clientApi.ts、clientHttp 的 fetchClientHttp 与超时封装整体退场,账户、认证、素材直传、错误上报与游戏分发发布改走 Rust typed command。
- 新增宿主封装:accountHost、editorAssetHost、platformSessionRetry、agentRuntimeErrorDetail 与 features/app-shell/workspacePreferences;渲染层不再读写 access token / refresh cookie,localStorage 只剩测试态旧键迁移 shim。
- 修复 DirectProject 失败文案 v2 映射与「查看详情」按需读取,画布标签面板高度夹紧、框选每帧重算、文档预览入口与场景标题栏 key、资源筛选面板样式。
- 补齐与调整回归:accountHost、agentRuntimeErrorDetail、clientAuthHost、clientHttp、platformSession、platformSessionRetry、resourceFilterPanelStyle、directThreadChatSubscription 等定向用例,并清理测试 harness 中无调用方的残留。
2026-09-28 14:15:02 +08:00

642 lines
22 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
*
* 开启后测试会注册一个临时作者,并通过真实 HTTP(而不是 mock 请求层)验证
* AGC 发布 facade 的请求合同,覆盖:创建游戏、同 `localProjectId` 复用游戏身份、
* 真实分片上传、送审与版本回读。jsdom 只替代 Tauri command 的原生执行。
*
* jsdom 里没有 Tauri 运行时,`upload_local_project_game_package` 由本测试按服务端
* 分片协议(upload-state → chunk → complete)代跑,等同于原生上传器的行为;
* 原生实现自身的分片规划、权威偏移续传与错误分类在 Rust 单测里覆盖。
*/
import { createHash, randomBytes } from 'node:crypto';
import JSZip from 'jszip';
import { expect, test, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { StagedGamePackage } from '../src/app/types';
import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload';
import { setClientServerSelection } from '../src/services/clientHttp';
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;
// AGC 服务默认按渠道选 dev / release 域名;跑真实链路时把「平台服务器」切到传入的本地栈,
// 否则请求会打到线上域名而不是这台机器上的 api-server。
if (liveBaseUrl) {
setClientServerSelection({ preset: 'custom', customBaseUrl: liveBaseUrl });
}
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;
// jsdom realm 的 Headers / AbortSignal / Blob 都不是 undici 认得的类型(同 2026-09-20
// 那条「跨 realm BodyInit 被 undici 拒绝」的坑):统一降级成 Node 侧能接受的原生值。
const headers = init?.headers
? Object.fromEntries(Array.from(new Headers(init.headers).entries()))
: undefined;
const signal = undefined;
const body = init?.body;
if (typeof FormData !== 'undefined' && body instanceof FormData) {
// jsdom 的 FormData 同样不被 undici 接受:这里手工序列化成 multipart 字节。
const multipart = await serializeFormData(body);
return realFetch(url as string, {
...init,
headers: { ...headers, 'Content-Type': multipart.contentType },
signal,
body: multipart.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,
headers,
signal,
body: Buffer.from(bytes),
});
}
return realFetch(url as string, { ...init, headers, signal });
},
);
}
async function serializeFormData(form: FormData): Promise<{
body: Buffer;
contentType: string;
}> {
const boundary = `----agcLive${Date.now().toString(16)}`;
const chunks: Buffer[] = [];
for (const [name, value] of form.entries()) {
if (typeof value === 'string') {
chunks.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
),
);
continue;
}
const bytes = await readBlobBytes(value);
const fileName =
(value as File).name || `agc-live-${Date.now().toString(16)}.bin`;
const contentType = value.type || 'application/octet-stream';
chunks.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`,
),
);
chunks.push(Buffer.from(bytes));
chunks.push(Buffer.from('\r\n'));
}
chunks.push(Buffer.from(`--${boundary}--\r\n`));
return {
body: Buffer.concat(chunks),
contentType: `multipart/form-data; boundary=${boundary}`,
};
}
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);
});
}
/**
* 代跑 Rust `upload_platform_media_asset` 的网络合同。
*
* 渲染层只交出文件字节与结构化参数;凭证、直传表单、confirm 与字段校验都发生在原生侧。
* 这里按同一协议逐步请求本地栈,校验 IPC 载荷足以驱动服务端合同。
*/
async function simulateNativePlatformAssetUpload(
args: { input: Record<string, unknown> },
token: string,
): Promise<{ assetObjectId: string; objectKey: string }> {
const input = args.input;
const bytes = Buffer.from(input.bytes as number[]);
const fileName = String(input.fileName);
const contentType = String(input.contentType);
if (bytes.length === 0) {
throw new Error('原生载荷缺少素材字节');
}
const ticketPayload = await unwrap<{
upload: {
host: string;
bucket: string;
objectKey: string;
successActionStatus?: number;
formFields: Record<string, string>;
};
}>(
await realFetch(apiUrl('/api/assets/direct-upload-tickets'), {
method: 'POST',
headers: {
...packageAuthHeaders(token),
'Content-Type': 'application/json',
},
body: JSON.stringify({
legacyPrefix: 'generated-character-drafts',
pathSegments: input.pathSegments,
fileName,
contentType,
access: 'private',
maxSizeBytes: bytes.length,
metadata: input.metadata,
}),
}),
);
const form = new FormData();
for (const [key, value] of Object.entries(
ticketPayload.upload.formFields ?? {},
)) {
form.append(key, value);
}
form.append('file', new Blob([bytes], { type: contentType }), fileName);
const multipart = await serializeFormData(form);
const uploadResponse = await realFetch(ticketPayload.upload.host, {
method: 'POST',
headers: { 'Content-Type': multipart.contentType },
body: multipart.body,
});
if (
uploadResponse.status !== (ticketPayload.upload.successActionStatus ?? 204)
) {
throw new Error(`对象存储直传失败:${uploadResponse.status}`);
}
const confirmed = await unwrap<{
assetObject: { assetObjectId: string; objectKey: string };
}>(
await realFetch(apiUrl('/api/assets/objects/confirm'), {
method: 'POST',
headers: {
...packageAuthHeaders(token),
'Content-Type': 'application/json',
},
body: JSON.stringify({
bucket: ticketPayload.upload.bucket,
objectKey: ticketPayload.upload.objectKey,
contentType,
contentLength: bytes.length,
assetKind: input.assetKind,
accessPolicy: 'private',
entityId: input.entityId,
}),
}),
);
return {
assetObjectId: confirmed.assetObject.assetObjectId,
objectKey: confirmed.assetObject.objectKey,
};
}
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 buildStagedPackage(): Promise<{
staged: StagedGamePackage;
bytes: Uint8Array;
}> {
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);
// 让发行包超过单个分片(8 MiB):分片续传只有跨片才有意义,随机字节保证不可压缩。
zip.file('assets/bulk.bin', randomBytes(9 * 1024 * 1024));
const bytes = await zip.generateAsync({ type: 'uint8array' });
const sha256 = createHash('sha256').update(bytes).digest('hex');
return {
staged: {
stagingPath: '/tmp/agc-live-staging/game.zip',
packageSha256: sha256,
packageSizeBytes: bytes.length,
packageFileCount: 3,
},
bytes,
};
}
type PackageUploadState = {
receivedBytes: number;
chunkBytes: number;
declaredPackageBytes: number;
};
function packageAuthHeaders(token: string) {
return {
Authorization: `Bearer ${token}`,
...ENVELOPE_HEADERS,
};
}
/** 读取服务端权威已收字节(原生上传器同样以它为准)。 */
async function readPackageUploadState(
versionId: string,
token: string,
): Promise<PackageUploadState> {
return await unwrap<PackageUploadState>(
await realFetch(
apiUrl(
`/api/game-distribution/versions/${versionId}/package/upload-state`,
),
{ headers: packageAuthHeaders(token) },
),
);
}
/** 上传一个分片;偏移由调用方按权威偏移给出。 */
async function uploadPackageChunk(input: {
versionId: string;
token: string;
idempotencyKey: string;
offset: number;
body: Uint8Array;
}): Promise<number> {
const response = await realFetch(
apiUrl(`/api/game-distribution/versions/${input.versionId}/package/chunk`),
{
method: 'PUT',
headers: {
...packageAuthHeaders(input.token),
'Content-Type': 'application/octet-stream',
'x-genarrative-upload-offset': String(input.offset),
'Idempotency-Key': `${input.idempotencyKey}:chunk`,
},
body: Buffer.from(input.body),
},
);
if (!response.ok) {
throw new Error(
`分片上传失败:${response.status} ${await response.text()}`,
);
}
const payload = (await response.json()) as {
data?: { receivedBytes?: number };
receivedBytes?: number;
};
return payload.data?.receivedBytes ?? payload.receivedBytes ?? input.offset;
}
async function completePackageUpload(input: {
versionId: string;
token: string;
idempotencyKey: string;
}) {
return await unwrap<{ versionId: string; status: string }>(
await realFetch(
apiUrl(
`/api/game-distribution/versions/${input.versionId}/package/complete`,
),
{
method: 'POST',
headers: {
...packageAuthHeaders(input.token),
'Idempotency-Key': `${input.idempotencyKey}:complete`,
},
},
),
);
}
/** 从权威偏移继续发送剩余分片,返回本次实际发送过的偏移序列。 */
async function uploadRemainingChunks(input: {
versionId: string;
bytes: Uint8Array;
token: string;
idempotencyKey: string;
}): Promise<number[]> {
const state = await readPackageUploadState(input.versionId, input.token);
const sentOffsets: number[] = [];
let received = state.receivedBytes;
while (received < input.bytes.length) {
const length = Math.min(state.chunkBytes, input.bytes.length - received);
await uploadPackageChunk({
versionId: input.versionId,
token: input.token,
idempotencyKey: input.idempotencyKey,
offset: received,
body: input.bytes.subarray(received, received + length),
});
sentOffsets.push(received);
received = (await readPackageUploadState(input.versionId, input.token))
.receivedBytes;
}
return sentOffsets;
}
/**
* 按服务端分片协议上传整包:与原生上传器同一套请求形状,用于验证服务端合同。
* 第一次调用会**只传第一片就停下**,模拟传输中断;后续调用按权威偏移续传,
* 因此这里能直接证明「中断后不重传已收字节」。
*/
async function uploadStagedPackageViaProtocol(input: {
versionId: string;
bytes: Uint8Array;
token: string;
idempotencyKey: string;
sentOffsets: number[];
}) {
const state = await readPackageUploadState(input.versionId, input.token);
if (state.receivedBytes === 0) {
const firstLength = Math.min(state.chunkBytes, input.bytes.length);
await uploadPackageChunk({
versionId: input.versionId,
token: input.token,
idempotencyKey: input.idempotencyKey,
offset: 0,
body: input.bytes.subarray(0, firstLength),
});
input.sentOffsets.push(0);
}
input.sentOffsets.push(
...(await uploadRemainingChunks({
versionId: input.versionId,
bytes: input.bytes,
token: input.token,
idempotencyKey: input.idempotencyKey,
})),
);
const completed = await completePackageUpload({
versionId: input.versionId,
token: input.token,
idempotencyKey: input.idempotencyKey,
});
return { versionId: completed.versionId, status: completed.status };
}
liveTest(
'AGC 发布函数在真实后端完成创建、上传、送审并在重复发布时复用游戏身份',
async () => {
installFetchBridge();
const token = await registerAuthor();
const { staged, bytes } = await buildStagedPackage();
const stamp = String(Date.now());
const manifest = {
projectId: `agc-live-${stamp}`,
name: `AGC 真实发布${stamp.slice(-4)}`,
goal: '验证 AGC 一键发布链路',
} as unknown as GameCreationAppManifest;
// 记录本次发布实际发送过的分片偏移,用来证明「中断后不重传已收字节」。
const sentOffsets: number[] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command !== 'publish_local_project_game') {
throw new Error(`未预期的命令:${command}`);
}
const metadata = args?.metadata as Record<string, unknown>;
const rootKey = String(args?.idempotencyKey ?? '');
const game = await unwrap<{
id: string;
publicationRevision?: number;
}>(
await realFetch(apiUrl('/api/game-distribution/games'), {
method: 'POST',
headers: {
...packageAuthHeaders(token),
'Content-Type': 'application/json',
'Idempotency-Key': `${rootKey}:game`,
},
body: JSON.stringify(metadata),
}),
);
const version = await unwrap<{
versionId: string;
versionNumber: number;
status: string;
}>(
await realFetch(
apiUrl(`/api/game-distribution/games/${game.id}/versions`),
{
method: 'POST',
headers: {
...packageAuthHeaders(token),
'Content-Type': 'application/json',
'Idempotency-Key': `${rootKey}:version`,
},
body: JSON.stringify({
localProjectId: metadata.localProjectId,
packageSha256: staged.packageSha256,
packageBytes: staged.packageSizeBytes,
packageFileCount: staged.packageFileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
}),
},
),
);
const uploaded = await uploadStagedPackageViaProtocol({
versionId: version.versionId,
bytes,
token,
idempotencyKey: `${rootKey}:upload`,
sentOffsets,
});
const submitted = await unwrap<{
version?: { status?: string };
}>(
await realFetch(
apiUrl(
`/api/game-distribution/versions/${version.versionId}/submit`,
),
{
method: 'POST',
headers: {
...packageAuthHeaders(token),
'Content-Type': 'application/json',
'Idempotency-Key': `${rootKey}:submit`,
},
body: JSON.stringify({
expectedPublicationRevision: game.publicationRevision ?? 0,
}),
},
),
);
return {
gameId: game.id,
versionId: version.versionId,
versionNumber: version.versionNumber,
status: submitted.version?.status ?? uploaded.status,
packageSha256: staged.packageSha256,
packageSizeBytes: staged.packageSizeBytes,
fileCount: staged.packageFileCount,
};
},
);
// 服务端要求发布必须带封面。真实 Tauri 里 `upload_platform_media_asset` 由 Rust
// 完成「凭证 → 对象存储直传 → confirm」;jsdom 没有原生宿主,这里用同一个
// invoke 合同按服务端协议代跑,证明渲染层只提交字节和结构化元数据。
const uploadedCover = await uploadPlatformMediaAsset({
file: buildLiveCoverFile(),
assetKind: 'game_distribution_cover',
pathSegments: ['game-distribution', 'cover', stamp],
entityId: 'game-distribution-cover',
invoke: async (command, args) => {
if (command !== 'upload_platform_media_asset') {
throw new Error(`未预期的命令:${command}`);
}
return await simulateNativePlatformAssetUpload(
args as { input: Record<string, unknown> },
token,
);
},
});
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(staged.packageSha256);
// 分片续传证据:第一片(偏移 0)只发送一次;中断后的续传从权威偏移开始,
// 已收字节不重放、也不跳段。
expect(sentOffsets[0]).toBe(0);
expect(sentOffsets.filter((offset) => offset === 0)).toHaveLength(1);
expect(sentOffsets[1]).toBeGreaterThan(0);
expect(sentOffsets).toEqual(
Array.from(
{ length: Math.ceil(staged.packageSizeBytes / 8 / 1024 / 1024) },
(_, index) => index * 8 * 1024 * 1024,
),
);
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,
);