#!/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_TASK781_FIXTURE_DIR || "knowledge-rag-fixtures-7-68"; const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task781-knowledge-bases-registry-smoke"); const RESULT_PATH = path.join(OUTPUT_DIR, "result.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_TASK781_WEKNORA_TIMEOUT_MS || 240_000); const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK781_WEKNORA_POLL_MS || 5_000); 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 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 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()}`); assert(result.ok, `status HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`); return result.payload; } function assertKnowledgeBases(payload) { assert.equal(payload?.provider, "weknora", `status provider 应为 weknora: ${JSON.stringify(payload?.providerConfig || payload, null, 2).slice(0, 1000)}`); assert.equal(payload?.knowledgeBases?.schema, "mnote.knowledge_bases.registry.v1", "status 应返回 mnote_knowledge_bases registry schema"); assert.equal(payload?.registry?.schema, "mnote.knowledge_rag.source_registry.v1", "status 应保留旧 source registry schema"); assert.equal(payload?.providerConfig?.weknora?.knowledgeBaseRegistry, "mnote_knowledge_bases", "providerConfig 应暴露 KB registry 边界"); assert.equal(payload?.providerConfig?.weknora?.sourceRegistry, "mnote_knowledge_sources", "providerConfig 应暴露 source registry 边界"); const bases = payload?.knowledgeBases?.bases || []; assert(Array.isArray(bases) && bases.length > 0, `knowledgeBases.bases 应至少包含默认 WeKnora KB: ${JSON.stringify(payload?.knowledgeBases, null, 2)}`); const base = bases.find((item) => item.provider === "weknora" && item.providerKbId); assert(base, `缺少 provider-neutral WeKnora KB: ${JSON.stringify(bases, null, 2)}`); assert.equal(base.defaultToolEnabled, true, "默认 WeKnora KB 应开启 tool 可见性"); return base; } 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", `ingest HTTP ${result.status}`, { response: result.payload || result.text }); } const payload = result.payload; assert.equal(payload?.provider, "weknora", "ingest provider 应为 weknora"); assert.equal(payload?.knowledgeBases?.schema, "mnote.knowledge_bases.registry.v1", "ingest 应返回 knowledgeBases registry"); const item = (payload.configuredSources || []).find((source) => source.sourceRootRelativePath === sourcePath); assert(item, `ingest 结果缺少目标 source: ${JSON.stringify(payload.configuredSources, null, 2).slice(0, 1000)}`); 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)}`); const registryEntry = (payload.registry?.entries || []).find((entry) => entry.sourceRootRelativePath === sourcePath); assert(registryEntry, "source registry 应写入目标 source"); assert.equal(registryEntry.provider, "weknora", `source registry 应写 provider: ${JSON.stringify(registryEntry, null, 2)}`); assert.equal(registryEntry.providerKnowledgeBaseId, item.providerKnowledgeBaseId, "source registry 应写 providerKnowledgeBaseId"); assert.equal(registryEntry.providerKnowledgeId, item.providerKnowledgeId, "source registry 应写 providerKnowledgeId"); assert.equal(registryEntry.providerSourceId, item.providerKnowledgeId, "source registry 应写 providerSourceId"); assert(registryEntry.lightRagDocId, "旧 lightRagDocId 兼容字段仍应保留 provider knowledge id"); return { payload, item, registryEntry }; } async function search(context, marker, sourcePath) { 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: marker, mode: "hybrid", topK: 10, chunkTopK: 10, includeChunkContent: true, sourcePaths: [sourcePath], }); assert(result.ok, `search HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`); lastPayload = result.payload; const hit = (lastPayload.references || []).find((reference) => { const quote = `${reference.rawQuote || ""}\n${reference.displayQuote || ""}\n${reference.quote || ""}`; return reference.provider === "weknora" && (reference.sourceRootRelativePath === sourcePath || quote.includes(marker)); }); if (hit) return { payload: lastPayload, reference: hit }; await sleep(POLL_INTERVAL_MS); } throw failLayer("weknora_search_timeout", "等待 WeKnora search 返回 task781 source 超时", { marker, sourcePath, lastPayload }); } async function main() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); 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 beforeStatus = await status(context); const baseBefore = assertKnowledgeBases(beforeStatus); const startedAt = Date.now(); const marker = `task781 knowledge bases registry marker ${startedAt}`; const sourcePath = `${FIXTURE_DIR}/task781-knowledge-base-registry-${startedAt}.md`; const absoluteSourcePath = path.join(ROOT_PATH, sourcePath); fs.mkdirSync(path.dirname(absoluteSourcePath), { recursive: true }); fs.writeFileSync( absoluteSourcePath, [ "# Task 781 Knowledge Base Registry", "", marker, "", "This file verifies MNote provider-neutral knowledge base and source registry boundaries.", "", ].join("\n"), "utf8", ); const ingestResult = await ingest(context, sourcePath); const searchResult = await search(context, marker, sourcePath); const afterStatus = await status(context); const baseAfter = assertKnowledgeBases(afterStatus); const persistedRegistryPath = path.join(ROOT_PATH, ".mnote", "index", "mnote-knowledge-bases.json"); assert(fs.existsSync(persistedRegistryPath), `应持久化 mnote_knowledge_bases registry: ${persistedRegistryPath}`); const persistedRegistry = JSON.parse(fs.readFileSync(persistedRegistryPath, "utf8")); assert.equal(persistedRegistry.schema, "mnote.knowledge_bases.registry.v1", "持久化 KB registry schema 不正确"); assert((persistedRegistry.bases || []).some((base) => base.provider === "weknora" && base.providerKbId), "持久化 KB registry 缺少 WeKnora base"); const result = { ok: true, task: "task781-knowledge-bases-registry-smoke", baseUrl: BASE_URL, rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, sourcePath, absoluteSourcePath, marker, knowledgeBase: { before: { baseId: baseBefore.baseId, provider: baseBefore.provider, providerKbId: baseBefore.providerKbId, defaultToolEnabled: baseBefore.defaultToolEnabled, }, after: { baseId: baseAfter.baseId, provider: baseAfter.provider, providerKbId: baseAfter.providerKbId, defaultToolEnabled: baseAfter.defaultToolEnabled, sourceCount: baseAfter.sourceCount, }, persistedRegistryPath, }, sourceRegistry: { provider: ingestResult.registryEntry.provider, providerStatus: ingestResult.registryEntry.providerStatus, providerKnowledgeBaseId: ingestResult.registryEntry.providerKnowledgeBaseId, providerKnowledgeId: ingestResult.registryEntry.providerKnowledgeId, providerSourceId: ingestResult.registryEntry.providerSourceId, lightRagDocIdCompat: ingestResult.registryEntry.lightRagDocId, }, search: { provider: searchResult.payload.provider, referenceProvider: searchResult.reference.provider, sourceRootRelativePath: searchResult.reference.sourceRootRelativePath, providerKnowledgeBaseId: searchResult.reference.providerKnowledgeBaseId, providerKnowledgeId: searchResult.reference.providerKnowledgeId, providerChunkId: searchResult.reference.providerChunkId, }, }; 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: "task781-knowledge-bases-registry-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); });