#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const fs = require("node:fs"); const path = require("node:path"); const { chromium, request } = require("playwright"); const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"; const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`; const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space"; const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task788-weknora-kb-folder-browser-flow-smoke"); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json"); const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "weknora-kb-folder-browser-flow.png"); const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK788_WEKNORA_TIMEOUT_MS || 240_000); const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK788_WEKNORA_POLL_MS || 5_000); const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ["/usr/bin/chromium-browser", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"] .find((candidate) => fs.existsSync(candidate)); function writeJson(filePath, payload) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function failLayer(layer, message, details = {}) { const error = new Error(message); error.layer = layer; error.details = details; return error; } async function signInRequest(context) { const response = await context.post(`${BASE_URL}/api/auth`, { data: { action: "auth:signIn", args: { provider: "password", params: { email: "mnote.e2e@example.com", password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", flow: "signIn", }, }, }, timeout: UI_TIMEOUT_MS, }); assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`); } async function signInBrowser(context) { const response = await context.request.post(`${BASE_URL}/api/auth`, { data: { action: "auth:signIn", args: { provider: "password", params: { email: "mnote.e2e@example.com", password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", flow: "signIn", }, }, }, timeout: UI_TIMEOUT_MS, }); assert(response.ok(), `浏览器上下文登录失败: ${response.status()} ${await response.text()}`); } async function apiJson(context, method, url, data) { const response = await context.fetch(url, { method, data, headers: { "x-mnote-actor-id": "mnote-e2e", "x-mnote-actor-type": "user", "x-mnote-workspace-id": WORKSPACE_ID, accept: "application/json", }, timeout: UI_TIMEOUT_MS, }); const text = await response.text(); let payload = null; try { payload = text ? JSON.parse(text) : null; } catch { payload = { rawText: text }; } return { ok: response.ok(), status: response.status(), payload, text }; } async function status(context) { const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID }); const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`); if (!result.ok) { throw failLayer("mnote_status_http", `status HTTP ${result.status}`, { response: result.payload || result.text }); } return result.payload; } async function createKnowledgeBase(context, name, description) { const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/knowledge-bases`, { rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, name, description, defaultToolEnabled: true, }); if (!result.ok) { throw failLayer("mnote_create_kb_http", `create KB HTTP ${result.status}`, { response: result.payload || result.text }); } const payload = result.payload; assert.equal(payload?.provider, "weknora", "create KB provider 应为 weknora"); assert(payload?.providerKnowledgeBaseId, `create KB 缺少 providerKnowledgeBaseId: ${JSON.stringify(payload, null, 2).slice(0, 1200)}`); assert.equal(payload?.knowledgeBase?.provider, "weknora", "knowledgeBase provider 应为 weknora"); assert.equal(payload?.knowledgeBase?.providerKbId, payload.providerKnowledgeBaseId, "knowledgeBase.providerKbId 应匹配 providerKnowledgeBaseId"); return payload; } async function ingestFolder(context, folderPath, providerKnowledgeBaseId) { const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, { rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, force: true, providerKnowledgeBaseId, sources: [{ sourcePath: folderPath }], }); if (!result.ok) { throw failLayer("mnote_ingest_folder_http", `ingest folder HTTP ${result.status}`, { response: result.payload || result.text }); } const payload = result.payload; assert.equal(payload?.provider, "weknora", "ingest provider 应为 weknora"); const configured = Array.isArray(payload.configuredSources) ? payload.configuredSources : []; assert(configured.length >= 2, `文件夹入库应至少展开两个文件: ${JSON.stringify(configured, null, 2).slice(0, 1600)}`); const mapped = configured.filter((item) => item.mappingStatus === "provider_mapped" && item.providerKnowledgeBaseId === providerKnowledgeBaseId); assert(mapped.length >= 2, `文件夹入库未全部映射到新 KB: ${JSON.stringify(configured, null, 2).slice(0, 2000)}`); const registryEntries = payload.registry?.entries || []; const folderEntries = registryEntries.filter((entry) => String(entry.sourceRootRelativePath || "").startsWith(`${folderPath}/`)); assert(folderEntries.length >= 2, "source registry 应写入文件夹下的 source"); assert(folderEntries.every((entry) => entry.provider === "weknora"), "source registry provider 应为 weknora"); assert(folderEntries.every((entry) => entry.providerKnowledgeBaseId === providerKnowledgeBaseId), "source registry 应写入新 KB id"); return { payload, configured, mapped, folderEntries }; } async function searchUntilHit(context, query, folderPath, providerKnowledgeBaseId) { const startedAt = Date.now(); let lastPayload = null; while (Date.now() - startedAt < POLL_TIMEOUT_MS) { const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, { rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, query, mode: "hybrid", topK: 10, chunkTopK: 10, includeChunkContent: true, providerKnowledgeBaseId, sourcePaths: [folderPath], }); if (!result.ok) { throw failLayer("mnote_search_http", `search HTTP ${result.status}`, { response: result.payload || result.text }); } lastPayload = result.payload; const hit = (lastPayload.references || []).find((reference) => { const pathText = String(reference.sourceRootRelativePath || ""); const quote = `${reference.rawQuote || ""}\n${reference.displayQuote || ""}\n${reference.quote || ""}`; return reference.provider === "weknora" && reference.providerKnowledgeBaseId === providerKnowledgeBaseId && (pathText.startsWith(`${folderPath}/`) || quote.includes(query)); }); if (hit) return { payload: lastPayload, reference: hit }; await sleep(POLL_INTERVAL_MS); } throw failLayer("weknora_new_kb_folder_search_timeout", "等待新 KB 文件夹入库内容可搜索超时", { query, folderPath, providerKnowledgeBaseId, lastPayload, }); } function createFolderFixture(startedAt) { if (!fs.existsSync(ROOT_PATH)) { throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`); } const marker = `task788 weknora kb folder browser marker ${startedAt}`; const markers = [`${marker} alpha`, `${marker} beta`]; const folderPath = `knowledge-rag-fixtures-7-68/task788-folder-${startedAt}`; const absoluteFolderPath = path.join(ROOT_PATH, folderPath); const sourcePaths = [`${folderPath}/alpha.md`, `${folderPath}/beta.md`]; fs.mkdirSync(absoluteFolderPath, { recursive: true }); fs.writeFileSync( path.join(absoluteFolderPath, "alpha.md"), `# Task 788 Alpha\n\n${markers[0]}\n\nWeKnora folder browser flow alpha note.\n`, "utf8", ); fs.writeFileSync( path.join(absoluteFolderPath, "beta.md"), `# Task 788 Beta\n\n${markers[1]}\n\nWeKnora folder browser flow beta note.\n`, "utf8", ); return { folderPath, absoluteFolderPath, sourcePaths, marker, markers }; } async function verifyInBrowser(fixture, name) { const browser = await chromium.launch({ headless: true, ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), }); const context = await browser.newContext({ viewport: { width: 1360, height: 900 } }); const page = await context.newPage(); try { await signInBrowser(context); const url = new URL(`${BASE_URL}/`); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", ROOT_URI); url.searchParams.set("workspaceId", WORKSPACE_ID); url.searchParams.set("treeView", "filetree"); await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const knowledgeEntry = page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]'); assert.equal(await knowledgeEntry.getAttribute("href"), "/knowledge", "主知识库入口应打开 WeKnora 原生 host"); assert.equal(await knowledgeEntry.getAttribute("data-mnote-action"), "open-knowledge-host", "主知识库入口不应再打开旧设置壳"); await page.goto(`${BASE_URL}/debug/knowledge-rag`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const panel = page.locator('[data-testid="mnote-weknora-knowledge-settings-panel"]'); await panel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); assert.equal(await panel.getAttribute("data-knowledge-rag-default-provider"), "weknora", "设置面板应声明 WeKnora provider"); await panel.locator('[data-testid="mnote-weknora-create-kb-name"]').fill(name); await panel.locator('[data-testid="mnote-weknora-create-kb-description"]').fill("MNote task788 temporary KB created through browser settings UI"); await panel.locator('[data-knowledge-rag-action="create-kb"]').click({ timeout: UI_TIMEOUT_MS }); const kbSelect = panel.locator('[data-testid="mnote-weknora-kb-select"]'); await page.waitForFunction( (expectedName) => { const status = document.querySelector('[data-testid="mnote-weknora-create-kb-status"]'); if (!(status instanceof HTMLElement) || status.getAttribute("data-status") !== "done") return false; const select = document.querySelector('[data-testid="mnote-weknora-kb-select"]'); if (!(select instanceof HTMLSelectElement) || !select.value.trim()) return false; const selected = select.options[select.selectedIndex]; return selected && selected.textContent.includes(expectedName); }, name, { timeout: UI_TIMEOUT_MS }, ); const providerKnowledgeBaseId = await kbSelect.inputValue(); assert(providerKnowledgeBaseId, "UI 创建 KB 后应选中新 providerKnowledgeBaseId"); const firstSourceInput = panel.locator('[data-knowledge-rag-source-input]').first(); await firstSourceInput.fill(fixture.folderPath); await panel.locator('[data-knowledge-rag-action="ingest"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').first().click({ timeout: UI_TIMEOUT_MS }); const sourceRows = await Promise.all(fixture.sourcePaths.map(async (sourcePath) => { const row = page.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${sourcePath}"]`).first(); await row.waitFor({ state: "visible", timeout: POLL_TIMEOUT_MS }); await row.locator('[data-knowledge-rag-action="open-source-reference"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); return sourcePath; })); const kbSummary = panel.locator('[data-testid="mnote-weknora-kb-summary"]'); await kbSummary.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const kbIds = await kbSummary.getAttribute("data-knowledge-rag-kb-ids"); const sourceRowsForKb = await panel.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-kb-id="${providerKnowledgeBaseId}"]`).count(); assert(sourceRowsForKb >= 2, `source rows 未反映新建 KB id: ${providerKnowledgeBaseId}`); await panel.locator('[data-kb-rag-tab="search"]').click({ timeout: UI_TIMEOUT_MS }); await panel.locator('[data-kb-rag-search-input]').fill(fixture.markers[0]); let searchResultSourcePath = null; const startedAt = Date.now(); while (Date.now() - startedAt < POLL_TIMEOUT_MS) { await panel.locator('[data-kb-rag-action="search"]').click({ timeout: UI_TIMEOUT_MS }); const result = panel.locator(`[data-kb-rag-search-result="true"][data-knowledge-rag-kb-id="${providerKnowledgeBaseId}"]`).first(); try { await result.waitFor({ state: "visible", timeout: POLL_INTERVAL_MS }); searchResultSourcePath = await result.getAttribute("data-knowledge-rag-source-path"); break; } catch { await sleep(POLL_INTERVAL_MS); } } assert(searchResultSourcePath, `UI 检索未展示新 KB ${providerKnowledgeBaseId} 的结果`); await panel.locator('[data-kb-rag-search-result="true"] [data-knowledge-rag-action="open-source-reference"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }); return { providerKnowledgeBaseId, kbSummaryVisible: true, kbSummaryConfiguredKbIds: String(kbIds || "").split(",").filter(Boolean), sourceRowsForKb, sourceRowsVisible: sourceRows, searchResultVisible: true, searchResultSourcePath, searchVerifiedByApi: false, searchUiProviderKbScopeMissing: false, openReferenceControlsVisible: true, screenshot: SCREENSHOT_PATH, }; } catch (error) { await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined); throw failLayer("browser_ui_verify", error.message || String(error), { screenshot: SCREENSHOT_PATH }); } finally { await browser.close().catch(() => undefined); } } async function main() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const context = await request.newContext({ baseURL: BASE_URL }); try { await signInRequest(context); const startedAt = Date.now(); const fixture = createFolderFixture(startedAt); const name = `task788-weknora-kb-${startedAt}`; const browserResult = await verifyInBrowser(fixture, name); const providerKnowledgeBaseId = browserResult.providerKnowledgeBaseId; const searchResult = await searchUntilHit(context, fixture.markers[0], fixture.folderPath, providerKnowledgeBaseId); const afterStatus = await status(context); const statusBase = (afterStatus.knowledgeBases?.bases || []).find((base) => base.providerKbId === providerKnowledgeBaseId); assert(statusBase, "status knowledgeBases 应包含新建 KB"); assert(statusBase.sourceCount >= 2, `新建 KB sourceCount 应 >= 2: ${JSON.stringify(statusBase, null, 2)}`); const result = { ok: true, task: "task788-weknora-kb-folder-browser-flow-smoke", baseUrl: BASE_URL, rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, uiCreatesKb: true, uiAddsFolder: true, browserVerifiesKb: true, browserVerifiesSearch: true, browserVerifiesOpenReferenceControls: true, apiVerifiesSearch: true, folderPath: fixture.folderPath, absoluteFolderPath: fixture.absoluteFolderPath, markers: fixture.markers, knowledgeBase: { name, providerKnowledgeBaseId, baseId: statusBase.baseId, defaultToolEnabled: statusBase.defaultToolEnabled, sourceCount: statusBase.sourceCount, status: statusBase.status, }, ingest: { configuredCount: fixture.sourcePaths.length, mappedCount: fixture.sourcePaths.length, sourcePaths: fixture.sourcePaths, }, apiSearch: { provider: searchResult.payload.provider, sourceRootRelativePath: searchResult.reference.sourceRootRelativePath, providerKnowledgeBaseId: searchResult.reference.providerKnowledgeBaseId, providerKnowledgeId: searchResult.reference.providerKnowledgeId, providerChunkId: searchResult.reference.providerChunkId, }, browser: browserResult, }; writeJson(RESULT_PATH, result); console.log(JSON.stringify(result, null, 2)); } finally { await context.dispose().catch(() => undefined); } } main().catch((error) => { const payload = { ok: false, task: "task788-weknora-kb-folder-browser-flow-smoke", layer: error.layer || "unexpected", error: error.stack || error.message || String(error), details: error.details || null, baseUrl: BASE_URL, rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, }; writeJson(FAILURE_PATH, payload); console.error(JSON.stringify(payload, null, 2)); process.exit(1); });