#!/usr/bin/env node // S5: RAG status health + summary shape + FileTree tooltip static contract. // Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1 (LightRAG may be down — status still ok:true). "use strict"; const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const BASE = process.env.MNOTE_S5_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017"; const ACTOR_ID = process.env.MNOTE_S5_ACTOR_ID || "s5-rag-admin"; const AUTH = process.env.MNOTE_S5_AUTH || "Bearer s5-rag"; const ROOT = process.env.MNOTE_S5_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s5-rag-")); function assert(condition, message) { if (!condition) throw new Error(message); } async function fetchJson(url, options = {}) { const headers = { "Content-Type": "application/json", Authorization: AUTH, "x-mnote-actor-id": ACTOR_ID, "x-mnote-actor-type": "admin", ...(options.headers || {}), }; const res = await fetch(url, { ...options, headers }); const text = await res.text(); let body = {}; try { body = text ? JSON.parse(text) : {}; } catch { body = { raw: text }; } return { status: res.status, body }; } async function seedWorkspace(rootUri, rootPath) { const seed = await fetchJson(`${BASE}/api/dev/seed`, { method: "POST", body: JSON.stringify({ seeds: [ { kind: "setupWorkspace", user_id: ACTOR_ID, email: `${ACTOR_ID}@example.com`, username: ACTOR_ID, display_name: ACTOR_ID, role: "admin", password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", workspace_id: `local-ws:${ACTOR_ID}:s5`, workspace_name: "S5 RAG", root_uri: rootUri, root_path: rootPath, source_kind: "local_folder", permission: "write", capabilities: ["ai"], grant_source: "s5_rag_smoke", grant_created_by: ACTOR_ID, }, ], }), }); if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") { throw new Error( "S5 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。", ); } assert( seed.status === 200 && seed.body.ok === true, `/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`, ); } function staticChecks() { const liveApply = path.join( __dirname, "..", "rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js", ); const aiAdmin = path.join( __dirname, "..", "rust/crates/mnote-web/src/ssr/pages/ai_admin.rs", ); const liveSrc = fs.readFileSync(liveApply, "utf8"); const adminSrc = fs.readFileSync(aiAdmin, "utf8"); assert(liveSrc.includes("indexStatusLabel"), "FileTree must compute indexStatusLabel"); assert( liveSrc.includes("已索引") && liveSrc.includes("索引中") && liveSrc.includes("索引失败"), "FileTree tooltip labels for indexed/indexing/failed", ); assert( liveSrc.includes("linkTitle") && (liveSrc.includes("title=\"") || liveSrc.includes("title='") || liveSrc.includes("title=\"") || liveSrc.includes("title=") || liveSrc.includes("title=")), "FileTree rows should set title/linkTitle for tooltip", ); // Prefer title attribute wiring for tooltip. assert( /linkTitle[\s\S]{0,400}title=/.test(liveSrc) || /title=[\s\S]{0,200}linkTitle/.test(liveSrc) || liveSrc.includes("escapeHtml(linkTitle)"), "linkTitle must be wired into element title attribute", ); assert( adminSrc.includes("/api/knowledge-rag/status"), "AI admin knowledge panel must fetch /api/knowledge-rag/status", ); assert( adminSrc.includes("data-ai-admin-knowledge-summary") || adminSrc.includes("knowledge-summary"), "AI admin must render knowledge summary region", ); console.log(" [static] FileTree tooltip + admin knowledge summary OK"); } async function main() { staticChecks(); fs.mkdirSync(ROOT, { recursive: true }); fs.writeFileSync(path.join(ROOT, "s5.md"), "# S5 RAG\n", "utf8"); const rootUri = `file://${ROOT}`; const workspaceId = `local-ws:${ACTOR_ID}:s5`; console.log(`\n🧪 S5 RAG health + summary (base=${BASE})\n`); await seedWorkspace(rootUri, ROOT); const bare = await fetchJson(`${BASE}/api/knowledge-rag/status`); assert(bare.status === 200 && bare.body.ok === true, `status bare: ${bare.status} ${JSON.stringify(bare.body).slice(0, 300)}`); assert( String(bare.body.schema) === "mnote.knowledge_rag.provider_status.v1", `schema: ${bare.body.schema}`, ); assert(bare.body.health !== undefined, "status must include health"); assert(bare.body.pipeline !== undefined || bare.body.documents !== undefined, "status must include pipeline or documents"); console.log( ` bare: provider=${bare.body.provider} health.ok=${bare.body.health && bare.body.health.ok}`, ); const scoped = await fetchJson( `${BASE}/api/knowledge-rag/status?workspaceId=${encodeURIComponent(workspaceId)}&rootUri=${encodeURIComponent(rootUri)}`, ); assert( scoped.status === 200 && scoped.body.ok === true, `status scoped: ${scoped.status} ${JSON.stringify(scoped.body).slice(0, 300)}`, ); // Scoped should attach registry when root is readable. assert( scoped.body.registry !== undefined || scoped.body.registryDiagnostics !== undefined, "scoped status should include registry or diagnostics", ); console.log( ` scoped: registry=${scoped.body.registry ? "yes" : "no"} knowledgeBases=${scoped.body.knowledgeBases ? "yes" : "no"}`, ); // Admin UI page should mention knowledge status (SSR shell). const adminPage = await fetch(`${BASE}/ai-admin`, { headers: { Authorization: AUTH, "x-mnote-actor-id": ACTOR_ID, "x-mnote-actor-type": "admin", }, redirect: "manual", }); // May 200 or redirect depending on auth shell — only soft-check when 200. if (adminPage.status === 200) { const html = await adminPage.text(); assert( html.includes("knowledge-rag") || html.includes("data-ai-admin-knowledge") || html.includes("知识"), "ai-admin HTML should include knowledge panel markers", ); console.log(" ai-admin HTML knowledge panel markers present"); } else { console.log(` ai-admin HTTP ${adminPage.status} (skip HTML; API shape verified)`); } console.log("\n✅ S5 RAG health + summary + FileTree smoke passed\n"); } main().catch((err) => { console.error("\n❌ S5 smoke failed:", err.message || err); process.exit(1); });