build-flow.admin/e2e/obsidian-sync.spec.ts
向宁 77aac58f22
Some checks failed
CI / lint (push) Has been cancelled
CI / build (20, macOS-latest) (push) Has been cancelled
CI / build (20, ubuntu-latest) (push) Has been cancelled
CI / build (20, windows-latest) (push) Has been cancelled
coverage CI / build (push) Has been cancelled
test: add playwright e2e suites for create-forms and obsidian-sync
2026-10-25 16:27:43 +08:00

226 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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