#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const fs = require("node:fs"); const path = require("node:path"); const { 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 FIXTURE_DIR = process.env.MNOTE_TASK772_FIXTURE_DIR || "knowledge-rag-fixtures-7-68"; const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task772-weknora-ingest-search-open-reference-e2e"); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); const SKIP_PATH = path.join(OUTPUT_DIR, "skip.json"); const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json"); const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK772_WEKNORA_TIMEOUT_MS || 240_000); const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK772_WEKNORA_POLL_MS || 5_000); function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function envList(name) { return String(process.env[name] || "") .split(",") .map((value) => value.trim()) .filter(Boolean); } function configuredKnowledgeBaseIds() { return envList("MNOTE_WEKNORA_KNOWLEDGE_BASE_IDS").concat(envList("MNOTE_WEKNORA_KNOWLEDGE_BASE_ID")); } function hasWeKnoraApiKey() { return Boolean(String(process.env.MNOTE_WEKNORA_API_KEY || process.env.WEKNORA_API_KEY || "").trim()); } function writeJson(filePath, payload) { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); } function skip(reason, details = {}) { const payload = { ok: false, skipped: true, reason, baseUrl: BASE_URL, rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, ...details, }; writeJson(SKIP_PATH, payload); console.log(JSON.stringify(payload, null, 2)); } function failLayer(layer, message, details = {}) { const error = new Error(message); error.layer = layer; error.details = details; return error; } 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 signIn(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 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", `knowledge-rag status 失败: ${result.status}`, { response: result.payload || result.text, }); } return result.payload; } function assertWeKnoraStatus(statusPayload, expectedKbIds) { const activeProvider = statusPayload?.providerConfig?.active || statusPayload?.provider; assert.equal(activeProvider, "weknora", `MNote knowledge provider 不是 weknora: ${JSON.stringify(statusPayload?.providerConfig || statusPayload, null, 2)}`); const serverKbIds = statusPayload?.providerConfig?.weknora?.knowledgeBaseIds || []; assert(serverKbIds.length > 0, `MNote 3000 未加载 WeKnora KB id: ${JSON.stringify(statusPayload?.providerConfig || statusPayload, null, 2)}`); for (const kbId of expectedKbIds) { assert(serverKbIds.includes(kbId), `MNote 3000 未加载当前 WeKnora KB id ${kbId}: ${JSON.stringify(serverKbIds)}`); } } async function ingest(context, sourcePath) { const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, { rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, force: true, sources: [{ sourcePath }], }); if (!result.ok) { throw failLayer("mnote_ingest_http", `knowledge-rag ingest HTTP 失败: ${result.status}`, { response: result.payload || result.text, }); } const payload = result.payload; assert.equal(payload?.provider, "weknora", `ingest provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`); if (payload?.ok !== true || payload?.retryRequired) { throw failLayer("weknora_ingest_provider", "WeKnora ingest 未成功完成 provider upload", { response: payload, }); } const configured = Array.isArray(payload.configuredSources) ? payload.configuredSources : []; const item = configured.find((source) => source.sourceRootRelativePath === sourcePath); assert(item, `ingest 结果缺少目标 source: ${JSON.stringify(configured, null, 2).slice(0, 3000)}`); assert.equal(item.provider, "weknora", `ingest source provider 应为 weknora: ${JSON.stringify(item, null, 2)}`); assert.equal(item.mappingStatus, "provider_mapped", `ingest 应写回 provider_mapped: ${JSON.stringify(item, null, 2)}`); assert(item.providerKnowledgeBaseId, `ingest 缺少 providerKnowledgeBaseId: ${JSON.stringify(item, null, 2)}`); assert(item.providerKnowledgeId, `ingest 缺少 providerKnowledgeId: ${JSON.stringify(item, null, 2)}`); assert(item.upload?.attempted === true, `ingest 应真实调用 WeKnora upload: ${JSON.stringify(item, null, 2)}`); return { payload, item }; } async function search(context, query, sourcePath) { 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, sourcePaths: [sourcePath], }); if (!result.ok) { throw failLayer("mnote_search_http", `knowledge-rag search HTTP 失败: ${result.status}`, { response: result.payload || result.text, }); } return result.payload; } function referenceProviderIds(reference) { return { knowledgeBaseId: reference?.providerIds?.knowledgeBaseId || reference?.providerKnowledgeBaseId || "", knowledgeId: reference?.providerIds?.knowledgeId || reference?.providerKnowledgeId || "", chunkId: reference?.providerIds?.chunkId || reference?.providerChunkId || reference?.chunkId || "", }; } function findTargetReference(searchPayload, ingestItem, marker, sourcePath) { const references = Array.isArray(searchPayload?.references) ? searchPayload.references : []; return references.find((reference) => { const ids = referenceProviderIds(reference); const quote = `${reference.displayQuote || ""}\n${reference.rawQuote || ""}\n${reference.quote || ""}`; return reference.provider === "weknora" && ids.knowledgeId === ingestItem.providerKnowledgeId && (reference.sourceRootRelativePath === sourcePath || quote.includes(marker)); }) || null; } async function waitForSearchHit(context, marker, sourcePath, ingestItem) { const startedAt = Date.now(); let lastPayload = null; let lastError = null; while (Date.now() - startedAt < POLL_TIMEOUT_MS) { try { lastPayload = await search(context, marker, sourcePath); const reference = findTargetReference(lastPayload, ingestItem, marker, sourcePath); if (reference) return { payload: lastPayload, reference }; } catch (error) { lastError = { layer: error.layer || "search_poll", message: error.message, details: error.details, }; } await sleep(POLL_INTERVAL_MS); } throw failLayer("weknora_search_timeout", "等待 WeKnora search 返回目标 chunk 超时", { marker, sourcePath, providerKnowledgeId: ingestItem.providerKnowledgeId, lastError, lastPayload, }); } function rawWeKnoraChunkFromReference(reference) { const raw = reference?.reference; assert(raw && typeof raw === "object" && !Array.isArray(raw), `search reference 缺少原始 WeKnora chunk: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`); return { provider: "weknora", ...raw }; } function assertMappedReference(reference, ingestItem, sourcePath, label) { assert.equal(reference?.provider, "weknora", `${label} provider 应为 weknora: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`); const ids = referenceProviderIds(reference); assert(ids.knowledgeBaseId, `${label} 缺少 provider knowledgeBaseId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`); assert(ids.knowledgeId, `${label} 缺少 provider knowledgeId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`); assert(ids.chunkId, `${label} 缺少 provider chunkId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`); assert.equal(ids.knowledgeId, ingestItem.providerKnowledgeId, `${label} provider knowledgeId 未保留 ingest 映射: ${JSON.stringify(ids)}`); assert.equal(reference.filePath ?? null, null, `${label} 不应把 WeKnora provider filename 伪造成 filePath: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`); assert.equal(reference.sourceRootRelativePath, sourcePath, `${label} 应通过 registry 映射回 sourceRootRelativePath: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`); assert.equal(reference.citationDiagnostics?.providerFilenameIsNotLocalPath, true, `${label} 应声明 provider filename 不是本地路径真相: ${JSON.stringify(reference.citationDiagnostics, null, 2)}`); assert.equal(reference.openAction?.params?.path, sourcePath, `${label} openAction 应使用 registry path,不使用 provider filename: ${JSON.stringify(reference.openAction, null, 2)}`); } async function openReference(context, rawChunk) { const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/open-reference`, { rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, reference: rawChunk, }); if (!result.ok) { throw failLayer("mnote_open_reference_http", `knowledge-rag open-reference HTTP 失败: ${result.status}`, { response: result.payload || result.text, }); } const payload = result.payload; assert.equal(payload?.ok, true, `open-reference ok 应为 true: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`); assert.equal(payload?.provider, "weknora", `open-reference provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`); return payload; } async function main() { const expectedKbIds = configuredKnowledgeBaseIds(); if (expectedKbIds.length === 0 || !hasWeKnoraApiKey()) { console.warn(JSON.stringify({ warning: "local_weknora_env_not_visible", message: "当前 smoke shell 未看到 WeKnora env;继续以 MNote 3000 status 和真实 API 调用为准。", requiredAny: { knowledgeBaseId: ["MNOTE_WEKNORA_KNOWLEDGE_BASE_ID", "MNOTE_WEKNORA_KNOWLEDGE_BASE_IDS"], apiKey: ["MNOTE_WEKNORA_API_KEY", "WEKNORA_API_KEY"], }, hasKnowledgeBaseId: expectedKbIds.length > 0, hasApiKey: hasWeKnoraApiKey(), }, null, 2)); } if (!fs.existsSync(ROOT_PATH)) { throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`); } const context = await request.newContext({ baseURL: BASE_URL }); try { await signIn(context); const statusPayload = await status(context); try { assertWeKnoraStatus(statusPayload, expectedKbIds); } catch (error) { skip("mnote_weknora_provider_or_kb_not_configured", { assertion: error.message, providerConfig: statusPayload?.providerConfig || null, health: statusPayload?.health || null, }); return; } const startedAt = Date.now(); const marker = `task772 weknora e2e marker ${startedAt}`; const sourceName = `task772-weknora-e2e-${startedAt}.md`; const sourcePath = `${FIXTURE_DIR}/${sourceName}`; const absoluteSourcePath = path.join(ROOT_PATH, sourcePath); fs.mkdirSync(path.dirname(absoluteSourcePath), { recursive: true }); fs.writeFileSync( absoluteSourcePath, [ "# Task 772 WeKnora E2E", "", marker, "", "This markdown file is created by the MNote task772 smoke to verify real WeKnora ingest, search, and open-reference mapping.", "The provider filename must not become a forged local file path; MNote should map back through its source registry.", "", ].join("\n"), "utf8", ); const ingestResult = await ingest(context, sourcePath); const searchResult = await waitForSearchHit(context, marker, sourcePath, ingestResult.item); assert.equal(searchResult.payload?.provider, "weknora", `search provider 应为 weknora: ${JSON.stringify(searchResult.payload, null, 2).slice(0, 3000)}`); assertMappedReference(searchResult.reference, ingestResult.item, sourcePath, "search reference"); const rawChunk = rawWeKnoraChunkFromReference(searchResult.reference); const openPayload = await openReference(context, rawChunk); assertMappedReference(openPayload.reference, ingestResult.item, sourcePath, "open-reference"); const result = { ok: true, skipped: false, baseUrl: BASE_URL, rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, sourcePath, absoluteSourcePath, marker, provider: "weknora", providerKnowledgeBaseId: ingestResult.item.providerKnowledgeBaseId, providerKnowledgeId: ingestResult.item.providerKnowledgeId, search: { resultCount: Array.isArray(searchResult.payload.results) ? searchResult.payload.results.length : 0, referenceCount: Array.isArray(searchResult.payload.references) ? searchResult.payload.references.length : 0, providerIds: referenceProviderIds(searchResult.reference), citationUrl: searchResult.reference.citationUrl || null, filePath: searchResult.reference.filePath ?? null, sourceRootRelativePath: searchResult.reference.sourceRootRelativePath || null, }, openReference: { providerIds: referenceProviderIds(openPayload.reference), citationUrl: openPayload.reference?.citationUrl || null, filePath: openPayload.reference?.filePath ?? null, sourceRootRelativePath: openPayload.reference?.sourceRootRelativePath || null, openAction: openPayload.reference?.openAction || null, }, }; writeJson(RESULT_PATH, result); console.log(JSON.stringify(result, null, 2)); } finally { await context.dispose().catch(() => undefined); } } main().catch((error) => { const payload = { ok: false, skipped: false, 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); });