c2c5e1ced5
- 发行包上限 100→200 MiB、展开总量 250→500 MiB,整包路由请求体上限继续从包上限派生;发行静态资源进程内缓存预算提到 256 MiB - 反代放行量同步放宽到 210 MiB:Nginx 三份模板的 client_max_body_size、Pingora 网关默认值与 env 样例、路由对照矩阵 - platform-oss 新增内部对象追加写 append_internal_object(_with_retry),以 OSS 返回的 next-append-position 作为权威已收字节 - api-server 新增 upload-state / chunk / complete / reset 四条分片路由,抽出共享收口 confirm_validated_package;偏移不符返回 409 与权威偏移,校验失败删除半包并落 upload_failed - AGC 新增原生上传器 game_package_upload.rs(内容寻址暂存、分片续传、受控重试、进度事件)与 prepare / upload 两条命令,退役整包回传命令 - 渲染进程改为 prepare → 创建游戏 → 创建版本 → 原生分片上传 → 送审,LocalProjectExportPackagePayload 整包类型退役 - 同时修正 live 用例无法指向本地栈的两处基础设施问题:平台基址按传入 URL 选择,桥接层把 jsdom realm 的 Headers / Blob / FormData 降级成 Node 原生值 - 测试:platform-oss 74、api-server game_distribution 23、AGC 原生 4、发布相关前端 20;live 用例补真实栈「中断 → 续传 → 确认」断言(分片偏移序列 [0, 8388608]) - 文档:玩法创作主规范的上传合同、运维与 Pingora 文档、决策记录、发行里程碑口径,以及新增的续传里程碑与实施计划
489 lines
17 KiB
TypeScript
489 lines
17 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` 复用游戏身份、真实分片上传、送审与版本回读。
|
|
*
|
|
* 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 { setStoredAuthAccessToken } from '../src/services/clientAuth';
|
|
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);
|
|
});
|
|
}
|
|
|
|
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();
|
|
setStoredAccessToken(token);
|
|
|
|
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 === 'prepare_local_project_game_package') {
|
|
return staged;
|
|
}
|
|
if (command === 'upload_local_project_game_package') {
|
|
const uploaded = await uploadStagedPackageViaProtocol({
|
|
versionId: String(args?.versionId ?? ''),
|
|
bytes,
|
|
token,
|
|
idempotencyKey: String(args?.idempotencyKey ?? ''),
|
|
sentOffsets,
|
|
});
|
|
return {
|
|
versionId: uploaded.versionId,
|
|
status: uploaded.status,
|
|
uploadedBytes: bytes.length,
|
|
};
|
|
}
|
|
throw new Error(`未预期的命令:${command}`);
|
|
},
|
|
);
|
|
// 服务端要求发布必须带封面:真实走一遍凭证 → 直传 → confirm。
|
|
const uploadedCover = await uploadPlatformMediaAsset({
|
|
file: buildLiveCoverFile(),
|
|
assetKind: 'game_distribution_cover',
|
|
pathSegments: ['game-distribution', 'cover', stamp],
|
|
entityId: 'game-distribution-cover',
|
|
// jsdom 里没有 Tauri HTTP 插件:直传也走同一个桥,跨 realm 的 FormData 会被
|
|
// 先序列化成 Node 侧 multipart 字节,否则 OSS 会以 405 拒绝。
|
|
fetchImpl: (input, init) => globalThis.fetch(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(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,
|
|
);
|
|
|
|
function setStoredAccessToken(token: string) {
|
|
setStoredAuthAccessToken(token);
|
|
}
|