Retire ACP/Hermes/OpenCode surfaces and rename hermes_tools to mnote_agent_tools so Page AI stays on Pi Lab only. Add Chrome vault extension + extension token route, pre-release purge design, and soft-retire legacy smokes for the small-group production cut.
222 lines
7.6 KiB
JavaScript
222 lines
7.6 KiB
JavaScript
#!/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);
|
||
});
|