e5460280ce
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- 后端:GET /api/runtime/frontend-config 新增 gameDistributionPublishEnabled,复用 `game-distribution:publish` 判据(未配置或 enabled=false 时对已登录作者默认开放,显式收紧后只放行白名单/灰度命中用户,匿名恒为 false),前端入口与写入口共用同一事实源。 - 后端用例:新增 frontend_runtime_config_game_distribution_publish_is_scoped_to_authenticated_gate,覆盖默认开放、enabled=true 无白名单、白名单命中、deny 名单、enabled=false 回退与 rolloutPercent=100。 - 网页:平台壳按灰度隐藏「发布游戏 / 发布新版本」入口;/games/publish 直接访问时渲染「发布功能正在灰度中」并提供重新检查;读取失败按放行处理,由后端写入口把关并返回可读文案。 - AGC:新增 readGamePublishAvailability(同一运行时配置字段),只有命中才把发布回调交给 DirectProject 聊天头;字段缺失或读取失败按不开放处理。 - 文档:玩法链路、后端数据契约与实施计划记录灰度口径、入口行为与验证命令。
194 lines
6.4 KiB
TypeScript
194 lines
6.4 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { beforeEach, expect, test, vi } from 'vitest';
|
|
|
|
const fetchClientHttp = vi.fn();
|
|
|
|
vi.mock('../src/services/clientHttp', () => ({
|
|
AGC_DEVELOPMENT_API_BASE_URL: 'https://dev.genarrative.world',
|
|
fetchClientHttp: (...args: unknown[]) => fetchClientHttp(...args),
|
|
getClientServerBaseUrl: () => 'https://dev.genarrative.world',
|
|
readClientHttpResponseText: (response: Response) => response.text(),
|
|
}));
|
|
|
|
vi.mock('../src/services/errorReporting', () => ({
|
|
captureClientError: vi.fn(),
|
|
}));
|
|
|
|
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
|
import {
|
|
publishLocalProjectGame,
|
|
readGamePublishAvailability,
|
|
} from '../src/services/gameDistributionPublish';
|
|
|
|
const MANIFEST = {
|
|
projectId: 'local-proj-1',
|
|
name: '星轨防线',
|
|
goal: '守住轨道城',
|
|
} as unknown as GameCreationAppManifest;
|
|
|
|
function jsonResponse(payload: unknown) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
ok: true,
|
|
data: payload,
|
|
error: null,
|
|
meta: { apiVersion: 'v1' },
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
fetchClientHttp.mockReset();
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
test('发布时携带本地项目标识,让重复发布复用同一个平台游戏', async () => {
|
|
fetchClientHttp
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({ id: 'game_1', publicationRevision: 0 }),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
gameId: 'game_1',
|
|
versionId: 'gamever_1',
|
|
versionNumber: 1,
|
|
status: 'awaiting_upload',
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({ versionId: 'gamever_1', status: 'uploaded' }),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({ version: { status: 'pending_review' } }),
|
|
);
|
|
|
|
const result = await publishLocalProjectGame({
|
|
invoke: (async (command: string) => {
|
|
expect(command).toBe('read_local_project_export_package');
|
|
return {
|
|
packageRelativePath: 'exports/playtest-package-1.zip',
|
|
packageBytes: [1, 2, 3],
|
|
packageSha256: 'a'.repeat(64),
|
|
packageSizeBytes: 3,
|
|
files: [{ path: 'index.html', sizeBytes: 3, sha256: 'a'.repeat(64) }],
|
|
};
|
|
}) as never,
|
|
projectPath: '/tmp/project',
|
|
packageRelativePath: 'exports/playtest-package-1.zip',
|
|
manifest: MANIFEST,
|
|
metadata: {
|
|
coverAssetId: 'asset_cover',
|
|
screenshots: ['asset_shot_1', 'asset_shot_2'],
|
|
},
|
|
});
|
|
|
|
const createGameCall = fetchClientHttp.mock.calls[0];
|
|
expect(createGameCall?.[0]).toBe('/api/game-distribution/games');
|
|
const createGameBody = JSON.parse(
|
|
String((createGameCall?.[1] as RequestInit).body),
|
|
);
|
|
expect(createGameBody.localProjectId).toBe('local-proj-1');
|
|
expect(createGameBody.coverAssetId).toBe('asset_cover');
|
|
expect(createGameBody.screenshots).toEqual(['asset_shot_1', 'asset_shot_2']);
|
|
|
|
const createVersionCall = fetchClientHttp.mock.calls[1];
|
|
expect(createVersionCall?.[0]).toBe(
|
|
'/api/game-distribution/games/game_1/versions',
|
|
);
|
|
expect(
|
|
JSON.parse(String((createVersionCall?.[1] as RequestInit).body))
|
|
.localProjectId,
|
|
).toBe('local-proj-1');
|
|
|
|
expect(result.gameId).toBe('game_1');
|
|
expect(result.versionId).toBe('gamever_1');
|
|
});
|
|
|
|
test('缺少本地项目标识时在发起请求前失败关闭', async () => {
|
|
await expect(
|
|
publishLocalProjectGame({
|
|
invoke: (async () => ({
|
|
packageRelativePath: 'exports/playtest-package-1.zip',
|
|
packageBytes: [1],
|
|
packageSha256: 'a'.repeat(64),
|
|
packageSizeBytes: 1,
|
|
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
|
|
})) as never,
|
|
projectPath: '/tmp/project',
|
|
packageRelativePath: 'exports/playtest-package-1.zip',
|
|
manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest,
|
|
metadata: { coverAssetId: 'asset_cover' },
|
|
}),
|
|
).rejects.toThrow('发布需要本地项目标识');
|
|
expect(fetchClientHttp).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('缺少封面时在创建游戏前失败关闭', async () => {
|
|
await expect(
|
|
publishLocalProjectGame({
|
|
invoke: (async () => ({
|
|
packageRelativePath: 'exports/playtest-package-1.zip',
|
|
packageBytes: [1],
|
|
packageSha256: 'a'.repeat(64),
|
|
packageSizeBytes: 1,
|
|
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
|
|
})) as never,
|
|
projectPath: '/tmp/project',
|
|
packageRelativePath: 'exports/playtest-package-1.zip',
|
|
manifest: MANIFEST,
|
|
metadata: { coverAssetId: ' ' },
|
|
}),
|
|
).rejects.toThrow('请先选择游戏封面');
|
|
expect(fetchClientHttp).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('截图超过 6 张时在创建游戏前失败关闭', async () => {
|
|
await expect(
|
|
publishLocalProjectGame({
|
|
invoke: (async () => ({
|
|
packageRelativePath: 'exports/playtest-package-1.zip',
|
|
packageBytes: [1],
|
|
packageSha256: 'a'.repeat(64),
|
|
packageSizeBytes: 1,
|
|
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
|
|
})) as never,
|
|
projectPath: '/tmp/project',
|
|
packageRelativePath: 'exports/playtest-package-1.zip',
|
|
manifest: MANIFEST,
|
|
metadata: {
|
|
coverAssetId: 'asset_cover',
|
|
screenshots: Array.from({ length: 7 }, (_, index) => `asset_${index}`),
|
|
},
|
|
}),
|
|
).rejects.toThrow('游戏截图最多 6 张');
|
|
expect(fetchClientHttp).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('发布灰度按后端运行时配置判定,命中才开放入口', async () => {
|
|
fetchClientHttp.mockResolvedValueOnce(
|
|
jsonResponse({ gameDistributionPublishEnabled: true }),
|
|
);
|
|
await expect(readGamePublishAvailability()).resolves.toBe(true);
|
|
expect(fetchClientHttp.mock.calls[0]?.[0]).toBe(
|
|
'/api/runtime/frontend-config',
|
|
);
|
|
|
|
fetchClientHttp.mockResolvedValueOnce(
|
|
jsonResponse({ gameDistributionPublishEnabled: false }),
|
|
);
|
|
await expect(readGamePublishAvailability()).resolves.toBe(false);
|
|
|
|
// 老后端/字段缺失时按未命中处理,入口不暴露。
|
|
fetchClientHttp.mockResolvedValueOnce(jsonResponse({}));
|
|
await expect(readGamePublishAvailability()).resolves.toBe(false);
|
|
});
|
|
|
|
test('发布灰度读取失败时抛出,由调用方按不开放处理', async () => {
|
|
fetchClientHttp.mockRejectedValueOnce(new Error('network down'));
|
|
await expect(readGamePublishAvailability()).rejects.toThrow();
|
|
});
|