Files
Genarrative/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx
T
kdletters 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
发布灰度默认关闭:修掉客户端“看得到点不动”与后台看不到 key (#482)
## 解决什么

你反馈的两个现象都是真问题,这个 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>
2026-09-22 21:38:03 +08:00

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();
});