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