#!/usr/bin/env node "use strict"; /** * task-editor-delta-channel-smoke.js * * 验证 Phase B — 编辑器增量 delta channel: * - blockDelta 出现在 Hermes 写工具响应中 * - blockDelta 可被前端拦截并推送给 leptos-tiptap 编辑器 * - 编辑器通过 CustomEvent 接收到 delta 后可应用(链式调用 ProseMirror) * * 前提:运行中的 mnote-web (3000)、已登录浏览器、测试文档 */ const assert = require("node:assert"); const fs = require("node:fs/promises"); const path = require("node:path"); const { chromium } = require("playwright"); const { BASE_URL, createTempDocument, ensureAuthenticated, openDocument, cleanupDocuments, requestJson, } = require("./tree-shell-smoke-helpers"); const OUT_DIR = path.join(process.cwd(), "tmp", "editor-delta-channel-smoke"); const SUFFIX = `edc-${Date.now().toString(36)}`; async function callTool(request, payload) { return requestJson(request, "/api/mnote/tools/call", { method: "POST", headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" }, data: payload, }); } async function main() { await fs.mkdir(OUT_DIR, { recursive: true }); const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); const page = await context.newPage(); const request = page.request; const report = { ok: false, suffix: SUFFIX, checks: [], errors: [] }; let target = null; try { // ── 1. 准备测试文档 ── target = await createTempDocument(request, SUFFIX, { workspaceName: `ws-editor-delta-${SUFFIX}`, documentTitle: `测试-Delta-${SUFFIX}`, content: [ { type: "p", children: [{ text: `段落A ${SUFFIX}` }] }, { type: "p", children: [{ text: `段落B ${SUFFIX}` }] }, { type: "p", children: [{ text: `段落C ${SUFFIX}` }] }, ], }); report.documentId = target.documentId; report.workspaceId = target.workspaceId; console.log(`文档创建: ${target.documentId}`); // ── 2. 打开文档页 ── await ensureAuthenticated(page); await openDocument(page, target.documentId, target.workspaceId); await page.waitForTimeout(3000); // ── 3. 注入 CustomEvent 监听器 ── // 在浏览器中注册 block-delta 监听器,用于验证 blockDelta 推送链 const receivedDeltas = []; await page.exposeFunction("__smoke_record_delta", (json) => { try { receivedDeltas.push(typeof json === "string" ? JSON.parse(json) : json); } catch (e) { console.error("delta parse error:", e); } }); await page.evaluate(() => { window.addEventListener( "mnote:editor:block-delta", (event) => { const detail = event.detail; window.__smoke_record_delta(detail); }, { once: false }, ); }); // ── 4. 调用 block.replace,验证响应包含 blockDelta ── const fetchRes = await callTool(request, { toolName: "mnote.doc.fetch", workspaceId: target.workspaceId, documentId: target.documentId, actorId: "smoke-user", sessionId: `sess_fetch_${SUFFIX}`, runId: `run_fetch_${SUFFIX}`, toolCallId: `call_fetch_${SUFFIX}`, traceId: `trace_fetch_${SUFFIX}`, capabilityScope: ["page.read"], args: { scope: "full", detail: "with_ids", maxBlocks: 20 }, }); const blocks = fetchRes.body?.blockDocument?.blocks || []; assert.ok(blocks.length >= 3, `预期 ≥3 块,实际 ${blocks.length}`); const blockIdB = blocks[1].blockId; const replaceRes = await callTool(request, { toolName: "mnote.block.replace", workspaceId: target.workspaceId, documentId: target.documentId, actorId: "smoke-user", sessionId: `sess_replace_${SUFFIX}`, runId: `run_replace_${SUFFIX}`, toolCallId: `call_replace_${SUFFIX}`, traceId: `trace_replace_${SUFFIX}`, idempotencyKey: `idem_replace_${SUFFIX}`, capabilityScope: ["page.write", "page.read"], args: { blockId: blockIdB, content: [ { type: "paragraph", content: [{ type: "text", text: `段落B已替换 ${SUFFIX}` }] }, ], revision: fetchRes.body?.revision, conflictDetectionKey: fetchRes.body?.conflictDetectionKey, blockRevisionRef: blocks[1].revisionRef, }, }); console.log("replace 响应 keys:", Object.keys(replaceRes).join(", ")); const hasBlockDelta = replaceRes.hasOwnProperty("blockDelta"); report.checks.push({ name: "replace 响应包含 blockDelta", passed: hasBlockDelta, details: hasBlockDelta ? `blockDelta 包含 ${replaceRes.blockDelta?.operations?.length || 0} 条操作` : "响应中无 blockDelta 字段(可能 actor 未启用或未运行 mnote-web)", }); if (hasBlockDelta) { const delta = replaceRes.blockDelta; report.deltaReceived = delta; // 验证 delta 结构 assert.ok(delta.documentId, "delta 应包含 documentId"); assert.ok(delta.revision > 0, "delta 应包含 revision"); assert.ok( Array.isArray(delta.operations) && delta.operations.length > 0, "delta 应包含至少一条操作", ); report.checks.push({ name: "delta 结构有效", passed: true }); // ── 5. 在浏览器端主动触发 CustomEvent(模拟真实推送) ── await page.evaluate( (deltaJson) => { const event = new CustomEvent("mnote:editor:block-delta", { detail: deltaJson, bubbles: true, }); window.dispatchEvent(event); }, delta, ); await page.waitForTimeout(1000); report.checks.push({ name: "CustomEvent 成功分派到 window", passed: receivedDeltas.length > 0, details: `收到 ${receivedDeltas.length} 条 delta 事件`, }); // ── 6. 验证编辑器内容已更新(通过 runtime 回读) ── const readbackRes = await callTool(request, { toolName: "mnote.doc.fetch", workspaceId: target.workspaceId, documentId: target.documentId, actorId: "smoke-user", sessionId: `sess_readback_${SUFFIX}`, runId: `run_readback_${SUFFIX}`, toolCallId: `call_readback_${SUFFIX}`, traceId: `trace_readback_${SUFFIX}`, capabilityScope: ["page.read"], args: { scope: "full", detail: "with_ids", maxBlocks: 20 }, }); const finalBlocks = readbackRes.body?.blockDocument?.blocks || []; const hasReplaced = finalBlocks.some((b) => b.text?.includes("段落B已替换")); report.checks.push({ name: "runtime 回读确认替换成功", passed: hasReplaced, details: finalBlocks.map((b) => b.text).join(" | "), }); } report.ok = report.checks.every((c) => c.passed); report.passedCount = report.checks.filter((c) => c.passed).length; report.totalCount = report.checks.length; console.log( `\n${report.ok ? "✅" : "⚠️"} Phase B smoke: ${report.passedCount}/${report.totalCount}`, ); } catch (err) { report.errors.push({ message: err.message, stack: err.stack }); console.error("❌ Phase B smoke 失败:", err); } finally { await fs.writeFile( path.join(OUT_DIR, `${SUFFIX}.json`), JSON.stringify(report, null, 2), ); console.log(`报告: ${OUT_DIR}/${SUFFIX}.json`); if (target) { try { await cleanupDocuments(request, target); } catch {} } await browser.close(); } } main().catch((err) => { console.error(err); process.exit(1); });