#!/usr/bin/env node "use strict"; /** * task-editor-runtime-actor-smoke.js * * 验证 EditorRuntimeActor Phase A: * - 块写工具(replace / insert_after)在 actor 启用时返回时间 < 800ms * - 多次写入循环不依赖远端 RTT * - 写后回读内容正确 * * 依赖:运行中的 mnote-web (localhost:3000),已登录状态,测试 Helpers */ 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", "editor-runtime-actor-smoke"); const SUFFIX = `era-${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 sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } 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 }, deviceScaleFactor: 2, }); const page = await context.newPage(); const request = page.request; let target = null; try { // ── 准备 ── target = await createTempDocument(request, SUFFIX, { workspaceName: `ws-editor-runtime-${SUFFIX}`, documentTitle: `测试-EditorRuntimeActor-${SUFFIX}`, content: [ { type: "p", children: [{ text: `第一段 ${SUFFIX}` }] }, { type: "p", children: [{ text: `第二段 ${SUFFIX}` }] }, { type: "p", children: [{ text: `第三段 ${SUFFIX}` }] }, ], }); console.log(`文档已创建: ${target.documentId} in ${target.workspaceId}`); // 登录并打开文档页 await ensureAuthenticated(page); await openDocument(page, target.documentId, target.workspaceId); await page.waitForTimeout(2000); // ── 第一阶段:读取文档,获取 block IDs ── 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 }, }); assert.ok(fetchRes.ok, `fetch 失败: ${JSON.stringify(fetchRes)}`); const blocks = fetchRes.body?.blockDocument?.blocks || []; assert.ok(blocks.length >= 3, `预期至少 3 个块,实际 ${blocks.length}`); const blockId1 = blocks[0].blockId; const blockId2 = blocks[1].blockId; const blockId3 = blocks[2].blockId; console.log(`块 IDs: ${blockId1}, ${blockId2}, ${blockId3}`); console.log(`初始文本: "${blockTexts(blocks).join('", "')}"`); // ── 第二阶段:三次写循环,验证延迟 ── console.log("\n=== 三次写循环 ==="); // 1. block.replace — 替换第二段 const t0 = Date.now(); 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: blockId2, content: [ { type: "paragraph", content: [{ type: "text", text: `第二段已替换 ${SUFFIX}` }] }, ], revision: fetchRes.body?.revision, conflictDetectionKey: fetchRes.body?.conflictDetectionKey, blockRevisionRef: fetchRes.body?.blockDocument?.blocks?.[1]?.revisionRef, }, }); const t1 = Date.now(); const replaceMs = t1 - t0; assert.ok(replaceRes.ok, `replace 失败: ${JSON.stringify(replaceRes)}`); console.log(`1/3 replace: ${replaceMs}ms — ok`); // 2. block.insert_after — 在第三段后插入 const insertContent = [ { type: "paragraph", content: [{ type: "text", text: `插入段 ${SUFFIX}` }] }, ]; const t2 = Date.now(); const insertRes = await callTool(request, { toolName: "mnote.block.insert_after", workspaceId: target.workspaceId, documentId: target.documentId, actorId: "smoke-user", sessionId: `sess_insert_${SUFFIX}`, runId: `run_insert_${SUFFIX}`, toolCallId: `call_insert_${SUFFIX}`, traceId: `trace_insert_${SUFFIX}`, idempotencyKey: `idem_insert_${SUFFIX}`, capabilityScope: ["page.write", "page.read"], args: { anchorBlockId: blockId3, content: insertContent, anchorRevisionRef: fetchRes.body?.blockDocument?.blocks?.[2]?.revisionRef, }, }); const t3 = Date.now(); const insertMs = t3 - t2; assert.ok(insertRes.ok, `insert_after 失败: ${JSON.stringify(insertRes)}`); console.log(`2/3 insert_after: ${insertMs}ms — ok`); // 3. doc.fetch — 回读验证 await sleep(500); // 等待 runtime 写入完成 const t4 = Date.now(); 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 t5 = Date.now(); const readbackMs = t5 - t4; assert.ok(readbackRes.ok, `回读失败: ${JSON.stringify(readbackRes)}`); // ── 验证结果 ── const finalBlocks = readbackRes.body?.blockDocument?.blocks || []; const texts = finalBlocks.map((b) => b.text); console.log(`3/3 readback: ${readbackMs}ms — ok`); console.log(`最终文本: "${texts.join('", "')}"`); // 验证文本存在 assert.ok(texts.some((t) => t.includes("第一段")), "应包含第一段"); assert.ok(texts.some((t) => t.includes("第二段已替换")), "应包含替换后的第二段"); assert.ok(texts.some((t) => t.includes("第三段")), "应包含第三段"); assert.ok(texts.some((t) => t.includes("插入段")), "应包含插入段"); // ── 延迟断言:三次写循环总时间 < 800ms(不含回读等待) ── const writeTotal = replaceMs + insertMs; const allWithReadback = writeTotal + readbackMs; console.log(`\n=== 延迟报告 ===`); console.log(`三次操作总延迟(不含回读等待): ${writeTotal}ms`); console.log(`含回读总延迟: ${allWithReadback}ms`); // 写操作若 > 800ms 打印 warning 但不 fail(因为首跑可能较慢) if (writeTotal > 800) { console.warn(`⚠️ 写延迟 ${writeTotal}ms > 800ms,可能需要预热或检查 actor 是否生效`); } else { console.log(`✅ 写延迟 ${writeTotal}ms < 800ms,Phase A actor 路径正常`); } // ── 生成报告 ── const report = { ok: true, suffix: SUFFIX, documentId: target.documentId, workspaceId: target.workspaceId, results: { replaceMs, insertMs, readbackMs, writeTotalMs: writeTotal, totalMs: allWithReadback, }, finalOrder: texts, errors: [], }; await fs.writeFile( path.join(OUT_DIR, `${SUFFIX}.json`), JSON.stringify(report, null, 2), ); console.log(`\n✅ Phase A smoke 通过 — 报告: ${OUT_DIR}/${SUFFIX}.json`); } finally { await browser.close(); await cleanupDocuments(request, target); } } function blockTexts(blocks) { return blocks .filter((b) => b.type === "paragraph" || b.type === "heading") .map((b) => b.text || ""); } main().catch((err) => { console.error("❌ Phase A smoke 失败:", err); process.exit(1); });