298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
import { expect, type Page, test } from '@playwright/test';
|
||
|
||
/**
|
||
* 新建表单冒烟 · UI 级端到端测试
|
||
* 覆盖本轮补齐的「未实现功能」:创建知识库 / 文件上传 / 新建用户 /
|
||
* 新建 AI 模型 / 新建流程定义。IM SignalR 为真实 WebSocket,不在 mock 范围内。
|
||
*
|
||
* 后端 API 通过 page.route 拦截 mock(envelope {code,data,message} 与
|
||
* ApiResponseMiddleware 对齐),保证确定性、无外部基础设施依赖。
|
||
*/
|
||
|
||
const envelope = (data: unknown) => ({ code: 0, data, message: 'ok' });
|
||
const json = (body: unknown, status = 200) => ({
|
||
status,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
interface CapturedRequest {
|
||
path: string;
|
||
method: string;
|
||
/** JSON body 或 multipart 原始文本 */
|
||
body: string;
|
||
}
|
||
|
||
interface MockState {
|
||
authed: boolean;
|
||
posts: CapturedRequest[];
|
||
}
|
||
|
||
/** 安装全量 /api mock;POST 请求体统一捕获到 state.posts 供断言。 */
|
||
async function installApiMocks(page: Page): Promise<MockState> {
|
||
const state: MockState = { authed: false, posts: [] };
|
||
|
||
await page.route('**/api/**', async (route) => {
|
||
const req = route.request();
|
||
const url = new URL(req.url());
|
||
const path = url.pathname;
|
||
|
||
if (path === '/api/auth/login' && req.method() === 'POST') {
|
||
state.authed = true;
|
||
return route.fulfill(
|
||
json(
|
||
envelope({
|
||
accessToken: 'e2e-access-token',
|
||
refreshToken: 'e2e-refresh-token',
|
||
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
|
||
if (!state.authed) {
|
||
return route.fulfill(
|
||
json({ code: 'unauthorized', data: null, message: '未登录' }, 401),
|
||
);
|
||
}
|
||
|
||
if (path === '/api/user/info' && req.method() === 'GET') {
|
||
return route.fulfill(
|
||
json(
|
||
envelope({
|
||
userId: 'u-e2e',
|
||
username: 'admin',
|
||
realName: 'E2E 管理员',
|
||
homePath: '/welcome',
|
||
roles: ['admin'],
|
||
email: 'admin@e2e.local',
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
|
||
if (req.method() === 'POST') {
|
||
state.posts.push({ path, method: 'POST', body: req.postData() ?? '' });
|
||
return route.fulfill(json(envelope({ id: 'created-e2e-id' })));
|
||
}
|
||
|
||
// 各列表页 GET:按页面期望的返回结构给空数据
|
||
if (path === '/api/workflow-definitions') {
|
||
return route.fulfill(
|
||
json(envelope({ items: [], total: 0, pageIndex: 1, pageSize: 20 })),
|
||
);
|
||
}
|
||
return route.fulfill(json(envelope([])));
|
||
});
|
||
|
||
return state;
|
||
}
|
||
|
||
/** 走完登录流程。 */
|
||
async function login(page: Page) {
|
||
await page.goto('/user/login');
|
||
await page.getByPlaceholder('邮箱 / 手机号 / 用户名').fill('admin');
|
||
await page.getByPlaceholder('请输入密码').fill('e2e-password');
|
||
await page.getByRole('button', { name: /登\s*录|登录/ }).click();
|
||
await page.waitForURL(/\/(dashboard|welcome)?$/, { timeout: 30_000 });
|
||
}
|
||
|
||
test.describe('创建知识库', () => {
|
||
test('填写名称提交后 POST /knowledge-bases 且名称正确', async ({ page }) => {
|
||
const state = await installApiMocks(page);
|
||
await login(page);
|
||
await page.goto('/ai/knowledge');
|
||
|
||
await page
|
||
.getByRole('button', { name: /创建知识库/ })
|
||
.first()
|
||
.click();
|
||
await page.getByTestId('kb-name-input').fill('E2E 冒烟知识库');
|
||
await page.getByTestId('kb-create-submit').click();
|
||
|
||
await expect(page.getByText('E2E 冒烟知识库')).toBeHidden({
|
||
timeout: 10_000,
|
||
});
|
||
const post = state.posts.find((p) => p.path === '/api/knowledge-bases');
|
||
const body = JSON.parse((post as CapturedRequest).body);
|
||
expect(body.name).toBe('E2E 冒烟知识库');
|
||
// 未填的可选项一律传 null,与后端默认值约定一致
|
||
expect(body.chunkingModeEnum).toBeNull();
|
||
expect(body.retrievalModeEnum).toBeNull();
|
||
});
|
||
|
||
test('名称为空时前端必填校验拦截,不发起请求', async ({ page }) => {
|
||
const state = await installApiMocks(page);
|
||
await login(page);
|
||
await page.goto('/ai/knowledge');
|
||
|
||
await page
|
||
.getByRole('button', { name: /创建知识库/ })
|
||
.first()
|
||
.click();
|
||
await page.getByTestId('kb-create-submit').click();
|
||
|
||
await expect(page.getByText('请输入知识库名称')).toBeVisible();
|
||
expect(
|
||
state.posts.find((p) => p.path === '/api/knowledge-bases'),
|
||
).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
test.describe('文件上传', () => {
|
||
test('选择文件后以 multipart 提交 /files/upload(含 ObjectKey 与 ContentHash)', async ({
|
||
page,
|
||
}) => {
|
||
const state = await installApiMocks(page);
|
||
await login(page);
|
||
await page.goto('/files/list');
|
||
await expect(page.getByTestId('file-upload-input')).toBeAttached();
|
||
|
||
await page.getByTestId('file-upload-input').setInputFiles({
|
||
name: 'e2e-note.md',
|
||
mimeType: 'text/markdown',
|
||
buffer: Buffer.from('# E2E 冒烟文件\n'),
|
||
});
|
||
|
||
await expect(
|
||
page.locator('.ant-message').getByText(/上传完成:1 个文件/),
|
||
).toBeVisible({
|
||
timeout: 10_000,
|
||
});
|
||
const post = state.posts.find((p) => p.path === '/api/files/upload');
|
||
// multipart 原始体中包含三个表单字段
|
||
expect((post as CapturedRequest).body).toContain('name="File"');
|
||
expect((post as CapturedRequest).body).toContain('name="ObjectKey"');
|
||
expect((post as CapturedRequest).body).toContain('uploads/');
|
||
expect((post as CapturedRequest).body).toContain('name="ContentHash"');
|
||
expect((post as CapturedRequest).body).toContain('e2e-note.md');
|
||
});
|
||
});
|
||
|
||
test.describe('系统管理 · 新建用户', () => {
|
||
test('填写用户名/邮箱/密码提交后 POST /users', async ({ page }) => {
|
||
const state = await installApiMocks(page);
|
||
await login(page);
|
||
await page.goto('/system/user');
|
||
|
||
await page
|
||
.getByRole('button', { name: /新建用户/ })
|
||
.first()
|
||
.click();
|
||
await page.getByPlaceholder('登录账号').fill('e2e-user');
|
||
await page.getByPlaceholder('name@example.com').fill('e2e@example.com');
|
||
await page.getByPlaceholder('至少 8 位,含字母与数字').fill('Passw0rd123');
|
||
await page.getByTestId('create-form-submit').click();
|
||
|
||
await expect(
|
||
page.locator('.ant-message').getByText('用户已创建'),
|
||
).toBeVisible({ timeout: 10_000 });
|
||
const post = state.posts.find((p) => p.path === '/api/users');
|
||
const body = JSON.parse((post as CapturedRequest).body);
|
||
expect(body).toEqual({
|
||
username: 'e2e-user',
|
||
email: 'e2e@example.com',
|
||
password: 'Passw0rd123',
|
||
});
|
||
});
|
||
});
|
||
|
||
test.describe('AI 模型 · 新建模型', () => {
|
||
test('完整填写后 POST /ai/models,枚举值与后端对齐', async ({ page }) => {
|
||
const state = await installApiMocks(page);
|
||
await login(page);
|
||
await page.goto('/ai/models');
|
||
await expect(page.getByText('暂无模型配置')).toBeVisible();
|
||
|
||
await page.getByRole('button', { name: /新建模型/ }).click();
|
||
await page.getByTestId('ai-model-name').fill('E2E 对话模型');
|
||
// antd Select:点开 combobox,再点下拉选项(role 定位,不依赖 antd 类名)
|
||
await page.getByRole('combobox', { name: /提供商/ }).click();
|
||
await page.getByText('Ollama · 本地推理', { exact: true }).click();
|
||
await page.getByRole('combobox', { name: /模型类型/ }).click();
|
||
await page.getByText('Chat · 对话/生成', { exact: true }).click();
|
||
await page
|
||
.getByPlaceholder('例如 qwen3.6-27b / gpt-4o-mini')
|
||
.fill('qwen3.6-27b');
|
||
await page
|
||
.getByPlaceholder('例如 http://localhost:1234/v1')
|
||
.fill('http://localhost:11434/v1');
|
||
await page.getByPlaceholder('sk-...').fill('e2e-placeholder-key');
|
||
await page.getByTestId('ai-model-submit').click();
|
||
|
||
await page.waitForTimeout(500);
|
||
const post = state.posts.find((p) => p.path === '/api/ai/models');
|
||
const body = JSON.parse((post as CapturedRequest).body);
|
||
expect(body.name).toBe('E2E 对话模型');
|
||
expect(body.provider).toBe(2); // Ollama
|
||
expect(body.modelType).toBe(0); // Chat
|
||
expect(body.modelId).toBe('qwen3.6-27b');
|
||
expect(body.baseUrl).toBe('http://localhost:11434/v1');
|
||
expect(body.apiKey).toBe('e2e-placeholder-key');
|
||
expect(body.isDefault).toBe(false);
|
||
});
|
||
|
||
test('必填项缺失时前端校验拦截,不发起请求', async ({ page }) => {
|
||
const state = await installApiMocks(page);
|
||
await login(page);
|
||
await page.goto('/ai/models');
|
||
|
||
await page.getByRole('button', { name: /新建模型/ }).click();
|
||
await page.getByTestId('ai-model-submit').click();
|
||
|
||
await expect(page.getByText('请输入显示名称')).toBeVisible();
|
||
expect(
|
||
state.posts.find((p) => p.path === '/api/ai/models'),
|
||
).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
test.describe('工作流 · 新建流程定义', () => {
|
||
test('填写名称与编码提交后 POST /workflow-definitions', async ({ page }) => {
|
||
const state = await installApiMocks(page);
|
||
await login(page);
|
||
await page.goto('/workflow/list');
|
||
await expect(page.getByText(/暂无流程定义/)).toBeVisible();
|
||
|
||
await page
|
||
.getByRole('button', { name: /新建流程/ })
|
||
.first()
|
||
.click();
|
||
await page.getByPlaceholder('例如:请假审批流').fill('E2E 审批流');
|
||
await page
|
||
.getByPlaceholder('唯一标识,如 leave-approval')
|
||
.fill('e2e-approval');
|
||
await page.getByTestId('create-form-submit').click();
|
||
|
||
await expect(
|
||
page.locator('.ant-message').getByText(/流程定义已创建/),
|
||
).toBeVisible({ timeout: 10_000 });
|
||
const post = state.posts.find(
|
||
(p) => p.path === '/api/workflow-definitions',
|
||
);
|
||
const body = JSON.parse((post as CapturedRequest).body);
|
||
expect(body.name).toBe('E2E 审批流');
|
||
expect(body.code).toBe('e2e-approval');
|
||
expect(body.description).toBeNull();
|
||
expect(body.formDefinitionId).toBeNull();
|
||
});
|
||
|
||
test('编码为空时前端必填校验拦截,不发起请求', async ({ page }) => {
|
||
const state = await installApiMocks(page);
|
||
await login(page);
|
||
await page.goto('/workflow/list');
|
||
|
||
await page
|
||
.getByRole('button', { name: /新建流程/ })
|
||
.first()
|
||
.click();
|
||
await page.getByPlaceholder('例如:请假审批流').fill('只有名称');
|
||
await page.getByTestId('create-form-submit').click();
|
||
|
||
await expect(page.getByText('请输入流程编码')).toBeVisible();
|
||
expect(
|
||
state.posts.find((p) => p.path === '/api/workflow-definitions'),
|
||
).toBeUndefined();
|
||
});
|
||
});
|