#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const fs = require("node:fs/promises"); const path = require("node:path"); const { chromium } = require("playwright"); const { BASE_URL, UI_TIMEOUT_MS, cleanupDocuments, createTempDocument, ensureAuthenticated, openDocument, renameDocument, requestJson, } = require("./tree-shell-smoke-helpers"); const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-tools-smoke"); function blockById(blocks, blockId) { return blocks.find((block) => block.blockId === blockId); } function blockTexts(blocks) { return blocks.map((block) => block.text); } async function waitForVisibleTexts(page, expectedTexts) { await page.waitForFunction( ({ expected }) => { const visibleText = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); while (walker.nextNode()) { const node = walker.currentNode; const parent = node.parentElement; if (!(parent instanceof HTMLElement)) continue; const style = window.getComputedStyle(parent); const rect = parent.getBoundingClientRect(); const text = (node.textContent || "").trim(); if ( text && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0 ) { visibleText.push(text); } } return expected.every((text) => visibleText.some((visible) => visible.includes(text))); }, { expected: expectedTexts }, { timeout: UI_TIMEOUT_MS }, ); } async function callMnoteTool(request, payload) { return requestJson(request, "/api/hermes/tools/mnote/call", { method: "POST", headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" }, data: payload, }); } async function fetchBlocks(request, target, suffix, label, actorId) { const response = await callMnoteTool(request, { toolName: "mnote.doc.fetch", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_block_fetch_${suffix}`, runId: `run_block_fetch_${suffix}_${label}`, toolCallId: `call_block_fetch_${suffix}_${label}`, traceId: `trace_block_fetch_${suffix}_${label}`, capabilityScope: ["page.read"], args: { scope: "full", detail: "with_ids", maxBlocks: 20, }, }); assert.equal(response.ok, true, `${label}: doc.fetch 应成功`); assert(Array.isArray(response.result.blocks), `${label}: doc.fetch 应返回 blocks`); return response.result; } async function main() { const suffix = Date.now().toString(36); const title = `TEST-AI-BLOCK-TOOLS-${suffix}`; const createdIds = []; const evidence = { ok: false, baseUrl: BASE_URL, title, steps: [], }; await fs.mkdir(OUT_DIR, { recursive: true }); const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const page = await context.newPage(); try { const viewer = await ensureAuthenticated(page, context.request); const actorId = viewer.userId; const target = await createTempDocument(context.request); createdIds.push(target.documentId); evidence.documentId = target.documentId; evidence.workspaceId = target.workspaceId; await renameDocument(context.request, target.workspaceId, target.documentId, title); const initialContent = [ { id: "p_1", type: "paragraph", content: [{ type: "text", text: `第一段 ${suffix}` }] }, { id: "p_2", type: "paragraph", content: [{ type: "text", text: `第二段 ${suffix}` }] }, { id: "p_3", type: "paragraph", content: [{ type: "text", text: `第三段 ${suffix}` }] }, ]; const seed = await callMnoteTool(context.request, { toolName: "mnote.page.save", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_seed_${suffix}`, runId: `run_seed_${suffix}`, toolCallId: `call_seed_${suffix}`, traceId: `trace_seed_${suffix}`, idempotencyKey: `idem_seed_${suffix}`, dryRun: false, capabilityScope: ["page.write"], args: { mode: "replace", content: initialContent, }, }); assert.equal(seed.ok, true, "初始化 page.save 应成功"); evidence.steps.push({ name: "seed", commandName: seed.result.commandName }); let snapshot = await fetchBlocks(context.request, target, suffix, "initial", actorId); const p1 = blockById(snapshot.blocks, "p_1"); const p2 = blockById(snapshot.blocks, "p_2"); const p3 = blockById(snapshot.blocks, "p_3"); assert(p1 && p2 && p3, "初始化后应能读取 p_1/p_2/p_3"); assert(p1.revisionRef && p2.revisionRef && p3.revisionRef, "块投影必须返回 revisionRef"); evidence.steps.push({ name: "doc.fetch.initial", revision: snapshot.revision, conflictDetectionKey: snapshot.conflictDetectionKey, blockIds: snapshot.blocks.map((block) => block.blockId), }); const blockFetch = await callMnoteTool(context.request, { toolName: "mnote.block.fetch", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_block_${suffix}`, runId: `run_block_${suffix}`, toolCallId: `call_block_${suffix}`, traceId: `trace_block_${suffix}`, capabilityScope: ["block.read"], args: { blockId: "p_2", contextBefore: 1, contextAfter: 1, }, }); assert.equal(blockFetch.result.block.blockId, "p_2", "block.fetch 应读取目标块"); assert.equal(blockFetch.result.context.before[0].blockId, "p_1", "block.fetch before 应来自同父级"); assert.equal(blockFetch.result.context.after[0].blockId, "p_3", "block.fetch after 应来自同父级"); evidence.steps.push({ name: "block.fetch", blockId: "p_2" }); const replacedText = `第二段已替换 ${suffix}`; const replace = await callMnoteTool(context.request, { toolName: "mnote.block.replace", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_replace_${suffix}`, runId: `run_replace_${suffix}`, toolCallId: `call_replace_${suffix}`, traceId: `trace_replace_${suffix}`, idempotencyKey: `idem_replace_${suffix}`, dryRun: false, capabilityScope: ["block.write"], args: { blockId: "p_2", content: replacedText, revision: snapshot.revision, conflictDetectionKey: snapshot.conflictDetectionKey, blockRevisionRef: p2.revisionRef, }, }); assert.equal(replace.result.commandName, "page.body.save", "block.replace 必须走 page.body.save"); snapshot = await fetchBlocks(context.request, target, suffix, "after_replace", actorId); assert.equal(blockById(snapshot.blocks, "p_2").text, replacedText, "替换后 doc.fetch 应回读新文本"); evidence.steps.push({ name: "block.replace", changedBlocks: replace.result.changedBlocks }); const insertedText = `插入段 ${suffix}`; const anchorAfterReplace = blockById(snapshot.blocks, "p_1"); const insert = await callMnoteTool(context.request, { toolName: "mnote.block.insert_after", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_insert_${suffix}`, runId: `run_insert_${suffix}`, toolCallId: `call_insert_${suffix}`, traceId: `trace_insert_${suffix}`, idempotencyKey: `idem_insert_${suffix}`, dryRun: false, capabilityScope: ["block.write"], args: { anchorBlockId: "p_1", content: insertedText, revision: snapshot.revision, conflictDetectionKey: snapshot.conflictDetectionKey, anchorRevisionRef: anchorAfterReplace.revisionRef, }, }); const insertedBlockId = insert.result.changedBlocks[0].blockId; assert(insertedBlockId.startsWith("ai_block_"), "插入块 id 必须由 Rust/mnote 侧生成"); snapshot = await fetchBlocks(context.request, target, suffix, "after_insert", actorId); assert.equal(blockById(snapshot.blocks, insertedBlockId).text, insertedText, "插入后 doc.fetch 应回读新块"); evidence.steps.push({ name: "block.insert_after", insertedBlockId }); const moveBlock = blockById(snapshot.blocks, "p_3"); const moveAnchor = blockById(snapshot.blocks, "p_1"); const moveDryRun = await callMnoteTool(context.request, { toolName: "mnote.doc.plan_update", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_plan_${suffix}`, runId: `run_plan_${suffix}`, toolCallId: `call_plan_${suffix}`, traceId: `trace_plan_${suffix}`, idempotencyKey: `idem_plan_${suffix}`, dryRun: true, capabilityScope: ["block.write"], args: { command: "block_move_after", blockId: "p_3", anchorBlockId: "p_1", }, }); assert.equal(moveDryRun.result.blocked, false, "同父级普通叶子块 move dry-run 不应阻断"); const move = await callMnoteTool(context.request, { toolName: "mnote.block.move_after", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_move_${suffix}`, runId: `run_move_${suffix}`, toolCallId: `call_move_${suffix}`, traceId: `trace_move_${suffix}`, idempotencyKey: `idem_move_${suffix}`, dryRun: false, capabilityScope: ["block.write"], args: { blockId: "p_3", anchorBlockId: "p_1", revision: snapshot.revision, conflictDetectionKey: snapshot.conflictDetectionKey, blockRevisionRef: moveBlock.revisionRef, anchorRevisionRef: moveAnchor.revisionRef, }, }); assert.equal(move.result.changedBlocks[0].op, "move_after", "move_after 应返回 changedBlocks"); snapshot = await fetchBlocks(context.request, target, suffix, "after_move", actorId); const order = snapshot.blocks.map((block) => block.blockId); assert(order.indexOf("p_3") === order.indexOf("p_1") + 1, "移动后 p_3 必须紧跟 p_1"); evidence.steps.push({ name: "block.move_after", order, texts: blockTexts(snapshot.blocks) }); await openDocument(page, target.workspaceId, target.documentId); await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]); const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); evidence.screenshot = screenshotPath; evidence.visibleTextCheck = [replacedText, insertedText, `第三段 ${suffix}`]; evidence.finalTexts = blockTexts(snapshot.blocks); evidence.ok = true; const evidencePath = path.join(OUT_DIR, `${suffix}.json`); await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8"); console.log(JSON.stringify({ ...evidence, evidencePath }, null, 2)); } finally { await cleanupDocuments(context.request, createdIds).catch(() => undefined); await context.close().catch(() => undefined); await browser.close().catch(() => undefined); } } main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exit(1); });