226 lines
7.1 KiB
TypeScript
226 lines
7.1 KiB
TypeScript
import { expect, type Page, test } from '@playwright/test';
|
||
|
||
/**
|
||
* Obsidian Vault 同步 · UI 级端到端测试
|
||
* 对应规格:build-flow.api/src/modules/rag/Application/Obsidian/docs/BDD.md「React 管理端」章节
|
||
*
|
||
* 后端 API 通过 page.route 拦截 mock(envelope {code,data,message} 与
|
||
* ApiResponseMiddleware 对齐),保证确定性、无外部基础设施依赖。
|
||
*/
|
||
|
||
const KB = {
|
||
id: 'kb-e2e-1',
|
||
name: '团队笔记',
|
||
description: 'Obsidian 同步 E2E 测试库',
|
||
embeddingModel: 'embeddinggemma-300m',
|
||
chunkSize: 512,
|
||
chunkOverlap: 50,
|
||
chunkingMode: 0,
|
||
retrievalMode: 2,
|
||
rerankStrategy: 0,
|
||
retrievalTopK: 5,
|
||
contextTopK: 3,
|
||
similarityThreshold: 0.5,
|
||
vectorWeight: 0.7,
|
||
status: 'Active',
|
||
createdAt: '2026-08-01T08:00:00Z',
|
||
};
|
||
|
||
const SYNC_RESULT = {
|
||
knowledgeBaseId: KB.id,
|
||
vaultDirectory: '/data/vaults/team-notes',
|
||
totalFiles: 3,
|
||
added: 2,
|
||
updated: 1,
|
||
skipped: 0,
|
||
message: '同步完成:新增 2,更新 1,跳过 0(未变更)。',
|
||
};
|
||
|
||
type SyncBehavior =
|
||
| { kind: 'success' }
|
||
| { kind: 'bizError'; code: string; message: string }
|
||
| { kind: 'slow'; delayMs: number };
|
||
|
||
interface MockState {
|
||
authed: boolean;
|
||
syncBehavior: SyncBehavior;
|
||
syncRequestCount: number;
|
||
}
|
||
|
||
const envelope = (data: unknown) => ({ code: 0, data, message: 'ok' });
|
||
const json = (body: unknown, status = 200) => ({
|
||
status,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
/** 安装全量 /api mock,返回可变状态供断言。 */
|
||
async function installApiMocks(
|
||
page: Page,
|
||
syncBehavior: SyncBehavior,
|
||
): Promise<MockState> {
|
||
const state: MockState = { authed: false, syncBehavior, syncRequestCount: 0 };
|
||
|
||
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 (path === '/api/knowledge-bases' && req.method() === 'GET') {
|
||
return route.fulfill(json(envelope([KB])));
|
||
}
|
||
|
||
if (
|
||
path === `/api/knowledge-bases/${KB.id}/obsidian/sync` &&
|
||
req.method() === 'POST'
|
||
) {
|
||
state.syncRequestCount += 1;
|
||
const behavior = state.syncBehavior;
|
||
if (behavior.kind === 'bizError') {
|
||
return route.fulfill(
|
||
json(
|
||
{ code: behavior.code, data: null, message: behavior.message },
|
||
400,
|
||
),
|
||
);
|
||
}
|
||
if (behavior.kind === 'slow') {
|
||
await new Promise((r) => setTimeout(r, behavior.delayMs));
|
||
}
|
||
return route.fulfill(json(envelope(SYNC_RESULT)));
|
||
}
|
||
|
||
// 其余 API(通知等)返回空数据,避免干扰
|
||
return route.fulfill(json(envelope([])));
|
||
});
|
||
|
||
return state;
|
||
}
|
||
|
||
/** 走完登录流程并进入知识库列表页。 */
|
||
async function loginAndGotoKnowledge(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();
|
||
// 登录成功 → 拉取用户信息 → 跳首页(整页刷新,mock 与 localStorage 均在)
|
||
await page.waitForURL(/\/(dashboard|welcome)?$/, { timeout: 30_000 });
|
||
await page.goto('/ai/knowledge');
|
||
await expect(page.getByText(KB.name)).toBeVisible();
|
||
}
|
||
|
||
test.describe('Obsidian vault 同步(UI E2E)', () => {
|
||
test('从知识库列表发起同步并展示结果', async ({ page }) => {
|
||
const state = await installApiMocks(page, { kind: 'success' });
|
||
await loginAndGotoKnowledge(page);
|
||
|
||
await page.getByTestId('obsidian-sync-open').click();
|
||
await expect(page.getByText(`Obsidian 同步 — ${KB.name}`)).toBeVisible();
|
||
|
||
await page
|
||
.getByTestId('obsidian-vault-input')
|
||
.fill('/data/vaults/team-notes');
|
||
await page.getByTestId('obsidian-sync-submit').click();
|
||
|
||
// 结果弹窗:antd v6 confirm 弹窗的可见标题在 .ant-modal-confirm-title
|
||
// (经典 .ant-modal-header 对 confirm 类型 display:none,勿用 getByText 撞隐藏副本)
|
||
await expect(page.locator('.ant-modal-confirm-title')).toHaveText(
|
||
'同步完成',
|
||
);
|
||
await expect(page.getByText('扫描文件总数:3')).toBeVisible();
|
||
await expect(page.getByText(/新增:2/)).toBeVisible();
|
||
await expect(page.getByText(SYNC_RESULT.message)).toBeVisible();
|
||
expect(state.syncRequestCount).toBe(1);
|
||
});
|
||
|
||
test('路径必填校验:不输入路径不发起请求', async ({ page }) => {
|
||
const state = await installApiMocks(page, { kind: 'success' });
|
||
await loginAndGotoKnowledge(page);
|
||
|
||
await page.getByTestId('obsidian-sync-open').click();
|
||
await page.getByTestId('obsidian-sync-submit').click();
|
||
|
||
await expect(
|
||
page.getByText('请输入服务器上的 vault 绝对路径'),
|
||
).toBeVisible();
|
||
expect(state.syncRequestCount).toBe(0);
|
||
});
|
||
|
||
test('后端业务错误以中文 message 透出', async ({ page }) => {
|
||
await installApiMocks(page, {
|
||
kind: 'bizError',
|
||
code: 'invalid_param',
|
||
message: 'Obsidian 路径不在允许的根目录内',
|
||
});
|
||
await loginAndGotoKnowledge(page);
|
||
|
||
await page.getByTestId('obsidian-sync-open').click();
|
||
await page.getByTestId('obsidian-vault-input').fill('/etc/evil');
|
||
await page.getByTestId('obsidian-sync-submit').click();
|
||
|
||
await expect(
|
||
page.locator('.ant-message').getByText('Obsidian 路径不在允许的根目录内'),
|
||
).toBeVisible();
|
||
});
|
||
|
||
test('同步进行中按钮 loading 且防重复提交', async ({ page }) => {
|
||
const state = await installApiMocks(page, { kind: 'slow', delayMs: 2500 });
|
||
await loginAndGotoKnowledge(page);
|
||
|
||
await page.getByTestId('obsidian-sync-open').click();
|
||
await page
|
||
.getByTestId('obsidian-vault-input')
|
||
.fill('/data/vaults/team-notes');
|
||
|
||
const submit = page.getByTestId('obsidian-sync-submit');
|
||
await submit.click();
|
||
// 请求进行中:按钮禁用,重复点击不产生新请求
|
||
await expect(submit).toBeDisabled();
|
||
await submit.click({ force: true }).catch(() => {});
|
||
expect(state.syncRequestCount).toBe(1);
|
||
|
||
// 慢请求最终完成,结果正常展示
|
||
await expect(page.locator('.ant-modal-confirm-title')).toHaveText(
|
||
'同步完成',
|
||
{
|
||
timeout: 15_000,
|
||
},
|
||
);
|
||
expect(state.syncRequestCount).toBe(1);
|
||
});
|
||
});
|