#!/usr/bin/env node "use strict"; const fs = require("node:fs/promises"); const path = require("node:path"); const { chromium } = require("playwright"); const { UI_TIMEOUT_MS, assert } = require("./tree-shell-smoke-helpers"); const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); const TASK = "task503-mindmap-skill-capability-smoke"; const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); const TEST_EMAIL = "mnote.e2e@example.com"; const TEST_PASSWORD = "MnoteE2E123!"; async function writeResult(payload) { await fs.mkdir(OUTPUT_DIR, { recursive: true }); await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); } async function callJson(url, init = {}) { const response = await fetch(url, { ...init, headers: { ...(init.data !== undefined ? { "content-type": "application/json" } : {}), ...(init.headers || {}), }, body: init.data !== undefined ? JSON.stringify(init.data) : init.body, }); const text = await response.text(); let payload = null; try { payload = text ? JSON.parse(text) : null; } catch { payload = text; } if (!response.ok || payload?.ok === false) { throw new Error(`${url} failed: ${response.status} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); } return payload; } async function callMnoteTool({ toolName, workspaceId, documentId, rootUri, args, idempotencyKey }) { const payload = { toolName, workspaceId, documentId, sourceKind: "local_folder", rootUri, actorId: "mnote-e2e", sessionId: `sess_${TASK}_${toolName.replace(/\W+/g, "_")}`, runId: `run_${Date.now()}`, toolCallId: `call_${Date.now()}`, traceId: `trace_${Date.now()}`, idempotencyKey, dryRun: false, args, }; const result = await callJson(`${BASE_URL}/api/mnote/tools/call`, { method: "POST", headers: { "content-type": "application/json", "x-mnote-actor-id": "mnote-e2e", "x-mnote-actor-type": "user", }, body: JSON.stringify(payload), }); return result.result ?? result; } async function signInBrowserContext(context) { const auth = await context.request.post(`${BASE_URL}/api/auth`, { data: { action: "auth:signIn", args: { provider: "password", params: { email: TEST_EMAIL, password: TEST_PASSWORD, flow: "signIn", }, }, }, }); assert(auth.ok(), `测试账号登录失败: ${auth.status()} ${await auth.text()}`); const whoami = await context.request.get(`${BASE_URL}/api/auth/whoami`); assert(whoami.ok(), `whoami 失败: ${whoami.status()} ${await whoami.text()}`); const viewer = await whoami.json(); assert(viewer.userId === "mnote-e2e", `当前登录用户不是 mnote-e2e: ${JSON.stringify(viewer)}`); assert(viewer.actorType === "user", `当前登录不是真实 user 会话: ${JSON.stringify(viewer)}`); return viewer; } function skillCapabilityOutline() { return [ { text: "触发场景", children: [ { text: "用户提到脑图", children: [] }, { text: "资源标签页", children: [] }, { text: "PDF 转导图", children: [] }, ], }, { text: "读取能力", children: [ { text: "先解析目标", children: [] }, { text: "fetch 回读树", children: [] }, { text: "完整 envelope", children: [] }, ], }, { text: "创建能力", children: [ { text: "outline 写入", children: [] }, { text: "自动生成 uid", children: [] }, { text: "保留 sourceRefs", children: [] }, ], }, { text: "权限约束", children: [ { text: "需要 read_write", children: [] }, { text: "限定资源路径", children: [] }, { text: "拒绝越权写入", children: [] }, ], }, { text: "写后验证", children: [ { text: "fetch 再确认", children: [] }, { text: "报告 JSON 路径", children: [] }, { text: "截图看渲染", children: [] }, ], }, { text: "当前边界", children: [ { text: "非裸树文件", children: [] }, { text: "apply_ops 受限", children: [] }, { text: "长文本需压缩", children: [] }, ], }, ]; } function collectOutlineTexts(nodes, texts = []) { for (const node of nodes) { texts.push(String(node.text || "")); collectOutlineTexts(Array.isArray(node.children) ? node.children : [], texts); } return texts; } async function main() { await fs.mkdir(OUTPUT_DIR, { recursive: true }); const workspace = await callJson(`${BASE_URL}/api/local-folder/workspaces/default`, { method: "POST", headers: { "content-type": "application/json", "x-mnote-actor-id": "mnote-e2e", "x-mnote-actor-type": "user", }, data: {}, }); const rootUri = workspace.workspace?.rootUri || ""; const rootPath = workspace.workspace?.rootPath || ""; const workspaceId = workspace.workspace?.manifest?.workspaceId || "local-ws:mnote-e2e:my-space"; assert(rootUri.startsWith("file://"), `默认本地工作区 rootUri 异常: ${JSON.stringify(workspace)}`); assert(rootPath, `默认本地工作区 rootPath 缺失: ${JSON.stringify(workspace)}`); const documentName = `MindmapSkillCapabilitySmoke-${Date.now()}.md`; const documentId = `local-md:${documentName}`; const resourcePath = `maps/task503-mindmap-skill-capabilities-${Date.now()}.mindmap.json`; const title = "mnote-mindmap skill 能力"; await fs.mkdir(path.join(rootPath, "maps"), { recursive: true }); await fs.writeFile(path.join(rootPath, documentName), "# Mindmap Skill Capability Smoke\n", "utf8"); const skill = await callMnoteTool({ toolName: "mnote.skill.read", workspaceId, documentId, rootUri, args: { skillId: "mnote-mindmap", agentId: "reasonix" }, }); const skillContent = String(skill.content || ""); assert(skillContent.includes("Mindmap outline format"), "skill 缺少导图格式小节"); assert(skillContent.includes("Use concise keywords or short phrases"), "skill 缺少短语化节点要求"); assert(skillContent.includes("Avoid long paragraphs"), "skill 缺少避免长段落要求"); const outline = skillCapabilityOutline(); const outlineTexts = collectOutlineTexts(outline); assert(outlineTexts.every((text) => text.length > 0 && text.length <= 32), "outline 节点应保持短语化"); assert(outlineTexts.every((text) => !text.includes("\n")), "outline 节点不能用换行模拟层级"); const aiAccessScope = { permissionLevel: "read_write", allowedResourceIds: [resourcePath], }; const created = await callMnoteTool({ toolName: "mnote.mindmap.create_from_outline", workspaceId, documentId, rootUri, idempotencyKey: `idem_${TASK}_${Date.now()}`, args: { mindmapId: resourcePath, resourcePath, title, outline, embedIntoPage: true, sourceRefs: [{ kind: "skill", title: "mnote-mindmap", note: TASK }], aiAccessScope, }, }); assert(created.root?.data?.text === title, `创建结果根节点异常: ${JSON.stringify(created.root)}`); assert(created.embedResult?.status === "embedded", `创建结果缺少页面绑定: ${JSON.stringify(created.embedResult)}`); assert( Array.isArray(created.changedFiles) && created.changedFiles.includes(documentName), `changedFiles 应包含当前 Markdown 页面: ${JSON.stringify(created.changedFiles)}`, ); const markdownAfterEmbed = await fs.readFile(path.join(rootPath, documentName), "utf8"); assert( markdownAfterEmbed.includes(`[${title}](${resourcePath})`), `当前 Markdown 页面未写入 mindmap 链接: ${markdownAfterEmbed}`, ); const fetched = await callMnoteTool({ toolName: "mnote.mindmap.fetch", workspaceId, documentId, rootUri, args: { mindmapId: resourcePath, resourcePath, scope: "full_envelope", aiAccessScope, }, }); const nodeCount = Array.isArray(fetched.nodes) ? fetched.nodes.length : 0; assert(fetched.root?.data?.text === title, `回读根节点异常: ${JSON.stringify(fetched.root)}`); assert(nodeCount >= 25, `回读节点数不足: ${nodeCount}`); const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); const page = await context.newPage(); const events = []; page.on("pageerror", (error) => events.push({ kind: "pageerror", text: error.message })); page.on("console", (message) => { if (message.type() === "error") { events.push({ kind: "console", text: message.text() }); } }); page.on("response", (response) => { if (response.url().includes("/api/mindmap/")) { events.push({ kind: "mindmap-response", status: response.status(), url: response.url() }); } }); const viewer = await signInBrowserContext(context); const url = `${BASE_URL}/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(`local-file:${resourcePath}`)}?sourceKind=local_folder&rootUri=${encodeURIComponent(rootUri)}&workspaceId=${encodeURIComponent(workspaceId)}`; await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-mindmap-editor-root"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.waitForFunction( (expectedTitle) => { const text = document.body.innerText || ""; const countText = document.querySelector('[data-testid="mindmap-schema-count"]')?.textContent || ""; return text.includes(expectedTitle) && text.includes("触发场景") && /节点\s*(?:[1-9]|[1-9][0-9]+)/.test(countText); }, title, { timeout: UI_TIMEOUT_MS }, ); const screenshotPath = path.join(OUTPUT_DIR, "skill-capability-mindmap.png"); await page.screenshot({ path: screenshotPath, fullPage: true }); const ui = await page.evaluate(() => ({ bodyHasTitle: (document.body.innerText || "").includes("mnote-mindmap skill 能力"), bodyHasBranch: (document.body.innerText || "").includes("触发场景"), bodyHasPermission: (document.body.innerText || "").includes("权限约束"), schemaCount: document.querySelector('[data-testid="mindmap-schema-count"]')?.textContent || null, rootVisible: Boolean(document.querySelector('[data-testid="mnote-mindmap-editor-root"]')), errorText: document.querySelector('[data-testid="leptos-mindmap-error"]')?.textContent || null, })); await browser.close(); assert(ui.bodyHasTitle && ui.bodyHasBranch && ui.bodyHasPermission, `导图页面文本缺失: ${JSON.stringify(ui)}`); assert(!ui.errorText, `导图页面出现错误: ${ui.errorText}`); await writeResult({ ok: true, rootUri, workspaceId, documentId, resourcePath, mindmapFile: path.join(rootPath, resourcePath), screenshotPath, url, viewer, skillFormatRules: { hasFormat: skillContent.includes("Mindmap outline format"), hasShortPhraseRule: skillContent.includes("Use concise keywords or short phrases"), hasAvoidParagraphRule: skillContent.includes("Avoid long paragraphs"), }, nodeCount, embedResult: created.embedResult, markdownHasMindmapLink: markdownAfterEmbed.includes(`[${title}](${resourcePath})`), firstLevel: (fetched.root?.children || []).map((child) => child?.data?.text), ui, events, }); console.log(`task503 mindmap skill capability smoke passed: ${RESULT_PATH}`); } main().catch(async (error) => { await writeResult({ ok: false, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : null, }).catch(() => undefined); console.error(error instanceof Error ? error.stack : error); process.exit(1); });