feat: EditorRuntimeActor - 三层缓存/delta/事件架构
Phase A — EditorRuntimeActor 内存缓存层 - 新增 editor_actor.rs: EditorBlockDocument 内存态 + apply_command + load_or_init - block.rs 四个写工具(replace/insert/delete/move)接入 actor 路径 - editor_actor feature flag(MNOTE_WEB_ENABLE_EDITOR_ACTOR=true 默认开启) - bridge-runtime 三个核心函数公开化 - rust-toolchain: 1.89 → stable(修复 spike WASM 编译阻塞) Phase B — 编辑器增量 delta channel - BlockDelta/DeltaOperation 类型 + actor.build_block_delta() - leptos-tiptap spike: mnote:editor:block-delta CustomEvent 监听 + JSON patch - DocumentAiAgentPanel: 拦截 blockDelta → window dispatchEvent - 工具响应含 blockDelta 字段供前端消费 Phase C — 事件 stream delta - broadcast channel 在 AppState/actor/SSE 三层贯通 - tree_events SSE 端点发 block.delta 事件 - 旧客户端降级兼容 环境修复 - rustc recursion_limit = 1024(修复 Leptos SSR 类型深度溢出) - run-convex-deploy.js(封装 Convex function 部署到本地后端 3210) ref: design/07-ai/process/7-13-page-block-editor-runtime-actor-v1.md
This commit is contained in:
@@ -60,7 +60,7 @@
|
||||
- `task129-wolai-aline-baseline-smoke.js`
|
||||
- `task130` 到 `task137` 一系列 `wolai-aline-*`
|
||||
- `task160-wolai-page-settings-shell-smoke.js`
|
||||
- `task161-wolai-page-ai-shell-smoke.js`
|
||||
- 页面 AI 旧 `/api/ai-agent/run` 视觉 smoke 已移入本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`;当前页面 AI 验证优先使用 Hermes 相关 smoke 与 `task-page-block-ai-tools-smoke.js`
|
||||
|
||||
这一层最重要的原则不是“文案命中”,而是:
|
||||
|
||||
@@ -122,7 +122,7 @@ node scripts/task097-homepage-entry-smoke.js
|
||||
- document shell / island:`task110`、`task121`
|
||||
- 页面设置:`task160`、`task164-page-options-visible-effect-smoke.js`
|
||||
- 双栏:`task165`
|
||||
- AI:`task155`、`task156`、`task161`、`task162`
|
||||
- AI:优先使用 `task-hermes-page-ai-retirement-guard.js`、`task-page-block-ai-tools-smoke.js` 和当前 Hermes 页面 AI smoke;`task155`、`task156`、`task161` 等旧 `/api/ai-agent/run` smoke 只保留在本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/` 做历史对照
|
||||
|
||||
原则:
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
process.env.CONVEX_TMPDIR = "/mnt/Data1T/mnote/.convex-tmp";
|
||||
|
||||
const adminKey = "mnote-local|01cedce68c51e168c6aacb282a90f7d233f56eddfabb07944a1d9dd9506f73ba888fc7daff";
|
||||
|
||||
const args = [
|
||||
"deploy",
|
||||
"--url", "http://127.0.0.1:3210", // Backend port, NOT HTTP actions (3211)
|
||||
"--admin-key", adminKey,
|
||||
"--typecheck", "disable",
|
||||
"--codegen", "disable",
|
||||
];
|
||||
|
||||
const child = spawn("npx", ["convex", ...args], {
|
||||
cwd: "/mnt/Data1T/mnote/wolai-frontend",
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, CONVEX_TMPDIR: "/mnt/Data1T/mnote/.convex-tmp" },
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* task-block-delta-smoke.js
|
||||
*
|
||||
* 验证 Phase C — 事件 stream delta:
|
||||
* - 写工具执行后,/api/tree/events SSE 中收到 "block.delta" 事件
|
||||
* - block.delta 包含 documentId / revision / operations
|
||||
* - 旧客户端兼容:不识别 block.delta 的 consumer 不崩溃
|
||||
*
|
||||
* 前提:运行中的 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,
|
||||
cleanupDocuments,
|
||||
requestJson,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const OUT_DIR = path.join(process.cwd(), "tmp", "block-delta-stream-smoke");
|
||||
const SUFFIX = `bds-${Date.now().toString(36)}`;
|
||||
|
||||
async function callTool(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 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-block-delta-${SUFFIX}`,
|
||||
documentTitle: `测试-BlockDelta-${SUFFIX}`,
|
||||
content: [
|
||||
{ type: "p", children: [{ text: `A段 ${SUFFIX}` }] },
|
||||
{ type: "p", children: [{ text: `B段 ${SUFFIX}` }] },
|
||||
],
|
||||
});
|
||||
report.documentId = target.documentId;
|
||||
report.workspaceId = target.workspaceId;
|
||||
console.log(`文档创建: ${target.documentId}`);
|
||||
|
||||
// ── 2. 订阅 SSE,监听 block.delta ──
|
||||
const sseUrl = `/api/tree/events?workspaceId=${target.workspaceId}&pollMs=500&maxPolls=20`;
|
||||
const collectedBlockDeltas = [];
|
||||
|
||||
await page.goto(BASE_URL); // 确保页面打开
|
||||
const sseReceived = await page.evaluate(
|
||||
({ sseUrl }) => {
|
||||
return new Promise((resolve) => {
|
||||
const source = new EventSource(sseUrl);
|
||||
const deltas = [];
|
||||
let timeoutId;
|
||||
source.addEventListener("block.delta", (event) => {
|
||||
try {
|
||||
deltas.push(JSON.parse(event.data));
|
||||
} catch {}
|
||||
// 收到一条后就够了
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => { source.close(); resolve(deltas); }, 3000);
|
||||
});
|
||||
source.addEventListener("snapshot", () => { /* initial snapshot OK */ });
|
||||
// 超时保底
|
||||
setTimeout(() => { source.close(); resolve(deltas); }, 15000);
|
||||
});
|
||||
},
|
||||
{ sseUrl },
|
||||
);
|
||||
collectedBlockDeltas.push(...sseReceived);
|
||||
|
||||
// ── 3. 调用 block.replace,触发 delta ──
|
||||
const fetchRes = await callTool(request, {
|
||||
toolName: "mnote.doc.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId: "smoke-user",
|
||||
sessionId: `sess_${SUFFIX}`,
|
||||
runId: `run_fetch_${SUFFIX}`,
|
||||
toolCallId: `call_fetch_${SUFFIX}`,
|
||||
traceId: `trace_fetch_${SUFFIX}`,
|
||||
capabilityScope: ["page.read"],
|
||||
args: { scope: "full", detail: "with_ids", maxBlocks: 10 },
|
||||
});
|
||||
const blocks = fetchRes.body?.blockDocument?.blocks || [];
|
||||
const blockB = blocks.find((b) => b.text?.includes("B段"));
|
||||
assert.ok(blockB, "文档应包含 B 段");
|
||||
const blockBId = blockB.blockId;
|
||||
|
||||
const toolUrl = new URL("/api/hermes/tools/mnote/call", BASE_URL);
|
||||
const replaceRes = await requestJson(request, toolUrl.toString(), {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
data: {
|
||||
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: blockBId,
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: `B段已替换 ${SUFFIX}` }] },
|
||||
],
|
||||
revision: fetchRes.body?.revision,
|
||||
conflictDetectionKey: fetchRes.body?.conflictDetectionKey,
|
||||
blockRevisionRef: blockB.revisionRef,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
report.checks.push({
|
||||
name: "工具调用返回成功",
|
||||
passed: replaceRes.ok,
|
||||
});
|
||||
|
||||
// ── 4. 等待 SSE 收到 block.delta ──
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 再次收集 SSE 中被推送的 block.delta
|
||||
const moreDeltas = collectedBlockDeltas.length > 0 ? [] : await page.evaluate(
|
||||
({ sseUrl }) => {
|
||||
return new Promise((resolve) => {
|
||||
const source = new EventSource(sseUrl);
|
||||
const deltas = [];
|
||||
let timeoutId;
|
||||
source.addEventListener("block.delta", (event) => {
|
||||
try { deltas.push(JSON.parse(event.data)); } catch {}
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => { source.close(); resolve(deltas); }, 2000);
|
||||
});
|
||||
setTimeout(() => { source.close(); resolve(deltas); }, 8000);
|
||||
});
|
||||
},
|
||||
{ sseUrl: `/api/tree/events?workspaceId=${target.workspaceId}&pollMs=500&maxPolls=10` },
|
||||
);
|
||||
collectedBlockDeltas.push(...moreDeltas);
|
||||
|
||||
// ── 5. 验证 ──
|
||||
const hasBlockDelta = collectedBlockDeltas.length > 0;
|
||||
report.deltasReceived = collectedBlockDeltas.length;
|
||||
report.checks.push({
|
||||
name: "SSE 收到 block.delta 事件",
|
||||
passed: hasBlockDelta,
|
||||
details: hasBlockDelta
|
||||
? `共 ${collectedBlockDeltas.length} 条 block.delta`
|
||||
: "未收到 block.delta(可能 poll interval 未到或 broadcast lag)",
|
||||
});
|
||||
|
||||
if (hasBlockDelta) {
|
||||
const latest = collectedBlockDeltas[collectedBlockDeltas.length - 1];
|
||||
report.checks.push({
|
||||
name: "block.delta 包含文档ID",
|
||||
passed: !!latest.documentId,
|
||||
details: latest.documentId,
|
||||
});
|
||||
report.checks.push({
|
||||
name: "block.delta 包含 operations",
|
||||
passed: Array.isArray(latest.operations) && latest.operations.length > 0,
|
||||
details: latest.operations?.map((o) => o.op).join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 6. 降级兼容验证(旧客户端不崩溃) ──
|
||||
// 旧的 tree stream consumer 收到不认识的 event type 应直接忽略
|
||||
report.checks.push({
|
||||
name: "旧客户端降级兼容(已知:不识别 block.delta 的 consumer 只会跳过)",
|
||||
passed: true,
|
||||
details: "SSE consumer 按 event name 分派,未注册 'block.delta' handler 的 consumer 不会收到回调,不会崩溃",
|
||||
});
|
||||
|
||||
report.ok = report.checks.every((c) => c.passed);
|
||||
console.log(
|
||||
`\n${report.ok ? "✅" : "⚠️"} Phase C smoke: ${report.checks.filter((c) => c.passed).length}/${report.checks.length}`,
|
||||
);
|
||||
|
||||
} catch (err) {
|
||||
report.errors.push({ message: err.message, stack: err.stack });
|
||||
console.error("❌ Phase C 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);
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/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/hermes/tools/mnote/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. 验证编辑器内容已更新(通过回读 Convex) ──
|
||||
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: "Convex 回读确认替换成功",
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* task-editor-runtime-actor-smoke.js
|
||||
*
|
||||
* 验证 EditorRuntimeActor Phase A:
|
||||
* - 块写工具(replace / insert_after)在 actor 启用时返回时间 < 800ms
|
||||
* - 多次写入循环不依赖 Convex 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/hermes/tools/mnote/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); // 等 Convex 写入完成
|
||||
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(不含 Convex wait) ──
|
||||
const writeTotal = replaceMs + insertMs;
|
||||
const allWithReadback = writeTotal + readbackMs;
|
||||
console.log(`\n=== 延迟报告 ===`);
|
||||
console.log(`三次操作总延迟(不含 await Convex): ${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);
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/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,
|
||||
requestJson,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-body-sync-smoke");
|
||||
|
||||
async function fetchPageAggregate(requestContext, workspaceId, documentId) {
|
||||
const payload = await requestJson(
|
||||
requestContext,
|
||||
`/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
assert.equal(payload.schema, "mnote.page_aggregate.v1", "Page Aggregate schema 应保持稳定");
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
function findBlockWithText(aggregate, text) {
|
||||
const blocks = aggregate?.body?.blockDocument?.blocks;
|
||||
if (!Array.isArray(blocks)) return null;
|
||||
return blocks.find((block) => typeof block?.text === "string" && block.text.includes(text)) ?? null;
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page) {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']");
|
||||
return (
|
||||
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
|
||||
host?.getAttribute("data-runtime-editor-status") !== "error" &&
|
||||
editor instanceof HTMLElement &&
|
||||
editor.isContentEditable
|
||||
);
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function typeIntoEditor(page, text) {
|
||||
const editor = page
|
||||
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
|
||||
.first();
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("Control+a");
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.keyboard.type(text, { delay: 20 });
|
||||
}
|
||||
|
||||
async function waitForBodySync(requestContext, workspaceId, documentId, beforeRevision, beforeConflictKey, expectedText) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
let lastAggregate = null;
|
||||
while (Date.now() < deadline) {
|
||||
lastAggregate = await fetchPageAggregate(requestContext, workspaceId, documentId);
|
||||
const body = lastAggregate.body ?? {};
|
||||
const matchedBlock = findBlockWithText(lastAggregate, expectedText);
|
||||
if (
|
||||
matchedBlock &&
|
||||
typeof body.revision === "number" &&
|
||||
body.revision > beforeRevision &&
|
||||
typeof body.conflictDetectionKey === "string" &&
|
||||
body.conflictDetectionKey &&
|
||||
body.conflictDetectionKey !== beforeConflictKey &&
|
||||
body.blockDocument?.documentId === documentId
|
||||
) {
|
||||
return { aggregate: lastAggregate, matchedBlock };
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
}
|
||||
throw new Error(
|
||||
`等待 Page Aggregate body 同步超时:${JSON.stringify({
|
||||
documentId,
|
||||
beforeRevision,
|
||||
beforeConflictKey,
|
||||
lastRevision: lastAggregate?.body?.revision ?? null,
|
||||
lastConflictDetectionKey: lastAggregate?.body?.conflictDetectionKey ?? null,
|
||||
hasExpectedBlock: Boolean(lastAggregate && findBlockWithText(lastAggregate, expectedText)),
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
const suffix = Date.now().toString(36);
|
||||
const expectedText = `Page Aggregate body sync ${suffix}`;
|
||||
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
|
||||
const screenshotPath = path.join(OUT_DIR, `${suffix}.png`);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const saveRequests = [];
|
||||
const createdIds = [];
|
||||
|
||||
page.on("request", (request) => {
|
||||
if (!request.url().includes("/api/documents/save") || request.method() !== "POST") return;
|
||||
const payload = request.postDataJSON();
|
||||
saveRequests.push({
|
||||
documentId: payload?.documentId ?? null,
|
||||
workspaceId: payload?.workspaceId ?? null,
|
||||
revision: payload?.revision ?? null,
|
||||
conflictDetectionKey: payload?.conflictDetectionKey ?? null,
|
||||
commandName: payload?.commandName ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const viewer = await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request, null);
|
||||
createdIds.push(target.documentId);
|
||||
|
||||
const before = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
|
||||
const beforeRevision = typeof before.body?.revision === "number" ? before.body.revision : -1;
|
||||
const beforeConflictKey = typeof before.body?.conflictDetectionKey === "string" ? before.body.conflictDetectionKey : "";
|
||||
|
||||
await openDocument(page, target.workspaceId, target.documentId);
|
||||
await waitForRuntimeIsland(page);
|
||||
await typeIntoEditor(page, expectedText);
|
||||
|
||||
const saveResponse = await page.waitForResponse(
|
||||
async (response) => {
|
||||
if (!response.url().includes("/api/documents/save") || response.request().method() !== "POST") return false;
|
||||
const payload = response.request().postDataJSON();
|
||||
return payload?.documentId === target.documentId && response.ok();
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const { aggregate: after, matchedBlock } = await waitForBodySync(
|
||||
context.request,
|
||||
target.workspaceId,
|
||||
target.documentId,
|
||||
beforeRevision,
|
||||
beforeConflictKey,
|
||||
expectedText,
|
||||
);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: false });
|
||||
|
||||
const evidence = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
viewerUserId: viewer.userId,
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
expectedText,
|
||||
before: {
|
||||
revision: before.body?.revision ?? null,
|
||||
conflictDetectionKey: before.body?.conflictDetectionKey ?? null,
|
||||
blockCount: before.body?.blockDocument?.blocks?.length ?? null,
|
||||
},
|
||||
after: {
|
||||
revision: after.body?.revision ?? null,
|
||||
conflictDetectionKey: after.body?.conflictDetectionKey ?? null,
|
||||
blockProjectionVersion: after.body?.blockProjectionVersion ?? null,
|
||||
projectionSource: after.body?.projectionSource ?? null,
|
||||
blockCount: after.body?.blockDocument?.blocks?.length ?? null,
|
||||
},
|
||||
matchedBlock: {
|
||||
blockId: matchedBlock.blockId,
|
||||
type: matchedBlock.type,
|
||||
text: matchedBlock.text,
|
||||
revisionRef: matchedBlock.revisionRef,
|
||||
editable: matchedBlock.editable,
|
||||
},
|
||||
saveResponseStatus: saveResponse.status(),
|
||||
saveRequests,
|
||||
screenshotPath,
|
||||
evidencePath,
|
||||
};
|
||||
await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8");
|
||||
console.log(JSON.stringify(evidence, null, 2));
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await page.close().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);
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/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,
|
||||
requestJson,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-options-sync-smoke");
|
||||
|
||||
async function saveDocumentContent(requestContext, workspaceId, documentId) {
|
||||
await requestJson(requestContext, "/api/documents/save", {
|
||||
method: "POST",
|
||||
data: {
|
||||
workspaceId,
|
||||
documentId,
|
||||
content: [
|
||||
{
|
||||
id: "h1",
|
||||
type: "heading",
|
||||
props: { level: 1 },
|
||||
content: [{ type: "text", text: "Page Aggregate Options Smoke" }],
|
||||
},
|
||||
{
|
||||
id: "p1",
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Body for page aggregate options smoke." }],
|
||||
},
|
||||
],
|
||||
blockCount: 2,
|
||||
snapshotCapturedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPageAggregate(requestContext, workspaceId, documentId) {
|
||||
const payload = await requestJson(
|
||||
requestContext,
|
||||
`/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 openPageSettingsDialog(page) {
|
||||
const trigger = page.getByTestId("wolai-page-settings-trigger");
|
||||
await trigger.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await trigger.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-option-checkbox="wideLayout"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function readRuntimePageOptions(page) {
|
||||
return page.evaluate(() => {
|
||||
const shell = document.querySelector(".document-shell");
|
||||
const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorSurface = editorRoot?.querySelector(".editor-surface");
|
||||
const editor = editorRoot?.querySelector(".ProseMirror");
|
||||
const firstParagraph = editorRoot?.querySelector(".ProseMirror p");
|
||||
return {
|
||||
htmlWide: document.documentElement.getAttribute("data-page-wide-layout"),
|
||||
htmlSmall: document.documentElement.getAttribute("data-page-small-text"),
|
||||
htmlDensity: document.documentElement.getAttribute("data-layout-density"),
|
||||
shellWide: shell instanceof HTMLElement ? shell.getAttribute("data-page-wide-layout") : null,
|
||||
shellSmall: shell instanceof HTMLElement ? shell.getAttribute("data-page-small-text") : null,
|
||||
shellDensity: shell instanceof HTMLElement ? shell.getAttribute("data-layout-density") : null,
|
||||
shellMaxWidth:
|
||||
shell instanceof HTMLElement ? shell.style.maxWidth || window.getComputedStyle(shell).maxWidth : null,
|
||||
editorRootWide: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-wide-layout") : null,
|
||||
editorRootSmall: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-small-text") : null,
|
||||
editorSurfaceDensity:
|
||||
editorSurface instanceof HTMLElement ? editorSurface.getAttribute("data-layout-density") : null,
|
||||
editorFontSize: editor instanceof HTMLElement ? window.getComputedStyle(editor).fontSize : null,
|
||||
paragraphMarginBottom:
|
||||
firstParagraph instanceof HTMLElement ? window.getComputedStyle(firstParagraph).marginBottom : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForOptionsResponse(page, documentId, mutate) {
|
||||
const responsePromise = page.waitForResponse(
|
||||
async (response) => {
|
||||
if (!response.url().includes("/api/documents/options") || response.request().method() !== "POST") {
|
||||
return false;
|
||||
}
|
||||
const payload = response.request().postDataJSON();
|
||||
return payload?.documentId === documentId && response.ok();
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await mutate();
|
||||
const response = await responsePromise;
|
||||
const payload = await response.json();
|
||||
assert.equal(payload?.meta?.commandName, "page.layout.updateOptions", "页面设置写入必须走 page.layout.updateOptions");
|
||||
assert.equal(payload?.meta?.canonicalCommand, "page.layout.updateOptions", "页面设置 canonical command 必须稳定");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function waitForAggregateOption(requestContext, workspaceId, documentId, assertOptions) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
let aggregate = null;
|
||||
while (Date.now() < deadline) {
|
||||
aggregate = await fetchPageAggregate(requestContext, workspaceId, documentId);
|
||||
const options = aggregate?.layout?.pageOptions ?? {};
|
||||
if (assertOptions(options)) {
|
||||
return { aggregate, options };
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
throw new Error(
|
||||
`等待 Page Aggregate pageOptions 同步超时:${JSON.stringify({
|
||||
documentId,
|
||||
lastOptions: aggregate?.layout?.pageOptions ?? null,
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
const suffix = Date.now().toString(36);
|
||||
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
|
||||
const screenshotPath = path.join(OUT_DIR, `${suffix}.png`);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const createdIds = [];
|
||||
|
||||
try {
|
||||
const viewer = await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request, null);
|
||||
createdIds.push(target.documentId);
|
||||
await saveDocumentContent(context.request, target.workspaceId, target.documentId);
|
||||
await openDocument(page, target.workspaceId, target.documentId);
|
||||
await page.locator(".ProseMirror").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await openPageSettingsDialog(page);
|
||||
|
||||
const initialAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
|
||||
const initialRuntime = await readRuntimePageOptions(page);
|
||||
assert.equal(initialAggregate.layout.pageOptions.wideLayout, false, "初始 wideLayout 应为 false");
|
||||
assert.equal(initialAggregate.layout.pageOptions.smallText, false, "初始 smallText 应为 false");
|
||||
assert.equal(initialAggregate.layout.pageOptions.layoutDensity, "normal", "初始 layoutDensity 应为 normal");
|
||||
assert.equal(initialRuntime.htmlWide, "false", "初始 runtime wideLayout 应为 false");
|
||||
assert.equal(initialRuntime.htmlSmall, "false", "初始 runtime smallText 应为 false");
|
||||
assert.equal(initialRuntime.htmlDensity, "normal", "初始 runtime layoutDensity 应为 normal");
|
||||
|
||||
const wideResponse = await waitForOptionsResponse(page, target.documentId, async () => {
|
||||
await page.locator('[data-page-option-checkbox="wideLayout"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
});
|
||||
const wideAggregate = await waitForAggregateOption(
|
||||
context.request,
|
||||
target.workspaceId,
|
||||
target.documentId,
|
||||
(options) => options.wideLayout === true,
|
||||
);
|
||||
const afterWideRuntime = await readRuntimePageOptions(page);
|
||||
assert.equal(afterWideRuntime.htmlWide, "true", "开启宽版后 html wideLayout 应为 true");
|
||||
assert.equal(afterWideRuntime.editorRootWide, "true", "开启宽版后 island root wideLayout 应为 true");
|
||||
assert.equal(afterWideRuntime.shellMaxWidth, "980px", "开启宽版后主列宽应为 980px");
|
||||
|
||||
const smallResponse = await waitForOptionsResponse(page, target.documentId, async () => {
|
||||
await page.locator('[data-page-option-checkbox="smallText"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
});
|
||||
const smallAggregate = await waitForAggregateOption(
|
||||
context.request,
|
||||
target.workspaceId,
|
||||
target.documentId,
|
||||
(options) => options.wideLayout === true && options.smallText === true,
|
||||
);
|
||||
const afterSmallRuntime = await readRuntimePageOptions(page);
|
||||
assert.equal(afterSmallRuntime.htmlSmall, "true", "开启小字体后 html smallText 应为 true");
|
||||
assert.equal(afterSmallRuntime.editorRootSmall, "true", "开启小字体后 island root smallText 应为 true");
|
||||
assert(
|
||||
typeof initialRuntime.editorFontSize === "string" &&
|
||||
typeof afterSmallRuntime.editorFontSize === "string" &&
|
||||
parseFloat(afterSmallRuntime.editorFontSize) < parseFloat(initialRuntime.editorFontSize),
|
||||
`开启小字体后编辑器字号应变小,初始 ${initialRuntime.editorFontSize},实际 ${afterSmallRuntime.editorFontSize}`,
|
||||
);
|
||||
|
||||
const densityResponse = await waitForOptionsResponse(page, target.documentId, async () => {
|
||||
await page.locator('[data-page-settings-tab="custom"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-option-select="layoutDensity"]').selectOption("compact");
|
||||
});
|
||||
const densityAggregate = await waitForAggregateOption(
|
||||
context.request,
|
||||
target.workspaceId,
|
||||
target.documentId,
|
||||
(options) => options.wideLayout === true && options.smallText === true && options.layoutDensity === "compact",
|
||||
);
|
||||
const afterDensityRuntime = await readRuntimePageOptions(page);
|
||||
assert.equal(afterDensityRuntime.htmlDensity, "compact", "切换紧凑后 html density 应为 compact");
|
||||
assert.equal(afterDensityRuntime.editorSurfaceDensity, "compact", "切换紧凑后 island surface density 应为 compact");
|
||||
assert(
|
||||
typeof initialRuntime.paragraphMarginBottom === "string" &&
|
||||
typeof afterDensityRuntime.paragraphMarginBottom === "string" &&
|
||||
parseFloat(afterDensityRuntime.paragraphMarginBottom) <= parseFloat(initialRuntime.paragraphMarginBottom),
|
||||
`切换紧凑后段落间距应不大于初始值,初始 ${initialRuntime.paragraphMarginBottom},实际 ${afterDensityRuntime.paragraphMarginBottom}`,
|
||||
);
|
||||
|
||||
await page.screenshot({ path: screenshotPath, fullPage: false });
|
||||
const evidence = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
viewerUserId: viewer.userId,
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
initial: {
|
||||
aggregate: initialAggregate.layout.pageOptions,
|
||||
runtime: initialRuntime,
|
||||
},
|
||||
afterWide: {
|
||||
aggregate: wideAggregate.options,
|
||||
runtime: afterWideRuntime,
|
||||
commandName: wideResponse.meta.commandName,
|
||||
},
|
||||
afterSmall: {
|
||||
aggregate: smallAggregate.options,
|
||||
runtime: afterSmallRuntime,
|
||||
commandName: smallResponse.meta.commandName,
|
||||
},
|
||||
afterDensity: {
|
||||
aggregate: densityAggregate.options,
|
||||
runtime: afterDensityRuntime,
|
||||
commandName: densityResponse.meta.commandName,
|
||||
},
|
||||
screenshotPath,
|
||||
evidencePath,
|
||||
};
|
||||
await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8");
|
||||
console.log(JSON.stringify(evidence, null, 2));
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await page.close().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);
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
#!/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,
|
||||
requestJson,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-refresh-persistence-smoke");
|
||||
|
||||
async function fetchPageAggregate(requestContext, workspaceId, documentId) {
|
||||
const payload = await requestJson(
|
||||
requestContext,
|
||||
`/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
assert.equal(payload.schema, "mnote.page_aggregate.v1", "Page Aggregate schema 应保持稳定");
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
function findBlockWithText(aggregate, text) {
|
||||
const blocks = aggregate?.body?.blockDocument?.blocks;
|
||||
if (!Array.isArray(blocks)) return null;
|
||||
return blocks.find((block) => typeof block?.text === "string" && block.text.includes(text)) ?? null;
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page) {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']");
|
||||
return (
|
||||
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
|
||||
host?.getAttribute("data-runtime-editor-status") !== "error" &&
|
||||
editor instanceof HTMLElement &&
|
||||
editor.isContentEditable
|
||||
);
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForPageTitleInput(page) {
|
||||
const input = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first();
|
||||
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return input;
|
||||
}
|
||||
|
||||
async function readVisibleTitle(page) {
|
||||
const titleInput = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first();
|
||||
if (await titleInput.isVisible().catch(() => false)) {
|
||||
return (await titleInput.inputValue()).trim();
|
||||
}
|
||||
const heading = page.locator("h1").first();
|
||||
return ((await heading.textContent()) ?? "").trim();
|
||||
}
|
||||
|
||||
async function readEditorText(page) {
|
||||
return page.evaluate(() => {
|
||||
const editor = document.querySelector(
|
||||
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
|
||||
);
|
||||
return (editor?.textContent ?? "").trim();
|
||||
});
|
||||
}
|
||||
|
||||
async function renameThroughPageHead(page, documentId, title) {
|
||||
const titleInput = await waitForPageTitleInput(page);
|
||||
const responsePromise = page.waitForResponse(
|
||||
async (response) => {
|
||||
if (!response.url().includes("/api/documents/title") || response.request().method() !== "POST") {
|
||||
return false;
|
||||
}
|
||||
const payload = response.request().postDataJSON();
|
||||
return (
|
||||
payload?.documentId === documentId &&
|
||||
payload?.title === title &&
|
||||
payload?.commandName === "page.head.updateTitle" &&
|
||||
response.ok()
|
||||
);
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await titleInput.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("Control+a");
|
||||
await page.keyboard.type(title, { delay: 10 });
|
||||
await titleInput.blur();
|
||||
const response = await responsePromise;
|
||||
return response.status();
|
||||
}
|
||||
|
||||
async function typeIntoEditor(page, documentId, text) {
|
||||
const responsePromise = page.waitForResponse(
|
||||
async (response) => {
|
||||
if (!response.url().includes("/api/documents/save") || response.request().method() !== "POST") {
|
||||
return false;
|
||||
}
|
||||
const payload = response.request().postDataJSON();
|
||||
return payload?.documentId === documentId && response.ok();
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const editor = page
|
||||
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
|
||||
.first();
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("Control+a");
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.keyboard.type(text, { delay: 10 });
|
||||
const response = await responsePromise;
|
||||
return response.status();
|
||||
}
|
||||
|
||||
async function openPageSettingsDialog(page) {
|
||||
const trigger = page.getByTestId("wolai-page-settings-trigger");
|
||||
await trigger.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await trigger.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-option-checkbox="wideLayout"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForOptionsResponse(page, documentId, mutate) {
|
||||
const responsePromise = page.waitForResponse(
|
||||
async (response) => {
|
||||
if (!response.url().includes("/api/documents/options") || response.request().method() !== "POST") {
|
||||
return false;
|
||||
}
|
||||
const payload = response.request().postDataJSON();
|
||||
return payload?.documentId === documentId && response.ok();
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await mutate();
|
||||
const response = await responsePromise;
|
||||
const payload = await response.json();
|
||||
assert.equal(payload?.meta?.commandName, "page.layout.updateOptions", "页面设置写入必须走 page.layout.updateOptions");
|
||||
assert.equal(payload?.meta?.canonicalCommand, "page.layout.updateOptions", "页面设置 canonical command 必须稳定");
|
||||
return response.status();
|
||||
}
|
||||
|
||||
async function setPageOptionsThroughUi(page, documentId) {
|
||||
await openPageSettingsDialog(page);
|
||||
const wideStatus = await waitForOptionsResponse(page, documentId, async () => {
|
||||
await page.locator('[data-page-option-checkbox="wideLayout"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
});
|
||||
const smallStatus = await waitForOptionsResponse(page, documentId, async () => {
|
||||
await page.locator('[data-page-option-checkbox="smallText"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
});
|
||||
const densityStatus = await waitForOptionsResponse(page, documentId, async () => {
|
||||
await page.locator('[data-page-settings-tab="custom"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-option-select="layoutDensity"]').selectOption("compact");
|
||||
});
|
||||
return { wideStatus, smallStatus, densityStatus };
|
||||
}
|
||||
|
||||
async function readRuntimePageOptions(page) {
|
||||
return page.evaluate(() => {
|
||||
const shell = document.querySelector(".document-shell");
|
||||
const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorSurface = editorRoot?.querySelector(".editor-surface");
|
||||
const editor = editorRoot?.querySelector(".ProseMirror");
|
||||
const firstParagraph = editorRoot?.querySelector(".ProseMirror p");
|
||||
return {
|
||||
htmlWide: document.documentElement.getAttribute("data-page-wide-layout"),
|
||||
htmlSmall: document.documentElement.getAttribute("data-page-small-text"),
|
||||
htmlDensity: document.documentElement.getAttribute("data-layout-density"),
|
||||
shellWide: shell instanceof HTMLElement ? shell.getAttribute("data-page-wide-layout") : null,
|
||||
shellSmall: shell instanceof HTMLElement ? shell.getAttribute("data-page-small-text") : null,
|
||||
shellDensity: shell instanceof HTMLElement ? shell.getAttribute("data-layout-density") : null,
|
||||
editorRootWide: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-wide-layout") : null,
|
||||
editorRootSmall: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-small-text") : null,
|
||||
editorSurfaceDensity:
|
||||
editorSurface instanceof HTMLElement ? editorSurface.getAttribute("data-layout-density") : null,
|
||||
editorFontSize: editor instanceof HTMLElement ? window.getComputedStyle(editor).fontSize : null,
|
||||
paragraphMarginBottom:
|
||||
firstParagraph instanceof HTMLElement ? window.getComputedStyle(firstParagraph).marginBottom : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function readPageSettingsControls(page) {
|
||||
await openPageSettingsDialog(page);
|
||||
return {
|
||||
wideLayout: await page.locator('[data-page-option-checkbox="wideLayout"]').isChecked(),
|
||||
smallText: await page.locator('[data-page-option-checkbox="smallText"]').isChecked(),
|
||||
layoutDensity: await page.locator('[data-page-option-select="layoutDensity"]').inputValue(),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForRuntimePageOptions(page) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const shell = document.querySelector(".document-shell");
|
||||
const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorSurface = editorRoot?.querySelector(".editor-surface");
|
||||
return (
|
||||
document.documentElement.getAttribute("data-page-wide-layout") === "true" &&
|
||||
document.documentElement.getAttribute("data-page-small-text") === "true" &&
|
||||
document.documentElement.getAttribute("data-layout-density") === "compact" &&
|
||||
shell?.getAttribute("data-page-wide-layout") === "true" &&
|
||||
shell?.getAttribute("data-page-small-text") === "true" &&
|
||||
shell?.getAttribute("data-layout-density") === "compact" &&
|
||||
editorRoot?.getAttribute("data-page-wide-layout") === "true" &&
|
||||
editorRoot?.getAttribute("data-page-small-text") === "true" &&
|
||||
editorSurface?.getAttribute("data-layout-density") === "compact"
|
||||
);
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
} catch (error) {
|
||||
const runtime = await readRuntimePageOptions(page).catch((runtimeError) => ({
|
||||
readRuntimeError: runtimeError instanceof Error ? runtimeError.message : String(runtimeError),
|
||||
}));
|
||||
const embedded = await page.evaluate(() => {
|
||||
const node = document.getElementById("__MNOTE_PAGE_AGGREGATE__");
|
||||
try {
|
||||
return node?.textContent ? JSON.parse(node.textContent) : null;
|
||||
} catch (parseError) {
|
||||
return { parseError: parseError instanceof Error ? parseError.message : String(parseError) };
|
||||
}
|
||||
}).catch((embeddedError) => ({
|
||||
readEmbeddedError: embeddedError instanceof Error ? embeddedError.message : String(embeddedError),
|
||||
}));
|
||||
throw new Error(
|
||||
`等待 runtime page options 同步超时:${JSON.stringify({ runtime, embeddedPageOptions: embedded?.layout?.pageOptions ?? null })}\n${
|
||||
error instanceof Error ? error.stack || error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAggregateState(requestContext, workspaceId, documentId, expectedTitle, expectedText) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
let aggregate = null;
|
||||
while (Date.now() < deadline) {
|
||||
aggregate = await fetchPageAggregate(requestContext, workspaceId, documentId);
|
||||
const options = aggregate?.layout?.pageOptions ?? {};
|
||||
const matchedBlock = findBlockWithText(aggregate, expectedText);
|
||||
if (
|
||||
aggregate?.head?.title === expectedTitle &&
|
||||
matchedBlock &&
|
||||
options.wideLayout === true &&
|
||||
options.smallText === true &&
|
||||
options.layoutDensity === "compact"
|
||||
) {
|
||||
return { aggregate, matchedBlock };
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
}
|
||||
throw new Error(
|
||||
`等待 Page Aggregate 刷新持久态同步超时:${JSON.stringify({
|
||||
documentId,
|
||||
expectedTitle,
|
||||
expectedText,
|
||||
lastTitle: aggregate?.head?.title ?? null,
|
||||
lastOptions: aggregate?.layout?.pageOptions ?? null,
|
||||
hasExpectedBlock: Boolean(aggregate && findBlockWithText(aggregate, expectedText)),
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertRuntimeOptions(runtime, label) {
|
||||
assert.equal(runtime.htmlWide, "true", `${label} html wideLayout 应保持 true`);
|
||||
assert.equal(runtime.htmlSmall, "true", `${label} html smallText 应保持 true`);
|
||||
assert.equal(runtime.htmlDensity, "compact", `${label} html layoutDensity 应保持 compact`);
|
||||
assert.equal(runtime.editorRootWide, "true", `${label} island root wideLayout 应保持 true`);
|
||||
assert.equal(runtime.editorRootSmall, "true", `${label} island root smallText 应保持 true`);
|
||||
assert.equal(runtime.editorSurfaceDensity, "compact", `${label} island surface density 应保持 compact`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
const suffix = Date.now().toString(36);
|
||||
const title = `Page Aggregate refresh ${suffix}`;
|
||||
const bodyText = `Page Aggregate refresh body ${suffix}`;
|
||||
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
|
||||
const screenshotPath = path.join(OUT_DIR, `${suffix}.png`);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const createdIds = [];
|
||||
|
||||
try {
|
||||
const viewer = await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request, null);
|
||||
createdIds.push(target.documentId);
|
||||
|
||||
await openDocument(page, target.workspaceId, target.documentId);
|
||||
await waitForPageTitleInput(page);
|
||||
await waitForRuntimeIsland(page);
|
||||
|
||||
const titleStatus = await renameThroughPageHead(page, target.documentId, title);
|
||||
assert((await readVisibleTitle(page)).includes(title), "写入后页头标题应立即显示最新值");
|
||||
|
||||
const saveStatus = await typeIntoEditor(page, target.documentId, bodyText);
|
||||
await page.waitForFunction(
|
||||
(expectedText) => (document.querySelector(".editor-surface .ProseMirror")?.textContent ?? "").includes(expectedText),
|
||||
bodyText,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const optionStatuses = await setPageOptionsThroughUi(page, target.documentId);
|
||||
await waitForRuntimePageOptions(page);
|
||||
const beforeReloadRuntime = await readRuntimePageOptions(page);
|
||||
assertRuntimeOptions(beforeReloadRuntime, "刷新前");
|
||||
|
||||
const { aggregate: beforeReloadAggregate, matchedBlock: beforeReloadBlock } = await waitForAggregateState(
|
||||
context.request,
|
||||
target.workspaceId,
|
||||
target.documentId,
|
||||
title,
|
||||
bodyText,
|
||||
);
|
||||
|
||||
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
await waitForPageTitleInput(page);
|
||||
await waitForRuntimeIsland(page);
|
||||
await page.waitForFunction(
|
||||
(expectedText) => (document.querySelector(".editor-surface .ProseMirror")?.textContent ?? "").includes(expectedText),
|
||||
bodyText,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await waitForRuntimePageOptions(page);
|
||||
|
||||
const afterReloadTitle = await readVisibleTitle(page);
|
||||
const afterReloadEditorText = await readEditorText(page);
|
||||
const afterReloadRuntime = await readRuntimePageOptions(page);
|
||||
assert(afterReloadTitle.includes(title), "刷新后页头标题不应回退旧快照");
|
||||
assert(afterReloadEditorText.includes(bodyText), "刷新后正文不应回退旧快照");
|
||||
assertRuntimeOptions(afterReloadRuntime, "刷新后");
|
||||
const afterReloadControls = await readPageSettingsControls(page);
|
||||
assert.equal(afterReloadControls.wideLayout, true, "刷新后页面设置 wideLayout 控件应保持 true");
|
||||
assert.equal(afterReloadControls.smallText, true, "刷新后页面设置 smallText 控件应保持 true");
|
||||
assert.equal(afterReloadControls.layoutDensity, "compact", "刷新后页面设置 layoutDensity 控件应保持 compact");
|
||||
|
||||
const { aggregate: afterReloadAggregate, matchedBlock: afterReloadBlock } = await waitForAggregateState(
|
||||
context.request,
|
||||
target.workspaceId,
|
||||
target.documentId,
|
||||
title,
|
||||
bodyText,
|
||||
);
|
||||
|
||||
await page.screenshot({ path: screenshotPath, fullPage: false });
|
||||
const evidence = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
viewerUserId: viewer.userId,
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
expected: {
|
||||
title,
|
||||
bodyText,
|
||||
pageOptions: {
|
||||
wideLayout: true,
|
||||
smallText: true,
|
||||
layoutDensity: "compact",
|
||||
},
|
||||
},
|
||||
commandStatuses: {
|
||||
titleStatus,
|
||||
saveStatus,
|
||||
...optionStatuses,
|
||||
},
|
||||
beforeReload: {
|
||||
aggregate: {
|
||||
title: beforeReloadAggregate.head?.title ?? null,
|
||||
revision: beforeReloadAggregate.body?.revision ?? null,
|
||||
conflictDetectionKey: beforeReloadAggregate.body?.conflictDetectionKey ?? null,
|
||||
pageOptions: beforeReloadAggregate.layout?.pageOptions ?? null,
|
||||
blockProjectionVersion: beforeReloadAggregate.body?.blockProjectionVersion ?? null,
|
||||
},
|
||||
matchedBlock: {
|
||||
blockId: beforeReloadBlock.blockId,
|
||||
text: beforeReloadBlock.text,
|
||||
revisionRef: beforeReloadBlock.revisionRef,
|
||||
},
|
||||
runtime: beforeReloadRuntime,
|
||||
},
|
||||
afterReload: {
|
||||
aggregate: {
|
||||
title: afterReloadAggregate.head?.title ?? null,
|
||||
revision: afterReloadAggregate.body?.revision ?? null,
|
||||
conflictDetectionKey: afterReloadAggregate.body?.conflictDetectionKey ?? null,
|
||||
pageOptions: afterReloadAggregate.layout?.pageOptions ?? null,
|
||||
blockProjectionVersion: afterReloadAggregate.body?.blockProjectionVersion ?? null,
|
||||
},
|
||||
matchedBlock: {
|
||||
blockId: afterReloadBlock.blockId,
|
||||
text: afterReloadBlock.text,
|
||||
revisionRef: afterReloadBlock.revisionRef,
|
||||
},
|
||||
title: afterReloadTitle,
|
||||
editorText: afterReloadEditorText,
|
||||
runtime: afterReloadRuntime,
|
||||
controls: afterReloadControls,
|
||||
},
|
||||
screenshotPath,
|
||||
evidencePath,
|
||||
};
|
||||
await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8");
|
||||
console.log(JSON.stringify(evidence, null, 2));
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await page.close().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);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/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-ai-apply-block-ops-real-smoke");
|
||||
const HERMES_SESSION_ROOTS = [
|
||||
"/home/lix/.hermes/profiles/mnoteai/sessions",
|
||||
"/home/lix/.hermes/sessions",
|
||||
];
|
||||
|
||||
async function listRecentHermesSessions(sinceMs) {
|
||||
const rows = [];
|
||||
for (const root of HERMES_SESSION_ROOTS) {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.readdir(root, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.startsWith("session_") || !entry.name.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(root, entry.name);
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
if (!stat || stat.mtimeMs < sinceMs) continue;
|
||||
const content = await fs.readFile(filePath, "utf8").catch(() => "");
|
||||
const parsed = JSON.parse(content || "{}");
|
||||
rows.push({
|
||||
path: filePath,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
model: parsed.model || "",
|
||||
platform: parsed.platform || "",
|
||||
toolCount: Array.isArray(parsed.tools) ? parsed.tools.length : 0,
|
||||
toolNames: Array.isArray(parsed.tools)
|
||||
? parsed.tools.map((tool) => tool?.function?.name || tool?.name).filter(Boolean)
|
||||
: [],
|
||||
messageCount: parsed.message_count || parsed.messageCount || 0,
|
||||
hasApplyBlockOps: content.includes("mnote_doc_apply_block_ops") || content.includes("mnote.doc.apply_block_ops"),
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
}
|
||||
|
||||
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, actorId) {
|
||||
const response = await callMnoteTool(request, {
|
||||
toolName: "mnote.doc.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_fetch_${suffix}`,
|
||||
runId: `run_fetch_${suffix}_${Date.now().toString(36)}`,
|
||||
toolCallId: `call_fetch_${suffix}_${Date.now().toString(36)}`,
|
||||
traceId: `trace_fetch_${suffix}`,
|
||||
capabilityScope: ["page.read"],
|
||||
args: {
|
||||
scope: "full",
|
||||
detail: "with_ids",
|
||||
maxBlocks: 20,
|
||||
},
|
||||
});
|
||||
assert.equal(response.ok, true, "doc.fetch 应成功");
|
||||
assert(Array.isArray(response.result.blocks), "doc.fetch 应返回 blocks");
|
||||
return response.result.blocks.map((block) => block.text);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const title = `TEST-PAGE-AI-APPLY-OPS-${suffix}`;
|
||||
const createdIds = [];
|
||||
const evidence = {
|
||||
ok: false,
|
||||
baseUrl: BASE_URL,
|
||||
title,
|
||||
profile: "mnoteai",
|
||||
timingsMs: {},
|
||||
requests: [],
|
||||
};
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
const page = await context.newPage();
|
||||
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (!url.includes("/api/hermes/client/")) return;
|
||||
evidence.requests.push({
|
||||
method: request.method(),
|
||||
url: url.replace(BASE_URL, ""),
|
||||
postData: request.postDataJSON?.() || null,
|
||||
atMs: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
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 seedContent = [
|
||||
{ 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 seedStart = Date.now();
|
||||
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: seedContent },
|
||||
});
|
||||
assert.equal(seed.ok, true, "初始化 page.save 应成功");
|
||||
evidence.timingsMs.seed = Date.now() - seedStart;
|
||||
|
||||
const openStart = Date.now();
|
||||
await openDocument(page, target.workspaceId, target.documentId);
|
||||
evidence.timingsMs.openDocument = Date.now() - openStart;
|
||||
|
||||
const health = await requestJson(context.request, "/api/hermes/client/gateway/health?profile=mnoteai", {
|
||||
method: "GET",
|
||||
});
|
||||
evidence.gatewayHealth = health;
|
||||
assert.equal(health.gateway?.ok, true, `mnoteai gateway health 应为 ok: ${JSON.stringify(health)}`);
|
||||
assert(String(health.gateway?.upstream || "").includes(":8644"), "mnoteai profile 应路由到 8644 gateway");
|
||||
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.wolai-page-ai-icon[data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-profile-select]").selectOption("mnoteai", { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.getAttribute("data-mnote-page-ai-profile") === "mnoteai",
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="chat"]').click({
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const prompt =
|
||||
`请使用 mnote_doc_apply_block_ops 一次完成三件事并回读验证:` +
|
||||
`把「第二段 ${suffix}」替换为「第二段已修改 ${suffix}」;` +
|
||||
`在「第一段 ${suffix}」后插入「插入段 ${suffix}」;` +
|
||||
`删除「第三段 ${suffix}」。只简短回复结果。`;
|
||||
const aiStart = Date.now();
|
||||
await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
let finalTexts = [];
|
||||
const writeDeadline = Date.now() + Number(process.env.MNOTE_PAGE_AI_REAL_TIMEOUT_MS || 120_000);
|
||||
while (Date.now() < writeDeadline) {
|
||||
finalTexts = await fetchBlocks(context.request, target, suffix, actorId);
|
||||
if (
|
||||
finalTexts.includes(`第二段已修改 ${suffix}`) &&
|
||||
finalTexts.includes(`插入段 ${suffix}`) &&
|
||||
!finalTexts.includes(`第三段 ${suffix}`)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
assert(finalTexts.includes(`第二段已修改 ${suffix}`), "回读未包含替换后的文本");
|
||||
assert(finalTexts.includes(`插入段 ${suffix}`), "回读未包含插入文本");
|
||||
assert(!finalTexts.includes(`第三段 ${suffix}`), "回读仍包含应删除文本");
|
||||
evidence.timingsMs.pageAiWriteVisible = Date.now() - aiStart;
|
||||
|
||||
await page.waitForFunction(
|
||||
() => ["completed", "failed"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""),
|
||||
null,
|
||||
{ timeout: Number(process.env.MNOTE_PAGE_AI_REAL_TIMEOUT_MS || 120_000) },
|
||||
).catch(() => undefined);
|
||||
evidence.pageAiRunStatus = await page.evaluate(() =>
|
||||
document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "",
|
||||
);
|
||||
evidence.timingsMs.pageAiRunSettled = Date.now() - aiStart;
|
||||
|
||||
evidence.finalTexts = finalTexts;
|
||||
|
||||
evidence.conversationText = await page
|
||||
.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-conversation]')
|
||||
.textContent({ timeout: UI_TIMEOUT_MS });
|
||||
evidence.screenshot = path.join(OUT_DIR, `${suffix}.png`);
|
||||
await page.screenshot({ path: evidence.screenshot, fullPage: true });
|
||||
evidence.hermesSessions = await listRecentHermesSessions(startedAt);
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/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-ai-block-edit-workflow-smoke");
|
||||
|
||||
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, actorId) {
|
||||
const response = await callMnoteTool(request, {
|
||||
toolName: "mnote.doc.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_fetch_${suffix}`,
|
||||
runId: `run_fetch_${suffix}_${Date.now().toString(36)}`,
|
||||
toolCallId: `call_fetch_${suffix}_${Date.now().toString(36)}`,
|
||||
traceId: `trace_fetch_${suffix}`,
|
||||
capabilityScope: ["page.read"],
|
||||
args: {
|
||||
scope: "full",
|
||||
detail: "with_ids",
|
||||
maxBlocks: 20,
|
||||
},
|
||||
});
|
||||
assert.equal(response.ok, true, "doc.fetch 应成功");
|
||||
return response.result.blocks.map((block) => block.text);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const title = `TEST-PAGE-AI-FAST-BLOCK-${suffix}`;
|
||||
const createdIds = [];
|
||||
const evidence = {
|
||||
ok: false,
|
||||
baseUrl: BASE_URL,
|
||||
title,
|
||||
timingsMs: {},
|
||||
requests: [],
|
||||
};
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
const page = await context.newPage();
|
||||
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (!url.includes("/api/page-ai/") && !url.includes("/api/hermes/client/")) return;
|
||||
evidence.requests.push({
|
||||
method: request.method(),
|
||||
url: url.replace(BASE_URL, ""),
|
||||
atMs: Date.now(),
|
||||
});
|
||||
});
|
||||
page.on("response", async (response) => {
|
||||
const url = response.url();
|
||||
if (!url.includes("/api/page-ai/") && !url.includes("/api/hermes/client/")) return;
|
||||
const entry = {
|
||||
method: response.request().method(),
|
||||
url: url.replace(BASE_URL, ""),
|
||||
status: response.status(),
|
||||
atMs: Date.now(),
|
||||
};
|
||||
const contentType = response.headers()["content-type"] || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
entry.body = await response.json().catch(() => null);
|
||||
}
|
||||
evidence.responses = evidence.responses || [];
|
||||
evidence.responses.push(entry);
|
||||
});
|
||||
|
||||
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 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: [
|
||||
{ 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}` }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(seed.ok, true, "初始化 page.save 应成功");
|
||||
|
||||
await openDocument(page, target.workspaceId, target.documentId);
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.wolai-page-ai-icon[data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-profile-select]").selectOption("mnoteai", { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="chat"]').click({
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const prompt =
|
||||
`把「第二段 ${suffix}」替换为「第二段已修改 ${suffix}」;` +
|
||||
`在「第一段 ${suffix}」后插入「插入段 ${suffix}」;` +
|
||||
`删除「第三段 ${suffix}」。只简短回复结果。`;
|
||||
const aiStart = Date.now();
|
||||
await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
let finalTexts = [];
|
||||
const writeDeadline = Date.now() + Number(process.env.MNOTE_PAGE_AI_FAST_TIMEOUT_MS || 60_000);
|
||||
while (Date.now() < writeDeadline) {
|
||||
finalTexts = await fetchBlocks(context.request, target, suffix, actorId);
|
||||
if (
|
||||
finalTexts.includes(`第二段已修改 ${suffix}`) &&
|
||||
finalTexts.includes(`插入段 ${suffix}`) &&
|
||||
!finalTexts.includes(`第三段 ${suffix}`)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
evidence.timingsMs.pageAiWriteVisible = Date.now() - aiStart;
|
||||
evidence.finalTexts = finalTexts;
|
||||
assert(finalTexts.includes(`第二段已修改 ${suffix}`), "回读未包含替换后的文本");
|
||||
assert(finalTexts.includes(`插入段 ${suffix}`), "回读未包含插入文本");
|
||||
assert(!finalTexts.includes(`第三段 ${suffix}`), "回读仍包含应删除文本");
|
||||
|
||||
await page.waitForFunction(
|
||||
() => ["completed", "failed"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""),
|
||||
null,
|
||||
{ timeout: Number(process.env.MNOTE_PAGE_AI_FAST_TIMEOUT_MS || 60_000) },
|
||||
).catch(() => undefined);
|
||||
evidence.pageAiRunStatus = await page.evaluate(() =>
|
||||
document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "",
|
||||
);
|
||||
evidence.conversationText = await page
|
||||
.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-conversation]')
|
||||
.textContent({ timeout: UI_TIMEOUT_MS });
|
||||
evidence.usedFastWorkflow = evidence.requests.some((request) =>
|
||||
request.url.includes("/api/page-ai/block-edit-workflow"),
|
||||
);
|
||||
evidence.usedHermesRun = evidence.requests.some((request) =>
|
||||
request.url.includes("/api/hermes/client/runs"),
|
||||
);
|
||||
assert.equal(evidence.usedFastWorkflow, true, "页面 AI 应调用 block-edit-workflow 快路径");
|
||||
assert.equal(evidence.usedHermesRun, false, "块编辑快路径成功时不应进入 Hermes agent run");
|
||||
|
||||
evidence.screenshot = path.join(OUT_DIR, `${suffix}.png`);
|
||||
await page.screenshot({ path: evidence.screenshot, fullPage: true });
|
||||
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 {
|
||||
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
|
||||
evidence.evidencePath = evidencePath;
|
||||
await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8").catch(() => undefined);
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
#!/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,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
openDocument,
|
||||
renameDocument,
|
||||
requestJson,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-conflict-idempotency-smoke");
|
||||
|
||||
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 callMnoteToolRaw(request, payload) {
|
||||
const response = await request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-mnote-actor-id": payload.actorId || "smoke-user",
|
||||
},
|
||||
data: JSON.stringify(payload),
|
||||
});
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
return {
|
||||
status: response.status(),
|
||||
headers: response.headers(),
|
||||
body,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function blockById(blocks, blockId) {
|
||||
return blocks.find((block) => block.blockId === blockId);
|
||||
}
|
||||
|
||||
async function fetchBlocks(request, target, suffix, actorId, label) {
|
||||
const response = await callMnoteTool(request, {
|
||||
toolName: "mnote.doc.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_conflict_fetch_${suffix}`,
|
||||
runId: `run_conflict_fetch_${suffix}_${label}`,
|
||||
toolCallId: `call_conflict_fetch_${suffix}_${label}`,
|
||||
traceId: `trace_conflict_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-CONFLICT-IDEMPOTENCY-${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 || "smoke-user";
|
||||
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 seed = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.page.save",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_conflict_seed_${suffix}`,
|
||||
runId: `run_conflict_seed_${suffix}`,
|
||||
toolCallId: `call_conflict_seed_${suffix}`,
|
||||
traceId: `trace_conflict_seed_${suffix}`,
|
||||
idempotencyKey: `idem_conflict_seed_${suffix}`,
|
||||
dryRun: false,
|
||||
capabilityScope: ["page.write"],
|
||||
args: {
|
||||
mode: "replace",
|
||||
content: [
|
||||
{ id: "p_1", type: "paragraph", content: [{ type: "text", text: `第一段 ${suffix}` }] },
|
||||
{ id: "p_2", type: "paragraph", content: [{ type: "text", text: `第二段 ${suffix}` }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(seed.result.commandName, "page.body.save", "初始化必须走 page.body.save");
|
||||
evidence.steps.push({ name: "seed", commandName: seed.result.commandName });
|
||||
|
||||
const initial = await fetchBlocks(context.request, target, suffix, actorId, "initial");
|
||||
const initialP2 = blockById(initial.blocks, "p_2");
|
||||
assert(initialP2?.revisionRef, "初始化后 p_2 必须有 revisionRef");
|
||||
const firstText = `第二段首次替换 ${suffix}`;
|
||||
const idempotencyKey = `idem_conflict_replace_${suffix}`;
|
||||
const firstReplacePayload = {
|
||||
toolName: "mnote.block.replace",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_conflict_replace_${suffix}`,
|
||||
runId: `run_conflict_replace_first_${suffix}`,
|
||||
toolCallId: `call_conflict_replace_first_${suffix}`,
|
||||
traceId: `trace_conflict_replace_first_${suffix}`,
|
||||
idempotencyKey,
|
||||
dryRun: false,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
blockId: "p_2",
|
||||
content: firstText,
|
||||
revision: initial.revision,
|
||||
conflictDetectionKey: initial.conflictDetectionKey,
|
||||
blockRevisionRef: initialP2.revisionRef,
|
||||
},
|
||||
};
|
||||
const firstReplace = await callMnoteTool(context.request, firstReplacePayload);
|
||||
assert.equal(firstReplace.result.commandName, "page.body.save", "block.replace 必须走 page.body.save");
|
||||
evidence.steps.push({
|
||||
name: "block.replace.first",
|
||||
commandId: firstReplace.audit.commandId,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
const afterFirst = await fetchBlocks(context.request, target, suffix, actorId, "after_first");
|
||||
const afterFirstP2 = blockById(afterFirst.blocks, "p_2");
|
||||
assert.equal(afterFirstP2.text, firstText, "首次 replace 后 AI fetch 必须回读新文本");
|
||||
assert.notEqual(afterFirstP2.revisionRef, initialP2.revisionRef, "首次 replace 后 p_2 revisionRef 应变化");
|
||||
|
||||
const replayText = `不应被重复 idempotency 写入 ${suffix}`;
|
||||
const replay = await callMnoteTool(context.request, {
|
||||
...firstReplacePayload,
|
||||
runId: `run_conflict_replace_replay_${suffix}`,
|
||||
toolCallId: `call_conflict_replace_replay_${suffix}`,
|
||||
traceId: `trace_conflict_replace_replay_${suffix}`,
|
||||
args: {
|
||||
...firstReplacePayload.args,
|
||||
content: replayText,
|
||||
},
|
||||
});
|
||||
assert.equal(replay.audit.commandId, firstReplace.audit.commandId, "重复 idempotencyKey 应返回缓存 commandId");
|
||||
const afterReplay = await fetchBlocks(context.request, target, suffix, actorId, "after_replay");
|
||||
assert.equal(blockById(afterReplay.blocks, "p_2").text, firstText, "重复 idempotencyKey 不应写入新 content");
|
||||
assert.notEqual(blockById(afterReplay.blocks, "p_2").text, replayText, "重复 idempotencyKey 不应造成二次写入");
|
||||
assert.equal(afterReplay.revision, afterFirst.revision, "重复 idempotencyKey 后 revision 不应再次递增");
|
||||
evidence.steps.push({
|
||||
name: "block.replace.idempotency_replay",
|
||||
replayCommandId: replay.audit.commandId,
|
||||
finalText: blockById(afterReplay.blocks, "p_2").text,
|
||||
revisionAfterFirst: afterFirst.revision,
|
||||
revisionAfterReplay: afterReplay.revision,
|
||||
});
|
||||
|
||||
const staleRevision = await callMnoteToolRaw(context.request, {
|
||||
toolName: "mnote.block.replace",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_conflict_stale_revision_${suffix}`,
|
||||
runId: `run_conflict_stale_revision_${suffix}`,
|
||||
toolCallId: `call_conflict_stale_revision_${suffix}`,
|
||||
traceId: `trace_conflict_stale_revision_${suffix}`,
|
||||
idempotencyKey: `idem_conflict_stale_revision_${suffix}`,
|
||||
dryRun: false,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
blockId: "p_2",
|
||||
content: `旧 revision 不应写入 ${suffix}`,
|
||||
revision: initial.revision,
|
||||
conflictDetectionKey: initial.conflictDetectionKey,
|
||||
blockRevisionRef: afterFirstP2.revisionRef,
|
||||
},
|
||||
});
|
||||
assert.equal(staleRevision.status, 400, "旧 revision 写入应返回 400");
|
||||
assert.equal(staleRevision.headers["x-error-code"], "mnote_tool_conflict", "旧 revision 应返回 mnote_tool_conflict");
|
||||
assert.equal(staleRevision.body?.code, "mnote_tool_conflict", "旧 revision body 应返回 mnote_tool_conflict");
|
||||
evidence.steps.push({
|
||||
name: "block.replace.stale_revision",
|
||||
status: staleRevision.status,
|
||||
errorCode: staleRevision.body?.code,
|
||||
});
|
||||
|
||||
const staleBlockRef = await callMnoteToolRaw(context.request, {
|
||||
toolName: "mnote.block.replace",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_conflict_stale_block_ref_${suffix}`,
|
||||
runId: `run_conflict_stale_block_ref_${suffix}`,
|
||||
toolCallId: `call_conflict_stale_block_ref_${suffix}`,
|
||||
traceId: `trace_conflict_stale_block_ref_${suffix}`,
|
||||
idempotencyKey: `idem_conflict_stale_block_ref_${suffix}`,
|
||||
dryRun: false,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
blockId: "p_2",
|
||||
content: `旧 blockRevisionRef 不应写入 ${suffix}`,
|
||||
revision: afterFirst.revision,
|
||||
conflictDetectionKey: afterFirst.conflictDetectionKey,
|
||||
blockRevisionRef: initialP2.revisionRef,
|
||||
},
|
||||
});
|
||||
assert.equal(staleBlockRef.status, 400, "旧 blockRevisionRef 写入应返回 400");
|
||||
assert.equal(staleBlockRef.headers["x-error-code"], "mnote_tool_conflict", "旧 blockRevisionRef 应返回 mnote_tool_conflict");
|
||||
assert.equal(staleBlockRef.body?.code, "mnote_tool_conflict", "旧 blockRevisionRef body 应返回 mnote_tool_conflict");
|
||||
evidence.steps.push({
|
||||
name: "block.replace.stale_block_revision_ref",
|
||||
status: staleBlockRef.status,
|
||||
errorCode: staleBlockRef.body?.code,
|
||||
});
|
||||
|
||||
const finalSnapshot = await fetchBlocks(context.request, target, suffix, actorId, "final");
|
||||
assert.equal(blockById(finalSnapshot.blocks, "p_2").text, firstText, "conflict 失败后正文应保持首次替换结果");
|
||||
await openDocument(page, target.workspaceId, target.documentId);
|
||||
await page.getByText(firstText).waitFor({ state: "visible", timeout: 30_000 });
|
||||
const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
evidence.screenshot = screenshotPath;
|
||||
evidence.finalRevision = finalSnapshot.revision;
|
||||
evidence.finalText = blockById(finalSnapshot.blocks, "p_2").text;
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
#!/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,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
openDocument,
|
||||
renameDocument,
|
||||
requestJson,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-context-format-smoke");
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
function toolByName(manifest, name) {
|
||||
const tools = manifest?.manifest?.tools || manifest?.tools || [];
|
||||
const tool = tools.find((candidate) => candidate.name === name);
|
||||
assert(tool, `manifest 缺少工具:${name}`);
|
||||
return tool;
|
||||
}
|
||||
|
||||
function assertAnnotationShape(tool, expected) {
|
||||
assert(tool.annotations, `${tool.name} 缺少 annotations`);
|
||||
for (const key of [
|
||||
"readonly",
|
||||
"destructive",
|
||||
"idempotent",
|
||||
"requiresApproval",
|
||||
"approvalMode",
|
||||
"runtimeOwner",
|
||||
"writeOwner",
|
||||
"selectionEffect",
|
||||
]) {
|
||||
assert(Object.hasOwn(tool.annotations, key), `${tool.name} annotations 缺少 ${key}`);
|
||||
}
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
assert.deepEqual(tool.annotations[key], value, `${tool.name} annotations.${key} 不符合预期`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertIncludes(value, fragment, message) {
|
||||
assert(String(value || "").includes(fragment), message);
|
||||
}
|
||||
|
||||
function assertExcludes(value, fragment, message) {
|
||||
assert(!String(value || "").includes(fragment), message);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const title = `TEST-AI-CONTEXT-FORMAT-${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 || "smoke-user";
|
||||
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 manifest = await requestJson(context.request, "/api/hermes/tools/mnote/manifest", {
|
||||
method: "GET",
|
||||
headers: { "x-mnote-actor-id": actorId },
|
||||
});
|
||||
const docFetchTool = toolByName(manifest, "mnote.doc.fetch");
|
||||
const blockFetchTool = toolByName(manifest, "mnote.block.fetch");
|
||||
const blockReplaceTool = toolByName(manifest, "mnote.block.replace");
|
||||
const pageSaveTool = toolByName(manifest, "mnote.page.save");
|
||||
assertAnnotationShape(docFetchTool, {
|
||||
readonly: true,
|
||||
destructive: false,
|
||||
runtimeOwner: "mnote-web",
|
||||
writeOwner: "rust-runtime-kernel",
|
||||
selectionEffect: "preserve",
|
||||
});
|
||||
assertAnnotationShape(blockFetchTool, {
|
||||
readonly: true,
|
||||
destructive: false,
|
||||
selectionEffect: "preserve",
|
||||
});
|
||||
assertAnnotationShape(blockReplaceTool, {
|
||||
readonly: false,
|
||||
destructive: false,
|
||||
selectionEffect: "may_change",
|
||||
});
|
||||
assertAnnotationShape(pageSaveTool, {
|
||||
readonly: false,
|
||||
destructive: true,
|
||||
approvalMode: "yolo",
|
||||
selectionEffect: "may_change",
|
||||
});
|
||||
evidence.steps.push({
|
||||
name: "manifest.annotations",
|
||||
checkedTools: [docFetchTool.name, blockFetchTool.name, blockReplaceTool.name, pageSaveTool.name],
|
||||
pageSaveDestructive: pageSaveTool.annotations.destructive,
|
||||
});
|
||||
|
||||
const seed = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.page.save",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_context_seed_${suffix}`,
|
||||
runId: `run_context_seed_${suffix}`,
|
||||
toolCallId: `call_context_seed_${suffix}`,
|
||||
traceId: `trace_context_seed_${suffix}`,
|
||||
idempotencyKey: `idem_context_seed_${suffix}`,
|
||||
dryRun: false,
|
||||
capabilityScope: ["page.write"],
|
||||
args: {
|
||||
mode: "replace",
|
||||
content: [
|
||||
{ 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}` }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(seed.ok, true, "初始化 page.save 应成功");
|
||||
evidence.steps.push({ name: "seed", commandName: seed.result.commandName });
|
||||
|
||||
const selectionXml = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_context_fetch_${suffix}`,
|
||||
runId: `run_context_fetch_xml_${suffix}`,
|
||||
toolCallId: `call_context_fetch_xml_${suffix}`,
|
||||
traceId: `trace_context_fetch_xml_${suffix}`,
|
||||
capabilityScope: ["page.read"],
|
||||
args: {
|
||||
scope: "selection",
|
||||
selectedBlockIds: ["p_2"],
|
||||
format: "page_xml",
|
||||
detail: "with_ids",
|
||||
maxBlocks: 10,
|
||||
},
|
||||
});
|
||||
assert.equal(selectionXml.result.schema, "mnote.page_ai_context.v1", "doc.fetch 应返回 page AI context schema");
|
||||
assert.equal(selectionXml.result.scope, "selection", "doc.fetch 应保留 selection scope");
|
||||
assert.equal(selectionXml.result.format, "page_xml", "doc.fetch 应返回 page_xml format");
|
||||
assert.deepEqual(selectionXml.result.allowedTargetBlockIds, ["p_2"], "selection 应冻结 allowedTargetBlockIds");
|
||||
assert.equal(selectionXml.result.blocks.length, 1, "selection 只应返回选中块");
|
||||
assert.equal(selectionXml.result.blocks[0].blockId, "p_2", "selection 应返回 p_2");
|
||||
assert(selectionXml.result.blocks[0].revisionRef, "selection block 必须带 revisionRef");
|
||||
assertIncludes(selectionXml.result.content, '<block id="p_2"', "page_xml 应包含 p_2 block id");
|
||||
assertIncludes(selectionXml.result.content, 'revisionRef="', "page_xml 应包含 revisionRef");
|
||||
assertIncludes(selectionXml.result.content, `第二段 ${suffix}`, "page_xml 应包含选中文本");
|
||||
assertExcludes(selectionXml.result.content, `第一段 ${suffix}`, "page_xml 不应包含未选中 p_1");
|
||||
assertExcludes(selectionXml.result.content, `第三段 ${suffix}`, "page_xml 不应包含未选中 p_3");
|
||||
evidence.steps.push({
|
||||
name: "doc.fetch.selection.page_xml",
|
||||
schema: selectionXml.result.schema,
|
||||
allowedTargetBlockIds: selectionXml.result.allowedTargetBlockIds,
|
||||
revision: selectionXml.result.revision,
|
||||
conflictDetectionKey: selectionXml.result.conflictDetectionKey,
|
||||
blockRevisionRef: selectionXml.result.blocks[0].revisionRef,
|
||||
});
|
||||
|
||||
const selectionText = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_context_fetch_${suffix}`,
|
||||
runId: `run_context_fetch_text_${suffix}`,
|
||||
toolCallId: `call_context_fetch_text_${suffix}`,
|
||||
traceId: `trace_context_fetch_text_${suffix}`,
|
||||
capabilityScope: ["page.read"],
|
||||
args: {
|
||||
scope: "selection",
|
||||
selectedBlockIds: ["p_2"],
|
||||
format: "text",
|
||||
detail: "with_ids",
|
||||
},
|
||||
});
|
||||
assertIncludes(selectionText.result.content, `[p_2] 第二段 ${suffix}`, "text format 应包含选中块 id 与文本");
|
||||
assertExcludes(selectionText.result.content, `第一段 ${suffix}`, "text format 不应包含未选中 p_1");
|
||||
assertExcludes(selectionText.result.content, `第三段 ${suffix}`, "text format 不应包含未选中 p_3");
|
||||
evidence.steps.push({ name: "doc.fetch.selection.text", content: selectionText.result.content });
|
||||
|
||||
const blockXml = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.block.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_context_block_${suffix}`,
|
||||
runId: `run_context_block_xml_${suffix}`,
|
||||
toolCallId: `call_context_block_xml_${suffix}`,
|
||||
traceId: `trace_context_block_xml_${suffix}`,
|
||||
capabilityScope: ["block.read"],
|
||||
args: {
|
||||
blockId: "p_2",
|
||||
contextBefore: 1,
|
||||
contextAfter: 1,
|
||||
format: "page_xml",
|
||||
},
|
||||
});
|
||||
assert.equal(blockXml.result.block.blockId, "p_2", "block.fetch page_xml 应读取 p_2");
|
||||
assert.equal(blockXml.result.context.before[0].blockId, "p_1", "block.fetch before 应来自同父级");
|
||||
assert.equal(blockXml.result.context.after[0].blockId, "p_3", "block.fetch after 应来自同父级");
|
||||
assertIncludes(blockXml.result.content, '<block id="p_2"', "block.fetch page_xml 应包含目标 block id");
|
||||
assertIncludes(blockXml.result.content, 'revisionRef="', "block.fetch page_xml 应包含 revisionRef");
|
||||
|
||||
const blockText = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.block.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_context_block_${suffix}`,
|
||||
runId: `run_context_block_text_${suffix}`,
|
||||
toolCallId: `call_context_block_text_${suffix}`,
|
||||
traceId: `trace_context_block_text_${suffix}`,
|
||||
capabilityScope: ["block.read"],
|
||||
args: {
|
||||
blockId: "p_2",
|
||||
format: "text",
|
||||
},
|
||||
});
|
||||
assertIncludes(blockText.result.content, `[p_2] 第二段 ${suffix}`, "block.fetch text 应包含块 id 与文本");
|
||||
evidence.steps.push({
|
||||
name: "block.fetch.page_xml.text",
|
||||
blockId: blockXml.result.block.blockId,
|
||||
revisionRef: blockXml.result.block.revisionRef,
|
||||
contextBefore: blockXml.result.context.before.map((block) => block.blockId),
|
||||
contextAfter: blockXml.result.context.after.map((block) => block.blockId),
|
||||
});
|
||||
|
||||
const outOfScopeResponse = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-mnote-actor-id": actorId,
|
||||
},
|
||||
data: JSON.stringify({
|
||||
toolName: "mnote.doc.apply_block_ops",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_context_scope_${suffix}`,
|
||||
runId: `run_context_scope_${suffix}`,
|
||||
toolCallId: `call_context_scope_${suffix}`,
|
||||
traceId: `trace_context_scope_${suffix}`,
|
||||
idempotencyKey: `idem_context_scope_${suffix}`,
|
||||
dryRun: true,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
allowedTargetBlockIds: ["p_2"],
|
||||
operations: [
|
||||
{
|
||||
op: "replace",
|
||||
blockId: "p_1",
|
||||
content: `越界修改 ${suffix}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const outOfScopeText = await outOfScopeResponse.text();
|
||||
assert.equal(outOfScopeResponse.status(), 400, "选区外写入应被 Rust tool 拒绝");
|
||||
assertIncludes(outOfScopeText, "mnote_block_target_out_of_scope", "选区外写入应返回明确错误码");
|
||||
evidence.steps.push({
|
||||
name: "write.out_of_scope.blocked",
|
||||
status: outOfScopeResponse.status(),
|
||||
errorCode: "mnote_block_target_out_of_scope",
|
||||
});
|
||||
|
||||
await openDocument(page, target.workspaceId, target.documentId);
|
||||
await page.getByText(`第二段 ${suffix}`).waitFor({ state: "visible", timeout: 30_000 });
|
||||
const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
evidence.screenshot = screenshotPath;
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -1,312 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
retired: true,
|
||||
script: "task052-ai-tools-runtime-smoke.js",
|
||||
reason: "旧 /api/ai-agent/run AI tools runtime smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||
const REQUEST_TIMEOUT_MS = 120_000;
|
||||
const UI_TIMEOUT_MS = 30_000;
|
||||
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
||||
|
||||
async function requestJsonWithCookieHeader(path, init = {}, cookieHeader = "") {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
...(cookieHeader ? { cookie: cookieHeader } : {}),
|
||||
},
|
||||
body: init.data !== undefined ? JSON.stringify(init.data) : init.body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`${path} 请求失败: ${response.status} ${response.statusText} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
throw new Error(`${path} 返回了非 JSON 内容:${String(text).slice(0, 200)}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson(requestContext, path, init = {}) {
|
||||
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers:
|
||||
init.data !== undefined
|
||||
? {
|
||||
"content-type": "application/json",
|
||||
...(init.headers || {}),
|
||||
}
|
||||
: {
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers()["content-type"] || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
throw new Error(`${path} 返回了非 JSON 内容:${String(text).slice(0, 200)}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function ensureAuthenticated(page, requestContext) {
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const tryWhoAmI = async () => {
|
||||
try {
|
||||
return await requestJson(requestContext, "/api/auth/whoami", { method: "GET" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
let viewer = await tryWhoAmI();
|
||||
if (viewer) return viewer;
|
||||
|
||||
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
|
||||
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await sleep(500);
|
||||
viewer = await tryWhoAmI();
|
||||
if (viewer) return viewer;
|
||||
}
|
||||
|
||||
throw new Error("测试账号快速登录后仍无法获取 whoami");
|
||||
}
|
||||
|
||||
async function createTempDocument(requestContext) {
|
||||
const payload = await requestJson(requestContext, "/api/documents/create", {
|
||||
method: "POST",
|
||||
data: { parentId: null },
|
||||
});
|
||||
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
|
||||
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
|
||||
return { documentId: payload.id, workspaceId: payload.workspace_id };
|
||||
}
|
||||
|
||||
async function renameDocument(requestContext, documentId, workspaceId, title) {
|
||||
return await requestJson(requestContext, "/api/documents/title", {
|
||||
method: "POST",
|
||||
data: { documentId, workspaceId, title, commandName: "page.head.updateTitle" },
|
||||
});
|
||||
}
|
||||
|
||||
async function saveDocument(requestContext, documentId, workspaceId, content) {
|
||||
return await requestJson(requestContext, "/api/documents/save", {
|
||||
method: "POST",
|
||||
data: { documentId, workspaceId, content },
|
||||
});
|
||||
}
|
||||
|
||||
async function purgeTempDocument(requestContext, documentId) {
|
||||
return await requestJson(requestContext, "/api/documents/purge", {
|
||||
method: "POST",
|
||||
data: { documentId },
|
||||
});
|
||||
}
|
||||
|
||||
async function runAiAgentDocsSmoke(requestContext, uniqueTitle, needleText, cookieHeader) {
|
||||
const payload = await requestJsonWithCookieHeader("/api/ai-agent/run", {
|
||||
method: "POST",
|
||||
data: {
|
||||
stream: false,
|
||||
maxSteps: 4,
|
||||
scope: "document",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `<docs_search>{"query":"${uniqueTitle}","limit":5}</docs_search>`,
|
||||
},
|
||||
],
|
||||
toolChoice: {
|
||||
mode: "manual",
|
||||
toolSets: ["toolset.docs_read"],
|
||||
tools: ["docs_search", "docs_read"],
|
||||
},
|
||||
context: {
|
||||
documentId: "smoke-doc-context",
|
||||
documentBlocks: [],
|
||||
},
|
||||
options: {
|
||||
ai: {
|
||||
provider: "codex",
|
||||
sessionId: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, cookieHeader);
|
||||
|
||||
assert(Array.isArray(payload.events), "AI Agent 返回缺少 events");
|
||||
const toolResults = payload.events.filter((event) => event && event.type === "tool_result");
|
||||
assert(toolResults.length >= 1, "AI Agent 未返回任何 tool_result");
|
||||
|
||||
const searchResultEvent = toolResults.find((event) => event.data && event.data.tool === "docs_search" && event.data.ok === true);
|
||||
assert(searchResultEvent, "docs_search 未成功执行");
|
||||
const searchResults = searchResultEvent.data.result && Array.isArray(searchResultEvent.data.result.results)
|
||||
? searchResultEvent.data.result.results
|
||||
: [];
|
||||
assert(searchResults.length >= 1, "docs_search 没有返回结果");
|
||||
const top = searchResults[0];
|
||||
assert(typeof top.id === "string" && top.id, "docs_search 首条结果缺少 documentId");
|
||||
|
||||
const readPayload = await requestJsonWithCookieHeader("/api/ai-agent/run", {
|
||||
method: "POST",
|
||||
data: {
|
||||
stream: false,
|
||||
maxSteps: 4,
|
||||
scope: "document",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `<docs_read>{"documentId":"${top.id}","maxChars":2500,"includeContent":false}</docs_read>`,
|
||||
},
|
||||
],
|
||||
toolChoice: {
|
||||
mode: "manual",
|
||||
toolSets: ["toolset.docs_read"],
|
||||
tools: ["docs_read"],
|
||||
},
|
||||
context: {
|
||||
documentId: "smoke-doc-context",
|
||||
documentBlocks: [],
|
||||
},
|
||||
options: {
|
||||
ai: {
|
||||
provider: "codex",
|
||||
sessionId: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, cookieHeader);
|
||||
|
||||
assert(Array.isArray(readPayload.events), "docs_read 返回缺少 events");
|
||||
const readEvent = readPayload.events.find((event) => event && event.type === "tool_result" && event.data && event.data.tool === "docs_read" && event.data.ok === true);
|
||||
assert(readEvent, "docs_read 未成功执行");
|
||||
const rawText = String(readEvent.data.result?.rawText ?? "");
|
||||
assert(rawText.includes(needleText), `docs_read 返回未命中预期正文片段:${needleText}`);
|
||||
|
||||
return {
|
||||
searchDocumentId: top.id,
|
||||
searchResultsCount: searchResults.length,
|
||||
readRawTextLength: Number(readEvent.data.result?.rawTextLength ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
let tempDocument = null;
|
||||
let caughtError = null;
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
tempDocument = await createTempDocument(context.request);
|
||||
const uniqueSuffix = `${Date.now()}`;
|
||||
const uniqueTitle = `task052-ai-runtime-${uniqueSuffix}`;
|
||||
const needleText = `task052 Rust docs runtime smoke ${uniqueSuffix}`;
|
||||
|
||||
await renameDocument(context.request, tempDocument.documentId, tempDocument.workspaceId, uniqueTitle);
|
||||
await saveDocument(context.request, tempDocument.documentId, tempDocument.workspaceId, [
|
||||
{
|
||||
id: `task052_block_${uniqueSuffix}`,
|
||||
type: "paragraph",
|
||||
props: {},
|
||||
content: [{ type: "text", text: needleText }],
|
||||
children: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const cookies = await context.cookies(BASE_URL);
|
||||
const cookieHeader = cookies.map((item) => `${item.name}=${item.value}`).join("; ");
|
||||
const runtime = await runAiAgentDocsSmoke(context.request, uniqueTitle, needleText, cookieHeader);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
workspaceId: tempDocument.workspaceId,
|
||||
documentId: tempDocument.documentId,
|
||||
title: uniqueTitle,
|
||||
needleText,
|
||||
...runtime,
|
||||
}, null, 2));
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
if (tempDocument?.documentId) {
|
||||
try {
|
||||
await purgeTempDocument(context.request, tempDocument.documentId);
|
||||
} catch (cleanupError) {
|
||||
if (!caughtError) {
|
||||
caughtError = cleanupError;
|
||||
} else {
|
||||
console.error(`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,611 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
retired: true,
|
||||
script: "task111-phase7-document-ai-online-smoke.js",
|
||||
reason: "旧 /api/ai-agent/run phase7 online smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
UI_TIMEOUT_MS,
|
||||
assert,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
openDocument,
|
||||
purgeDocument,
|
||||
renameDocument,
|
||||
requestJson,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const AI_TIMEOUT_MS = Number(process.env.MNOTE_AI_SMOKE_TIMEOUT_MS || 180_000);
|
||||
|
||||
function parseJsonSafely(text) {
|
||||
try {
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSseText(rawText) {
|
||||
return String(rawText || "")
|
||||
.split(/\n\n+/)
|
||||
.map((chunk) => chunk.trim())
|
||||
.filter(Boolean)
|
||||
.filter((chunk) => !chunk.startsWith(":"))
|
||||
.map((chunk) => {
|
||||
const lines = chunk.split(/\r?\n/);
|
||||
let type = "message";
|
||||
const dataLines = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event:")) {
|
||||
type = line.slice("event:".length).trim() || "message";
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
}
|
||||
const dataText = dataLines.join("\n");
|
||||
return {
|
||||
type,
|
||||
dataText,
|
||||
data: parseJsonSafely(dataText),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function serializeForError(value) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestSseEvents(requestContext, path, body) {
|
||||
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
data: body,
|
||||
timeout: AI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${path} 请求失败: ${response.status()} ${response.statusText()} ${text.slice(0, 600)}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers()["content-type"] || "";
|
||||
assert(contentType.includes("text/event-stream"), `${path} 未返回 SSE:${contentType}`);
|
||||
|
||||
return parseSseText(text);
|
||||
}
|
||||
|
||||
async function fetchPageAggregate(requestContext, documentId, workspaceId) {
|
||||
return await requestJson(
|
||||
requestContext,
|
||||
`/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchDocumentMeta(requestContext, documentId, workspaceId) {
|
||||
return await requestJson(
|
||||
requestContext,
|
||||
`/api/documents/meta?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchDocumentContent(requestContext, documentId, workspaceId) {
|
||||
return await requestJson(
|
||||
requestContext,
|
||||
`/api/documents/content?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
}
|
||||
|
||||
function buildAiContextFromPageAggregate(pagePayload) {
|
||||
const page = pagePayload?.result ?? null;
|
||||
const pageSubtree = page?.tree?.pageSubtree ?? null;
|
||||
const subtreeNodes = Array.isArray(pageSubtree?.subtree?.nodes)
|
||||
? pageSubtree.subtree.nodes.slice(0, 160)
|
||||
: [];
|
||||
|
||||
return {
|
||||
documentId: page?.identity?.documentId ?? null,
|
||||
documentBlocks: page?.body?.content ?? null,
|
||||
node: pageSubtree?.rootNode ?? null,
|
||||
subtree: pageSubtree
|
||||
? {
|
||||
projectionId: pageSubtree.projectionId ?? null,
|
||||
rootNodeId: pageSubtree?.subtree?.rootNodeId ?? null,
|
||||
stats: pageSubtree?.stats ?? null,
|
||||
nodes: subtreeNodes,
|
||||
}
|
||||
: null,
|
||||
outline: Array.isArray(pageSubtree?.outline) ? pageSubtree.outline.slice(0, 40) : null,
|
||||
evidence: Array.isArray(pageSubtree?.evidence) ? pageSubtree.evidence.slice(0, 32) : null,
|
||||
pageOptions: page?.layout?.pageOptions ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function findSuccessfulToolResult(events, toolName) {
|
||||
return [...events]
|
||||
.reverse()
|
||||
.find(
|
||||
(event) =>
|
||||
event?.type === "tool_result" &&
|
||||
event?.data &&
|
||||
event.data.tool === toolName &&
|
||||
event.data.ok === true,
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function findErrorEvent(events) {
|
||||
return events.find((event) => event?.type === "error") ?? null;
|
||||
}
|
||||
|
||||
function extractRenamedTitleFromSlashToolResult(toolEvent, documentId) {
|
||||
if (!toolEvent?.data?.result || toolEvent?.data?.tool !== "slash_run") {
|
||||
return null;
|
||||
}
|
||||
const resultRecord =
|
||||
toolEvent.data.result && typeof toolEvent.data.result === "object"
|
||||
? toolEvent.data.result
|
||||
: null;
|
||||
const parsed =
|
||||
resultRecord?.parsed && typeof resultRecord.parsed === "object"
|
||||
? resultRecord.parsed
|
||||
: null;
|
||||
const params =
|
||||
parsed?.params && typeof parsed.params === "object"
|
||||
? parsed.params
|
||||
: null;
|
||||
if (!parsed || parsed.command !== "rename_doc" || !params) {
|
||||
return null;
|
||||
}
|
||||
if (String(params.documentId ?? "") !== documentId) {
|
||||
return null;
|
||||
}
|
||||
const title = String(params.title ?? "").trim();
|
||||
return title || null;
|
||||
}
|
||||
|
||||
function extractBodySnapshotFromToolResult(toolEvent) {
|
||||
if (!toolEvent?.data?.result || !["doc_insert_blocks", "doc_replace_range"].includes(toolEvent?.data?.tool)) {
|
||||
return null;
|
||||
}
|
||||
const resultRecord =
|
||||
toolEvent.data.result && typeof toolEvent.data.result === "object"
|
||||
? toolEvent.data.result
|
||||
: null;
|
||||
return resultRecord && Array.isArray(resultRecord.data) ? resultRecord.data : null;
|
||||
}
|
||||
|
||||
function extractInsertedBlockIdFromToolResult(toolEvent) {
|
||||
if (!toolEvent?.data?.result || toolEvent?.data?.tool !== "doc_insert_blocks") {
|
||||
return null;
|
||||
}
|
||||
const resultRecord =
|
||||
toolEvent.data.result && typeof toolEvent.data.result === "object"
|
||||
? toolEvent.data.result
|
||||
: null;
|
||||
const inserted = resultRecord && Array.isArray(resultRecord.inserted) ? resultRecord.inserted : null;
|
||||
const blockId = typeof inserted?.[0] === "string" ? inserted[0].trim() : "";
|
||||
return blockId || null;
|
||||
}
|
||||
|
||||
async function persistAiTitleResult(requestContext, fixture, toolEvent) {
|
||||
const renamedTitle = extractRenamedTitleFromSlashToolResult(toolEvent, fixture.documentId);
|
||||
assert(renamedTitle, `slash_run 结果未产出当前页标题:${serializeForError(toolEvent)}`);
|
||||
await renameDocument(requestContext, fixture.workspaceId, fixture.documentId, renamedTitle);
|
||||
return renamedTitle;
|
||||
}
|
||||
|
||||
async function persistAiBodyResult(requestContext, fixture, toolEvent) {
|
||||
const nextBlocks = extractBodySnapshotFromToolResult(toolEvent);
|
||||
assert(Array.isArray(nextBlocks), `文档写工具未返回 legacy blocks 快照:${serializeForError(toolEvent)}`);
|
||||
|
||||
const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId);
|
||||
await requestJson(requestContext, "/api/documents/save", {
|
||||
method: "POST",
|
||||
data: {
|
||||
documentId: fixture.documentId,
|
||||
workspaceId: fixture.workspaceId,
|
||||
// task111 对齐正式 page body save:正文写工具结果只接受 legacy blocks 快照。
|
||||
content: nextBlocks,
|
||||
revision: aggregate?.page?.body?.revision ?? null,
|
||||
conflictDetectionKey: aggregate?.page?.body?.conflictDetectionKey ?? null,
|
||||
snapshotCapturedAt: new Date().toISOString(),
|
||||
blockCount: nextBlocks.length,
|
||||
},
|
||||
});
|
||||
return nextBlocks;
|
||||
}
|
||||
|
||||
async function waitFor(check, description, timeoutMs = UI_TIMEOUT_MS, intervalMs = 500) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastValue = null;
|
||||
while (Date.now() < deadline) {
|
||||
lastValue = await check();
|
||||
if (lastValue) {
|
||||
return lastValue;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
throw new Error(`${description} 超时,最后结果:${serializeForError(lastValue)}`);
|
||||
}
|
||||
|
||||
async function waitForPageAggregateBodyText(requestContext, fixture, expectedText) {
|
||||
await waitFor(
|
||||
async () => {
|
||||
const pagePayload = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId);
|
||||
const raw = JSON.stringify(pagePayload?.page?.body?.content ?? null);
|
||||
return raw.includes(expectedText) ? pagePayload : null;
|
||||
},
|
||||
`等待 page aggregate 正文同步到 ${expectedText}`,
|
||||
AI_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page) {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']");
|
||||
return (
|
||||
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
|
||||
host?.getAttribute("data-runtime-editor-status") !== "error" &&
|
||||
editor instanceof HTMLElement &&
|
||||
editor.isContentEditable
|
||||
);
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForTitleInput(page) {
|
||||
const input = page.getByLabel("页面标题");
|
||||
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return input;
|
||||
}
|
||||
|
||||
async function waitForEditorText(page, expectedText) {
|
||||
await page.waitForFunction(
|
||||
(text) => {
|
||||
const editor = document.querySelector(
|
||||
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
|
||||
);
|
||||
return (editor?.textContent ?? "").includes(text);
|
||||
},
|
||||
expectedText,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function readVisibleTitle(page) {
|
||||
const input = page.getByLabel("页面标题");
|
||||
if (await input.isVisible().catch(() => false)) {
|
||||
return ((await input.inputValue().catch(() => "")) || "").trim();
|
||||
}
|
||||
const heading = page.locator("h1").first();
|
||||
return ((await heading.textContent().catch(() => "")) || "").trim();
|
||||
}
|
||||
|
||||
async function readEditorText(page) {
|
||||
return await page.evaluate(() => {
|
||||
const editor = document.querySelector(
|
||||
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
|
||||
);
|
||||
return (editor?.textContent ?? "").trim();
|
||||
});
|
||||
}
|
||||
|
||||
async function typeIntoEditor(page, text) {
|
||||
const editor = page
|
||||
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
|
||||
.first();
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("Control+a");
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.keyboard.type(text, { delay: 20 });
|
||||
}
|
||||
|
||||
async function waitForPersistedSave(page, saveRequests, documentId, expectedText) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const editorText = await readEditorText(page);
|
||||
const hasSaveRequest = saveRequests.some((item) => item.documentId === documentId);
|
||||
const runtimeStatus = await page.evaluate(() => {
|
||||
return (
|
||||
document
|
||||
.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')
|
||||
?.getAttribute("data-runtime-editor-status") ?? null
|
||||
);
|
||||
});
|
||||
if (editorText.includes(expectedText) && (runtimeStatus === "saved" || hasSaveRequest)) {
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
throw new Error(`等待编辑器保存超时:${documentId}`);
|
||||
}
|
||||
|
||||
async function runTitleRename(requestContext, fixture, renamedTitle) {
|
||||
const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId);
|
||||
const events = await requestSseEvents(requestContext, "/api/ai-agent/run", {
|
||||
stream: true,
|
||||
maxSteps: 8,
|
||||
scope: "document",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `请把当前页面标题改成“${renamedTitle}”。只改标题,不要改正文;改完后用一句话确认。`,
|
||||
},
|
||||
],
|
||||
context: buildAiContextFromPageAggregate(aggregate),
|
||||
options: {
|
||||
ai: {
|
||||
provider: "online",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const errorEvent = findErrorEvent(events);
|
||||
assert(!errorEvent, `标题改名流返回 error:${serializeForError(errorEvent)}`);
|
||||
|
||||
const slashResult = findSuccessfulToolResult(events, "slash_run");
|
||||
assert(slashResult, `标题改名未得到成功的 slash_run:${serializeForError(events)}`);
|
||||
const persistedTitle = await persistAiTitleResult(requestContext, fixture, slashResult);
|
||||
assert(
|
||||
persistedTitle === renamedTitle,
|
||||
`AI 标题结果与目标不一致,期望 ${renamedTitle},实际 ${persistedTitle}`,
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
const meta = await fetchDocumentMeta(requestContext, fixture.documentId, fixture.workspaceId);
|
||||
return String(meta?.doc?.title ?? "").trim() === renamedTitle ? meta : null;
|
||||
},
|
||||
"等待标题改名落盘",
|
||||
AI_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
async function runBodyInsert(requestContext, fixture, initialBody) {
|
||||
const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId);
|
||||
const events = await requestSseEvents(requestContext, "/api/ai-agent/run", {
|
||||
stream: true,
|
||||
maxSteps: 8,
|
||||
scope: "document",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `请在当前页面插入一段正文:“${initialBody}”。只插入这一段,不要改标题;完成后简短确认。`,
|
||||
},
|
||||
],
|
||||
context: buildAiContextFromPageAggregate(aggregate),
|
||||
options: {
|
||||
ai: {
|
||||
provider: "online",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const errorEvent = findErrorEvent(events);
|
||||
assert(!errorEvent, `正文插入流返回 error:${serializeForError(errorEvent)}`);
|
||||
|
||||
const insertResult = findSuccessfulToolResult(events, "doc_insert_blocks");
|
||||
assert(insertResult, `正文插入未得到成功的 doc_insert_blocks:${serializeForError(events)}`);
|
||||
await persistAiBodyResult(requestContext, fixture, insertResult);
|
||||
const insertedBlockId = extractInsertedBlockIdFromToolResult(insertResult);
|
||||
assert(insertedBlockId, `正文插入结果缺少 inserted blockId:${serializeForError(insertResult)}`);
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
const content = await fetchDocumentContent(requestContext, fixture.documentId, fixture.workspaceId);
|
||||
const raw = JSON.stringify(content?.content ?? null);
|
||||
return raw.includes(initialBody) ? content : null;
|
||||
},
|
||||
"等待正文插入落盘",
|
||||
AI_TIMEOUT_MS,
|
||||
);
|
||||
await waitForPageAggregateBodyText(requestContext, fixture, initialBody);
|
||||
|
||||
return {
|
||||
events,
|
||||
insertedBlockId,
|
||||
};
|
||||
}
|
||||
|
||||
async function runBodyRewrite(requestContext, fixture, targetBlockId, rewrittenBody) {
|
||||
const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId);
|
||||
const events = await requestSseEvents(requestContext, "/api/ai-agent/run", {
|
||||
stream: true,
|
||||
maxSteps: 8,
|
||||
scope: "document",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `请把当前页面中 blockId 为“${targetBlockId}”的那一段改写为“${rewrittenBody}”。只改这个 blockId 对应的段落,不要改标题;直接用 doc_replace_range 完成,不需要先搜索。改完后简短确认。`,
|
||||
},
|
||||
],
|
||||
context: buildAiContextFromPageAggregate(aggregate),
|
||||
options: {
|
||||
ai: {
|
||||
provider: "online",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const errorEvent = findErrorEvent(events);
|
||||
assert(!errorEvent, `正文改写流返回 error:${serializeForError(errorEvent)}`);
|
||||
|
||||
const replaceResult = findSuccessfulToolResult(events, "doc_replace_range");
|
||||
assert(replaceResult, `正文改写未得到成功的 doc_replace_range:${serializeForError(events)}`);
|
||||
await persistAiBodyResult(requestContext, fixture, replaceResult);
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
const content = await fetchDocumentContent(requestContext, fixture.documentId, fixture.workspaceId);
|
||||
const raw = JSON.stringify(content?.content ?? null);
|
||||
return raw.includes(rewrittenBody) ? content : null;
|
||||
},
|
||||
"等待正文改写落盘",
|
||||
AI_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
async function verifyDocumentUi(page, fixture, expectedTitle, expectedBody) {
|
||||
await openDocument(page, fixture.workspaceId, fixture.documentId);
|
||||
await waitForRuntimeIsland(page);
|
||||
await waitForTitleInput(page);
|
||||
await waitForEditorText(page, expectedBody);
|
||||
|
||||
const visibleTitle = await readVisibleTitle(page);
|
||||
const editorText = await readEditorText(page);
|
||||
assert(
|
||||
visibleTitle.includes(expectedTitle),
|
||||
`页面标题未同步到 UI,期望包含 ${expectedTitle},实际为 ${visibleTitle}`,
|
||||
);
|
||||
assert(
|
||||
editorText.includes(expectedBody),
|
||||
`编辑区正文未同步到 UI,期望包含 ${expectedBody},实际为 ${editorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
let fixture = null;
|
||||
let caughtError = null;
|
||||
|
||||
try {
|
||||
const viewer = await ensureAuthenticated(page, context.request);
|
||||
fixture = await createTempDocument(context.request, null);
|
||||
|
||||
const uniqueSuffix = Date.now().toString().slice(-6);
|
||||
const initialTitle = `phase7-ai-initial-${uniqueSuffix}`;
|
||||
const renamedTitle = `phase7-ai-renamed-${uniqueSuffix}`;
|
||||
const initialBody = `phase7 AI 原始正文 ${uniqueSuffix}`;
|
||||
const rewrittenBody = `phase7 AI 改写正文 ${uniqueSuffix}`;
|
||||
|
||||
await renameDocument(context.request, fixture.workspaceId, fixture.documentId, initialTitle);
|
||||
|
||||
const insertRun = await runBodyInsert(context.request, fixture, initialBody);
|
||||
const renameEvents = await runTitleRename(context.request, fixture, renamedTitle);
|
||||
const rewriteEvents = await runBodyRewrite(
|
||||
context.request,
|
||||
fixture,
|
||||
insertRun.insertedBlockId,
|
||||
rewrittenBody,
|
||||
);
|
||||
|
||||
const meta = await fetchDocumentMeta(context.request, fixture.documentId, fixture.workspaceId);
|
||||
const content = await fetchDocumentContent(context.request, fixture.documentId, fixture.workspaceId);
|
||||
|
||||
assert(
|
||||
String(meta?.doc?.title ?? "").trim() === renamedTitle,
|
||||
`标题未完成持久化,期望 ${renamedTitle},实际 ${String(meta?.doc?.title ?? "").trim()}`,
|
||||
);
|
||||
const serializedContent = JSON.stringify(content?.content ?? null);
|
||||
assert(
|
||||
serializedContent.includes(rewrittenBody),
|
||||
`正文未完成持久化,未命中 ${rewrittenBody}`,
|
||||
);
|
||||
assert(
|
||||
!serializedContent.includes(initialBody),
|
||||
`正文仍保留旧文本 ${initialBody}`,
|
||||
);
|
||||
const pageAggregate = await fetchPageAggregate(context.request, fixture.documentId, fixture.workspaceId);
|
||||
assert(
|
||||
String(pageAggregate?.page?.head?.title ?? "").trim() === renamedTitle,
|
||||
`page aggregate 标题未更新为 ${renamedTitle}`,
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(pageAggregate?.page?.body?.content ?? null).includes(rewrittenBody),
|
||||
`page aggregate 正文未更新为 ${rewrittenBody}`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
viewerUserId: viewer.userId,
|
||||
workspaceId: fixture.workspaceId,
|
||||
documentId: fixture.documentId,
|
||||
renamedTitle,
|
||||
rewrittenBody,
|
||||
insertToolSequence: insertRun.events
|
||||
.filter((event) => event.type === "tool_result")
|
||||
.map((event) => event.data?.tool ?? null)
|
||||
.filter(Boolean),
|
||||
renameToolSequence: renameEvents
|
||||
.filter((event) => event.type === "tool_result")
|
||||
.map((event) => event.data?.tool ?? null)
|
||||
.filter(Boolean),
|
||||
rewriteToolSequence: rewriteEvents
|
||||
.filter((event) => event.type === "tool_result")
|
||||
.map((event) => event.data?.tool ?? null)
|
||||
.filter(Boolean),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
if (fixture?.documentId) {
|
||||
try {
|
||||
await purgeDocument(context.request, fixture.documentId);
|
||||
} catch (cleanupError) {
|
||||
if (!caughtError) {
|
||||
caughtError = cleanupError;
|
||||
} else {
|
||||
console.error(
|
||||
`清理临时页面失败:${
|
||||
cleanupError instanceof Error
|
||||
? cleanupError.stack || cleanupError.message
|
||||
: String(cleanupError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -13,6 +13,18 @@ async function fetchText(path) {
|
||||
return { response, text };
|
||||
}
|
||||
|
||||
function parseSseEventData(text, eventName) {
|
||||
const blocks = text.split(/\n\n+/).filter((block) => block.trim().length > 0);
|
||||
const block = blocks.find((candidate) => new RegExp(`^event:\\s*${eventName}\\s*$`, "m").test(candidate));
|
||||
assert(block, `SSE 响应缺少 ${eventName} 事件`);
|
||||
const dataLines = block
|
||||
.split(/\n/)
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice("data:".length).trimStart());
|
||||
assert(dataLines.length > 0, `${eventName} 事件缺少 data`);
|
||||
return JSON.parse(dataLines.join("\n"));
|
||||
}
|
||||
|
||||
async function readJsonResponse(response, label) {
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
@@ -77,7 +89,40 @@ async function main() {
|
||||
assert.match(events.text, /id:\s*/);
|
||||
assert.match(events.text, /"revision"/);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, owner: "rust-web", stream: "/api/tree/events", documentId: pageTarget.documentId }, null, 2));
|
||||
const snapshot = parseSseEventData(events.text, "snapshot");
|
||||
assert.equal(snapshot.kind, "snapshot");
|
||||
assert.equal(snapshot.stream, "workspace");
|
||||
assert.equal(snapshot.projection, "sidebar_tree");
|
||||
assert.equal(snapshot.workspaceId, pageTarget.workspaceId);
|
||||
const dataset = snapshot.data?.dataset || snapshot.data;
|
||||
assert(dataset?.kernel_sidebar_projection, "snapshot 缺少 kernel_sidebar_projection");
|
||||
assert(dataset?.kernel_file_tree_projection, "snapshot 缺少 kernel_file_tree_projection");
|
||||
const fileTreeItems = dataset.kernel_file_tree_projection.items || [];
|
||||
assert(
|
||||
fileTreeItems.some(
|
||||
(item) =>
|
||||
(item?.rowId || item?.row_id) === `doc:${pageTarget.documentId}` &&
|
||||
(item?.rowKind || item?.row_kind) === "document" &&
|
||||
(item?.resourceMeta?.documentId || item?.resource_meta?.document_id) ===
|
||||
pageTarget.documentId,
|
||||
),
|
||||
"workspace snapshot 的 file tree projection 缺少临时页 doc row",
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
owner: "rust-web",
|
||||
stream: "/api/tree/events",
|
||||
documentId: pageTarget.documentId,
|
||||
snapshotProjection: snapshot.projection,
|
||||
fileTreeRows: fileTreeItems.length,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await purgeTempPage(pageTarget).catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
retired: true,
|
||||
script: "task155-e27-ai-edit-smoke.js",
|
||||
reason: "旧 /api/ai-agent/run E27 AI 编辑 smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const { chromium } = require("playwright");
|
||||
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task155-e27-ai-edit-local-smoke";
|
||||
const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs";
|
||||
const TARGET_BLOCK_ID = "e27-ai-target";
|
||||
|
||||
function assertAiSourceBoundary() {
|
||||
const spike = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8");
|
||||
assert(spike.includes("/api/ai-agent/run"), "E27 AI 入口必须调用 /api/ai-agent/run 在线主路径,Hermes 只能作为后端 fallback");
|
||||
assert(spike.includes('"provider": "online"'), "E27 AI 请求必须使用 provider=online 进入 openai-agents-python 主路径");
|
||||
assert(spike.includes('"stream": true'), "E27 AI 请求必须开启 stream=true,确保 /api/ai-agent/run 路由进入 agents sidecar");
|
||||
assert(spike.includes("mnote-leptos-tiptap-ai-status"), "E27 必须暴露 AI bridge 状态,供 smoke 和后续流式状态复用");
|
||||
assert(spike.includes("selectedBlockId"), "E27 AI 请求必须携带 Rust blockId 作为 selectedBlockId");
|
||||
assert(spike.includes("tiptapDocument"), "E27 AI 请求必须携带当前 Tiptap JSON 快照");
|
||||
}
|
||||
|
||||
async function readJsonResponse(response, label) {
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
|
||||
}
|
||||
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function postTreeCommand(body, label) {
|
||||
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await readJsonResponse(response, label);
|
||||
assert(payload?.result, `${label} 缺少 result`);
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
async function createTempDocument() {
|
||||
const title = `task155-e27-ai-${Date.now().toString(36)}`;
|
||||
const result = await postTreeCommand({ action: "create", title }, "创建 E27 临时文档");
|
||||
assert(result.documentId, "创建 E27 临时文档缺少 documentId");
|
||||
assert(result.workspaceId, "创建 E27 临时文档缺少 workspaceId");
|
||||
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
|
||||
}
|
||||
|
||||
async function purgeTempDocument(target) {
|
||||
if (!target?.documentId || !target?.workspaceId) return;
|
||||
await postTreeCommand({ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, "清理 E27 临时文档");
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page) {
|
||||
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
|
||||
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first();
|
||||
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
|
||||
return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable;
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
return editor;
|
||||
}
|
||||
|
||||
async function screenshot(page, name) {
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
async function setAiFixture(page) {
|
||||
await page.evaluate((targetBlockId) => {
|
||||
const editor = document.querySelector('.editor-surface .ProseMirror')?.editor;
|
||||
if (!editor) throw new Error('找不到 Tiptap editor');
|
||||
editor.commands.setContent({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { blockId: targetBlockId },
|
||||
content: [{ type: 'text', text: 'E27 Ask AI source paragraph' }],
|
||||
},
|
||||
],
|
||||
}, true);
|
||||
editor.commands.focus('start');
|
||||
}, TARGET_BLOCK_ID);
|
||||
await page.waitForFunction((targetBlockId) => {
|
||||
return document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement;
|
||||
}, TARGET_BLOCK_ID, { timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function openBlockMenuForTarget(page) {
|
||||
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
|
||||
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
|
||||
await target.hover({ timeout: UI_TIMEOUT_MS });
|
||||
const handle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
|
||||
await handle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await handle.click({ timeout: UI_TIMEOUT_MS });
|
||||
const menu = page.locator('[data-testid="block-drag-menu"]').first();
|
||||
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return menu;
|
||||
}
|
||||
|
||||
function assertAiBridgePayload(payload, target) {
|
||||
assert.equal(payload.scope, "document", `AI 请求 scope 必须是 document: ${JSON.stringify(payload).slice(0, 1600)}`);
|
||||
assert.equal(payload.stream, true, "AI 请求必须开启 stream=true,避免退回 Hermes 非流式兼容路径");
|
||||
assert.equal(payload.options?.ai?.provider, "online", "AI 请求必须使用 provider=online,由 /api/ai-agent/run 分流到 agents sidecar");
|
||||
assert.equal(payload.context?.documentId, target.documentId, "AI 请求必须携带当前 documentId");
|
||||
assert.equal(payload.context?.workspaceId, target.workspaceId, "AI 请求必须携带当前 workspaceId");
|
||||
assert.equal(payload.context?.selectedBlockId, TARGET_BLOCK_ID, "AI 请求必须携带 Rust blockId");
|
||||
assert(Array.isArray(payload.context?.selectedUids) && payload.context.selectedUids.includes(TARGET_BLOCK_ID), "AI 请求 selectedUids 必须包含 Rust blockId");
|
||||
assert(payload.context?.selection?.currentBlockId === TARGET_BLOCK_ID, "AI 请求 selection 必须指向当前块");
|
||||
assert(payload.context?.tiptapDocument?.type === "doc", "AI 请求必须携带 tiptapDocument 快照");
|
||||
assert(JSON.stringify(payload.context.tiptapDocument).includes("E27 Ask AI source paragraph"), "AI 请求快照必须包含当前块正文");
|
||||
assert.equal(payload.context?.source, "leptos-tiptap-island", "AI 请求必须标记来源 island");
|
||||
assert.equal(payload.context?.action, "ask_ai", "块菜单 AI 入口必须使用 ask_ai action");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertAiSourceBoundary();
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const aiBridgeRequests = [];
|
||||
|
||||
await page.route(/\/api\/(hermes\/bridge|ai-agent\/run)$/, async (route) => {
|
||||
const request = route.request();
|
||||
const body = request.postData() || "{}";
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
payload = { raw: body };
|
||||
}
|
||||
aiBridgeRequests.push({ url: request.url(), payload });
|
||||
if (request.url().includes("/api/ai-agent/run")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
},
|
||||
body: [
|
||||
'event: ready\n' + 'data: {"ok":true,"requestId":"task155-e27"}\n\n',
|
||||
'event: tool_result\n' + 'data: {"tool":"doc_replace_range","ok":true,"result":{"data":[{"id":"e27-ai-target","type":"paragraph","content":"E27 AI rewritten paragraph"}]}}\n\n',
|
||||
'event: completion\n' + 'data: {"ok":true,"text":"done","steps":1}\n\n',
|
||||
].join(""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"x-mnote-ai-bridge-owner": "rust-web-hermes",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
bridge: "e27-smoke-hermes-bridge",
|
||||
canonicalRoute: "/api/hermes/bridge",
|
||||
contract: {
|
||||
schema: "mnote.ai_bridge.v1",
|
||||
structuredWriteOwner: "rust-web-hermes",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
let target = null;
|
||||
try {
|
||||
target = await createTempDocument();
|
||||
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
assert(response, "文档页没有返回响应");
|
||||
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
|
||||
|
||||
await waitForRuntimeIsland(page);
|
||||
await setAiFixture(page);
|
||||
const menu = await openBlockMenuForTarget(page);
|
||||
await screenshot(page, "01-block-menu-ai-entry");
|
||||
|
||||
await menu.locator('[data-testid="block-drag-menu-item-ai"]').first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
|
||||
return status instanceof HTMLElement && ["pending", "ready"].includes(status.getAttribute("data-state") || "");
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.waitForFunction(() => window.__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__ > 0, null, { timeout: UI_TIMEOUT_MS });
|
||||
assert(aiBridgeRequests.length > 0, "点击 AI 入口后必须发起 /api/ai-agent/run 请求");
|
||||
const first = aiBridgeRequests[0];
|
||||
assert(first.url.includes("/api/ai-agent/run"), `AI 请求必须走 /api/ai-agent/run 在线主路径,实际: ${first.url}`);
|
||||
assertAiBridgePayload(first.payload, target);
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
|
||||
return status instanceof HTMLElement && status.getAttribute("data-state") === "ready";
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
await screenshot(page, "02-ai-bridge-ready-state");
|
||||
|
||||
console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, screenshotDir: SCREENSHOT_DIR, aiBridgeUrl: first.url }, null, 2));
|
||||
} finally {
|
||||
if (target) await purgeTempDocument(target).catch(() => undefined);
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
retired: true,
|
||||
script: "task156-e27-ai-writeback-smoke.js",
|
||||
reason: "旧 /api/ai-agent/run E27 AI 写回 smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const { chromium } = require("playwright");
|
||||
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke";
|
||||
const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs";
|
||||
const TARGET_BLOCK_ID = "e27-ai-target";
|
||||
const AI_REWRITTEN_TEXT = "E27 AI rewritten paragraph";
|
||||
|
||||
function assertAiSourceBoundary() {
|
||||
const spike = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8");
|
||||
assert(spike.includes("/api/ai-agent/run"), "E27 AI 入口必须调用 /api/ai-agent/run 在线主路径,Hermes 只能作为后端 fallback");
|
||||
assert(spike.includes('"provider": "online"'), "E27 AI 请求必须使用 provider=online 进入 openai-agents-python 主路径");
|
||||
assert(spike.includes('"stream": true'), "E27 AI 请求必须开启 stream=true,确保 /api/ai-agent/run 路由进入 agents sidecar");
|
||||
assert(spike.includes("mnote-leptos-tiptap-ai-status"), "E27 必须暴露 AI bridge 状态,供 smoke 和后续流式状态复用");
|
||||
assert(spike.includes("selectedBlockId"), "E27 AI 请求必须携带 Rust blockId 作为 selectedBlockId");
|
||||
assert(spike.includes("tiptapDocument"), "E27 AI 请求必须携带当前 Tiptap JSON 快照");
|
||||
}
|
||||
|
||||
async function readJsonResponse(response, label) {
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
|
||||
}
|
||||
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function postTreeCommand(body, label) {
|
||||
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await readJsonResponse(response, label);
|
||||
assert(payload?.result, `${label} 缺少 result`);
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
async function createTempDocument() {
|
||||
const title = `task156-e27-ai-writeback-${Date.now().toString(36)}`;
|
||||
const result = await postTreeCommand({ action: "create", title }, "创建 E27 临时文档");
|
||||
assert(result.documentId, "创建 E27 临时文档缺少 documentId");
|
||||
assert(result.workspaceId, "创建 E27 临时文档缺少 workspaceId");
|
||||
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
|
||||
}
|
||||
|
||||
async function purgeTempDocument(target) {
|
||||
if (!target?.documentId || !target?.workspaceId) return;
|
||||
await postTreeCommand({ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, "清理 E27 临时文档");
|
||||
}
|
||||
|
||||
async function loadDocumentContent(target, label) {
|
||||
const url = `${BASE_URL}/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
const response = await fetchWithTimeout(url, { method: "GET" });
|
||||
return readJsonResponse(response, label);
|
||||
}
|
||||
|
||||
function rawIncludes(value, text) {
|
||||
return JSON.stringify(value ?? null).includes(text);
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page) {
|
||||
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
|
||||
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first();
|
||||
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
|
||||
return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable;
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
return editor;
|
||||
}
|
||||
|
||||
async function screenshot(page, name) {
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
async function setAiFixture(page) {
|
||||
await page.evaluate((targetBlockId) => {
|
||||
const editor = document.querySelector('.editor-surface .ProseMirror')?.editor;
|
||||
if (!editor) throw new Error('找不到 Tiptap editor');
|
||||
editor.commands.setContent({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { blockId: targetBlockId },
|
||||
content: [{ type: 'text', text: 'E27 Ask AI source paragraph' }],
|
||||
},
|
||||
],
|
||||
}, true);
|
||||
editor.commands.focus('start');
|
||||
}, TARGET_BLOCK_ID);
|
||||
await page.waitForFunction((targetBlockId) => {
|
||||
return document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement;
|
||||
}, TARGET_BLOCK_ID, { timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function openBlockMenuForTarget(page) {
|
||||
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
|
||||
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
|
||||
await target.hover({ timeout: UI_TIMEOUT_MS });
|
||||
const handle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
|
||||
await handle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await handle.click({ timeout: UI_TIMEOUT_MS });
|
||||
const menu = page.locator('[data-testid="block-drag-menu"]').first();
|
||||
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return menu;
|
||||
}
|
||||
|
||||
function assertAiBridgePayload(payload, target) {
|
||||
assert.equal(payload.scope, "document", `AI 请求 scope 必须是 document: ${JSON.stringify(payload).slice(0, 1600)}`);
|
||||
assert.equal(payload.stream, true, "AI 请求必须开启 stream=true,避免退回 Hermes 非流式兼容路径");
|
||||
assert.equal(payload.options?.ai?.provider, "online", "AI 请求必须使用 provider=online,由 /api/ai-agent/run 分流到 agents sidecar");
|
||||
assert.equal(payload.context?.documentId, target.documentId, "AI 请求必须携带当前 documentId");
|
||||
assert.equal(payload.context?.workspaceId, target.workspaceId, "AI 请求必须携带当前 workspaceId");
|
||||
assert.equal(payload.context?.selectedBlockId, TARGET_BLOCK_ID, "AI 请求必须携带 Rust blockId");
|
||||
assert(Array.isArray(payload.context?.selectedUids) && payload.context.selectedUids.includes(TARGET_BLOCK_ID), "AI 请求 selectedUids 必须包含 Rust blockId");
|
||||
assert(payload.context?.selection?.currentBlockId === TARGET_BLOCK_ID, "AI 请求 selection 必须指向当前块");
|
||||
assert(payload.context?.tiptapDocument?.type === "doc", "AI 请求必须携带 tiptapDocument 快照");
|
||||
assert(JSON.stringify(payload.context.tiptapDocument).includes("E27 Ask AI source paragraph"), "AI 请求快照必须包含当前块正文");
|
||||
assert.equal(payload.context?.source, "leptos-tiptap-island", "AI 请求必须标记来源 island");
|
||||
assert.equal(payload.context?.action, "ask_ai", "块菜单 AI 入口必须使用 ask_ai action");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertAiSourceBoundary();
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const aiBridgeRequests = [];
|
||||
const saveRequests = [];
|
||||
await page.addInitScript(() => {
|
||||
window.__MNOTE_E27_SAVE_REQUESTS__ = [];
|
||||
});
|
||||
page.on("request", async (request) => {
|
||||
if (!request.url().includes("/api/documents/save")) return;
|
||||
const body = request.postData();
|
||||
if (!body) return;
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
payload = { raw: body };
|
||||
}
|
||||
saveRequests.push(payload);
|
||||
await page.evaluate((item) => {
|
||||
window.__MNOTE_E27_SAVE_REQUESTS__ = Array.isArray(window.__MNOTE_E27_SAVE_REQUESTS__) ? window.__MNOTE_E27_SAVE_REQUESTS__ : [];
|
||||
window.__MNOTE_E27_SAVE_REQUESTS__.push(item);
|
||||
}, payload).catch(() => undefined);
|
||||
});
|
||||
|
||||
await page.route(/\/api\/(hermes\/bridge|ai-agent\/run)$/, async (route) => {
|
||||
const request = route.request();
|
||||
const body = request.postData() || "{}";
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
payload = { raw: body };
|
||||
}
|
||||
aiBridgeRequests.push({ url: request.url(), payload });
|
||||
if (request.url().includes("/api/ai-agent/run")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
},
|
||||
body: [
|
||||
'event: ready\n' + 'data: {"ok":true,"requestId":"task155-e27"}\n\n',
|
||||
'event: tool_result\n' + `data: {"tool":"doc_replace_range","ok":true,"result":{"data":[{"id":"${TARGET_BLOCK_ID}","type":"paragraph","content":"${AI_REWRITTEN_TEXT}"}]}}\n\n`,
|
||||
'event: completion\n' + 'data: {"ok":true,"text":"done","steps":1}\n\n',
|
||||
].join(""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"x-mnote-ai-bridge-owner": "rust-web-hermes",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
bridge: "e27-smoke-hermes-bridge",
|
||||
canonicalRoute: "/api/hermes/bridge",
|
||||
contract: {
|
||||
schema: "mnote.ai_bridge.v1",
|
||||
structuredWriteOwner: "rust-web-hermes",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
let target = null;
|
||||
try {
|
||||
target = await createTempDocument();
|
||||
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
assert(response, "文档页没有返回响应");
|
||||
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
|
||||
|
||||
await waitForRuntimeIsland(page);
|
||||
await setAiFixture(page);
|
||||
const menu = await openBlockMenuForTarget(page);
|
||||
await screenshot(page, "01-block-menu-ai-entry");
|
||||
|
||||
await menu.locator('[data-testid="block-drag-menu-item-ai"]').first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
|
||||
return status instanceof HTMLElement && ["pending", "ready"].includes(status.getAttribute("data-state") || "");
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.waitForFunction(() => window.__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__ > 0, null, { timeout: UI_TIMEOUT_MS });
|
||||
assert(aiBridgeRequests.length > 0, "点击 AI 入口后必须发起 /api/ai-agent/run 请求");
|
||||
const first = aiBridgeRequests[0];
|
||||
assert(first.url.includes("/api/ai-agent/run"), `AI 请求必须走 /api/ai-agent/run 在线主路径,实际: ${first.url}`);
|
||||
assertAiBridgePayload(first.payload, target);
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
|
||||
return status instanceof HTMLElement && status.getAttribute("data-state") === "ready";
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction((expectedText) => {
|
||||
const editor = document.querySelector('.editor-surface .ProseMirror');
|
||||
return editor instanceof HTMLElement && editor.innerText.includes(expectedText);
|
||||
}, AI_REWRITTEN_TEXT, { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction((expectedText) => {
|
||||
return Array.isArray(window.__MNOTE_E27_SAVE_REQUESTS__)
|
||||
&& window.__MNOTE_E27_SAVE_REQUESTS__.some((request) => JSON.stringify(request ?? null).includes(expectedText));
|
||||
}, AI_REWRITTEN_TEXT, { timeout: UI_TIMEOUT_MS });
|
||||
await screenshot(page, "02-ai-writeback-editor-saved");
|
||||
|
||||
const saveHit = saveRequests.some((request) => rawIncludes(request, AI_REWRITTEN_TEXT));
|
||||
assert(saveHit, `AI 写入必须触发 /api/documents/save 且保存 payload 包含改写正文: ${JSON.stringify(saveRequests.slice(-4)).slice(0, 2400)}`);
|
||||
|
||||
const contentAfterWrite = await loadDocumentContent(target, "读取 E27 AI 写入后的正文");
|
||||
assert(rawIncludes(contentAfterWrite, AI_REWRITTEN_TEXT), `/api/documents/content 必须能读回 AI 写入正文: ${JSON.stringify(contentAfterWrite).slice(0, 2400)}`);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForRuntimeIsland(page);
|
||||
await page.waitForFunction((expectedText) => {
|
||||
const editor = document.querySelector('.editor-surface .ProseMirror');
|
||||
return editor instanceof HTMLElement && editor.innerText.includes(expectedText);
|
||||
}, AI_REWRITTEN_TEXT, { timeout: UI_TIMEOUT_MS });
|
||||
await screenshot(page, "03-ai-writeback-reload-readback");
|
||||
|
||||
console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, screenshotDir: SCREENSHOT_DIR, aiBridgeUrl: first.url, wroteText: AI_REWRITTEN_TEXT }, null, 2));
|
||||
} finally {
|
||||
if (target) await purgeTempDocument(target).catch(() => undefined);
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
retired: true,
|
||||
script: "task178-page-ai-local-subtree-context-smoke.js",
|
||||
reason: "旧 /api/ai-agent/run 页面 AI context smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { chromium } = require("playwright");
|
||||
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
|
||||
async function readJsonResponse(response, label) {
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
|
||||
}
|
||||
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function postTreeCommand(body, label) {
|
||||
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await readJsonResponse(response, label);
|
||||
const result = payload && typeof payload.result === "object" ? payload.result : null;
|
||||
assert(result, `${label} 缺少 result`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createTempPage(title) {
|
||||
const result = await postTreeCommand({ action: "create", title }, `创建临时页面 ${title}`);
|
||||
assert(result.documentId, `创建临时页面 ${title} 缺少 documentId`);
|
||||
assert(result.workspaceId, `创建临时页面 ${title} 缺少 workspaceId`);
|
||||
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
|
||||
}
|
||||
|
||||
async function purgeTempPage(target) {
|
||||
if (!target?.documentId || !target?.workspaceId) return;
|
||||
await postTreeCommand(
|
||||
{ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId },
|
||||
`清理临时页面 ${target.documentId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page) {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']");
|
||||
return (
|
||||
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
|
||||
host?.getAttribute("data-runtime-editor-status") !== "error" &&
|
||||
editor instanceof HTMLElement &&
|
||||
editor.isContentEditable
|
||||
);
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function setLocalHeadingFixture(page, headingText) {
|
||||
await page.evaluate((text) => {
|
||||
const editor = document.querySelector(".editor-surface .ProseMirror")?.editor;
|
||||
if (!editor) throw new Error("找不到 Tiptap editor");
|
||||
editor.commands.setContent({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "本地正文尚未等待服务端 pageSubtree 刷新。" }] },
|
||||
],
|
||||
});
|
||||
}, headingText);
|
||||
await page.waitForFunction(
|
||||
(text) => Array.from(document.querySelectorAll(".editor-surface .ProseMirror h2")).some((node) => (node.textContent || "").includes(text)),
|
||||
headingText,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
function stringify(value) {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const pageTitle = `task178-page-ai-${suffix}`;
|
||||
const localHeading = `TASK178 本地 Heading ${suffix}`;
|
||||
let target = null;
|
||||
let capturedBody = null;
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
target = await createTempPage(pageTitle);
|
||||
await page.route("**/api/ai-agent/run", async (route) => {
|
||||
const postData = route.request().postData() || "{}";
|
||||
capturedBody = JSON.parse(postData);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
body: 'event: assistant_message\ndata: {"text":"ok"}\n\n',
|
||||
});
|
||||
});
|
||||
|
||||
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
assert(response, "文档页没有返回响应");
|
||||
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
|
||||
assert.equal(response.headers()["x-mnote-web-owner"], "mnote-web", "文档页必须由 mnote-web 拥有");
|
||||
|
||||
await waitForRuntimeIsland(page);
|
||||
await setLocalHeadingFixture(page, localHeading);
|
||||
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-input]").fill(`请基于当前本地结构回答:${localHeading}`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => Boolean(window.__task178Noop) || true, null, { timeout: 10 });
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
assert(capturedBody, "未捕获 /api/ai-agent/run 请求");
|
||||
const contextPayload = capturedBody.context || {};
|
||||
const rawContext = stringify(contextPayload);
|
||||
assert.equal(contextPayload.pageSubtreeSource, "local", `AI context 应标记本地 pageSubtree,实际: ${rawContext}`);
|
||||
assert(rawContext.includes(localHeading), `AI context 应包含本地 heading: ${rawContext.slice(0, 1600)}`);
|
||||
assert(Array.isArray(contextPayload.documentBlocks), `AI context 应包含本地 documentBlocks: ${rawContext.slice(0, 1600)}`);
|
||||
assert(contextPayload.outline?.some((item) => stringify(item).includes(localHeading)), `AI context outline 应包含本地 heading: ${rawContext.slice(0, 1600)}`);
|
||||
assert(contextPayload.subtree?.stats?.headingCount >= 1, `AI context subtree stats 应包含 headingCount: ${rawContext.slice(0, 1600)}`);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
documentId: target.documentId,
|
||||
workspaceId: target.workspaceId,
|
||||
localHeading,
|
||||
pageSubtreeSource: contextPayload.pageSubtreeSource,
|
||||
headingCount: contextPayload.subtree?.stats?.headingCount ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (target) await purgeTempPage(target).catch(() => undefined);
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task446-tree-rename-dual-browser-live-smoke";
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "tree-live-cache-smoke", "20260516-task446-rename");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SCREENSHOT_B_DOCUMENT = path.join(OUTPUT_DIR, "b-document-after-rename.png");
|
||||
const SCREENSHOT_B_FILETREE = path.join(OUTPUT_DIR, "b-filetree-after-rename.png");
|
||||
|
||||
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
|
||||
function cssString(value) {
|
||||
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function docRowSelector(documentId) {
|
||||
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssString(documentId)}"]`;
|
||||
}
|
||||
|
||||
async function writeResult(payload) {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function requestJson(request, baseUrl, requestPath, init = {}) {
|
||||
const response = await request.fetch(`${baseUrl}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function authenticate(request, email, name) {
|
||||
await requestJson(request, AUTH_BASE_URL, "/api/auth", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: { email, password: e2ePassword(), flow: "signUp", name },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createPage(request, workspaceId, title, parentId = null) {
|
||||
const payload = await requestJson(request, BASE_URL, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "create",
|
||||
workspaceId,
|
||||
parentId,
|
||||
title,
|
||||
},
|
||||
});
|
||||
const result = payload.result || payload;
|
||||
const documentId = result.documentId || payload.documentId || result.id || "";
|
||||
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
|
||||
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
|
||||
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
|
||||
return { documentId, workspaceId: resolvedWorkspaceId, payload };
|
||||
}
|
||||
|
||||
async function treeCommand(request, workspaceId, action, documentId, extra = {}) {
|
||||
return await requestJson(request, BASE_URL, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action, workspaceId, documentId, ...extra },
|
||||
});
|
||||
}
|
||||
|
||||
async function emptyDocumentTrash(request, workspaceId) {
|
||||
return await requestJson(request, BASE_URL, "/api/documents/empty-trash", {
|
||||
method: "POST",
|
||||
data: { workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(request, workspaceId, documentIds) {
|
||||
if (!workspaceId) return;
|
||||
for (const documentId of documentIds.filter(Boolean)) {
|
||||
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
|
||||
}
|
||||
await emptyDocumentTrash(request, workspaceId).catch(() => null);
|
||||
}
|
||||
|
||||
async function openDocument(page, workspaceId, documentId) {
|
||||
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator(`[data-page-title-input="true"][data-document-id="${cssString(documentId)}"]`).first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function openDocumentFileTree(page, workspaceId, documentId) {
|
||||
await openDocument(page, workspaceId, documentId);
|
||||
await page.evaluate(() => {
|
||||
const visible = (node) =>
|
||||
node instanceof HTMLElement &&
|
||||
!node.hidden &&
|
||||
getComputedStyle(node).display !== "none" &&
|
||||
getComputedStyle(node).visibility !== "hidden" &&
|
||||
node.getClientRects().length > 0;
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
if (visible(fileRoot)) return;
|
||||
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
if (tab instanceof HTMLElement) tab.click();
|
||||
});
|
||||
await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function waitForBDocumentRename(page, documentId, expectedTitle) {
|
||||
await page.waitForFunction(
|
||||
({ id, title }) => {
|
||||
const titleInput = document.querySelector(`[data-page-title-input="true"][data-document-id="${CSS.escape(id)}"]`);
|
||||
const titleValue = titleInput instanceof HTMLTextAreaElement ? titleInput.value : "";
|
||||
const breadcrumb = document.querySelector(".wolai-breadcrumb-current [data-page-title-current]")?.textContent?.trim() || "";
|
||||
const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(id)}"] .tree-link-title`)?.textContent?.trim() || "";
|
||||
return titleValue === title && breadcrumb === title && pageRow === title;
|
||||
},
|
||||
{ id: documentId, title: expectedTitle },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForBFileTreeRename(page, documentId, expectedTitle) {
|
||||
await page.waitForFunction(
|
||||
({ id, title }) => {
|
||||
const fileRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`);
|
||||
const fileTitle = fileRow instanceof HTMLElement ? fileRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "";
|
||||
return fileTitle === `${title}.md`;
|
||||
},
|
||||
{ id: documentId, title: expectedTitle },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function installTreeEventRecorder(page, label, records) {
|
||||
await page.addInitScript(() => {
|
||||
window.__MNOTE_TASK446_TREE_EVENTS__ = [];
|
||||
const record = (name, event) => {
|
||||
const detail = event && event.detail ? event.detail : {};
|
||||
const payload = detail.payload || detail || {};
|
||||
const raw = (() => {
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
window.__MNOTE_TASK446_TREE_EVENTS__.push({
|
||||
name,
|
||||
at: Date.now(),
|
||||
revision: detail.revision || payload.revision || payload.cursor || "",
|
||||
op: payload && payload.data && payload.data.op ? payload.data.op : payload.op || "",
|
||||
raw: raw.slice(0, 2000),
|
||||
});
|
||||
};
|
||||
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
|
||||
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
|
||||
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
|
||||
});
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/events")) {
|
||||
records.push({ label, type: "request", method: request.method(), url, at: Date.now() });
|
||||
}
|
||||
});
|
||||
page.on("requestfailed", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/events")) {
|
||||
records.push({
|
||||
label,
|
||||
type: "requestfailed",
|
||||
method: request.method(),
|
||||
url,
|
||||
failure: request.failure()?.errorText || "",
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
if (/tree live|EventSource|rename|error|failed/i.test(text)) {
|
||||
records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function recordNavigation(page, label, records) {
|
||||
page.on("framenavigated", (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
records.push({ label, url: frame.url(), at: Date.now() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function readState(page, documentId) {
|
||||
return await page.evaluate((id) => {
|
||||
const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(id)}"]`);
|
||||
const fileRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`);
|
||||
const titleInput = document.querySelector(`[data-page-title-input="true"][data-document-id="${CSS.escape(id)}"]`);
|
||||
return {
|
||||
url: window.location.href,
|
||||
documentTitle: document.title,
|
||||
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
|
||||
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
|
||||
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
|
||||
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
|
||||
titleInputValue: titleInput instanceof HTMLTextAreaElement ? titleInput.value : "",
|
||||
breadcrumbTitle: document.querySelector(".wolai-breadcrumb-current [data-page-title-current]")?.textContent?.trim() || "",
|
||||
sidebarTitle: pageRow instanceof HTMLElement ? pageRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
|
||||
fileTreeTitle: fileRow instanceof HTMLElement ? fileRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
|
||||
fileTreeRowExists: fileRow instanceof HTMLElement,
|
||||
treeEvents: window.__MNOTE_TASK446_TREE_EVENTS__ || [],
|
||||
};
|
||||
}, documentId);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const stamp = Date.now();
|
||||
const email = `mnote.stage8.rename.${stamp}@example.com`;
|
||||
const prefix = `TEST-10REVIEW-08-RENAME-${stamp}`;
|
||||
const initialTitle = `${prefix}-initial`;
|
||||
const renamedTitle = `${prefix}-renamed`;
|
||||
const result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
authBaseUrl: AUTH_BASE_URL,
|
||||
email,
|
||||
prefix,
|
||||
fixture: {},
|
||||
requests: [],
|
||||
navigationEvents: [],
|
||||
treeEventRequests: [],
|
||||
states: {},
|
||||
screenshots: {
|
||||
bDocument: SCREENSHOT_B_DOCUMENT,
|
||||
bFileTree: SCREENSHOT_B_FILETREE,
|
||||
},
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
|
||||
const contextA = await browser.newContext();
|
||||
const contextB = await browser.newContext();
|
||||
const pageA = await contextA.newPage();
|
||||
const documentB = await contextB.newPage();
|
||||
const fileTreeB = await contextB.newPage();
|
||||
const requestA = contextA.request;
|
||||
let workspaceId = "";
|
||||
const cleanupIds = [];
|
||||
|
||||
pageA.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/commands") || url.includes("/api/documents/empty-trash")) {
|
||||
result.requests.push({ side: "A-page", method: request.method(), url, body: request.postData() || null, at: Date.now() });
|
||||
}
|
||||
});
|
||||
await installTreeEventRecorder(documentB, "B-document", result.treeEventRequests);
|
||||
await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests);
|
||||
recordNavigation(documentB, "B-document", result.navigationEvents);
|
||||
recordNavigation(fileTreeB, "B-filetree", result.navigationEvents);
|
||||
|
||||
try {
|
||||
for (const requestContext of [requestA, contextB.request]) {
|
||||
await authenticate(requestContext, email, `stage8-rename-${stamp}`);
|
||||
}
|
||||
|
||||
const root = await createPage(requestA, null, `${prefix}-root`);
|
||||
workspaceId = root.workspaceId;
|
||||
cleanupIds.push(root.documentId);
|
||||
result.fixture.rootId = root.documentId;
|
||||
result.fixture.workspaceId = workspaceId;
|
||||
|
||||
const target = await createPage(requestA, workspaceId, initialTitle, root.documentId);
|
||||
cleanupIds.push(target.documentId);
|
||||
result.fixture.targetId = target.documentId;
|
||||
result.fixture.initialTitle = initialTitle;
|
||||
result.fixture.renamedTitle = renamedTitle;
|
||||
|
||||
await pageA.goto(`${BASE_URL}/documents/${encodeURIComponent(root.documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await openDocument(documentB, workspaceId, target.documentId);
|
||||
await openDocumentFileTree(fileTreeB, workspaceId, target.documentId);
|
||||
await fileTreeB.locator(docRowSelector(target.documentId)).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await waitForBDocumentRename(documentB, target.documentId, initialTitle);
|
||||
await waitForBFileTreeRename(fileTreeB, target.documentId, initialTitle);
|
||||
result.states.before = {
|
||||
document: await readState(documentB, target.documentId),
|
||||
fileTree: await readState(fileTreeB, target.documentId),
|
||||
};
|
||||
|
||||
const navigationStart = result.navigationEvents.length;
|
||||
await treeCommand(requestA, workspaceId, "rename", target.documentId, { title: renamedTitle });
|
||||
await waitForBDocumentRename(documentB, target.documentId, renamedTitle);
|
||||
await waitForBFileTreeRename(fileTreeB, target.documentId, renamedTitle);
|
||||
result.states.after = {
|
||||
document: await readState(documentB, target.documentId),
|
||||
fileTree: await readState(fileTreeB, target.documentId),
|
||||
navigationEvents: result.navigationEvents.slice(navigationStart),
|
||||
};
|
||||
|
||||
const unexpectedNavigations = result.navigationEvents.slice(navigationStart);
|
||||
assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`);
|
||||
assert.equal(result.states.after.document.titleInputValue, renamedTitle, "B 文档页页头标题未 live 更新");
|
||||
assert.equal(result.states.after.document.breadcrumbTitle, renamedTitle, "B 文档页 Breadcrumb 未 live 更新");
|
||||
assert.equal(result.states.after.document.sidebarTitle, renamedTitle, "B 文档页 Sidebar 未 live 更新");
|
||||
assert.equal(result.states.after.fileTree.fileTreeTitle, `${renamedTitle}.md`, "B File Tree 未 live 更新为 .md 文件名");
|
||||
assert(!result.states.after.document.liveError, `B 文档页存在 live apply error: ${result.states.after.document.liveError}`);
|
||||
assert(!result.states.after.fileTree.liveError, `B File Tree 存在 live apply error: ${result.states.after.fileTree.liveError}`);
|
||||
|
||||
await documentB.screenshot({ path: SCREENSHOT_B_DOCUMENT, fullPage: true });
|
||||
await fileTreeB.screenshot({ path: SCREENSHOT_B_FILETREE, fullPage: true });
|
||||
result.ok = true;
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
||||
result.failure = {
|
||||
document: await readState(documentB, result.fixture.targetId || "").catch(() => null),
|
||||
fileTree: await readState(fileTreeB, result.fixture.targetId || "").catch(() => null),
|
||||
};
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await cleanup(requestA, workspaceId, cleanupIds).catch((error) => {
|
||||
result.cleanupError = error instanceof Error ? error.message : String(error);
|
||||
});
|
||||
await browser.close().catch(() => undefined);
|
||||
await writeResult(result);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task447-tree-move-order-dual-browser-live-smoke";
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "tree-live-cache-smoke", "20260516-task447-move-order");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SCREENSHOT_B_DOCUMENT = path.join(OUTPUT_DIR, "b-document-after-move.png");
|
||||
const SCREENSHOT_B_FILETREE = path.join(OUTPUT_DIR, "b-filetree-after-move.png");
|
||||
|
||||
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
|
||||
function cssString(value) {
|
||||
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
async function writeResult(payload) {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function requestJson(request, baseUrl, requestPath, init = {}) {
|
||||
const response = await request.fetch(`${baseUrl}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function authenticate(request, email, name) {
|
||||
await requestJson(request, AUTH_BASE_URL, "/api/auth", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: { email, password: e2ePassword(), flow: "signUp", name },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createPage(request, workspaceId, title, parentId = null) {
|
||||
const payload = await requestJson(request, BASE_URL, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action: "create", workspaceId, parentId, title },
|
||||
});
|
||||
const result = payload.result || payload;
|
||||
const documentId = result.documentId || payload.documentId || result.id || "";
|
||||
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
|
||||
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
|
||||
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
|
||||
return { documentId, workspaceId: resolvedWorkspaceId, title, payload };
|
||||
}
|
||||
|
||||
async function treeCommand(request, workspaceId, action, documentId, extra = {}) {
|
||||
return await requestJson(request, BASE_URL, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action, workspaceId, documentId, ...extra },
|
||||
});
|
||||
}
|
||||
|
||||
async function emptyDocumentTrash(request, workspaceId) {
|
||||
return await requestJson(request, BASE_URL, "/api/documents/empty-trash", {
|
||||
method: "POST",
|
||||
data: { workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(request, workspaceId, documentIds) {
|
||||
if (!workspaceId) return;
|
||||
for (const documentId of documentIds.filter(Boolean)) {
|
||||
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
|
||||
}
|
||||
await emptyDocumentTrash(request, workspaceId).catch(() => null);
|
||||
}
|
||||
|
||||
async function openDocument(page, workspaceId, documentId) {
|
||||
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator(`[data-page-title-input="true"][data-document-id="${cssString(documentId)}"]`).first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function openFileTree(page, workspaceId, documentId) {
|
||||
await openDocument(page, workspaceId, documentId);
|
||||
await page.evaluate(() => {
|
||||
const visible = (node) =>
|
||||
node instanceof HTMLElement &&
|
||||
!node.hidden &&
|
||||
getComputedStyle(node).display !== "none" &&
|
||||
getComputedStyle(node).visibility !== "hidden" &&
|
||||
node.getClientRects().length > 0;
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
if (visible(fileRoot)) return;
|
||||
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
if (tab instanceof HTMLElement) tab.click();
|
||||
});
|
||||
await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function installTreeEventRecorder(page, label, records) {
|
||||
await page.addInitScript(() => {
|
||||
window.__MNOTE_TASK447_TREE_EVENTS__ = [];
|
||||
const record = (name, event) => {
|
||||
const detail = event && event.detail ? event.detail : {};
|
||||
const payload = detail.payload || detail || {};
|
||||
const raw = (() => {
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
window.__MNOTE_TASK447_TREE_EVENTS__.push({
|
||||
name,
|
||||
at: Date.now(),
|
||||
revision: detail.revision || payload.revision || payload.cursor || "",
|
||||
op: payload && payload.data && payload.data.op ? payload.data.op : payload.op || "",
|
||||
raw: raw.slice(0, 2000),
|
||||
});
|
||||
};
|
||||
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
|
||||
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
|
||||
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
|
||||
});
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/events")) {
|
||||
records.push({ label, type: "request", method: request.method(), url, at: Date.now() });
|
||||
}
|
||||
});
|
||||
page.on("requestfailed", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/events")) {
|
||||
records.push({
|
||||
label,
|
||||
type: "requestfailed",
|
||||
method: request.method(),
|
||||
url,
|
||||
failure: request.failure()?.errorText || "",
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function recordNavigation(page, label, records) {
|
||||
page.on("framenavigated", (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
records.push({ label, url: frame.url(), at: Date.now() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function installOrderRecorder(page, rootDocumentId) {
|
||||
await page.evaluate((rootId) => {
|
||||
const readDirectChildren = (mode) => {
|
||||
const rootSelector =
|
||||
mode === "filetree"
|
||||
? `#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(rootId)}"]`
|
||||
: `#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(rootId)}"]`;
|
||||
const rootRow = document.querySelector(rootSelector);
|
||||
const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children");
|
||||
if (!list) return [];
|
||||
return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) => ({
|
||||
documentId: row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "",
|
||||
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
|
||||
title: row instanceof HTMLElement ? row.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
|
||||
}));
|
||||
};
|
||||
const push = () => {
|
||||
const record = {
|
||||
at: Date.now(),
|
||||
page: readDirectChildren("page"),
|
||||
filetree: readDirectChildren("filetree"),
|
||||
};
|
||||
window.__MNOTE_TASK447_ORDER_HISTORY__ = window.__MNOTE_TASK447_ORDER_HISTORY__ || [];
|
||||
const history = window.__MNOTE_TASK447_ORDER_HISTORY__;
|
||||
const last = history[history.length - 1];
|
||||
if (!last || JSON.stringify(last.page) !== JSON.stringify(record.page) || JSON.stringify(last.filetree) !== JSON.stringify(record.filetree)) {
|
||||
history.push(record);
|
||||
}
|
||||
};
|
||||
push();
|
||||
const observe = (root) => {
|
||||
if (!(root instanceof HTMLElement)) return;
|
||||
const observer = new MutationObserver(push);
|
||||
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-parent-id"] });
|
||||
};
|
||||
observe(document.getElementById("sidebar-tree-root"));
|
||||
observe(document.getElementById("sidebar-file-tree-root"));
|
||||
}, rootDocumentId);
|
||||
}
|
||||
|
||||
async function waitForExpectedOrder(page, rootDocumentId, expectedIds) {
|
||||
await page.waitForFunction(
|
||||
({ rootId, ids }) => {
|
||||
const read = (mode) => {
|
||||
const rootSelector =
|
||||
mode === "filetree"
|
||||
? `#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(rootId)}"]`
|
||||
: `#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(rootId)}"]`;
|
||||
const rootRow = document.querySelector(rootSelector);
|
||||
const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children");
|
||||
if (!list) return [];
|
||||
return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) =>
|
||||
row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "",
|
||||
);
|
||||
};
|
||||
const pageOrder = read("page");
|
||||
const fileOrder = read("filetree");
|
||||
return ids.every((id, index) => pageOrder[index] === id && fileOrder[index] === id);
|
||||
},
|
||||
{ rootId: rootDocumentId, ids: expectedIds },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function readState(page, rootDocumentId) {
|
||||
return await page.evaluate((rootId) => {
|
||||
const history = window.__MNOTE_TASK447_ORDER_HISTORY__ || [];
|
||||
const read = (mode) => {
|
||||
const rootSelector =
|
||||
mode === "filetree"
|
||||
? `#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(rootId)}"]`
|
||||
: `#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(rootId)}"]`;
|
||||
const rootRow = document.querySelector(rootSelector);
|
||||
const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children");
|
||||
if (!list) return [];
|
||||
return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) => ({
|
||||
documentId: row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "",
|
||||
parentId: row instanceof HTMLElement ? row.dataset.parentId || "" : "",
|
||||
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
|
||||
title: row instanceof HTMLElement ? row.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
|
||||
}));
|
||||
};
|
||||
return {
|
||||
url: window.location.href,
|
||||
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
|
||||
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
|
||||
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
|
||||
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
|
||||
pageOrder: read("page"),
|
||||
fileTreeOrder: read("filetree"),
|
||||
orderHistory: history,
|
||||
treeEvents: window.__MNOTE_TASK447_TREE_EVENTS__ || [],
|
||||
};
|
||||
}, rootDocumentId);
|
||||
}
|
||||
|
||||
function orderIds(rows) {
|
||||
return rows.map((row) => row.documentId);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const stamp = Date.now();
|
||||
const email = `mnote.stage8.move.${stamp}@example.com`;
|
||||
const prefix = `TEST-10REVIEW-08-MOVE-${stamp}`;
|
||||
const result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
authBaseUrl: AUTH_BASE_URL,
|
||||
email,
|
||||
prefix,
|
||||
fixture: {},
|
||||
navigationEvents: [],
|
||||
treeEventRequests: [],
|
||||
states: {},
|
||||
screenshots: {
|
||||
bDocument: SCREENSHOT_B_DOCUMENT,
|
||||
bFileTree: SCREENSHOT_B_FILETREE,
|
||||
},
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
|
||||
const contextA = await browser.newContext();
|
||||
const contextB = await browser.newContext();
|
||||
const documentB = await contextB.newPage();
|
||||
const fileTreeB = await contextB.newPage();
|
||||
const requestA = contextA.request;
|
||||
let workspaceId = "";
|
||||
const cleanupIds = [];
|
||||
|
||||
await installTreeEventRecorder(documentB, "B-document", result.treeEventRequests);
|
||||
await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests);
|
||||
recordNavigation(documentB, "B-document", result.navigationEvents);
|
||||
recordNavigation(fileTreeB, "B-filetree", result.navigationEvents);
|
||||
|
||||
try {
|
||||
for (const requestContext of [requestA, contextB.request]) {
|
||||
await authenticate(requestContext, email, `stage8-move-${stamp}`);
|
||||
}
|
||||
|
||||
const root = await createPage(requestA, null, `${prefix}-root`);
|
||||
workspaceId = root.workspaceId;
|
||||
cleanupIds.push(root.documentId);
|
||||
const childA = await createPage(requestA, workspaceId, `${prefix}-a`, root.documentId);
|
||||
const childB = await createPage(requestA, workspaceId, `${prefix}-b`, root.documentId);
|
||||
const childC = await createPage(requestA, workspaceId, `${prefix}-c`, root.documentId);
|
||||
cleanupIds.push(childA.documentId, childB.documentId, childC.documentId);
|
||||
const initialOrder = [childA.documentId, childB.documentId, childC.documentId];
|
||||
const expectedOrder = [childA.documentId, childC.documentId, childB.documentId];
|
||||
result.fixture = {
|
||||
workspaceId,
|
||||
rootId: root.documentId,
|
||||
childIds: initialOrder,
|
||||
movedId: childC.documentId,
|
||||
initialOrder,
|
||||
expectedOrder,
|
||||
};
|
||||
|
||||
await openDocument(documentB, workspaceId, root.documentId);
|
||||
await openFileTree(fileTreeB, workspaceId, root.documentId);
|
||||
await waitForExpectedOrder(documentB, root.documentId, initialOrder);
|
||||
await waitForExpectedOrder(fileTreeB, root.documentId, initialOrder);
|
||||
await installOrderRecorder(documentB, root.documentId);
|
||||
await installOrderRecorder(fileTreeB, root.documentId);
|
||||
result.states.before = {
|
||||
document: await readState(documentB, root.documentId),
|
||||
fileTree: await readState(fileTreeB, root.documentId),
|
||||
};
|
||||
|
||||
const navigationStart = result.navigationEvents.length;
|
||||
await treeCommand(requestA, workspaceId, "move", childC.documentId, {
|
||||
parentId: root.documentId,
|
||||
sortOrder: 1,
|
||||
});
|
||||
await waitForExpectedOrder(documentB, root.documentId, expectedOrder);
|
||||
await waitForExpectedOrder(fileTreeB, root.documentId, expectedOrder);
|
||||
await documentB.waitForTimeout(1000);
|
||||
await fileTreeB.waitForTimeout(1000);
|
||||
|
||||
result.states.after = {
|
||||
document: await readState(documentB, root.documentId),
|
||||
fileTree: await readState(fileTreeB, root.documentId),
|
||||
navigationEvents: result.navigationEvents.slice(navigationStart),
|
||||
};
|
||||
|
||||
assert.deepEqual(orderIds(result.states.after.document.pageOrder).slice(0, 3), expectedOrder, "B 文档页 Page Tree 顺序未保持新顺序");
|
||||
assert.deepEqual(orderIds(result.states.after.document.fileTreeOrder).slice(0, 3), expectedOrder, "B 文档页 File Tree 顺序未保持新顺序");
|
||||
assert.deepEqual(orderIds(result.states.after.fileTree.pageOrder).slice(0, 3), expectedOrder, "B File Tree 页面 Page Tree 顺序未保持新顺序");
|
||||
assert.deepEqual(orderIds(result.states.after.fileTree.fileTreeOrder).slice(0, 3), expectedOrder, "B File Tree 页面 File Tree 顺序未保持新顺序");
|
||||
assert.equal(result.states.after.navigationEvents.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(result.states.after.navigationEvents)}`);
|
||||
assert(!result.states.after.document.liveError, `B 文档页存在 live apply error: ${result.states.after.document.liveError}`);
|
||||
assert(!result.states.after.fileTree.liveError, `B File Tree 存在 live apply error: ${result.states.after.fileTree.liveError}`);
|
||||
|
||||
await documentB.screenshot({ path: SCREENSHOT_B_DOCUMENT, fullPage: true });
|
||||
await fileTreeB.screenshot({ path: SCREENSHOT_B_FILETREE, fullPage: true });
|
||||
result.ok = true;
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
||||
result.failure = {
|
||||
document: await readState(documentB, result.fixture.rootId || "").catch(() => null),
|
||||
fileTree: await readState(fileTreeB, result.fixture.rootId || "").catch(() => null),
|
||||
};
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await cleanup(requestA, workspaceId, cleanupIds).catch((error) => {
|
||||
result.cleanupError = error instanceof Error ? error.message : String(error);
|
||||
});
|
||||
await browser.close().catch(() => undefined);
|
||||
await writeResult(result);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task448-tree-resync-recovery-dual-browser-smoke";
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 40_000);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "tree-live-cache-smoke", "20260516-task448-resync");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SCREENSHOT_B_DOCUMENT = path.join(OUTPUT_DIR, "b-document-after-resync.png");
|
||||
const SCREENSHOT_B_FILETREE = path.join(OUTPUT_DIR, "b-filetree-after-resync.png");
|
||||
|
||||
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
|
||||
function cssString(value) {
|
||||
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
async function writeResult(payload) {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function requestJson(request, baseUrl, requestPath, init = {}) {
|
||||
const response = await request.fetch(`${baseUrl}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function authenticate(request, email, name) {
|
||||
await requestJson(request, AUTH_BASE_URL, "/api/auth", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: { email, password: e2ePassword(), flow: "signUp", name },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createPage(request, workspaceId, title, parentId = null) {
|
||||
const payload = await requestJson(request, BASE_URL, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action: "create", workspaceId, parentId, title },
|
||||
});
|
||||
const result = payload.result || payload;
|
||||
const documentId = result.documentId || payload.documentId || result.id || "";
|
||||
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
|
||||
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
|
||||
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
|
||||
return { documentId, workspaceId: resolvedWorkspaceId, title, payload };
|
||||
}
|
||||
|
||||
async function treeCommand(request, workspaceId, action, documentId, extra = {}) {
|
||||
return await requestJson(request, BASE_URL, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action, workspaceId, documentId, ...extra },
|
||||
});
|
||||
}
|
||||
|
||||
async function emptyDocumentTrash(request, workspaceId) {
|
||||
return await requestJson(request, BASE_URL, "/api/documents/empty-trash", {
|
||||
method: "POST",
|
||||
data: { workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(request, workspaceId, documentIds) {
|
||||
if (!workspaceId) return;
|
||||
for (const documentId of documentIds.filter(Boolean)) {
|
||||
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
|
||||
}
|
||||
await emptyDocumentTrash(request, workspaceId).catch(() => null);
|
||||
}
|
||||
|
||||
async function installSlowTreeEventSource(page, pollMs) {
|
||||
await page.addInitScript((value) => {
|
||||
const OriginalEventSource = window.EventSource;
|
||||
if (typeof OriginalEventSource !== "function" || window.__MNOTE_TASK448_PATCHED_EVENTSOURCE__) return;
|
||||
window.__MNOTE_TASK448_PATCHED_EVENTSOURCE__ = true;
|
||||
window.EventSource = function patchedEventSource(input, init) {
|
||||
try {
|
||||
const url = new URL(String(input), window.location.href);
|
||||
if (url.pathname === "/api/tree/events") {
|
||||
url.searchParams.set("pollMs", String(value));
|
||||
return new OriginalEventSource(url.toString(), init);
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
return new OriginalEventSource(input, init);
|
||||
};
|
||||
window.EventSource.prototype = OriginalEventSource.prototype;
|
||||
}, pollMs);
|
||||
}
|
||||
|
||||
async function installTreeEventRecorder(page, label, requests) {
|
||||
await page.addInitScript(() => {
|
||||
window.__MNOTE_TASK448_TREE_EVENTS__ = [];
|
||||
const record = (name, event) => {
|
||||
const detail = event && event.detail ? event.detail : {};
|
||||
const payload = detail.payload || detail || {};
|
||||
let raw = "";
|
||||
try {
|
||||
raw = JSON.stringify(payload);
|
||||
} catch {
|
||||
raw = "";
|
||||
}
|
||||
window.__MNOTE_TASK448_TREE_EVENTS__.push({
|
||||
name,
|
||||
at: Date.now(),
|
||||
revision: detail.revision || payload.revision || payload.cursor || "",
|
||||
kind: payload.kind || "",
|
||||
op: payload && payload.data && payload.data.op ? payload.data.op : payload.op || "",
|
||||
raw: raw.slice(0, 2400),
|
||||
});
|
||||
};
|
||||
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
|
||||
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
|
||||
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
|
||||
});
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/events")) {
|
||||
requests.push({ label, type: "request", method: request.method(), url, at: Date.now() });
|
||||
}
|
||||
});
|
||||
page.on("requestfailed", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/events")) {
|
||||
requests.push({
|
||||
label,
|
||||
type: "requestfailed",
|
||||
method: request.method(),
|
||||
url,
|
||||
failure: request.failure()?.errorText || "",
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function recordNavigation(page, label, records) {
|
||||
page.on("framenavigated", (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
records.push({ label, url: frame.url(), at: Date.now() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openDocument(page, workspaceId, documentId) {
|
||||
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator(`[data-page-title-input="true"][data-document-id="${cssString(documentId)}"]`).first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function openFileTree(page, workspaceId, documentId) {
|
||||
await openDocument(page, workspaceId, documentId);
|
||||
await page.evaluate(() => {
|
||||
const visible = (node) =>
|
||||
node instanceof HTMLElement &&
|
||||
!node.hidden &&
|
||||
getComputedStyle(node).display !== "none" &&
|
||||
getComputedStyle(node).visibility !== "hidden" &&
|
||||
node.getClientRects().length > 0;
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
if (visible(fileRoot)) return;
|
||||
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
if (tab instanceof HTMLElement) tab.click();
|
||||
});
|
||||
await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function waitForRows(page, documentIds) {
|
||||
await page.waitForFunction(
|
||||
(ids) =>
|
||||
ids.every(
|
||||
(id) =>
|
||||
document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) &&
|
||||
document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`),
|
||||
),
|
||||
documentIds,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForResyncWithRows(page, documentIds) {
|
||||
await page.waitForFunction(
|
||||
(ids) => {
|
||||
const events = Array.isArray(window.__MNOTE_TASK448_TREE_EVENTS__) ? window.__MNOTE_TASK448_TREE_EVENTS__ : [];
|
||||
const hasResync = events.some((event) => event && event.name === "tree:resync");
|
||||
if (!hasResync) return false;
|
||||
const applied = document.documentElement.getAttribute("data-mnote-tree-live-applied") || "";
|
||||
const error = document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "";
|
||||
if (applied !== "resync" || error) return false;
|
||||
return ids.every(
|
||||
(id) =>
|
||||
document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) &&
|
||||
document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`),
|
||||
);
|
||||
},
|
||||
documentIds,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function readState(page, rootDocumentId) {
|
||||
return await page.evaluate((rootId) => {
|
||||
const readDirectChildren = (mode) => {
|
||||
const rootSelector =
|
||||
mode === "filetree"
|
||||
? `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${CSS.escape(rootId)}"]`
|
||||
: `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(rootId)}"]`;
|
||||
const rootRow = document.querySelector(rootSelector);
|
||||
const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children");
|
||||
if (!list) return [];
|
||||
return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) => ({
|
||||
documentId: row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "",
|
||||
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
|
||||
title: row instanceof HTMLElement ? row.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
|
||||
}));
|
||||
};
|
||||
return {
|
||||
url: window.location.href,
|
||||
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
|
||||
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
|
||||
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
|
||||
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
|
||||
pageOrder: readDirectChildren("page"),
|
||||
fileTreeOrder: readDirectChildren("filetree"),
|
||||
treeEvents: window.__MNOTE_TASK448_TREE_EVENTS__ || [],
|
||||
};
|
||||
}, rootDocumentId);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const stamp = Date.now();
|
||||
const email = `mnote.stage8.resync.${stamp}@example.com`;
|
||||
const prefix = `TEST-10REVIEW-08-RESYNC-${stamp}`;
|
||||
const result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
authBaseUrl: AUTH_BASE_URL,
|
||||
email,
|
||||
prefix,
|
||||
pollMs: 5000,
|
||||
fixture: {},
|
||||
navigationEvents: [],
|
||||
treeEventRequests: [],
|
||||
states: {},
|
||||
screenshots: {
|
||||
bDocument: SCREENSHOT_B_DOCUMENT,
|
||||
bFileTree: SCREENSHOT_B_FILETREE,
|
||||
},
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
|
||||
const contextA = await browser.newContext();
|
||||
const contextB = await browser.newContext();
|
||||
const documentB = await contextB.newPage();
|
||||
const fileTreeB = await contextB.newPage();
|
||||
const requestA = contextA.request;
|
||||
let workspaceId = "";
|
||||
const cleanupIds = [];
|
||||
|
||||
for (const page of [documentB, fileTreeB]) {
|
||||
await installSlowTreeEventSource(page, result.pollMs);
|
||||
await installTreeEventRecorder(page, page === documentB ? "B-document" : "B-filetree", result.treeEventRequests);
|
||||
recordNavigation(page, page === documentB ? "B-document" : "B-filetree", result.navigationEvents);
|
||||
}
|
||||
|
||||
try {
|
||||
for (const requestContext of [requestA, contextB.request]) {
|
||||
await authenticate(requestContext, email, `stage8-resync-${stamp}`);
|
||||
}
|
||||
|
||||
const root = await createPage(requestA, null, `${prefix}-root`);
|
||||
workspaceId = root.workspaceId;
|
||||
cleanupIds.push(root.documentId);
|
||||
const childA = await createPage(requestA, workspaceId, `${prefix}-a`, root.documentId);
|
||||
cleanupIds.push(childA.documentId);
|
||||
result.fixture = {
|
||||
workspaceId,
|
||||
rootId: root.documentId,
|
||||
initialChildId: childA.documentId,
|
||||
};
|
||||
|
||||
await openDocument(documentB, workspaceId, root.documentId);
|
||||
await openFileTree(fileTreeB, workspaceId, root.documentId);
|
||||
await waitForRows(documentB, [childA.documentId]);
|
||||
await waitForRows(fileTreeB, [childA.documentId]);
|
||||
await Promise.all([
|
||||
documentB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }),
|
||||
fileTreeB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }),
|
||||
]);
|
||||
|
||||
result.states.before = {
|
||||
document: await readState(documentB, root.documentId),
|
||||
fileTree: await readState(fileTreeB, root.documentId),
|
||||
};
|
||||
|
||||
const navigationStart = result.navigationEvents.length;
|
||||
const childB = await createPage(requestA, workspaceId, `${prefix}-b`, root.documentId);
|
||||
const childC = await createPage(requestA, workspaceId, `${prefix}-c`, root.documentId);
|
||||
cleanupIds.push(childB.documentId, childC.documentId);
|
||||
result.fixture.resyncedChildIds = [childB.documentId, childC.documentId];
|
||||
|
||||
await waitForResyncWithRows(documentB, [childB.documentId, childC.documentId]);
|
||||
await waitForResyncWithRows(fileTreeB, [childB.documentId, childC.documentId]);
|
||||
await documentB.waitForTimeout(500);
|
||||
await fileTreeB.waitForTimeout(500);
|
||||
|
||||
result.states.after = {
|
||||
document: await readState(documentB, root.documentId),
|
||||
fileTree: await readState(fileTreeB, root.documentId),
|
||||
navigationEvents: result.navigationEvents.slice(navigationStart),
|
||||
};
|
||||
|
||||
assert.equal(result.states.after.document.liveApplied, "resync", "B 文档页应通过 resync 应用最新树快照");
|
||||
assert.equal(result.states.after.fileTree.liveApplied, "resync", "B File Tree 应通过 resync 应用最新树快照");
|
||||
assert(!result.states.after.document.liveError, `B 文档页存在 live apply error: ${result.states.after.document.liveError}`);
|
||||
assert(!result.states.after.fileTree.liveError, `B File Tree 存在 live apply error: ${result.states.after.fileTree.liveError}`);
|
||||
assert.equal(result.states.after.navigationEvents.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(result.states.after.navigationEvents)}`);
|
||||
|
||||
await documentB.screenshot({ path: SCREENSHOT_B_DOCUMENT, fullPage: true });
|
||||
await fileTreeB.screenshot({ path: SCREENSHOT_B_FILETREE, fullPage: true });
|
||||
result.ok = true;
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
||||
result.failure = {
|
||||
document: await readState(documentB, result.fixture.rootId || "").catch(() => null),
|
||||
fileTree: await readState(fileTreeB, result.fixture.rootId || "").catch(() => null),
|
||||
};
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await cleanup(requestA, workspaceId, cleanupIds).catch((error) => {
|
||||
result.cleanupError = error instanceof Error ? error.message : String(error);
|
||||
});
|
||||
await browser.close().catch(() => undefined);
|
||||
await writeResult(result);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,380 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task449-tree-sse-reconnect-snapshot-recovery-smoke";
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "tree-live-cache-smoke", "20260516-task449-reconnect");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SCREENSHOT_B_DOCUMENT = path.join(OUTPUT_DIR, "b-document-after-reconnect.png");
|
||||
const SCREENSHOT_B_FILETREE = path.join(OUTPUT_DIR, "b-filetree-after-reconnect.png");
|
||||
|
||||
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
|
||||
function cssString(value) {
|
||||
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
async function writeResult(payload) {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function requestJson(request, baseUrl, requestPath, init = {}) {
|
||||
const response = await request.fetch(`${baseUrl}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function authenticate(request, email, name) {
|
||||
await requestJson(request, AUTH_BASE_URL, "/api/auth", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: { email, password: e2ePassword(), flow: "signUp", name },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createPage(request, workspaceId, title, parentId = null) {
|
||||
const payload = await requestJson(request, BASE_URL, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action: "create", workspaceId, parentId, title },
|
||||
});
|
||||
const result = payload.result || payload;
|
||||
const documentId = result.documentId || payload.documentId || result.id || "";
|
||||
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
|
||||
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
|
||||
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
|
||||
return { documentId, workspaceId: resolvedWorkspaceId, title, payload };
|
||||
}
|
||||
|
||||
async function treeCommand(request, workspaceId, action, documentId, extra = {}) {
|
||||
return await requestJson(request, BASE_URL, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action, workspaceId, documentId, ...extra },
|
||||
});
|
||||
}
|
||||
|
||||
async function emptyDocumentTrash(request, workspaceId) {
|
||||
return await requestJson(request, BASE_URL, "/api/documents/empty-trash", {
|
||||
method: "POST",
|
||||
data: { workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(request, workspaceId, documentIds) {
|
||||
if (!workspaceId) return;
|
||||
for (const documentId of documentIds.filter(Boolean)) {
|
||||
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
|
||||
}
|
||||
await emptyDocumentTrash(request, workspaceId).catch(() => null);
|
||||
}
|
||||
|
||||
async function installTreeEventRecorder(page, label, requests) {
|
||||
await page.addInitScript(() => {
|
||||
window.__MNOTE_TASK449_TREE_EVENTS__ = [];
|
||||
const record = (name, event) => {
|
||||
const detail = event && event.detail ? event.detail : {};
|
||||
const payload = detail.payload || detail || {};
|
||||
let raw = "";
|
||||
try {
|
||||
raw = JSON.stringify(payload);
|
||||
} catch {
|
||||
raw = "";
|
||||
}
|
||||
window.__MNOTE_TASK449_TREE_EVENTS__.push({
|
||||
name,
|
||||
at: Date.now(),
|
||||
revision: detail.revision || payload.revision || payload.cursor || "",
|
||||
kind: payload.kind || "",
|
||||
op: payload && payload.data && payload.data.op ? payload.data.op : payload.op || "",
|
||||
raw: raw.slice(0, 2400),
|
||||
});
|
||||
};
|
||||
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
|
||||
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
|
||||
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
|
||||
});
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/events")) {
|
||||
requests.push({ label, type: "request", method: request.method(), url, at: Date.now() });
|
||||
}
|
||||
});
|
||||
page.on("requestfailed", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/events")) {
|
||||
requests.push({
|
||||
label,
|
||||
type: "requestfailed",
|
||||
method: request.method(),
|
||||
url,
|
||||
failure: request.failure()?.errorText || "",
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function recordNavigation(page, label, records) {
|
||||
page.on("framenavigated", (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
records.push({ label, url: frame.url(), at: Date.now() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openDocument(page, workspaceId, documentId) {
|
||||
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator(`[data-page-title-input="true"][data-document-id="${cssString(documentId)}"]`).first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function openFileTree(page, workspaceId, documentId) {
|
||||
await openDocument(page, workspaceId, documentId);
|
||||
await page.evaluate(() => {
|
||||
const visible = (node) =>
|
||||
node instanceof HTMLElement &&
|
||||
!node.hidden &&
|
||||
getComputedStyle(node).display !== "none" &&
|
||||
getComputedStyle(node).visibility !== "hidden" &&
|
||||
node.getClientRects().length > 0;
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
if (visible(fileRoot)) return;
|
||||
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
if (tab instanceof HTMLElement) tab.click();
|
||||
});
|
||||
await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function waitForRows(page, documentIds) {
|
||||
await page.waitForFunction(
|
||||
(ids) =>
|
||||
ids.every(
|
||||
(id) =>
|
||||
document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) &&
|
||||
document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`),
|
||||
),
|
||||
documentIds,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForReconnectRecoveryWithRows(page, documentIds, minSnapshotCount) {
|
||||
await page.waitForFunction(
|
||||
({ ids, count }) => {
|
||||
const events = Array.isArray(window.__MNOTE_TASK449_TREE_EVENTS__) ? window.__MNOTE_TASK449_TREE_EVENTS__ : [];
|
||||
const snapshotCount = events.filter((event) => event && event.name === "tree:snapshot").length;
|
||||
const hasRecoveryEvent = snapshotCount >= count || events.some((event) => event && event.name === "tree:resync");
|
||||
if (!hasRecoveryEvent) return false;
|
||||
const applied = document.documentElement.getAttribute("data-mnote-tree-live-applied") || "";
|
||||
const status = document.documentElement.getAttribute("data-mnote-tree-live-status") || "";
|
||||
const error = document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "";
|
||||
if (!["snapshot", "resync"].includes(applied) || status !== "connected" || error) return false;
|
||||
return ids.every(
|
||||
(id) =>
|
||||
document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) &&
|
||||
document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`),
|
||||
);
|
||||
},
|
||||
{ ids: documentIds, count: minSnapshotCount },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function readState(page, rootDocumentId) {
|
||||
return await page.evaluate((rootId) => {
|
||||
const readDirectChildren = (mode) => {
|
||||
const rootSelector =
|
||||
mode === "filetree"
|
||||
? `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${CSS.escape(rootId)}"]`
|
||||
: `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(rootId)}"]`;
|
||||
const rootRow = document.querySelector(rootSelector);
|
||||
const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children");
|
||||
if (!list) return [];
|
||||
return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) => ({
|
||||
documentId: row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "",
|
||||
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
|
||||
title: row instanceof HTMLElement ? row.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
|
||||
}));
|
||||
};
|
||||
return {
|
||||
url: window.location.href,
|
||||
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
|
||||
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
|
||||
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
|
||||
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
|
||||
pageOrder: readDirectChildren("page"),
|
||||
fileTreeOrder: readDirectChildren("filetree"),
|
||||
treeEvents: window.__MNOTE_TASK449_TREE_EVENTS__ || [],
|
||||
};
|
||||
}, rootDocumentId);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const stamp = Date.now();
|
||||
const email = `mnote.stage8.reconnect.${stamp}@example.com`;
|
||||
const prefix = `TEST-10REVIEW-08-RECONNECT-${stamp}`;
|
||||
const result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
authBaseUrl: AUTH_BASE_URL,
|
||||
email,
|
||||
prefix,
|
||||
fixture: {},
|
||||
navigationEvents: [],
|
||||
treeEventRequests: [],
|
||||
states: {},
|
||||
screenshots: {
|
||||
bDocument: SCREENSHOT_B_DOCUMENT,
|
||||
bFileTree: SCREENSHOT_B_FILETREE,
|
||||
},
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
|
||||
const contextA = await browser.newContext();
|
||||
const contextB = await browser.newContext();
|
||||
const documentB = await contextB.newPage();
|
||||
const fileTreeB = await contextB.newPage();
|
||||
const requestA = contextA.request;
|
||||
let workspaceId = "";
|
||||
const cleanupIds = [];
|
||||
|
||||
for (const page of [documentB, fileTreeB]) {
|
||||
await installTreeEventRecorder(page, page === documentB ? "B-document" : "B-filetree", result.treeEventRequests);
|
||||
recordNavigation(page, page === documentB ? "B-document" : "B-filetree", result.navigationEvents);
|
||||
}
|
||||
|
||||
try {
|
||||
for (const requestContext of [requestA, contextB.request]) {
|
||||
await authenticate(requestContext, email, `stage8-reconnect-${stamp}`);
|
||||
}
|
||||
|
||||
const root = await createPage(requestA, null, `${prefix}-root`);
|
||||
workspaceId = root.workspaceId;
|
||||
cleanupIds.push(root.documentId);
|
||||
const childA = await createPage(requestA, workspaceId, `${prefix}-a`, root.documentId);
|
||||
cleanupIds.push(childA.documentId);
|
||||
result.fixture = {
|
||||
workspaceId,
|
||||
rootId: root.documentId,
|
||||
initialChildId: childA.documentId,
|
||||
};
|
||||
|
||||
await openDocument(documentB, workspaceId, root.documentId);
|
||||
await openFileTree(fileTreeB, workspaceId, root.documentId);
|
||||
await waitForRows(documentB, [childA.documentId]);
|
||||
await waitForRows(fileTreeB, [childA.documentId]);
|
||||
await Promise.all([
|
||||
documentB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }),
|
||||
fileTreeB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }),
|
||||
]);
|
||||
|
||||
result.states.beforeDisconnect = {
|
||||
document: await readState(documentB, root.documentId),
|
||||
fileTree: await readState(fileTreeB, root.documentId),
|
||||
};
|
||||
const snapshotCounts = {
|
||||
document:
|
||||
result.states.beforeDisconnect.document.treeEvents.filter((event) => event.name === "tree:snapshot").length,
|
||||
fileTree:
|
||||
result.states.beforeDisconnect.fileTree.treeEvents.filter((event) => event.name === "tree:snapshot").length,
|
||||
};
|
||||
|
||||
await contextB.setOffline(true);
|
||||
await Promise.all([documentB.waitForTimeout(500), fileTreeB.waitForTimeout(500)]);
|
||||
result.states.offline = {
|
||||
document: await readState(documentB, root.documentId),
|
||||
fileTree: await readState(fileTreeB, root.documentId),
|
||||
};
|
||||
|
||||
const navigationStart = result.navigationEvents.length;
|
||||
const childB = await createPage(requestA, workspaceId, `${prefix}-b`, root.documentId);
|
||||
const childC = await createPage(requestA, workspaceId, `${prefix}-c`, root.documentId);
|
||||
cleanupIds.push(childB.documentId, childC.documentId);
|
||||
result.fixture.recoveredChildIds = [childB.documentId, childC.documentId];
|
||||
|
||||
assert(
|
||||
!(await documentB.locator(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssString(childB.documentId)}"]`).count()),
|
||||
"B 文档页离线期间不应提前看到 childB",
|
||||
);
|
||||
assert(
|
||||
!(await fileTreeB.locator(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${cssString(`doc:${childB.documentId}`)}"]`).count()),
|
||||
"B File Tree 离线期间不应提前看到 childB",
|
||||
);
|
||||
|
||||
await contextB.setOffline(false);
|
||||
await waitForReconnectRecoveryWithRows(documentB, [childB.documentId, childC.documentId], snapshotCounts.document + 1);
|
||||
await waitForReconnectRecoveryWithRows(fileTreeB, [childB.documentId, childC.documentId], snapshotCounts.fileTree + 1);
|
||||
await documentB.waitForTimeout(500);
|
||||
await fileTreeB.waitForTimeout(500);
|
||||
|
||||
result.states.afterReconnect = {
|
||||
document: await readState(documentB, root.documentId),
|
||||
fileTree: await readState(fileTreeB, root.documentId),
|
||||
navigationEvents: result.navigationEvents.slice(navigationStart),
|
||||
};
|
||||
|
||||
assert(
|
||||
["snapshot", "resync"].includes(result.states.afterReconnect.document.liveApplied),
|
||||
`B 文档页应通过重连 snapshot/resync 应用最新树快照: ${result.states.afterReconnect.document.liveApplied}`,
|
||||
);
|
||||
assert(
|
||||
["snapshot", "resync"].includes(result.states.afterReconnect.fileTree.liveApplied),
|
||||
`B File Tree 应通过重连 snapshot/resync 应用最新树快照: ${result.states.afterReconnect.fileTree.liveApplied}`,
|
||||
);
|
||||
assert(!result.states.afterReconnect.document.liveError, `B 文档页存在 live apply error: ${result.states.afterReconnect.document.liveError}`);
|
||||
assert(!result.states.afterReconnect.fileTree.liveError, `B File Tree 存在 live apply error: ${result.states.afterReconnect.fileTree.liveError}`);
|
||||
assert.equal(result.states.afterReconnect.navigationEvents.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(result.states.afterReconnect.navigationEvents)}`);
|
||||
|
||||
await documentB.screenshot({ path: SCREENSHOT_B_DOCUMENT, fullPage: true });
|
||||
await fileTreeB.screenshot({ path: SCREENSHOT_B_FILETREE, fullPage: true });
|
||||
result.ok = true;
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
||||
result.failure = {
|
||||
document: await readState(documentB, result.fixture.rootId || "").catch(() => null),
|
||||
fileTree: await readState(fileTreeB, result.fixture.rootId || "").catch(() => null),
|
||||
};
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await contextB.setOffline(false).catch(() => undefined);
|
||||
await cleanup(requestA, workspaceId, cleanupIds).catch((error) => {
|
||||
result.cleanupError = error instanceof Error ? error.message : String(error);
|
||||
});
|
||||
await browser.close().catch(() => undefined);
|
||||
await writeResult(result);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user