#!/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"); const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || ""; function blockById(blocks, blockId) { return blocks.find((block) => block.blockId === blockId); } function blockTexts(blocks) { return blocks.map((block) => block.text); } function aggregateBlocks(aggregate) { const blocks = aggregate?.body?.blockDocument?.blocks; assert(Array.isArray(blocks), "Page Aggregate 必须返回 body.blockDocument.blocks"); return blocks; } function aggregateBlockById(aggregate, blockId) { return aggregateBlocks(aggregate).find((block) => block.blockId === blockId); } 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/mnote/tools/call", { method: "POST", headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" }, data: payload, }); } async function fetchPageAggregate(request, workspaceId, documentId) { const payload = await requestJson( request, `/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { method: "GET" }, ); assert.equal(payload.schema, "mnote.page_aggregate.v1", "Page Aggregate schema 应保持稳定"); return payload.result; } 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 hermesTesterDir = path.join(process.cwd(), "tmp", "hermes-tester", `page-block-ai-tools-${suffix}`); await fs.mkdir(hermesTesterDir, { recursive: true }); const browser = await chromium.launch({ headless: true, ...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}), args: ["--no-sandbox", "--disable-dev-shm-usage"], }); 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: "heading_1", type: "heading", props: { level: 1 }, content: [{ type: "text", text: `标题 ${suffix}` }] }, { 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}` }] }, { id: "heading_parent", type: "heading", props: { level: 2 }, content: [{ type: "text", text: `带子块标题 ${suffix}` }], children: [ { id: "heading_child", type: "paragraph", content: [{ type: "text", text: `标题子段落 ${suffix}` }] }, ], }, { id: "list_item_1", type: "bullet_list_item", content: [{ type: "text", text: `列表项 ${suffix}` }] }, { id: "table_1", type: "table", content: [{ type: "text", text: `表格块 ${suffix}` }] }, { id: "mindmap_1", type: "mindmap", props: { mindmapId: `mindmap_smoke_${suffix}` }, content: [{ type: "text", text: `思维导图块 ${suffix}` }], }, { id: "resource_1", type: "resource", 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 initialAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId); await fs.writeFile( path.join(hermesTesterDir, "page-aggregate.json"), JSON.stringify(initialAggregate, null, 2), "utf8", ); const p1 = blockById(snapshot.blocks, "p_1"); const p2 = blockById(snapshot.blocks, "p_2"); const p3 = blockById(snapshot.blocks, "p_3"); const heading = blockById(snapshot.blocks, "heading_1"); assert(heading && p1 && p2 && p3, "初始化后应能读取 heading_1/p_1/p_2/p_3"); assert(p1.revisionRef && p2.revisionRef && p3.revisionRef, "块投影必须返回 revisionRef"); const initialAggregateBlocks = aggregateBlocks(initialAggregate); assert.deepEqual( initialAggregateBlocks.map((block) => block.blockId), snapshot.blocks.map((block) => block.blockId), "Page Aggregate blockDocument 与 mnote.doc.fetch 块顺序必须一致", ); assert( ["heading_1", "p_1", "p_2", "p_3", "list_item_1"].every((blockId) => { const block = aggregateBlockById(initialAggregate, blockId); return block?.editable === true && Boolean(block.revisionRef); }), "普通可编辑块必须在 Page Aggregate 中带 blockId/revisionRef", ); const complexBlocks = ["heading_parent", "list_item_1", "table_1", "mindmap_1", "resource_1"] .map((blockId) => blockById(snapshot.blocks, blockId)); assert(complexBlocks.every(Boolean), "初始化后应能读取复杂块投影"); const unsupportedBlocks = ["table_1", "mindmap_1", "resource_1"].map((blockId) => blockById(snapshot.blocks, blockId)); assert( unsupportedBlocks.every((block) => block.editable === false && block.unsupportedReason), "table/mindmap/resource 不能在 AI 投影中伪装成完全可编辑块", ); assert.deepEqual( blockById(snapshot.blocks, "heading_parent").children, ["heading_child"], "带子块标题必须在投影中保留 children,用于阻断 move_after", ); evidence.steps.push({ name: "doc.fetch.initial", revision: snapshot.revision, conflictDetectionKey: snapshot.conflictDetectionKey, blockIds: snapshot.blocks.map((block) => block.blockId), aggregatePath: `/api/page-aggregate/${target.documentId}`, aggregateEvidencePath: path.join(hermesTesterDir, "page-aggregate.json"), aggregateBlockProjectionVersion: initialAggregate.body.blockProjectionVersion, aggregateProjectionSource: initialAggregate.body.projectionSource, aggregateBlockCount: initialAggregateBlocks.length, unsupportedBlocks: unsupportedBlocks.map((block) => ({ blockId: block.blockId, type: block.type, editable: block.editable, unsupportedReason: block.unsupportedReason, })), }); const outline = await callMnoteTool(context.request, { toolName: "mnote.doc.fetch", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_outline_${suffix}`, runId: `run_outline_${suffix}`, toolCallId: `call_outline_${suffix}`, traceId: `trace_outline_${suffix}`, capabilityScope: ["page.read"], args: { scope: "outline", detail: "with_ids", }, }); assert.equal(outline.ok, true, "doc.fetch outline 应成功"); assert.deepEqual( outline.result.blocks.map((block) => block.blockId), ["heading_1", "heading_parent"], "outline 应只返回标题块", ); const truncated = await callMnoteTool(context.request, { toolName: "mnote.doc.fetch", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_truncated_${suffix}`, runId: `run_truncated_${suffix}`, toolCallId: `call_truncated_${suffix}`, traceId: `trace_truncated_${suffix}`, capabilityScope: ["page.read"], args: { scope: "full", detail: "with_ids", maxBlocks: 2, }, }); assert.equal(truncated.result.truncated, true, "maxBlocks 裁剪应返回 truncated=true"); assert.equal(truncated.result.blocks.length, 2, "maxBlocks=2 应只返回两个块"); assert(truncated.result.continuation, "truncated 结果必须返回 continuation"); const findP2 = await callMnoteTool(context.request, { toolName: "mnote.doc.find", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_find_${suffix}`, runId: `run_find_${suffix}`, toolCallId: `call_find_${suffix}`, traceId: `trace_find_${suffix}`, capabilityScope: ["page.read"], args: { query: `第二段 ${suffix}`, }, }); assert.equal(findP2.result.matches[0].blockId, "p_2", "doc.find 应定位第二段"); await fs.writeFile( path.join(hermesTesterDir, "doc-fetch-find.json"), JSON.stringify( { full: snapshot, outline: outline.result, truncated: truncated.result, find: findP2.result, }, null, 2, ), "utf8", ); evidence.steps.push({ name: "doc.fetch.find", outlineBlockIds: outline.result.blocks.map((block) => block.blockId), truncated: truncated.result.truncated, findBlockId: findP2.result.matches[0].blockId, evidencePath: path.join(hermesTesterDir, "doc-fetch-find.json"), }); 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: findP2.result.matches[0].blockId, includeChildren: true, contextBefore: 1, contextAfter: 1, }, }); assert.equal(blockFetch.result.block.blockId, "p_2", "block.fetch 应读取目标块"); assert(blockFetch.result.block.revisionRef, "block.fetch 应返回 revisionRef"); 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", revisionRef: blockFetch.result.block.revisionRef, before: blockFetch.result.context.before.map((block) => block.blockId), after: blockFetch.result.context.after.map((block) => block.blockId), }); const complexMoveBlocked = []; for (const blockId of ["heading_parent", "list_item_1", "table_1", "mindmap_1", "resource_1"]) { const complexBlock = blockById(snapshot.blocks, blockId); const plan = await callMnoteTool(context.request, { toolName: "mnote.doc.plan_update", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_plan_complex_${suffix}`, runId: `run_plan_complex_${suffix}_${blockId}`, toolCallId: `call_plan_complex_${suffix}_${blockId}`, traceId: `trace_plan_complex_${suffix}_${blockId}`, idempotencyKey: `idem_plan_complex_${suffix}_${blockId}`, dryRun: true, capabilityScope: ["block.write"], args: { command: "block_move_after", blockId, anchorBlockId: "p_1", }, }); assert.equal(plan.result.blocked, true, `${blockId} move_after dry-run 必须阻断`); const blockedMove = await callMnoteTool(context.request, { toolName: "mnote.block.move_after", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_move_complex_${suffix}`, runId: `run_move_complex_${suffix}_${blockId}`, toolCallId: `call_move_complex_${suffix}_${blockId}`, traceId: `trace_move_complex_${suffix}_${blockId}`, idempotencyKey: `idem_move_complex_${suffix}_${blockId}`, dryRun: false, capabilityScope: ["block.write"], args: { blockId, anchorBlockId: "p_1", revision: snapshot.revision, conflictDetectionKey: snapshot.conflictDetectionKey, blockRevisionRef: complexBlock.revisionRef, anchorRevisionRef: p1.revisionRef, }, }); assert.equal(blockedMove.result.blocked, true, `${blockId} 正式 move_after 必须返回 blocked`); assert.equal( blockedMove.result.warnings[0].code, "block_move_after_blocked", `${blockId} 正式 move_after 必须返回阻断 warning`, ); complexMoveBlocked.push({ blockId, type: complexBlock.type, editable: complexBlock.editable, dryRunBlocked: plan.result.blocked, writeBlocked: blockedMove.result.blocked, warningCode: blockedMove.result.warnings[0].code, }); } const afterComplexBlocked = await fetchBlocks(context.request, target, suffix, "after_complex_blocked", actorId); assert.equal(afterComplexBlocked.revision, snapshot.revision, "复杂块 move_after 阻断后 revision 不应变化"); assert.deepEqual(blockTexts(afterComplexBlocked.blocks), blockTexts(snapshot.blocks), "复杂块 move_after 阻断后正文不应变化"); evidence.steps.push({ name: "block.move_after.complex_blocked", blocked: complexMoveBlocked, revisionAfterBlocked: afterComplexBlocked.revision, }); const dryRunBaselineTexts = blockTexts(snapshot.blocks); const dryRunBaselineAggregate = await fetchPageAggregate( context.request, target.workspaceId, target.documentId, ); const replacePlan = await callMnoteTool(context.request, { toolName: "mnote.doc.plan_update", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_plan_replace_${suffix}`, runId: `run_plan_replace_${suffix}`, toolCallId: `call_plan_replace_${suffix}`, traceId: `trace_plan_replace_${suffix}`, idempotencyKey: `idem_plan_replace_${suffix}`, dryRun: true, capabilityScope: ["block.write"], args: { command: "block_replace", blockId: "p_2", content: `dry-run 替换 ${suffix}`, }, }); assert.equal(replacePlan.result.dryRun, true, "block_replace plan 必须是 dry-run"); assert.equal(replacePlan.result.diff[0].before, `第二段 ${suffix}`, "replace plan 应解释 before"); assert.equal(replacePlan.result.diff[0].after, `dry-run 替换 ${suffix}`, "replace plan 应解释 after"); const insertPlan = await callMnoteTool(context.request, { toolName: "mnote.doc.plan_update", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_plan_insert_${suffix}`, runId: `run_plan_insert_${suffix}`, toolCallId: `call_plan_insert_${suffix}`, traceId: `trace_plan_insert_${suffix}`, idempotencyKey: `idem_plan_insert_${suffix}`, dryRun: true, capabilityScope: ["block.write"], args: { command: "block_insert_after", anchorBlockId: "p_1", content: `dry-run 插入 ${suffix}`, }, }); assert.equal(insertPlan.result.dryRun, true, "block_insert_after plan 必须是 dry-run"); assert.equal(insertPlan.result.diff[0].after, `第一段 ${suffix}`, "insert plan 应解释 anchor after"); const blockedPlan = await callMnoteTool(context.request, { toolName: "mnote.doc.plan_update", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_plan_blocked_${suffix}`, runId: `run_plan_blocked_${suffix}`, toolCallId: `call_plan_blocked_${suffix}`, traceId: `trace_plan_blocked_${suffix}`, idempotencyKey: `idem_plan_blocked_${suffix}`, dryRun: true, capabilityScope: ["block.write"], args: { command: "block_move_after", blockId: "p_3", anchorBlockId: "p_3", }, }); assert.equal(blockedPlan.result.blocked, true, "不支持 move 场景应返回 blocked=true"); const dryRunAfterAggregate = await fetchPageAggregate( context.request, target.workspaceId, target.documentId, ); const dryRunAfter = await fetchBlocks(context.request, target, suffix, "after_dry_run", actorId); assert.equal(dryRunAfter.revision, snapshot.revision, "dry-run 后 revision 不应变化"); assert.deepEqual(blockTexts(dryRunAfter.blocks), dryRunBaselineTexts, "dry-run 后正文不应变化"); assert.equal( dryRunAfterAggregate.body.conflictDetectionKey, dryRunBaselineAggregate.body.conflictDetectionKey, "dry-run 后 Page Aggregate conflictDetectionKey 不应变化", ); evidence.steps.push({ name: "plan_update.dry_run", replaceDiff: replacePlan.result.diff[0], insertDiff: insertPlan.result.diff[0], blocked: blockedPlan.result.blocked, revisionAfterDryRun: dryRunAfter.revision, }); 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); const afterReplaceAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId); assert.equal(blockById(snapshot.blocks, "p_2").text, replacedText, "替换后 doc.fetch 应回读新文本"); assert.equal( aggregateBlockById(afterReplaceAggregate, "p_2").text, replacedText, "替换后 Page Aggregate 必须回读新文本", ); assert.equal(blockById(snapshot.blocks, "p_1").text, `第一段 ${suffix}`, "替换后相邻 p_1 不应变化"); assert.equal(blockById(snapshot.blocks, "p_3").text, `第三段 ${suffix}`, "替换后相邻 p_3 不应变化"); assert(blockById(snapshot.blocks, "p_2"), "替换后目标 block id 必须保持 p_2"); evidence.steps.push({ name: "block.replace", changedBlocks: replace.result.changedBlocks, targetBlockStillExists: Boolean(blockById(snapshot.blocks, "p_2")), aggregateRevision: afterReplaceAggregate.body.revision, aggregateConflictDetectionKey: afterReplaceAggregate.body.conflictDetectionKey, aggregateText: aggregateBlockById(afterReplaceAggregate, "p_2").text, adjacentTexts: [blockById(snapshot.blocks, "p_1").text, blockById(snapshot.blocks, "p_3").text], }); 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: { type: "todo", props: { checked: false }, content: [{ type: "text", text: 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 应回读新块"); const orderAfterInsert = snapshot.blocks.map((block) => block.blockId); assert.equal( orderAfterInsert.indexOf(insertedBlockId), orderAfterInsert.indexOf("p_1") + 1, "插入块必须紧跟 p_1", ); const insertedBlockFetch = await callMnoteTool(context.request, { toolName: "mnote.block.fetch", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_insert_fetch_${suffix}`, runId: `run_insert_fetch_${suffix}`, toolCallId: `call_insert_fetch_${suffix}`, traceId: `trace_insert_fetch_${suffix}`, capabilityScope: ["block.read"], args: { blockId: insertedBlockId, contextBefore: 1, contextAfter: 1, }, }); assert.equal(insertedBlockFetch.result.block.blockId, insertedBlockId, "block.fetch 应能读取新插入块"); assert.equal(insertedBlockFetch.result.block.type, "todo", "insert_after 应能插入 todo 块"); const replayInsertText = `不应重复插入 ${suffix}`; const insertReplay = await callMnoteTool(context.request, { toolName: "mnote.block.insert_after", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_insert_replay_${suffix}`, runId: `run_insert_replay_${suffix}`, toolCallId: `call_insert_replay_${suffix}`, traceId: `trace_insert_replay_${suffix}`, idempotencyKey: `idem_insert_${suffix}`, dryRun: false, capabilityScope: ["block.write"], args: { anchorBlockId: "p_1", content: replayInsertText, revision: snapshot.revision, conflictDetectionKey: snapshot.conflictDetectionKey, anchorRevisionRef: blockById(snapshot.blocks, "p_1").revisionRef, }, }); assert.equal( insertReplay.audit.commandId, insert.audit.commandId, "重复 insert_after idempotencyKey 应返回缓存 commandId", ); const afterInsertReplay = await fetchBlocks(context.request, target, suffix, "after_insert_replay", actorId); assert.equal( afterInsertReplay.blocks.filter((block) => block.blockId === insertedBlockId).length, 1, "重复 insert_after idempotencyKey 不应产生第二个相同插入块", ); assert(!afterInsertReplay.blocks.some((block) => block.text === replayInsertText), "重复 insert_after 不应写入新内容"); assert.equal(afterInsertReplay.revision, snapshot.revision, "重复 insert_after 后 revision 不应再次递增"); snapshot = afterInsertReplay; evidence.steps.push({ name: "block.insert_after", insertedBlockId, insertedBlockType: insertedBlockFetch.result.block.type, orderAfterInsert, replayCommandId: insertReplay.audit.commandId, revisionAfterReplay: snapshot.revision, }); 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"); assert(blockById(snapshot.blocks, "p_3"), "移动后 moving block id 必须保持 p_3"); evidence.steps.push({ name: "block.move_after", dryRunBlocked: moveDryRun.result.blocked, dryRunDiff: moveDryRun.result.diff[0], order, texts: blockTexts(snapshot.blocks), movingBlockStillExists: Boolean(blockById(snapshot.blocks, "p_3")), }); await openDocument(page, target.workspaceId, target.documentId); await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]); await page.reload({ waitUntil: "domcontentloaded" }); await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]); const afterRefreshAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId); assert.deepEqual( aggregateBlocks(afterRefreshAggregate).map((block) => block.blockId), snapshot.blocks.map((block) => block.blockId), "页面刷新后 Page Aggregate block ids 必须保持稳定", ); 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.afterRefreshBlockIds = aggregateBlocks(afterRefreshAggregate).map((block) => block.blockId); 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); });