// @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 { const maybeArrayBuffer = ( blob as Blob & { arrayBuffer?: () => Promise } ).arrayBuffer; if (typeof maybeArrayBuffer === 'function') { return new Uint8Array(await maybeArrayBuffer.call(blob)); } return await new Promise((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(response: Response): Promise { 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 { 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 = 'AGC Live' + '

AGC-LIVE

'; 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 { return await unwrap( 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 { 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 { 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) => { 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); }