f502829fde
Project CI / AI game creator shell Rust crates (push) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m2s
Project CI / Backend tests (push) Successful in 5m19s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m59s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m19s
Project CI / Native shell tests (push) Successful in 6m48s
Project CI / Frontend tests (push) Successful in 2m54s
Project CI / Repository checks (push) Successful in 2m58s
Project CI / AI game creator shell web tests (push) Successful in 2m34s
## 解决什么 你反馈的两个现象都是真问题,这个 PR 一次修掉: 1. **没开灰度也能看到发布按钮**:发布开关此前是「无 gate 行 = 默认开放」,所以没配灰度时后端把 `gameDistributionPublishEnabled` 判成 true,客户端就渲染了发布入口。 2. **按钮点了没反应**:发布动作的提示原先只写 `workspaceStatus`,而普通项目走 `DirectProjectChatView` 时并不渲染工作台状态行,于是「先打开一个项目再发布」「构建失败…」这类信息全都看不见。 3. **后台看不到 key**:预设项此前在未合并的另一个 PR 里,而且后台只列「已创建」的 gate 行。 ## 改成什么 - `is_game_distribution_publish_enabled_for_user` 要求 gate 行存在且 `enabled=true`:**灰度默认关闭**,未登录/无行/`enabled=false` 一律不开放;只有白名单 / 灰度比例 / 用户标签命中才开放。作者写入与新版本激活都按这条判据。 - 新增与作者无关的总开关 `is_game_distribution_publish_open`:管理员审核激活新版本只看「灰度是否开启」,不会因为审核员不在作者白名单里而被挡住(这是合入后立刻发现的回归,已修 + 有用例)。 - AGC 新增 `announcePublishMessage`:发布相关提示同时写工作台状态与 DirectProject 对话,点「发布到游戏广场」不会再像没反应。 - 后台「灰度发布配置」新增「可配置开关」列表:`game-distribution:publish` 等预设即使还没创建行也会显示,点「配置」按默认关闭填表,运营开启后填白名单/比例即可放量。 ## 验证 - `npm run check:game-distribution-media-e2e`(真实 Phaser 构建产物)现在覆盖:灰度关闭 → 作者拿不到入口 + 写入口 503 → 开启灰度 → 真实封面/截图直传 → 创建游戏/版本/上传/送审 → 管理员审核通过 → 网关 index.html 与 bundle 200 → nosniff;全流程通过。 - 后端:`cargo test -p api-server game_distribution` 19 passed(含新增 `game_distribution_publish_open_ignores_author_allowlist_for_activation`)、`frontend_runtime_config` 6 passed。 - 前端:admin 灰度页 10 用例(含「未创建的预设开关可见并可一键配置」)、AGC 聊天头 3 用例、网页发布页 15 用例;三端 typecheck、`check:encoding`、`check:doc-index`、`check:rustfmt`、全量 `npm test`(400 文件 / 4441 用例)与 `check:repository-ci -- origin/master HEAD` 全绿。 ## 上线注意 灰度默认关闭是**刻意**的:合入后到运营在后台把 `game-distribution:publish` 配置并开启之前,作者看不到发布入口、写入口 503;已公开游戏、目录、详情、发行网关、安全下架不受影响。 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/482 Co-authored-by: kdletters <kdletters@qq.com> Co-committed-by: kdletters <kdletters@qq.com>
342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { beforeEach, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
getAdminFeatureGateConfig,
|
|
upsertAdminFeatureGateConfig,
|
|
} from '../api/adminApiClient';
|
|
import type { AdminFeatureGateConfigResponse } from '../api/adminApiTypes';
|
|
import { AdminGrayReleaseConfigPage } from './AdminGrayReleaseConfigPage';
|
|
|
|
vi.mock('../api/adminApiClient', () => ({
|
|
formatAdminApiError: vi.fn((error: unknown) =>
|
|
error instanceof Error ? error.message : '请求失败',
|
|
),
|
|
getAdminFeatureGateConfig: vi.fn(),
|
|
isAdminApiError: vi.fn(() => false),
|
|
upsertAdminFeatureGateConfig: vi.fn(),
|
|
}));
|
|
|
|
const configResponse: AdminFeatureGateConfigResponse = {
|
|
gates: [
|
|
{
|
|
gateKey: 'editor.new-toolbar',
|
|
enabled: true,
|
|
rolloutPercent: 25,
|
|
allowUserIds: ['user-1'],
|
|
allowUserTags: ['beta'],
|
|
denyUserIds: ['blocked-1'],
|
|
description: '新版编辑器工具条',
|
|
updatedAt: '2026-07-07T01:00:00Z',
|
|
},
|
|
{
|
|
gateKey: 'image.generator.v2',
|
|
enabled: false,
|
|
rolloutPercent: 5,
|
|
allowUserIds: ['artist-1', 'artist-2'],
|
|
allowUserTags: ['internal', 'trial'],
|
|
denyUserIds: [],
|
|
description: '图片生成链路',
|
|
updatedAt: '2026-07-07T02:00:00Z',
|
|
},
|
|
],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(getAdminFeatureGateConfig).mockResolvedValue(configResponse);
|
|
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValue(configResponse);
|
|
});
|
|
|
|
test('灰度发布页加载并展示 gate 列表', async () => {
|
|
render(
|
|
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
expect(
|
|
await screen.findByRole('button', { name: 'editor.new-toolbar' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('button', { name: 'image.generator.v2' }),
|
|
).toBeTruthy();
|
|
expect(screen.getByText('25%')).toBeTruthy();
|
|
expect(getAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token');
|
|
});
|
|
|
|
test('灰度发布页可选择已有 gate 编辑', async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await user.click(
|
|
await screen.findByRole('button', { name: 'image.generator.v2' }),
|
|
);
|
|
|
|
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
|
'image.generator.v2',
|
|
);
|
|
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
|
false,
|
|
);
|
|
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
|
'5',
|
|
);
|
|
expect(
|
|
(screen.getByLabelText('允许用户 ID') as HTMLTextAreaElement).value,
|
|
).toBe('artist-1\nartist-2');
|
|
expect(
|
|
(screen.getByLabelText('允许用户标签') as HTMLTextAreaElement).value,
|
|
).toBe('internal\ntrial');
|
|
expect(
|
|
(screen.getByLabelText('拒绝用户 ID') as HTMLTextAreaElement).value,
|
|
).toBe('');
|
|
});
|
|
|
|
test('灰度发布页选择新 target 时重置旧 gate 规则', async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await user.click(
|
|
await screen.findByRole('button', { name: 'editor.new-toolbar' }),
|
|
);
|
|
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
|
'image-editor',
|
|
]);
|
|
|
|
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
|
'image-editor:agent-sidebar',
|
|
);
|
|
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
|
false,
|
|
);
|
|
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
|
'0',
|
|
);
|
|
expect(
|
|
(screen.getByLabelText('允许用户 ID') as HTMLTextAreaElement).value,
|
|
).toBe('');
|
|
expect(
|
|
(screen.getByLabelText('允许用户标签') as HTMLTextAreaElement).value,
|
|
).toBe('');
|
|
expect(
|
|
(screen.getByLabelText('拒绝用户 ID') as HTMLTextAreaElement).value,
|
|
).toBe('');
|
|
expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe(
|
|
'画布 Agent 入口灰度',
|
|
);
|
|
});
|
|
|
|
test('灰度发布页可通过功能入口生成画布 Agent Gate Key', async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
|
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
|
'image-editor',
|
|
]);
|
|
|
|
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
|
'image-editor:agent-sidebar',
|
|
);
|
|
expect(
|
|
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
|
|
).toBe('agent-sidebar');
|
|
expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe(
|
|
'画布 Agent 入口灰度',
|
|
);
|
|
});
|
|
|
|
test('灰度发布页可选择模板库并默认启用零比例灰度', async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
|
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), ['agc']);
|
|
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
|
'agc:template-library',
|
|
);
|
|
expect(
|
|
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
|
|
).toBe('template-library');
|
|
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
|
true,
|
|
);
|
|
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
|
'0',
|
|
);
|
|
});
|
|
|
|
test('灰度发布页可选择游戏发布开关,默认保持「未开启即开放」语义', async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
|
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
|
'game-distribution',
|
|
]);
|
|
|
|
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
|
'game-distribution:publish',
|
|
);
|
|
expect(
|
|
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
|
|
).toBe('publish');
|
|
// 该开关的语义是「未配置/关闭 = 默认开放」,所以选中后不能默认打开收紧。
|
|
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
|
false,
|
|
);
|
|
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
|
'0',
|
|
);
|
|
expect(
|
|
(screen.getByLabelText('描述') as HTMLTextAreaElement).value,
|
|
).toContain('游戏发布入口灰度');
|
|
});
|
|
|
|
test('灰度发布页保存游戏发布开关时写入白名单与比例', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
|
gates: [
|
|
...configResponse.gates,
|
|
{
|
|
gateKey: 'game-distribution:publish',
|
|
enabled: true,
|
|
rolloutPercent: 20,
|
|
allowUserIds: ['user-internal'],
|
|
allowUserTags: [],
|
|
denyUserIds: [],
|
|
description: '游戏发布入口灰度',
|
|
updatedAt: '2026-09-22T10:00:00Z',
|
|
},
|
|
],
|
|
});
|
|
render(
|
|
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
|
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
|
'game-distribution',
|
|
]);
|
|
fireEvent.click(screen.getByLabelText('启用'));
|
|
fireEvent.change(screen.getByLabelText('灰度比例'), {
|
|
target: { value: '20' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('允许用户 ID'), {
|
|
target: { value: 'user-internal' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('描述'), {
|
|
target: { value: '游戏发布入口灰度' },
|
|
});
|
|
await user.click(screen.getByRole('button', { name: '保存配置' }));
|
|
await user.click(screen.getByRole('button', { name: '确认' }));
|
|
|
|
await waitFor(() =>
|
|
expect(upsertAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token', {
|
|
gateKey: 'game-distribution:publish',
|
|
enabled: true,
|
|
rolloutPercent: 20,
|
|
allowUserIds: ['user-internal'],
|
|
allowUserTags: [],
|
|
denyUserIds: [],
|
|
description: '游戏发布入口灰度',
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('未创建的预设开关在后台可见并可一键配置', async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
const row = await screen.findByText('game-distribution:publish');
|
|
expect(row).not.toBeNull();
|
|
// 该开关默认未创建:列表里给出「配置」入口,点击后按默认关闭填充表单。
|
|
const configureButton = row.closest('tr')?.querySelector('button');
|
|
expect(configureButton).not.toBeNull();
|
|
await user.click(configureButton!);
|
|
|
|
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
|
'game-distribution:publish',
|
|
);
|
|
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('灰度发布页保存时转换数组和百分比', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
|
gates: [
|
|
...configResponse.gates,
|
|
{
|
|
gateKey: 'homepage.feed-redesign',
|
|
enabled: true,
|
|
rolloutPercent: 42,
|
|
allowUserIds: ['user-a', 'user-b'],
|
|
allowUserTags: ['beta', 'staff'],
|
|
denyUserIds: ['blocked-a'],
|
|
description: '首页信息流',
|
|
updatedAt: '2026-07-07T03:00:00Z',
|
|
},
|
|
],
|
|
});
|
|
render(
|
|
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
|
fireEvent.change(screen.getByLabelText('Gate Key'), {
|
|
target: { value: 'homepage.feed-redesign' },
|
|
});
|
|
await user.click(screen.getByLabelText('启用'));
|
|
fireEvent.change(screen.getByLabelText('灰度比例'), {
|
|
target: { value: '42' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('允许用户 ID'), {
|
|
target: { value: ' user-a \n\n user-b ' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('允许用户标签'), {
|
|
target: { value: ' beta \n staff ' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('拒绝用户 ID'), {
|
|
target: { value: ' blocked-a \n ' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('描述'), {
|
|
target: { value: ' 首页信息流 ' },
|
|
});
|
|
await user.click(screen.getByRole('button', { name: '保存配置' }));
|
|
await user.click(screen.getByRole('button', { name: '确认' }));
|
|
|
|
await waitFor(() => {
|
|
expect(upsertAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token', {
|
|
gateKey: 'homepage.feed-redesign',
|
|
enabled: true,
|
|
rolloutPercent: 42,
|
|
allowUserIds: ['user-a', 'user-b'],
|
|
allowUserTags: ['beta', 'staff'],
|
|
denyUserIds: ['blocked-a'],
|
|
description: '首页信息流',
|
|
});
|
|
});
|
|
});
|
|
|
|
test('灰度发布页无 token 时不请求配置', () => {
|
|
render(<AdminGrayReleaseConfigPage token="" onUnauthorized={vi.fn()} />);
|
|
|
|
expect(getAdminFeatureGateConfig).not.toHaveBeenCalled();
|
|
expect(upsertAdminFeatureGateConfig).not.toHaveBeenCalled();
|
|
});
|