收口 MNote P0 P1 P2 审查尾项

- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目
- 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线
- 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径

验证:
- cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1
- cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1
- git diff --check
- git diff --cached --check
- codegraph index . --force && codegraph status .
- codegraph sync . && codegraph status .
This commit is contained in:
lix-2026
2026-06-01 09:29:12 +08:00
parent 49a0545148
commit 1882db7681
143 changed files with 29810 additions and 3228 deletions
+8 -5
View File
@@ -28,17 +28,20 @@ node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js
node scripts/task490-runtime-surfaces-smoke.js
```
其中 `task490-runtime-surfaces-smoke.js` 是轻量浏览器 smoke验证页面设置、Page AI drawer、slash menu、block handle menu 能在当前 leptos-tiptap 文档页打开;它不要求真实模型返回,也不替代更重的页面设置、Hermes/Page AI 或编辑器菜单专项脚本。
其中 `task490-runtime-surfaces-smoke.js` 是轻量浏览器 smoke,验证页面设置、Page AI drawer 打开/关闭、Page AI stop-run abort、slash menu、block handle menu 能在当前 leptos-tiptap 文档页工作;它使用 mock run 覆盖停止按钮,不要求真实模型返回,也不替代更重的页面设置、Hermes/Page AI 或编辑器菜单专项脚本。
### 0.1 当前主链优先脚本
- 入口与认证:`task114-rust-web-gateway-entry-smoke.js``task159-auth-entry-smoke.js``task097-homepage-entry-smoke.js`
- Rust SSR 文档页 / Page Aggregatelocal-first 默认优先 `task167-local-markdown-title-body-options-no-convex-smoke.js`cloud/control-plane 文档可补跑 `task110-page-title-single-truth-smoke.js``task-page-aggregate-body-sync-smoke.js``task-page-aggregate-options-sync-smoke.js``task-page-aggregate-refresh-persistence-smoke.js`
- Rust SSR 文档页 / Page Aggregatelocal-first 默认优先 `task167-local-markdown-title-body-options-no-convex-smoke.js`Page Aggregate browser conversion / compat fallback 改动补跑 `task522-page-aggregate-compat-fallback-contract.js`cloud/control-plane 文档可补跑 `task110-page-title-single-truth-smoke.js``task-page-aggregate-body-sync-smoke.js``task-page-aggregate-options-sync-smoke.js``task-page-aggregate-refresh-persistence-smoke.js`
- leptos-tiptap runtime surface`task490-runtime-surfaces-smoke.js`;需要验证保存回读可补跑 `task121-rust-web-editor-island-hydration-smoke.js`,但它仍使用 `/api/tree/commands create` 准备文档,不作为无 Convex 默认基线;需要验证浮层互斥和更多菜单状态时再跑 `task158-e30-menu-state-smoke.js`
- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js``task166-local-first-managed-workspace-no-convex-smoke.js``task167-local-markdown-title-body-options-no-convex-smoke.js``task436-local-markdown-open-document-external-change-smoke.js``task443-local-markdown-asset-upload-smoke.js``task451-local-markdown-conflict-resolution-ui-smoke.js``task452-local-search-index-browser-smoke.js``task453-local-folder-page-ai-changed-files-smoke.js`
- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js``task166-local-first-managed-workspace-no-convex-smoke.js``task167-local-markdown-title-body-options-no-convex-smoke.js``task436-local-markdown-open-document-external-change-smoke.js``task443-local-markdown-asset-upload-smoke.js``task451-local-markdown-conflict-resolution-ui-smoke.js``task452-local-search-index-browser-smoke.js``task453-local-folder-page-ai-changed-files-smoke.js`WorkspacePath / ObjectIdentity runtime 消费统一改动补跑 `task524-workspace-object-identity-matrix-smoke.js`
- local-folder OCR`task526-local-folder-ocr-api-smoke.js` 覆盖 mock OCR API、sidecar 落盘、jobs/status/read、`includeOcr=true` 搜索 owner page、显式插入 OCR 链接和 OCR sidecar Markdown resource tab 打开;它不代表真实 MinerU token/upload/zip runtime、前端 OCR 入口或全局 OCR 任务栏已完成。
- local Markdown conflict regression`task510-local-markdown-conflict-regression-group.js` 串联真实冲突、连续上传、新建页面、外部恢复、多 tab CAS 与上传 409 孤儿策略;可用 `MNOTE_CONFLICT_REGRESSION_TASKS=a.js,b.js` 做定点子集。
- tree realtime / live cache`task446-tree-rename-dual-browser-live-smoke.js``task447-tree-move-order-dual-browser-live-smoke.js``task448-tree-resync-recovery-dual-browser-smoke.js``task449-tree-sse-reconnect-snapshot-recovery-smoke.js`
- 资源对象与 mindmap`task455-local-folder-mindmap-clean-smoke.js``task456-resource-object-shell-sync-smoke.js``task166-mindmap-phase6-block-smoke.js``task167-mindmap-kmind-parity-smoke.js``task168-mindmap-put-validator-smoke.js`
- 资源对象与 mindmap`task455-local-folder-mindmap-clean-smoke.js``task456-resource-object-shell-sync-smoke.js``task166-mindmap-phase6-block-smoke.js``task167-mindmap-kmind-parity-smoke.js``task168-mindmap-put-validator-smoke.js`Page AI mindmap skill / 资源生成改动补跑 `task503-mindmap-skill-capability-smoke.js`,真实 mindmap resource tab / Page AI target 改动补跑 `task525-page-ai-mindmap-resource-target-smoke.js`
- Page AI / agent history / ChatOnly`task502-page-ai-agent-selector-context-smoke.js` 覆盖 Page AI agent/context/target picker、skills source 和 run payloadraw local resource target 改动补跑 `task520-page-ai-raw-resource-target-smoke.js``task504-page-ai-history-agent-filter-smoke.js` 覆盖 Page AI 历史按 agent 过滤;ChatOnly / provider session 绑定改动优先跑 `task512-chatonly-doubao-sync-smoke.js`,跨 provider 同步补跑 `task513-chatonly-provider-sync-smoke.js`
- Dev hot / OnlyOffice live bridgeSidebar dev hot reload 入口改动补跑 `task514-sidebar-dev-hot-reload-gating-smoke.js`OnlyOffice live bridge session / scope / HTTP 工具边界改动补跑 `task515-onlyoffice-live-scope-http-smoke.js`bridge session/token/queue/current/close 和 `docKey/pageOrigin` 元数据改动补跑 `task516-onlyoffice-bridge-multisession-browser-smoke.js`bridge plugin index / `Asc.plugin` direct loop / plugin 元数据透传改动补跑 `task517-onlyoffice-bridge-plugin-direct-smoke.js`;真实 ONLYOFFICE iframe / DocumentServer session、同文档双 tab、resource scope 和非 dry-run 写入落点改动补跑 `task518-onlyoffice-real-iframe-session-scope-smoke.js`Page AI 真实 UI 选择 Office target 并冻结 live bridge session 改动补跑 `task523-page-ai-onlyoffice-real-target-session-smoke.js`
- local resource lifecycle API`task437-local-folder-asset-trash-lifecycle-smoke.js`,现在只验证 `local-file:*` delete / restore / purge,不再依赖普通 `.txt` 是否出现在 UI 文件树。
### 0.2 需要退役或拆分的历史脚本
@@ -182,7 +185,7 @@ node scripts/task097-homepage-entry-smoke.js
- local-first:优先使用 `task164-desktop-hot-local-folder-main-entry-smoke.js``task166-local-first-managed-workspace-no-convex-smoke.js``task167-local-markdown-title-body-options-no-convex-smoke.js``task455-local-folder-mindmap-clean-smoke.js`
- 页面设置:`task160``task164-page-options-visible-effect-smoke.js`
- 双栏:`task165`
- 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/` 做历史对照
- AI:优先使用 `task-hermes-page-ai-retirement-guard.js``task-page-block-ai-tools-smoke.js` 和当前 Hermes 页面 AI smokePage AI history / skills / ChatOnly 相关改动补跑 `task503-mindmap-skill-capability-smoke.js``task504-page-ai-history-agent-filter-smoke.js``task512-chatonly-doubao-sync-smoke.js``task513-chatonly-provider-sync-smoke.js`OnlyOffice live bridge 改动补跑 `task515-onlyoffice-live-scope-http-smoke.js``task516-onlyoffice-bridge-multisession-browser-smoke.js``task517-onlyoffice-bridge-plugin-direct-smoke.js``task518-onlyoffice-real-iframe-session-scope-smoke.js`,其中 `task516/517` 覆盖 bridge session lifecycle 与 `docKey/pageOrigin` 元数据;Page AI 真实 target picker / Office live session 改动补跑 `task523-page-ai-onlyoffice-real-target-session-smoke.js``task155``task156``task161` 等旧 `/api/ai-agent/run` smoke 只保留在本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/` 做历史对照
原则:
+61 -1
View File
@@ -58,6 +58,37 @@ function stableIdPart(value, fallback) {
return text || String(fallback || 'unknown');
}
function stableJson(value) {
try {
return JSON.stringify(value || null, null, 2);
} catch {
return 'null';
}
}
function buildMnotePromptText(userPrompt, context = {}) {
const capabilities = context.mnoteCapabilities || {};
const envelope = capabilities.agentRunEnvelope || null;
if (!envelope || capabilities.attachMnoteCapabilities !== true) {
return userPrompt;
}
const primaryPath = envelope.primaryTarget?.workspacePath?.relativePath
|| envelope.primaryTarget?.relativePath
|| '';
const allowedRoots = envelope.allowedRoots || capabilities.allowedRoots || [];
const guidance = [
'<mnote-agent-run>',
'MNote has attached a local-first agent run envelope.',
'Use your native file tools to read and edit real files inside the current working directory and allowed roots.',
'Do not use MNote doc/page write tools for ordinary local Markdown edits.',
primaryPath ? `Primary target relativePath: ${primaryPath}` : '',
`Allowed roots: ${stableJson(allowedRoots)}`,
`Agent run envelope: ${stableJson(envelope)}`,
'</mnote-agent-run>',
].filter(Boolean).join('\n');
return `${guidance}\n\n${userPrompt}`;
}
function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
const {
actorId,
@@ -183,6 +214,35 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
if (contextualPayload.args.aiAccessScope?.permissionLevel !== 'read_write') {
throw new Error('selftest expected aiAccessScope inherited from mnoteCapabilities');
}
const promptWithEnvelope = buildMnotePromptText('请把标题改成测试标题', {
mnoteCapabilities: {
attachMnoteCapabilities: true,
agentRunEnvelope: {
schema: 'mnote.agent_run_envelope.v1',
sourceKind: 'local_folder',
primaryTarget: {
workspacePath: {
relativePath: 'docs/current.md',
rootUri: 'file:///tmp/mnote-local',
},
},
allowedRoots: [{ rootUri: 'file:///tmp/mnote-local', permission: 'write' }],
resultPolicy: {
receiptSchema: 'mnote.agent_run_receipt.v1',
changedFiles: 'required',
},
},
},
});
if (!promptWithEnvelope.includes('mnote.agent_run_envelope.v1')) {
throw new Error('selftest expected prompt to include agentRunEnvelope schema');
}
if (!promptWithEnvelope.includes('docs/current.md')) {
throw new Error('selftest expected prompt to include primary target relativePath');
}
if (!promptWithEnvelope.includes('native file tools')) {
throw new Error('selftest expected prompt to instruct native file tools');
}
process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
process.exit(0);
}
@@ -575,7 +635,7 @@ onRequest('session/prompt', async (params) => {
const mnoteCapabilities = params.mnoteCapabilities || {};
const useMnoteTools = mnoteCapabilities.attachMnoteCapabilities === true;
const loop = useMnoteTools ? session.mnoteLoop : session.chatLoop;
const promptText = text;
const promptText = buildMnotePromptText(text, toolContext);
let stopReason = 'end_turn';
let hasAssistantOutput = false;
let hasToolCall = false;
@@ -78,6 +78,32 @@ async function insertMindmapThroughSlash(page) {
});
}
async function assertMindmapStyleDrawerClosedByDefault(page) {
await page.locator('[data-testid="mindmap-rust-shell"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const state = await page.evaluate(() => {
const shell = document.querySelector('[data-testid="mindmap-rust-shell"]');
const sidebar = document.querySelector('[data-testid="mindmap-schema-sidebar"]');
const drawer = document.querySelector('[data-testid="mindmap-schema-sidebar-drawer"]');
return {
panelOpen: shell instanceof HTMLElement ? shell.getAttribute("data-sidebar-panel-open") || "" : "",
activePanel: shell instanceof HTMLElement ? shell.getAttribute("data-sidebar-active-panel") || "" : "",
sidebarPresent: sidebar instanceof HTMLElement,
sidebarPanelOpen: sidebar instanceof HTMLElement ? sidebar.getAttribute("data-panel-open") || "" : "",
drawerVisible: drawer instanceof HTMLElement && drawer.getClientRects().length > 0,
bodyText: document.body?.innerText || "",
};
});
assert.equal(state.panelOpen, "false", `节点样式抽屉不应默认打开: ${JSON.stringify(state)}`);
if (state.sidebarPanelOpen) {
assert.equal(state.sidebarPanelOpen, "false", `sidebar panel 状态应与 shell 保持默认收起: ${JSON.stringify(state)}`);
}
assert.equal(state.drawerVisible, false, `节点样式抽屉不应默认渲染遮挡画布: ${JSON.stringify(state)}`);
return state;
}
async function assertSlashMenuAnchorsAfterMindmap(page) {
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror[contenteditable="true"]').first();
await editor.click({ timeout: UI_TIMEOUT_MS });
@@ -122,6 +148,134 @@ async function assertSlashMenuAnchorsAfterMindmap(page) {
return state;
}
async function resizeMindmapThroughCornerHandle(page) {
await page.locator('[data-testid="mindmap-resize-handle-nw"]').first().waitFor({
state: "attached",
timeout: UI_TIMEOUT_MS,
});
const before = await page.evaluate(() => {
const placeholder = document.querySelector('[data-testid="mnote-mindmap-placeholder"]');
const root = document.querySelector('[data-testid="mnote-mindmap-editor-root"]');
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const handles = Array.from(document.querySelectorAll('[data-mnote-mindmap-resize-handle]'))
.map((handle) => handle.getAttribute("data-mnote-mindmap-resize-handle") || "")
.sort();
const rect = placeholder?.getBoundingClientRect();
const editorRect = editor?.getBoundingClientRect();
return {
handles,
rect: rect ? { width: Math.round(rect.width), height: Math.round(rect.height) } : null,
centerDelta: rect && editorRect ? Math.round((rect.left + rect.width / 2) - (editorRect.left + editorRect.width / 2)) : null,
cssMaxWidth: root instanceof HTMLElement ? getComputedStyle(root).getPropertyValue("--mnote-mindmap-block-max-width").trim() : "",
htmlCssMaxWidth: getComputedStyle(document.documentElement).getPropertyValue("--mnote-mindmap-block-max-width").trim(),
};
});
assert.deepEqual(before.handles, ["ne", "nw", "se", "sw"], `mindmap 应渲染四角 resize handle: ${JSON.stringify(before)}`);
assert(before.rect, `mindmap resize 前应能读取占位块尺寸: ${JSON.stringify(before)}`);
assert(Math.abs(before.centerDelta) <= 2, `初始 mindmap 应以正文列中心对齐: ${JSON.stringify(before)}`);
await page.waitForFunction(() => {
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
return Object.values(registry).some((bridge) => typeof bridge?.instance?.resize === "function");
}, null, { timeout: UI_TIMEOUT_MS });
await page.evaluate(() => {
window.__MNOTE_TEST_MINDMAP_RESIZE_COUNT = 0;
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
Object.values(registry).forEach((bridge) => {
const instance = bridge?.instance;
if (!instance || typeof instance.resize !== "function" || instance.resize.__mnoteResizeCounterPatched) return;
const original = instance.resize.bind(instance);
const patched = function patchedMindmapResize() {
window.__MNOTE_TEST_MINDMAP_RESIZE_COUNT = Number(window.__MNOTE_TEST_MINDMAP_RESIZE_COUNT || 0) + 1;
return original();
};
patched.__mnoteResizeCounterPatched = true;
instance.resize = patched;
});
});
const handleBox = await page.locator('[data-testid="mindmap-resize-handle-nw"]').first().boundingBox();
assert(handleBox, "左上角 resize handle 应有可交互位置");
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
await page.mouse.down();
await page.mouse.move(handleBox.x + 160, handleBox.y + 110, { steps: 8 });
await page.mouse.up();
await page.waitForFunction(
({ previousWidth, previousHeight }) => {
const placeholder = document.querySelector('[data-testid="mnote-mindmap-placeholder"]');
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
if (!(placeholder instanceof HTMLElement) || !(scene instanceof HTMLElement)) return false;
const width = Number(placeholder.dataset.mnoteMindmapWidth || 0);
const height = Number(placeholder.dataset.mnoteMindmapHeight || 0);
return width >= 320
&& height >= 240
&& width < previousWidth
&& height < previousHeight
&& scene.dataset.mnoteMindmapHeight === String(height);
},
{ previousWidth: before.rect.width, previousHeight: before.rect.height },
{ timeout: UI_TIMEOUT_MS },
);
const resized = await page.evaluate(() => {
const placeholder = document.querySelector('[data-testid="mnote-mindmap-placeholder"]');
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const rect = placeholder?.getBoundingClientRect();
const editorRect = editor?.getBoundingClientRect();
return {
width: placeholder instanceof HTMLElement ? Number(placeholder.dataset.mnoteMindmapWidth || 0) : 0,
height: placeholder instanceof HTMLElement ? Number(placeholder.dataset.mnoteMindmapHeight || 0) : 0,
sceneHeight: scene instanceof HTMLElement ? Number(scene.dataset.mnoteMindmapHeight || 0) : 0,
rect: rect ? { width: Math.round(rect.width), height: Math.round(rect.height) } : null,
centerDelta: rect && editorRect ? Math.round((rect.left + rect.width / 2) - (editorRect.left + editorRect.width / 2)) : null,
cssMaxWidth: getComputedStyle(document.documentElement).getPropertyValue("--mnote-mindmap-block-max-width").trim(),
resizeCallCount: Number(window.__MNOTE_TEST_MINDMAP_RESIZE_COUNT || 0),
};
});
assert(Math.abs(resized.centerDelta) <= 2, `resize 后 mindmap 仍应以正文列中心对齐: ${JSON.stringify(resized)}`);
assert(resized.resizeCallCount <= 3, `拖动过程中不应连续触发 simple-mind-map resize 重绘: ${JSON.stringify(resized)}`);
const afterGlobalWidthPreference = await page.evaluate(() => {
const setMindmapWidthMax = (value) => {
document.documentElement.style.setProperty("--mnote-mindmap-block-max-width", value);
document.querySelector('.document-shell')?.style.setProperty("--mnote-mindmap-block-max-width", value);
document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.style.setProperty("--mnote-mindmap-block-max-width", value);
window.dispatchEvent(new CustomEvent("mnote:page-width-preference-changed", { detail: { type: "mindmap" } }));
};
setMindmapWidthMax("720px");
return true;
});
assert.equal(afterGlobalWidthPreference, true);
await page.waitForFunction(() => {
const placeholder = document.querySelector('[data-testid="mnote-mindmap-placeholder"]');
const rect = placeholder?.getBoundingClientRect();
return placeholder instanceof HTMLElement
&& !placeholder.dataset.mnoteMindmapWidth
&& rect
&& Math.round(rect.width) >= 900
&& Math.round(rect.width) <= 920;
}, null, { timeout: UI_TIMEOUT_MS });
const globalWidthState = await page.evaluate(() => {
const placeholder = document.querySelector('[data-testid="mnote-mindmap-placeholder"]');
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const rect = placeholder?.getBoundingClientRect();
const editorRect = editor?.getBoundingClientRect();
return {
widthAttr: placeholder instanceof HTMLElement ? placeholder.dataset.mnoteMindmapWidth || "" : "",
heightAttr: placeholder instanceof HTMLElement ? placeholder.dataset.mnoteMindmapHeight || "" : "",
rect: rect ? { width: Math.round(rect.width), height: Math.round(rect.height) } : null,
centerDelta: rect && editorRect ? Math.round((rect.left + rect.width / 2) - (editorRect.left + editorRect.width / 2)) : null,
cssMaxWidth: getComputedStyle(document.documentElement).getPropertyValue("--mnote-mindmap-block-max-width").trim(),
};
});
assert.equal(globalWidthState.widthAttr, "", `全局 Mindmap 宽度设置后应清除本块手动宽度: ${JSON.stringify(globalWidthState)}`);
assert(Math.abs(globalWidthState.centerDelta) <= 2, `全局 Mindmap 宽度设置后仍应居中: ${JSON.stringify(globalWidthState)}`);
return { ...resized, globalWidthState };
}
async function readState(page) {
return page.evaluate(() => {
const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
@@ -184,7 +338,9 @@ async function waitForMindmapId(page) {
await page.waitForFunction(
() => {
const root = document.querySelector('[data-testid="mnote-mindmap-editor-root"]');
return root instanceof HTMLElement && /^思维导图\d{6}\.json$/.test(root.dataset.mnoteMindmapId || "");
const mindmapId = root instanceof HTMLElement ? String(root.dataset.mnoteMindmapId || "") : "";
return /^思维导图\d{6}\.json$/.test(mindmapId)
|| /^mindmap[-_][^/\\]+(?:\.json)?$/.test(mindmapId);
},
null,
{ timeout: UI_TIMEOUT_MS },
@@ -193,6 +349,11 @@ async function waitForMindmapId(page) {
return state.mindmapId;
}
function localMindmapFileName(mindmapId) {
const value = String(mindmapId || "").trim();
return value.toLowerCase().endsWith(".json") ? value : `${value}.json`;
}
function rowMatchesMindmap(row, mindmapId) {
const assetId = String(row && row.assetId || "");
const title = String(row && row.title || "");
@@ -312,6 +473,7 @@ async function main() {
markdownMissingMindmapReferenceAfterInsert: false,
refreshSkippedBecauseMarkdownNotSaved: false,
commandResponseSummary: null,
resizeAfterInsert: null,
};
try {
@@ -319,13 +481,16 @@ async function main() {
result.screenshots.push(await screenshot(page, "01-open-clean-page"));
await insertMindmapThroughSlash(page);
result.mindmapId = await waitForMindmapId(page);
const mindmapFileName = localMindmapFileName(result.mindmapId);
result.screenshots.push(await screenshot(page, "02-after-insert-mindmap"));
result.defaultStyleDrawer = await assertMindmapStyleDrawerClosedByDefault(page);
result.resizeAfterInsert = await resizeMindmapThroughCornerHandle(page);
await page.waitForFunction(
({ expected }) => {
const tree = document.getElementById("sidebar-file-tree-root");
return (tree?.textContent || "").includes(expected);
},
{ expected: result.mindmapId },
{ expected: mindmapFileName },
{ timeout: UI_TIMEOUT_MS },
);
result.saveAfterInsert = await waitForStableEditorSave(page, networkRecords, "after-insert");
@@ -333,12 +498,12 @@ async function main() {
result.diskAfterInsert = fs.readdirSync(pageDir).sort();
assert(result.diskAfterInsert.includes("CleanPage.md"), `页面 Markdown 应存在: ${result.diskAfterInsert.join(",")}`);
assert(result.diskAfterInsert.includes(result.mindmapId), `mindmap 应直接出现在页面文件夹下: ${result.diskAfterInsert.join(",")}`);
assert(!fs.existsSync(path.join(root, result.mindmapId)), "root 同级不应残留 mindmap 文件");
assert(!fs.existsSync(path.join(pageDir, "assets", result.mindmapId)), "assets 下不应残留 mindmap 文件");
assert(result.diskAfterInsert.includes(mindmapFileName), `mindmap 应直接出现在页面文件夹下: ${result.diskAfterInsert.join(",")}`);
assert(!fs.existsSync(path.join(root, mindmapFileName)), "root 同级不应残留 mindmap 文件");
assert(!fs.existsSync(path.join(pageDir, "assets", mindmapFileName)), "assets 下不应残留 mindmap 文件");
result.markdown = fs.readFileSync(markdownPath, "utf8");
result.markdownMissingMindmapReferenceAfterInsert = !result.markdown.includes(`](${result.mindmapId})`);
result.markdownMissingMindmapReferenceAfterInsert = !result.markdown.includes(`](${mindmapFileName})`);
result.samples = await sampleFileTree(page, result.mindmapId, 4_000);
result.commandResponseSummary = await postMindmapCommand(page, documentId, result.mindmapId);
@@ -355,7 +520,7 @@ async function main() {
timeout: UI_TIMEOUT_MS,
});
const afterRefresh = await readState(page);
assert.equal(afterRefresh.mindmapId, result.mindmapId, `刷新后 mindmapId 应保持不变: ${JSON.stringify(afterRefresh)}`);
assert.equal(localMindmapFileName(afterRefresh.mindmapId), mindmapFileName, `刷新后 mindmapId 应保持不变: ${JSON.stringify(afterRefresh)}`);
assert(
afterRefresh.mindmapRows.filter((row) => rowMatchesMindmap(row, result.mindmapId)).length === 1,
`刷新后文件树应只有本轮 mindmap 一行: ${JSON.stringify(afterRefresh.mindmapRows)}`,
+34 -34
View File
@@ -50,6 +50,7 @@ async function uploadLocalAsset(page, root, documentId, fileName, mimeType, byte
const form = new FormData();
form.append("rootUri", rootUri);
form.append("documentId", documentId);
form.append("uploadIntent", "editor.markdown.attach");
form.append("kind", kind);
form.append("file", new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
const response = await fetch("/api/local-folder/assets/upload", { method: "POST", body: form });
@@ -137,8 +138,8 @@ async function main() {
timeout: UI_TIMEOUT_MS,
});
// ======== Test 1: docx opens in main editor tab with /onlyoffice iframe ========
console.log("Test 1: docx opens in main editor tab with /onlyoffice iframe");
// ======== Test 1: docx opens in main editor tab with Office reader iframe ========
console.log("Test 1: docx opens in main editor tab with Office reader iframe");
await page.evaluate(({ asset, documentId }) => {
window.dispatchEvent(new CustomEvent("tree.asset.open", {
detail: {
@@ -171,16 +172,20 @@ async function main() {
// docx should have badge kind "word"
assert.equal(officeTabInfo.badgeKind, "word", `docx should have badgeKind 'word', got '${officeTabInfo.badgeKind}'`);
assert.ok(officeTabInfo.iframeExists, "Office tab should have an iframe");
assert.ok(officeTabInfo.iframeSrc.includes("/onlyoffice"), `iframe src should include /onlyoffice: ${officeTabInfo.iframeSrc}`);
// Parse iframe URL and verify required params
const iframeUrl = new URL(officeTabInfo.iframeSrc, BASE_URL);
assert.equal(iframeUrl.pathname, "/onlyoffice", `iframe pathname should be /onlyoffice: ${iframeUrl.pathname}`);
assert.ok(
iframeUrl.pathname === "/office-preview" || iframeUrl.pathname === "/onlyoffice",
`iframe pathname should be /office-preview or /onlyoffice: ${iframeUrl.pathname}`,
);
// Check that assetId is in the URL (either in query params or encoded)
const iframeAssetId = iframeUrl.searchParams.get("assetId") || "";
assert.ok(iframeAssetId, "iframe URL should carry assetId param");
assert.ok(iframeUrl.searchParams.has("mode"), "iframe URL should carry mode param");
if (iframeUrl.pathname === "/onlyoffice") {
assert.equal(iframeUrl.searchParams.get("mode"), "view", "default /onlyoffice open should use view mode");
}
assert.ok(iframeUrl.searchParams.has("fileUrl"), "iframe URL should carry fileUrl param");
// The fileUrl for local-folder should point to /api/local-folder/files/open
@@ -304,7 +309,7 @@ async function main() {
await page.waitForTimeout(300);
// ======== Test 4: new-window defaults to view mode ========
console.log("Test 4: new-window opens /onlyoffice with view mode");
console.log("Test 4: new-window opens Office reader in view mode");
const [officePopup] = await Promise.all([
page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS }).catch(() => null),
page.evaluate(({ asset, documentId }) => {
@@ -324,11 +329,16 @@ async function main() {
await officePopup.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
const popupUrl = new URL(officePopup.url());
console.log(` New-window URL: ${popupUrl.toString()}`);
assert.equal(popupUrl.pathname, "/onlyoffice", `new-window pathname should be /onlyoffice: ${popupUrl.pathname}`);
assert.ok(
popupUrl.pathname === "/office-preview" || popupUrl.pathname === "/onlyoffice",
`new-window pathname should be /office-preview or /onlyoffice: ${popupUrl.pathname}`,
);
// new-window 是浏览器目标,不代表编辑权限;默认仍应只读
const popupMode = popupUrl.searchParams.get("mode") || "";
assert.equal(popupMode, "view", `new-window mode should be 'view', got '${popupMode}'`);
if (popupUrl.pathname === "/onlyoffice") {
assert.equal(popupMode, "view", `new-window mode should be 'view', got '${popupMode}'`);
}
// Check assetId is present
const popupAssetId = popupUrl.searchParams.get("assetId") || "";
@@ -387,21 +397,17 @@ async function main() {
onlyofficeUrl.searchParams.set("assetId", "test-asset-id");
onlyofficeUrl.searchParams.set("mode", "edit");
const response = await page.goto(onlyofficeUrl.toString(), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
// Verify the page rendered
const pageContent = await page.evaluate(() => {
return {
title: document.title,
hasFrameContainer: !!document.getElementById("onlyoffice-frame"),
hasErrorContainer: !!document.getElementById("onlyoffice-error"),
hasScript: typeof window.__MNOTE_ONLYOFFICE_READY__ !== "undefined",
initialConfig: typeof window.initial !== "undefined",
};
});
const response = await context.request.get(onlyofficeUrl.toString(), { timeout: UI_TIMEOUT_MS });
assert.equal(response.status(), 200, `/onlyoffice should respond 200, got ${response.status()}`);
const onlyofficeHtml = await response.text();
// Verify the page rendered without executing DocEditor boot against a fake file path.
const pageContent = {
hasFrameContainer: onlyofficeHtml.includes('id="onlyoffice-frame"'),
hasErrorContainer: onlyofficeHtml.includes('id="onlyoffice-error"'),
hasScript: onlyofficeHtml.includes("window.__MNOTE_ONLYOFFICE_READY__"),
initialConfig: onlyofficeHtml.includes("const initial ="),
};
console.log(` /onlyoffice page: ${JSON.stringify(pageContent)}`);
assert.ok(pageContent.hasFrameContainer, "/onlyoffice should have #onlyoffice-frame container");
@@ -409,17 +415,11 @@ async function main() {
assert.ok(pageContent.hasScript, "/onlyoffice should have runtime script");
// Verify the config has correct document URL and callback URL source
const scriptContent = await page.evaluate(() => {
const scripts = document.querySelectorAll("script");
const relevant = Array.from(scripts).filter(s => s.textContent && s.textContent.includes("buildCallbackUrl"));
const text = relevant.length > 0 ? relevant[0].textContent : "";
return {
hasCallbackUrlBuilder: text.includes("buildCallbackUrl"),
hasResolveDocumentUrl: text.includes("resolveDocumentUrl"),
hasProxyLogic: text.includes("/api/onlyoffice/proxy"),
snippet: text.substring(0, 200),
};
});
const scriptContent = {
hasCallbackUrlBuilder: onlyofficeHtml.includes("buildCallbackUrl"),
hasResolveDocumentUrl: onlyofficeHtml.includes("resolveDocumentUrl"),
hasProxyLogic: onlyofficeHtml.includes("/api/onlyoffice/proxy"),
};
console.log(` Script: hasCallbackUrl=${scriptContent.hasCallbackUrlBuilder}, hasResolveDocumentUrl=${scriptContent.hasResolveDocumentUrl}`);
assert.ok(scriptContent.hasCallbackUrlBuilder, "OnlyOffice page should define buildCallbackUrl");
+155 -1
View File
@@ -137,6 +137,10 @@ async function main() {
const screenshots = {};
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task490-"));
const relativePath = "RuntimeSurfaces.md";
const capturedPageAi = {
runs: [],
aborts: [],
};
writeWorkspaceManifest(root, "mnote-e2e");
fs.writeFileSync(
path.join(root, relativePath),
@@ -146,6 +150,106 @@ async function main() {
let caughtError = null;
try {
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
gateway: { ok: true, status: "mocked" },
profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true },
suggestions: [],
}),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, tools: [] }),
});
});
await page.route("**/api/hermes/client/profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
active: "mnoteai",
profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }],
}),
});
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
agentId: "reasonix",
profiles: [],
}),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: "mnote_task490",
title: "task490",
traceId: "trace_task490",
persistence: "local_ai_session_jsonl",
sessionStorage: "local_private",
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
capturedPageAi.runs.push(route.request().postData() || "");
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: "mnote_task490",
runId: "run_task490_stop",
upstream: { run_id: "run_task490_stop", trace_id: "trace_task490_stop" },
traceId: "trace_task490_stop",
}),
});
});
await page.route("**/api/hermes/client/events/run_task490_stop", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body: `data: ${JSON.stringify({ event: "message.delta", run_id: "run_task490_stop", delta: "Task490 streaming" })}\n\n`,
});
});
await page.route("**/api/hermes/client/runs/run_task490_stop/abort", async (route) => {
capturedPageAi.aborts.push(route.request().postData() || "");
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
status: "aborted",
runtime: { runId: "run_task490_stop", status: "aborted", queueLength: 0 },
events: [
{ event: "abort.started", runId: "run_task490_stop" },
{ event: "abort.completed", runId: "run_task490_stop" },
],
}),
});
});
await ensureAuthenticated(page, context.request);
const url = documentUrl(BASE_URL, root, relativePath);
@@ -153,7 +257,6 @@ async function main() {
assert(response, "文档页没有返回响应");
assert(response.status() === 200, `文档页状态码异常: ${response.status()}`);
const editor = await waitForRuntimeIsland(page, UI_TIMEOUT_MS);
await setBlockHandleFixture(page, UI_TIMEOUT_MS);
const moreButton = page.locator('[data-testid="wolai-page-settings-trigger"]').first();
await moreButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -175,7 +278,58 @@ async function main() {
assert(await page.locator("[data-page-ai-input]").first().isVisible(), "Page AI drawer 必须显示输入框");
screenshots.pageAi = await saveScreenshot(page, "02-page-ai");
await closePageAiIfOpen(page);
await page.waitForFunction(
() => {
const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
const trigger = document.querySelector('[data-testid="wolai-floating-ai"]');
return drawer instanceof HTMLElement
&& drawer.hidden === true
&& trigger instanceof HTMLElement
&& trigger.getAttribute("data-state") === "closed"
&& trigger.getAttribute("aria-expanded") === "false";
},
null,
{ timeout: UI_TIMEOUT_MS },
);
await aiButton.click({ timeout: UI_TIMEOUT_MS });
await aiDrawer.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill("Task490 stop smoke", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "running"
&& document.documentElement.getAttribute("data-mnote-page-ai-run-id") === "run_task490_stop",
null,
{ timeout: UI_TIMEOUT_MS },
);
const stopButton = page.locator('[data-page-ai-action="stop-run"]').first();
await stopButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const stop = document.querySelector('[data-page-ai-action="stop-run"]');
return stop instanceof HTMLButtonElement && stop.disabled === false && stop.getAttribute("aria-disabled") === "false";
},
null,
{ timeout: UI_TIMEOUT_MS },
);
await stopButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "aborted",
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('[data-page-ai-conversation]').getByText("已请求停止当前 AI run。").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
assert(capturedPageAi.runs.length === 1, "Page AI stop smoke 应先发起一个 run");
assert(capturedPageAi.aborts.length === 1, "点击停止应调用 abort API 一次");
const abortBody = JSON.parse(capturedPageAi.aborts[0] || "{}");
assert(abortBody.reason === "page_ai_user_stop", "abort API 应记录用户停止原因");
screenshots.pageAiStopped = await saveScreenshot(page, "02b-page-ai-stopped");
await closePageAiIfOpen(page);
await setBlockHandleFixture(page, UI_TIMEOUT_MS);
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.type("/");
const slashMenu = page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first();
@@ -142,6 +142,23 @@ async function main() {
body: JSON.stringify({ ok: true, tools: [] }),
});
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
agentId: "hermes",
profiles: [
{ profileId: "shared_deepseek_chat", kind: "shared", displayName: "DeepSeek Chat", baseProfile: "deepseek-chat", isolatedProfile: "openclaw-deepseek-chat", canRun: true, canManageSkills: false, canManageConfig: false, readonly: true },
{ profileId: "shared_doubao_chat", kind: "shared", displayName: "豆包 Chat", baseProfile: "doubao-chat", isolatedProfile: "openclaw-doubao-chat", canRun: true, canManageSkills: false, canManageConfig: false, readonly: true },
{ profileId: "shared_gemini_chat", kind: "shared", displayName: "Gemini Chat", baseProfile: "gemini-chat", isolatedProfile: "openclaw-gemini-chat", canRun: true, canManageSkills: false, canManageConfig: false, readonly: true },
{ profileId: "usr_task502_default", kind: "personal", displayName: "我的 Hermes", baseProfile: "default", isolatedProfile: "mnote-u-task502-default", canRun: true, canManageSkills: true, canManageConfig: true },
{ profileId: "shared_lite", kind: "shared", displayName: "Lite", baseProfile: "lite", isolatedProfile: "lite", canRun: true, canManageSkills: false, canManageConfig: false, readonly: true },
],
}),
});
});
await page.route("**/api/hermes/client/profiles", async (route) => {
await route.fulfill({
status: 200,
@@ -184,12 +201,18 @@ async function main() {
return;
}
const runtime = url.searchParams.get("runtime");
const profile = url.searchParams.get("profile") || "mnoteai";
const profile = url.searchParams.get("profileId") || url.searchParams.get("profile") || "usr_task502_default";
const body = runtime === "mnote"
? {
ok: true,
runtime: "mnote",
categories: [{ name: "mnote", skills: [{ id: "mnote-current-page", name: "mnote-current-page", title: "当前页读取", description: "读取当前页", enabled: true, source: "mnote", origin: "builtin" }] }],
categories: [{
name: "mnote",
skills: [
{ id: "mnote-current-page", name: "mnote-current-page", title: "当前页读取", description: "读取当前页", enabled: true, source: "mnote", origin: "builtin", builtin: true, configurable: true, configScope: "user_sqlite", skillKind: "mnote_builtin" },
{ id: "mnote-mindmap", name: "mnote-mindmap", title: "思维导图读写", description: "读取、编辑或从 outline 生成思维导图", enabled: true, source: "mnote", origin: "builtin", builtin: true, configurable: true, configScope: "user_sqlite", skillKind: "mnote_builtin", toolNames: ["mnote.mindmap.fetch", "mnote.mindmap.create_from_outline"] },
],
}],
archived: [],
}
: runtime === "reasonix"
@@ -205,8 +228,8 @@ async function main() {
categories: [{
name: "writing",
skills: [
{ id: "hermes-builtin", name: "hermes-builtin", title: "Hermes builtin", description: `Builtin ${profile}`, enabled: true, source: "builtin", origin: "builtin" },
{ id: profile === "chemist" ? "hermes-chemist" : "hermes-writer", name: profile === "chemist" ? "hermes-chemist" : "hermes-writer", title: profile === "chemist" ? "Hermes chemist" : "Hermes writer", description: `Hermes ${profile}`, enabled: profile !== "chemist", source: "local", origin: "installed" },
{ id: "hermes-builtin", name: "hermes-builtin", title: "Hermes builtin", description: `Builtin ${profile}`, enabled: true, source: "builtin", origin: "builtin", skillKind: "hermes_profile", profileId: profile, configurable: profile !== "shared_lite" },
{ id: profile === "shared_lite" ? "hermes-lite" : "hermes-writer", name: profile === "shared_lite" ? "hermes-lite" : "hermes-writer", title: profile === "shared_lite" ? "Hermes lite" : "Hermes writer", description: `Hermes ${profile}`, enabled: profile !== "shared_lite", source: "local", origin: "installed", skillKind: "hermes_profile", profileId: profile, configurable: profile !== "shared_lite", readonly: profile === "shared_lite" },
],
}],
archived: [],
@@ -320,24 +343,59 @@ async function main() {
await agentButton.click({ timeout: UI_TIMEOUT_MS });
const agentPopover = page.locator("[data-page-ai-agent-popover]");
await agentPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const agentIds = await page.$$eval("[data-page-ai-agent-id]", (nodes) =>
nodes.map((node) => node.getAttribute("data-page-ai-agent-id")).filter(Boolean),
await agentPopover.locator('[data-page-ai-agent-section="chat_only"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await agentPopover.locator('[data-page-ai-agent-section="hermes"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator('[data-page-ai-agent-section="chat_only"] [data-page-ai-profile-id="shared_deepseek_chat"]').count(),
1,
"ChatOnly 二级菜单应包含 DeepSeek",
);
assert.deepStrictEqual(agentIds.sort(), ["chat_only", "hermes", "reasonix"]);
await page.locator('[data-page-ai-agent-id="hermes"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-popover] [data-page-ai-profile-select]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-popover] [data-page-ai-profile-select]').selectOption("chemist", { timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator('[data-page-ai-agent-section="chat_only"] [data-page-ai-profile-id="shared_doubao_chat"]').count(),
1,
"ChatOnly 二级菜单应包含豆包",
);
assert.strictEqual(
await page.locator('[data-page-ai-agent-section="chat_only"] [data-page-ai-profile-id="shared_gemini_chat"]').count(),
1,
"ChatOnly 二级菜单应包含 Gemini",
);
assert.strictEqual(
await page.locator('[data-page-ai-agent-section="hermes"] [data-page-ai-profile-id="usr_task502_default"]').count(),
1,
"Hermes 二级菜单应包含个人 profile",
);
assert.strictEqual(
await page.locator('[data-page-ai-agent-section="hermes"] [data-page-ai-profile-id="shared_lite"]').count(),
1,
"Hermes 二级菜单应包含 shared_lite profile",
);
const agentChip = page.locator("[data-page-ai-agent-chip]");
await page.locator('[data-page-ai-agent-section="chat_only"] [data-page-ai-profile-id="shared_gemini_chat"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-profile") === "chemist",
() => document.documentElement.getAttribute("data-mnote-page-ai-agent-id") === "chat_only"
&& document.documentElement.getAttribute("data-mnote-page-ai-profile") === "shared_gemini_chat",
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('[data-page-ai-agent-id="reasonix"]').click({ timeout: UI_TIMEOUT_MS });
assert((await agentChip.innerText({ timeout: UI_TIMEOUT_MS })).includes("ChatOnly / Gemini"), "上下文按钮右侧标签应显示当前 ChatOnly agent");
await agentButton.click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-section="hermes"] [data-page-ai-profile-id="shared_lite"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-agent-id") === "hermes"
&& document.documentElement.getAttribute("data-mnote-page-ai-profile") === "shared_lite",
null,
{ timeout: UI_TIMEOUT_MS },
);
assert((await agentChip.innerText({ timeout: UI_TIMEOUT_MS })).includes("Hermes / Lite"), "上下文按钮右侧标签应显示当前 Hermes profile");
await agentButton.click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-section="reasonix"] [data-page-ai-agent-id="reasonix"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-agent-id") === "reasonix",
null,
{ timeout: UI_TIMEOUT_MS },
);
assert((await agentChip.innerText({ timeout: UI_TIMEOUT_MS })).includes("Reasonix"), "上下文按钮右侧标签应显示当前 Reasonix agent");
const contextRefs = page.locator("[data-page-ai-context-refs]");
assert.strictEqual(
@@ -351,6 +409,116 @@ async function main() {
await contextButton.evaluate((node) => Boolean(node.closest(".wolai-page-ai-composer-bar"))),
"上下文按钮应显示在输入区下方工具栏中",
);
const targetButton = page.locator("[data-page-ai-target-button]");
await targetButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
await targetButton.evaluate((node) => Boolean(node.closest(".wolai-page-ai-composer-bar"))),
"目标按钮应显示在输入区下方工具栏中",
);
const targetChip = page.locator("[data-page-ai-target-chip]");
await targetChip.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const targetChipText = (await targetChip.innerText({ timeout: UI_TIMEOUT_MS })).trim();
assert(targetChipText.includes("AgentContext") || targetChipText.includes("当前页"), `目标 chip 应展示当前写入目标: ${targetChipText}`);
await targetButton.click({ timeout: UI_TIMEOUT_MS });
const targetPopover = page.locator("[data-page-ai-target-popover]");
await targetPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
await targetPopover.locator("[data-page-ai-target-option]").first().isVisible(),
"目标 popover 应提供至少一个可选目标",
);
await targetPopover.locator('[data-page-ai-action="close-target-popover"]').click({ timeout: UI_TIMEOUT_MS });
await page.evaluate(({ documentId, rootUri, workspaceId }) => {
const runtime = window.__mnoteDocumentPaneRuntime;
if (!runtime || typeof runtime.getOpenEditorsSnapshot !== "function") {
throw new Error("缺少 open editors snapshot runtime");
}
const original = runtime.getOpenEditorsSnapshot.bind(runtime);
runtime.getOpenEditorsSnapshot = () => {
const snapshot = original();
const mindmapResource = {
objectIdentity: "resource:mindmap:task502",
workspacePath: {
schema: "mnote.workspace_path.v1",
workspaceId,
sourceKind: "local_folder",
rootUri,
relativePath: "maps/Task502.mindmap.json",
documentId,
objectIdentity: "resource:mindmap:task502",
assetId: "task502-mindmap",
resourceKind: "mindmap",
},
paneRole: "primary",
documentId,
workspaceId,
title: "Task502 Mindmap",
kind: "mindmap",
editorKind: "mindmap",
active: false,
dirtyState: "",
preview: false,
pinned: true,
lastActiveAt: Date.now(),
assetId: "task502-mindmap",
path: "maps/Task502.mindmap.json",
};
const officeResource = {
objectIdentity: "resource:office:task502",
workspacePath: {
schema: "mnote.workspace_path.v1",
workspaceId,
sourceKind: "local_folder",
rootUri,
relativePath: "office/Task502 Deck.pptx",
documentId,
objectIdentity: "resource:office:task502",
assetId: "task502-office",
resourceKind: "office",
},
paneRole: "primary",
documentId,
workspaceId,
title: "Task502 Deck",
kind: "office",
editorKind: "office",
active: false,
dirtyState: "",
preview: false,
pinned: true,
lastActiveAt: Date.now(),
assetId: "task502-office",
path: "office/Task502 Deck.pptx",
onlyofficeSessionId: "mnote-oo-task502-office",
bridgeSessionId: "mnote-oo-task502-office",
bridgeSessionReady: true,
};
const resources = [mindmapResource, officeResource];
const withoutResource = (items) => (Array.isArray(items) ? items : [])
.filter((item) => !resources.some((resource) => item?.objectIdentity === resource.objectIdentity));
const groups = snapshot.groups || {};
const primary = groups.primary || {};
return {
...snapshot,
editors: [...withoutResource(snapshot.editors), ...resources],
resourceEditors: [...withoutResource(snapshot.resourceEditors), ...resources],
groups: {
...groups,
primary: {
...primary,
resourceEditors: [...withoutResource(primary.resourceEditors), ...resources],
},
},
};
};
}, { documentId, rootUri, workspaceId });
await targetButton.click({ timeout: UI_TIMEOUT_MS });
await targetPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await targetPopover.locator('[data-page-ai-target-option="resource:office:task502"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector("[data-page-ai-target-chip]")?.textContent || "").includes("Task502 Deck"),
null,
{ timeout: UI_TIMEOUT_MS },
);
assert.strictEqual(
await page.locator('.wolai-page-ai-composer-bar [data-page-ai-action="history"]').count(),
0,
@@ -366,39 +534,70 @@ async function main() {
);
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="skills"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="skills"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const skillSourceSelect = page.locator('[data-page-ai-panel="skills"] [data-page-ai-skill-source-select]');
await skillSourceSelect.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const skillSourceOptions = await skillSourceSelect.locator("option").evaluateAll((nodes) =>
nodes.map((node) => ({ value: node.value, text: node.textContent || "" })),
);
assert(skillSourceOptions.some((item) => item.value === "mnote" && item.text.includes("mnote")), "技能来源应包含 mnote");
assert(skillSourceOptions.some((item) => item.value === "reasonix" && item.text.includes("reasonix")), "技能来源应包含 reasonix");
assert(skillSourceOptions.some((item) => item.value === "hermes:usr_task502_default" && item.text.includes("Hermes_user")), "技能来源应包含个人 Hermes profile");
assert(skillSourceOptions.some((item) => item.value === "hermes:shared_lite" && item.text.includes("hermes_lite")), "技能来源应包含 shared lite profile");
await skillSourceSelect.selectOption("mnote", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-mindmap"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const skillPanelText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(skillPanelText.includes("MNote 内置技能"), "技能面板应展示 MNote 内置技能分组");
assert(skillPanelText.includes("Reasonix 技能"), "技能面板应展示 Reasonix 技能分组");
assert(skillPanelText.includes("Hermes 技能"), "技能面板应展示 Hermes 技能分组");
assert(skillPanelText.includes("Hermes chemist"), "Hermes 技能应随 chemist profile 加载");
assert(!skillPanelText.includes("Hermes writer"), "Hermes profile 切到 chemist 后不应继续显示上一 profile 技能");
assert(skillPanelText.includes("mnote"), "技能面板应展示 mnote 来源");
assert(!skillPanelText.includes("Reasonix 技能"), "选择 mnote 时不应同时展示 Reasonix 技能");
assert(!skillPanelText.includes("Hermes 技能"), "选择 mnote 时不应同时展示 Hermes 技能");
await page.locator('[data-page-ai-skill-group-toggle="mnote"]').click({ timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').count(),
0,
"MNote 技能分组折叠后不应显示组内技能",
);
assert.strictEqual(
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-mindmap"]').count(),
0,
"MNote 技能分组折叠后不应显示 mindmap 技能",
);
await page.locator('[data-page-ai-skill-group-toggle="mnote"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-mindmap"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-mindmap"]').click({ timeout: UI_TIMEOUT_MS });
await skillSourceSelect.selectOption("reasonix", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="reasonix"] [data-page-ai-skill-toggle="reasonix-review"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const reasonixOnlyText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(reasonixOnlyText.includes("reasonix"), "选择 reasonix 时应展示 Reasonix 来源");
assert(!reasonixOnlyText.includes("当前页读取"), "选择 reasonix 时不应残留 MNote 技能");
assert(!reasonixOnlyText.includes("Hermes writer"), "选择 reasonix 时不应残留 Hermes 技能");
await page.locator('[data-page-ai-skill-group="reasonix"] [data-page-ai-skill-toggle="reasonix-review"]').click({ timeout: UI_TIMEOUT_MS });
await skillSourceSelect.selectOption("hermes:shared_lite", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-lite"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const sharedHermesText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(sharedHermesText.includes("hermes_lite"), "选择 hermes_lite 时应只展示 lite profile 技能");
assert(!sharedHermesText.includes("当前页读取"), "选择 hermes_lite 时不应残留 MNote 技能");
assert(!sharedHermesText.includes("reasonix-review"), "选择 hermes_lite 时不应残留 Reasonix 技能");
assert(
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-lite"]').isDisabled(),
"shared Hermes profile 普通用户应只读",
);
await page.locator('[data-page-ai-hide-hermes-builtin]').check({ timeout: UI_TIMEOUT_MS });
const hiddenBuiltinText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(!hiddenBuiltinText.includes("Hermes builtin"), "隐藏 Hermes 内置后不应显示 Hermes 内置技能");
await page.locator('[data-page-ai-hide-hermes-builtin]').uncheck({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="skills"] [data-page-ai-profile-select]').selectOption("mnoteai", { timeout: UI_TIMEOUT_MS });
await skillSourceSelect.selectOption("hermes:usr_task502_default", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-writer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-chemist"]').count(),
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-lite"]').count(),
0,
"Hermes profile 切回 mnoteai 后不应残留 chemist 技能",
);
await page.locator('[data-page-ai-panel="skills"] [data-page-ai-profile-select]').selectOption("chemist", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-chemist"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const skillsScreenshot = await saveScreenshot(page, "00-skills-panel");
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="reasonix"] [data-page-ai-skill-toggle="reasonix-review"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-chemist"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-writer"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-skill-toggled") === "hermes-chemist",
() => document.documentElement.getAttribute("data-mnote-page-ai-skill-toggled") === "hermes-writer",
null,
{ timeout: UI_TIMEOUT_MS },
);
@@ -454,6 +653,16 @@ async function main() {
assert.strictEqual(ackRunBody.pageContext?.aiContext?.contextBlocks, undefined, "Page AI run 不应默认上传 contextBlocks");
assert.strictEqual(ackRunBody.pageContext?.evidence, undefined, "Page AI run 不应默认上传选区 evidence 正文");
assert.strictEqual(ackRunBody.selectedText, "", "Page AI run 不应默认上传 selectedText 正文");
assert.strictEqual(ackRunBody.targetPackage?.schema, "mnote.agent_target_package.v1", "纯聊天 run 也应冻结目标包合同");
assert(ackRunBody.targetPackage?.primaryTargetId, "targetPackage 应包含 primaryTargetId");
assert(Array.isArray(ackRunBody.targetPackage?.targets), "targetPackage 应包含 targets 数组");
assert.strictEqual(ackRunBody.targetPackage?.primaryTargetId, "resource:office:task502", "targetPackage 应冻结用户选择的 Office target");
assert.strictEqual(ackRunBody.targetPackage?.onlyofficeSessionId, "mnote-oo-task502-office", "Office targetPackage 应携带 bridge session");
assert(
ackRunBody.targetPackage.targets.some((target) => target.resourceKind === "only_office" && target.relativePath === "office/Task502 Deck.pptx" && target.onlyofficeSessionId === "mnote-oo-task502-office"),
`默认目标应标记 only_office resourceKind: ${JSON.stringify(ackRunBody.targetPackage)}`,
);
assert.strictEqual(ackRunBody.targetPackage?.policy?.writeRequiresExplicitTarget, true, "targetPackage policy 应要求显式目标");
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="agent"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -495,6 +704,14 @@ async function main() {
`勾选变化后上下文按钮摘要应更新: ${updatedContextButtonLabel}`,
);
await contextPopover.locator('[data-page-ai-action="close-context-popover"]').click({ timeout: UI_TIMEOUT_MS });
await targetButton.click({ timeout: UI_TIMEOUT_MS });
await targetPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await targetPopover.locator('[data-page-ai-target-option="resource:mindmap:task502"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector("[data-page-ai-target-chip]")?.textContent || "").includes("Task502 Mindmap"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const runsBeforeDocumentTask = captured.filter((item) => item.kind === "run").length;
await page.locator("[data-page-ai-input]").fill(`Task502 agent/context ${suffix}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
@@ -520,20 +737,37 @@ async function main() {
assert.strictEqual(runBody.pageContext?.aiContext?.contextBlocks, undefined, "Page AI run 不应默认上传 contextBlocks");
assert.strictEqual(runBody.pageContext?.evidence, undefined, "Page AI run 不应默认上传选区 evidence 正文");
assert.strictEqual(runBody.selectedText, "", "Page AI run 不应默认上传 selectedText 正文");
assert(runBody.contextRefs.some((item) => item.kind === "active_editor" && item.documentId === documentId));
const activeEditorRef = runBody.contextRefs.find((item) => item.kind === "active_editor" && item.documentId === documentId);
assert(activeEditorRef, "run payload 应包含 active_editor contextRef");
assert.strictEqual(activeEditorRef.targetId, "resource:mindmap:task502", `active_editor contextRef 应冻结 mindmap targetId: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.objectIdentity, "resource:mindmap:task502", `active_editor contextRef 应冻结 mindmap objectIdentity: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.resourceKind, "mindmap", `active_editor contextRef 应标记 mindmap resourceKind: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.assetId, "task502-mindmap", `active_editor contextRef 应携带 mindmap assetId: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.relativePath, "maps/Task502.mindmap.json", `active_editor contextRef 应携带 mindmap relativePath: ${JSON.stringify(activeEditorRef)}`);
assert(runBody.contextRefs.some((item) => item.kind === "folder" && item.rootUri === rootUri));
assert(runBody.contextRefs.some((item) => item.kind === "changed_files"));
assert.strictEqual(runBody.skillPreferences?.mnote?.["mnote-current-page"], false, "MNote skill 开关应进入 run payload");
assert.strictEqual(runBody.skillPreferences?.mnote?.["mnote-mindmap"], false, "MNote mindmap skill 开关应进入 run payload");
assert.strictEqual(runBody.skillPreferences?.reasonix?.["reasonix-review"], false, "Reasonix skill 开关应进入 run payload");
assert.strictEqual(runBody.targetPackage?.schema, "mnote.agent_target_package.v1", "文档任务 run 应携带目标包");
assert(runBody.targetPackage?.primaryTargetId, "文档任务 targetPackage 应包含 primaryTargetId");
assert(Array.isArray(runBody.targetPackage?.targets), "文档任务 targetPackage 应包含 targets 数组");
assert.strictEqual(runBody.targetPackage?.primaryTargetId, "resource:mindmap:task502", "文档任务 targetPackage 应冻结用户选择的 mindmap target");
assert(
runBody.targetPackage.targets.some((target) => target.resourceKind === "mindmap" && target.relativePath === "maps/Task502.mindmap.json"),
`文档任务 targetPackage 应包含 mindmap 目标: ${JSON.stringify(runBody.targetPackage)}`,
);
assert.strictEqual(runBody.targetPackage?.policy?.writeRequiresExplicitTarget, true, "文档任务 targetPackage policy 应要求显式目标");
const preferenceBodies = captured
.filter((item) => item.kind === "ui-preferences" && item.method === "PUT")
.map((item) => JSON.parse(item.body || "{}"));
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.profile_id"] === "chemist"), "Hermes profile 选择应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.common.skills.mnote.enabled"]?.["mnote-current-page"] === false), "MNote skill 开关应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.profile_id"] === "shared_lite"), "Agent 内的 Hermes profile 选择应写入 SQLite UI preference");
assert(captured.some((item) => item.kind === "skill-toggle" && JSON.parse(item.body || "{}").skillKind === "mnote_builtin" && JSON.parse(item.body || "{}").name === "mnote-current-page"), "MNote skill 开关应调用服务端 per-user SQLite policy");
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.reasonix.skills.enabled"]?.["reasonix-review"] === false), "Reasonix skill 开关应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.common.skills.groups.collapsed"]?.mnote === true), "技能分组折叠状态应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.profile.chemist.skills.hide_builtin"] === true), "Hermes 隐藏内置技能应按 profile 写入 SQLite UI preference");
assert(captured.some((item) => item.kind === "skill-toggle" && JSON.parse(item.body || "{}").profile === "chemist"), "Hermes skill 开关应按 profile 调用");
assert(preferenceBodies.some((body) => body.updates?.["ai.common.skills.active_source"] === "hermes:usr_task502_default"), "技能来源选择应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.skills.hide_builtin"] === true), "Hermes 隐藏内置技能应写入用户 UI preference");
assert(captured.some((item) => item.kind === "skill-toggle" && JSON.parse(item.body || "{}").profileId === "usr_task502_default"), "Hermes skill 开关应按 profileId 调用");
assert(Array.isArray(runBody.allowedRoots), "run payload 必须包含 allowedRoots 数组");
assert(runBody.allowedRoots.some((item) =>
item.rootUri === rootUri
@@ -0,0 +1,319 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const { UI_TIMEOUT_MS, assert } = require("./tree-shell-smoke-helpers");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const TASK = "task503-mindmap-skill-capability-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const TEST_EMAIL = "mnote.e2e@example.com";
const TEST_PASSWORD = "MnoteE2E123!";
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 callJson(url, init = {}) {
const response = await fetch(url, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
body: init.data !== undefined ? JSON.stringify(init.data) : init.body,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok || payload?.ok === false) {
throw new Error(`${url} failed: ${response.status} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function callMnoteTool({ toolName, workspaceId, documentId, rootUri, args, idempotencyKey }) {
const payload = {
toolName,
workspaceId,
documentId,
sourceKind: "local_folder",
rootUri,
actorId: "mnote-e2e",
sessionId: `sess_${TASK}_${toolName.replace(/\W+/g, "_")}`,
runId: `run_${Date.now()}`,
toolCallId: `call_${Date.now()}`,
traceId: `trace_${Date.now()}`,
idempotencyKey,
dryRun: false,
args,
};
const result = await callJson(`${BASE_URL}/api/hermes/tools/mnote/call`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
},
body: JSON.stringify(payload),
});
return result.result ?? result;
}
async function signInBrowserContext(context) {
const auth = await context.request.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: TEST_EMAIL,
password: TEST_PASSWORD,
flow: "signIn",
},
},
},
});
assert(auth.ok(), `测试账号登录失败: ${auth.status()} ${await auth.text()}`);
const whoami = await context.request.get(`${BASE_URL}/api/auth/whoami`);
assert(whoami.ok(), `whoami 失败: ${whoami.status()} ${await whoami.text()}`);
const viewer = await whoami.json();
assert(viewer.userId === "mnote-e2e", `当前登录用户不是 mnote-e2e: ${JSON.stringify(viewer)}`);
assert(viewer.actorType === "user", `当前登录不是真实 user 会话: ${JSON.stringify(viewer)}`);
return viewer;
}
function skillCapabilityOutline() {
return [
{
text: "触发场景",
children: [
{ text: "用户提到脑图", children: [] },
{ text: "资源标签页", children: [] },
{ text: "PDF 转导图", children: [] },
],
},
{
text: "读取能力",
children: [
{ text: "先解析目标", children: [] },
{ text: "fetch 回读树", children: [] },
{ text: "完整 envelope", children: [] },
],
},
{
text: "创建能力",
children: [
{ text: "outline 写入", children: [] },
{ text: "自动生成 uid", children: [] },
{ text: "保留 sourceRefs", children: [] },
],
},
{
text: "权限约束",
children: [
{ text: "需要 read_write", children: [] },
{ text: "限定资源路径", children: [] },
{ text: "拒绝越权写入", children: [] },
],
},
{
text: "写后验证",
children: [
{ text: "fetch 再确认", children: [] },
{ text: "报告 JSON 路径", children: [] },
{ text: "截图看渲染", children: [] },
],
},
{
text: "当前边界",
children: [
{ text: "非裸树文件", children: [] },
{ text: "apply_ops 受限", children: [] },
{ text: "长文本需压缩", children: [] },
],
},
];
}
function collectOutlineTexts(nodes, texts = []) {
for (const node of nodes) {
texts.push(String(node.text || ""));
collectOutlineTexts(Array.isArray(node.children) ? node.children : [], texts);
}
return texts;
}
async function main() {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
const workspace = await callJson(`${BASE_URL}/api/local-folder/workspaces/default`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
},
data: {},
});
const rootUri = workspace.workspace?.rootUri || "";
const rootPath = workspace.workspace?.rootPath || "";
const workspaceId = workspace.workspace?.manifest?.workspaceId || "local-ws:mnote-e2e:my-space";
assert(rootUri.startsWith("file://"), `默认本地工作区 rootUri 异常: ${JSON.stringify(workspace)}`);
assert(rootPath, `默认本地工作区 rootPath 缺失: ${JSON.stringify(workspace)}`);
const documentName = `MindmapSkillCapabilitySmoke-${Date.now()}.md`;
const documentId = `local-md:${documentName}`;
const resourcePath = `maps/task503-mindmap-skill-capabilities-${Date.now()}.mindmap.json`;
const title = "mnote-mindmap skill 能力";
await fs.mkdir(path.join(rootPath, "maps"), { recursive: true });
await fs.writeFile(path.join(rootPath, documentName), "# Mindmap Skill Capability Smoke\n", "utf8");
const skill = await callMnoteTool({
toolName: "mnote.skill.read",
workspaceId,
documentId,
rootUri,
args: { skillId: "mnote-mindmap", agentId: "reasonix" },
});
const skillContent = String(skill.content || "");
assert(skillContent.includes("Mindmap outline format"), "skill 缺少导图格式小节");
assert(skillContent.includes("Use concise keywords or short phrases"), "skill 缺少短语化节点要求");
assert(skillContent.includes("Avoid long paragraphs"), "skill 缺少避免长段落要求");
const outline = skillCapabilityOutline();
const outlineTexts = collectOutlineTexts(outline);
assert(outlineTexts.every((text) => text.length > 0 && text.length <= 32), "outline 节点应保持短语化");
assert(outlineTexts.every((text) => !text.includes("\n")), "outline 节点不能用换行模拟层级");
const aiAccessScope = {
permissionLevel: "read_write",
allowedResourceIds: [resourcePath],
};
const created = await callMnoteTool({
toolName: "mnote.mindmap.create_from_outline",
workspaceId,
documentId,
rootUri,
idempotencyKey: `idem_${TASK}_${Date.now()}`,
args: {
mindmapId: resourcePath,
resourcePath,
title,
outline,
embedIntoPage: true,
sourceRefs: [{ kind: "skill", title: "mnote-mindmap", note: TASK }],
aiAccessScope,
},
});
assert(created.root?.data?.text === title, `创建结果根节点异常: ${JSON.stringify(created.root)}`);
assert(created.embedResult?.status === "embedded", `创建结果缺少页面绑定: ${JSON.stringify(created.embedResult)}`);
assert(
Array.isArray(created.changedFiles) && created.changedFiles.includes(documentName),
`changedFiles 应包含当前 Markdown 页面: ${JSON.stringify(created.changedFiles)}`,
);
const markdownAfterEmbed = await fs.readFile(path.join(rootPath, documentName), "utf8");
assert(
markdownAfterEmbed.includes(`[${title}](${resourcePath})`),
`当前 Markdown 页面未写入 mindmap 链接: ${markdownAfterEmbed}`,
);
const fetched = await callMnoteTool({
toolName: "mnote.mindmap.fetch",
workspaceId,
documentId,
rootUri,
args: {
mindmapId: resourcePath,
resourcePath,
scope: "full_envelope",
aiAccessScope,
},
});
const nodeCount = Array.isArray(fetched.nodes) ? fetched.nodes.length : 0;
assert(fetched.root?.data?.text === title, `回读根节点异常: ${JSON.stringify(fetched.root)}`);
assert(nodeCount >= 25, `回读节点数不足: ${nodeCount}`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const events = [];
page.on("pageerror", (error) => events.push({ kind: "pageerror", text: error.message }));
page.on("console", (message) => {
if (message.type() === "error") {
events.push({ kind: "console", text: message.text() });
}
});
page.on("response", (response) => {
if (response.url().includes("/api/mindmap/")) {
events.push({ kind: "mindmap-response", status: response.status(), url: response.url() });
}
});
const viewer = await signInBrowserContext(context);
const url = `${BASE_URL}/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(`local-file:${resourcePath}`)}?sourceKind=local_folder&rootUri=${encodeURIComponent(rootUri)}&workspaceId=${encodeURIComponent(workspaceId)}`;
await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-mindmap-editor-root"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(expectedTitle) => {
const text = document.body.innerText || "";
const countText = document.querySelector('[data-testid="mindmap-schema-count"]')?.textContent || "";
return text.includes(expectedTitle) && text.includes("触发场景") && /节点\s*(?:[1-9]|[1-9][0-9]+)/.test(countText);
},
title,
{ timeout: UI_TIMEOUT_MS },
);
const screenshotPath = path.join(OUTPUT_DIR, "skill-capability-mindmap.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
const ui = await page.evaluate(() => ({
bodyHasTitle: (document.body.innerText || "").includes("mnote-mindmap skill 能力"),
bodyHasBranch: (document.body.innerText || "").includes("触发场景"),
bodyHasPermission: (document.body.innerText || "").includes("权限约束"),
schemaCount: document.querySelector('[data-testid="mindmap-schema-count"]')?.textContent || null,
rootVisible: Boolean(document.querySelector('[data-testid="mnote-mindmap-editor-root"]')),
errorText: document.querySelector('[data-testid="leptos-mindmap-error"]')?.textContent || null,
}));
await browser.close();
assert(ui.bodyHasTitle && ui.bodyHasBranch && ui.bodyHasPermission, `导图页面文本缺失: ${JSON.stringify(ui)}`);
assert(!ui.errorText, `导图页面出现错误: ${ui.errorText}`);
await writeResult({
ok: true,
rootUri,
workspaceId,
documentId,
resourcePath,
mindmapFile: path.join(rootPath, resourcePath),
screenshotPath,
url,
viewer,
skillFormatRules: {
hasFormat: skillContent.includes("Mindmap outline format"),
hasShortPhraseRule: skillContent.includes("Use concise keywords or short phrases"),
hasAvoidParagraphRule: skillContent.includes("Avoid long paragraphs"),
},
nodeCount,
embedResult: created.embedResult,
markdownHasMindmapLink: markdownAfterEmbed.includes(`[${title}](${resourcePath})`),
firstLevel: (fetched.root?.children || []).map((child) => child?.data?.text),
ui,
events,
});
console.log(`task503 mindmap skill capability smoke passed: ${RESULT_PATH}`);
}
main().catch(async (error) => {
await writeResult({
ok: false,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : null,
}).catch(() => undefined);
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,499 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task504-page-ai-history-agent-filter-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task504-history-"));
const rootUri = fileUrl(root);
const workspaceId = `local-ws:${actorId}:task504`;
const relativePath = "HistoryAgentFilter.md";
const documentId = localMdDocumentId(relativePath);
const longGeminiMessage = [
"Gemini 历史预览应只显示摘要,不应把 ChatOnly 的完整长消息塞进历史列表。",
"这段内容用于模拟网页问答返回的完整长回复,历史列表应保留可扫描性。",
"FULL_TAIL_SHOULD_NOT_RENDER",
].join("");
const sessions = [
{
sessionId: `mnote_task504_gemini_${suffix}`,
runId: `run_task504_gemini_${suffix}`,
title: "Gemini 问答",
profile: "shared_gemini_chat",
acpRuntime: "hermes",
status: "completed",
payload: {
agentId: "chat_only",
profileId: "shared_gemini_chat",
profile: "shared_gemini_chat",
acpRuntime: "hermes",
message: longGeminiMessage,
},
createdAt: "2026-05-30T08:00:00Z",
updatedAt: "2026-05-30T08:02:00Z",
persistence: "sqlite_acp_runtime_store",
},
{
sessionId: `mnote_task504_doubao_${suffix}`,
runId: `run_task504_doubao_${suffix}`,
title: "豆包问答",
profile: "shared_doubao_chat",
acpRuntime: "hermes",
status: "completed",
payload: {
agentId: "chat_only",
profileId: "shared_doubao_chat",
profile: "shared_doubao_chat",
acpRuntime: "hermes",
message: "豆包短回复",
},
createdAt: "2026-05-30T08:01:00Z",
updatedAt: "2026-05-30T08:01:30Z",
persistence: "sqlite_acp_runtime_store",
},
{
sessionId: `mnote_task504_stale_chatonly_${suffix}`,
runId: `run_task504_stale_chatonly_${suffix}`,
title: "旧 ChatOnly profile",
profile: "myHermes",
acpRuntime: "hermes",
status: "completed",
payload: {
agentId: "chat_only",
profileId: "myHermes",
profile: "myHermes",
acpRuntime: "hermes",
message: "旧数据里误写成 Hermes profile 的 ChatOnly 会话",
},
createdAt: "2026-05-30T08:00:50Z",
updatedAt: "2026-05-30T08:01:00Z",
persistence: "sqlite_acp_runtime_store",
},
{
sessionId: `mnote_task504_hermes_${suffix}`,
runId: `run_task504_hermes_${suffix}`,
title: "Hermes 问答",
profile: "shared_lite",
acpRuntime: "hermes",
status: "completed",
payload: {
agentId: "hermes",
profileId: "shared_lite",
profile: "shared_lite",
acpRuntime: "hermes",
message: "Hermes 回复",
},
createdAt: "2026-05-30T08:00:30Z",
updatedAt: "2026-05-30T08:00:45Z",
persistence: "sqlite_acp_runtime_store",
},
];
const capturedRuns = [];
let caughtError = null;
const screenshots = {};
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, relativePath), ["# History Agent Filter", "", suffix, ""].join("\n"), "utf8");
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
controlPlane: "sqlite",
grants: [{
id: `grant_task504_${suffix}`,
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "write",
recursive: true,
capabilities: ["ai", "markdown_edit"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
if (route.request().method() === "GET") {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
owner: "mnote-web",
result: {
aiPreferences: {
"ai.common.default_agent_id": "chat_only",
"ai.agent.hermes.profile_id": "shared_deepseek_chat",
"ai.common.context_refs.default_selected": {
current_page: true,
selection: false,
active_editor: false,
file: false,
folder: false,
changed_files: false,
},
},
},
}),
});
return;
}
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
});
});
await page.route("**/api/documents/buffer-state**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
result: { dirtyState: "", fileVersion: `task504-${suffix}` },
}),
});
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
gateway: { ok: true, status: "mocked" },
profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true },
suggestions: [],
}),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, tools: [] }),
});
});
await page.route("**/api/hermes/client/profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
active: "mnoteai",
profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }],
}),
});
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
agentId: "hermes",
profiles: [
{ profileId: "shared_deepseek_chat", kind: "shared", displayName: "DeepSeek Chat", baseProfile: "deepseek-chat", isolatedProfile: "openclaw-deepseek-chat", readonly: true },
{ profileId: "shared_doubao_chat", kind: "shared", displayName: "豆包 Chat", baseProfile: "doubao-chat", isolatedProfile: "openclaw-doubao-chat", readonly: true },
{ profileId: "shared_gemini_chat", kind: "shared", displayName: "Gemini Chat", baseProfile: "gemini-chat", isolatedProfile: "openclaw-gemini-chat", readonly: true },
{ profileId: "shared_lite", kind: "shared", displayName: "Lite", baseProfile: "lite", isolatedProfile: "lite", readonly: true },
],
}),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions**", async (route) => {
if (route.request().method() === "GET") {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
persistence: "sqlite_acp_runtime_store",
sessions,
}),
});
return;
}
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: `mnote_task504_new_${suffix}`,
title: "当前页问答",
persistence: "sqlite_acp_runtime_store",
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
const body = JSON.parse(route.request().postData() || "{}");
capturedRuns.push(body);
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
upstream: {
runId: `run_task504_current_page_${suffix}`,
traceId: `trace_task504_current_page_${suffix}`,
},
}),
});
});
await page.route("**/api/hermes/client/events/run_task504_current_page_*", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body: [
"event: message.delta",
"data: {\"text\":\"### TASK504_CURRENT_PAGE_OK\\n- **Markdown 渲染**\"}",
"",
"event: run.completed",
"data: {\"output\":\"### TASK504_CURRENT_PAGE_OK\\n- **Markdown 渲染**\"}",
"",
].join("\n"),
});
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="history"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-session-row="mnote_task504_gemini_${suffix}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const historyPanel = page.locator('[data-page-ai-panel="history"]');
const historyText = await historyPanel.innerText({ timeout: UI_TIMEOUT_MS });
assert(historyText.includes("ChatOnly / Gemini"), "历史会话应显示 Gemini 所属 agent");
assert(historyText.includes("ChatOnly / 豆包"), "历史会话应显示豆包所属 agent");
assert(historyText.includes("Hermes / Lite"), "历史会话应显示 Hermes profile");
assert(!historyText.includes("ChatOnly / myHermes"), "ChatOnly 历史不应显示 Hermes profile 标签");
assert(!historyText.includes("FULL_TAIL_SHOULD_NOT_RENDER"), "历史预览不应显示 ChatOnly 完整长消息尾部");
const filter = page.locator('[data-page-ai-session-agent-filter]');
await filter.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await filter.selectOption("chat_only:shared_gemini_chat", { timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-session-row="mnote_task504_gemini_${suffix}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator(`[data-page-ai-session-row="mnote_task504_doubao_${suffix}"]`).count(),
0,
"筛选 Gemini 后不应显示豆包会话",
);
await filter.selectOption("chat_only:shared_doubao_chat", { timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-session-row="mnote_task504_doubao_${suffix}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator(`[data-page-ai-session-row="mnote_task504_gemini_${suffix}"]`).count(),
0,
"筛选豆包后不应显示 Gemini 会话",
);
screenshots.history = await saveScreenshot(page, "history-filter");
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="new-session"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.querySelector('[data-page-ai-agent-chip]')?.textContent?.includes("DeepSeek"),
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(({ staleWorkspaceId, documentId, rootUri: currentRootUri }) => {
const staleSnapshot = {
schema: "mnote.open_editors_snapshot.v1",
generatedAt: Date.now(),
activeObjectIdentity: "page:primary",
activeEditor: {
objectIdentity: "page:primary",
workspacePath: {
schema: "mnote.workspace_path.v1",
workspaceId: staleWorkspaceId,
sourceKind: "local_folder",
rootUri: currentRootUri,
relativePath: "HistoryAgentFilter.md",
documentId,
objectIdentity: "page:primary",
assetId: "",
resourceKind: "page",
},
paneRole: "primary",
documentId,
workspaceId: staleWorkspaceId,
title: "Stale page target",
kind: "page",
editorKind: "page",
active: true,
dirtyState: "",
preview: false,
pinned: true,
lastActiveAt: Date.now(),
assetId: "",
path: "HistoryAgentFilter.md",
},
editors: [],
resourceEditors: [],
groups: { primary: { paneRole: "primary", activeObjectIdentity: "page:primary", editors: [], resourceEditors: [] }, secondary: { paneRole: "secondary", activeObjectIdentity: "", editors: [], resourceEditors: [] } },
};
staleSnapshot.editors = [staleSnapshot.activeEditor];
staleSnapshot.groups.primary.editors = [staleSnapshot.activeEditor];
window.__mnoteOpenEditorsSnapshot = staleSnapshot;
const previous = window.__mnoteDocumentPaneRuntime || {};
window.__mnoteDocumentPaneRuntime = {
...previous,
getOpenEditorsSnapshot: () => staleSnapshot,
};
}, {
staleWorkspaceId: `${workspaceId}:stale`,
documentId,
rootUri,
});
await page.locator("[data-page-ai-input]").fill(`task504 current page target ${suffix}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').getByText("TASK504_CURRENT_PAGE_OK").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const markdownState = await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').evaluate((root) => ({
heading: Boolean(root.querySelector(".wolai-page-ai-message--assistant .wolai-page-ai-message-text h3")),
strong: Boolean(root.querySelector(".wolai-page-ai-message--assistant .wolai-page-ai-message-text strong")),
rawHeadingMarkers: root.textContent?.includes("### TASK504_CURRENT_PAGE_OK") || false,
rawStrongMarkers: root.textContent?.includes("**Markdown 渲染**") || false,
}));
assert.deepStrictEqual(markdownState, {
heading: true,
strong: true,
rawHeadingMarkers: false,
rawStrongMarkers: false,
}, `AI 面板应渲染 Markdown,而不是显示原始标记: ${JSON.stringify(markdownState)}`);
assert.strictEqual(capturedRuns.length, 1, "当前页 ChatOnly 请求应通过前置 target 校验并到达 runs API");
assert.strictEqual(capturedRuns[0].agentId, "chat_only", "应使用 ChatOnly agent");
assert.strictEqual(capturedRuns[0].profile, "shared_deepseek_chat", "应使用 DeepSeek ChatOnly profile");
assert.strictEqual(
capturedRuns[0].runTargetSnapshot?.editorTarget?.workspaceId,
workspaceId,
"只选择当前页时 runTargetSnapshot 应使用当前 workspaceId,而不是 stale active editor workspaceId",
);
assert(
capturedRuns[0].contextRefs.some((item) => item.kind === "current_page" && item.workspaceId === workspaceId),
"当前页 contextRef 应保留当前 workspaceId",
);
assert(
!capturedRuns[0].contextRefs.some((item) => item.kind === "active_editor"),
"取消打开资源后不应发送 active_editor contextRef",
);
screenshots.currentPageTarget = await saveScreenshot(page, "current-page-target");
} catch (error) {
caughtError = error;
try {
screenshots.failure = await saveScreenshot(page, "failure");
} catch (_) {}
} finally {
await browser.close();
const result = {
ok: !caughtError,
error: caughtError ? String(caughtError && caughtError.stack || caughtError) : null,
screenshots,
root,
capturedRuns,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
if (caughtError) {
console.error(JSON.stringify(result, null, 2));
process.exit(1);
}
console.log(JSON.stringify(result, null, 2));
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,499 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task512-chatonly-doubao-sync-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const DOUBAO_CDP_URL = process.env.MNOTE_DOUBAO_CDP_URL || "http://127.0.0.1:9233";
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function sqliteJson(sql, fallback = null) {
try {
const raw = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
}
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function grantWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task512 Doubao Smoke', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai","markdown_edit"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
async function firstDoubaoPage(cdpBrowser) {
for (const context of cdpBrowser.contexts()) {
const page = context.pages().find((candidate) => candidate.url().includes("doubao.com"));
if (page) return page;
}
const context = cdpBrowser.contexts()[0] || await cdpBrowser.newContext();
const page = await context.newPage();
await page.goto("https://www.doubao.com/chat/", { waitUntil: "domcontentloaded" });
return page;
}
async function installDoubaoFetchProbe(page) {
await page.evaluate(() => {
const win = window;
const shouldTrack = (url) => url.includes("/samantha/chat/completion")
|| url.includes("/im/conversation/batch_del_user_conv");
if (!win.__mnoteDoubaoOriginalFetch) {
win.__mnoteDoubaoOriginalFetch = win.fetch.bind(win);
}
win.__mnoteDoubaoFetchLog = [];
win.fetch = async function patchedMnoteDoubaoFetch(input, init) {
const url = String((input && input.url) || input || "");
const method = String((init && init.method) || "GET").toUpperCase();
if (shouldTrack(url)) {
win.__mnoteDoubaoFetchLog.push({
transport: "fetch",
url,
method,
body: init && init.body ? String(init.body) : "",
ts: Date.now(),
});
}
return win.__mnoteDoubaoOriginalFetch(input, init);
};
if (!win.__mnoteDoubaoOriginalXHROpen && win.XMLHttpRequest) {
win.__mnoteDoubaoOriginalXHROpen = win.XMLHttpRequest.prototype.open;
win.__mnoteDoubaoOriginalXHRSend = win.XMLHttpRequest.prototype.send;
win.XMLHttpRequest.prototype.open = function patchedMnoteDoubaoXHROpen(method, url, ...rest) {
this.__mnoteDoubaoProbeMethod = String(method || "GET").toUpperCase();
this.__mnoteDoubaoProbeUrl = String(url || "");
return win.__mnoteDoubaoOriginalXHROpen.call(this, method, url, ...rest);
};
win.XMLHttpRequest.prototype.send = function patchedMnoteDoubaoXHRSend(body) {
const url = String(this.__mnoteDoubaoProbeUrl || "");
if (shouldTrack(url)) {
win.__mnoteDoubaoFetchLog.push({
transport: "xhr",
url,
method: String(this.__mnoteDoubaoProbeMethod || "GET").toUpperCase(),
body: body ? String(body) : "",
ts: Date.now(),
});
}
return win.__mnoteDoubaoOriginalXHRSend.call(this, body);
};
}
});
}
async function readDoubaoFetchLog(page) {
return await page.evaluate(() => Array.isArray(window.__mnoteDoubaoFetchLog)
? window.__mnoteDoubaoFetchLog
: []);
}
async function readDoubaoConversationStats(page, { marker, prompt }) {
return await page.evaluate(({ marker, prompt }) => {
const normalize = (value) => String(value || "").replace(/\s+/g, " ").trim();
const markerText = String(marker || "");
const promptText = String(prompt || "");
const userBubbleTexts = Array.from(document.querySelectorAll(".bg-g-send-msg-bubble-bg"))
.map((node) => normalize(node.textContent));
const assistantMarkdownTexts = Array.from(document.querySelectorAll(".md-box-root"))
.map((node) => normalize(node.textContent));
return {
userPromptCount: userBubbleTexts.filter((text) => text === normalize(promptText)).length,
userMarkerCount: userBubbleTexts.filter((text) => text.includes(markerText)).length,
assistantMarkerCount: assistantMarkdownTexts.filter((text) => text.includes(markerText)).length,
userBubbleTexts,
assistantMarkdownTexts,
};
}, { marker, prompt });
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
function doubaoConversationUrl(remoteConversationId) {
return `https://www.doubao.com/chat/${encodeURIComponent(remoteConversationId)}`;
}
async function captureDoubaoPage(page, { name, url, remoteConversationId = "" }) {
let navigationError = "";
if (url) {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch((error) => {
navigationError = error instanceof Error ? error.message : String(error);
});
}
await page.bringToFront().catch(() => {});
await page.waitForTimeout(2500);
const screenshot = await saveScreenshot(page, name);
const title = await page.title().catch(() => "");
const pageUrl = page.url();
const visibleText = await page.locator("body").innerText({ timeout: 5000 }).catch(() => "");
const conversationRowCount = remoteConversationId
? await page.locator(`#conversation_${remoteConversationId}`).count().catch(() => -1)
: null;
return {
screenshot,
requestedUrl: url || "",
pageUrl,
title,
navigationError,
conversationRowCount,
visibleText: visibleText.slice(0, 2000),
};
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task512-doubao-"));
const rootUri = fileUrl(root);
const workspaceId = `local-ws:${actorId}:task512-${suffix}`;
const relativePath = "DoubaoChatOnlySync.md";
const marker = `MNOTE_DOUBAO_SYNC_${suffix.toUpperCase()}`;
const prompt = `请只回复以下字符串,不要添加空格或其他内容:${marker}`;
const screenshots = {};
const providerCaptures = {};
const runRequests = [];
const deleteResponses = [];
let caughtError = null;
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, relativePath), ["# Doubao ChatOnly Sync", "", marker, ""].join("\n"), "utf8");
grantWorkspaceAccess({
actorId,
workspaceId,
root,
rootUri,
grantId: `grant_task512_${suffix}`,
});
const doubaoBrowser = await chromium.connectOverCDP(DOUBAO_CDP_URL);
const doubaoPage = await firstDoubaoPage(doubaoBrowser);
await doubaoPage.goto("https://www.doubao.com/chat/", {
waitUntil: "domcontentloaded",
timeout: 60_000,
}).catch(() => {});
await doubaoPage.keyboard.press("Escape").catch(() => {});
await installDoubaoFetchProbe(doubaoPage);
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await page.route("**/api/hermes/client/runs", async (route) => {
runRequests.push(JSON.parse(route.request().postData() || "{}"));
await route.continue();
});
page.on("response", async (response) => {
const url = response.url();
const request = response.request();
if (request.method() === "DELETE" && url.includes("/api/hermes/client/sessions/")) {
deleteResponses.push({
url,
status: response.status(),
body: await response.text().catch(() => ""),
});
}
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="new-session"]').click({
timeout: UI_TIMEOUT_MS,
});
await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-id="chat_only"][data-page-ai-profile-id="shared_doubao_chat"]').click({
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => document.querySelector("[data-page-ai-agent-chip]")?.textContent?.includes("ChatOnly / 豆包"),
null,
{ timeout: UI_TIMEOUT_MS },
);
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 });
const replyState = await page.waitForFunction(
(expectedMarker) => {
const assistantText = Array.from(document.querySelectorAll(".wolai-page-ai-message--assistant"))
.map((node) => node.textContent || "")
.join("\n");
if (assistantText.includes(expectedMarker)) return "marker";
if (assistantText.includes("豆包暂时无法回复")) return "provider_error";
return "";
},
marker,
{ timeout: 180_000 },
).then((handle) => handle.jsonValue());
await page.waitForFunction(
() => (document.querySelector("[data-page-ai-run-status]")?.textContent || "").includes("完成"),
null,
{ timeout: 180_000 },
).catch(() => {});
screenshots.afterMessage = await saveScreenshot(page, "after-message");
assert.strictEqual(runRequests.length, 1, "MNote 本轮应只创建一个 run");
const run = runRequests[0];
assert.strictEqual(run.agentId, "chat_only", "应使用 ChatOnly agent");
assert.strictEqual(run.profileId, "shared_doubao_chat", "应使用豆包 ChatOnly profile");
assert.strictEqual(run.acpRuntime, "hermes", "豆包 ChatOnly 应走 Hermes/OpenClaw runtime");
assert(run.sessionId, "run payload 应包含 MNote sessionId");
const logAfterMessage = await readDoubaoFetchLog(doubaoPage);
const completionCalls = logAfterMessage.filter((entry) => entry.url.includes("/samantha/chat/completion"));
assert.strictEqual(completionCalls.length, 0, "豆包 ChatOnly 应走真实 UI 发送,不应再直调 samantha completion");
assert.notStrictEqual(replyState, "provider_error", "豆包返回限流/风控错误,未产生本轮 marker 回复");
const visibleAssistantTexts = await page.locator(".wolai-page-ai-message--assistant").allTextContents();
const markerAssistantCount = visibleAssistantTexts.filter((text) => text.includes(marker)).length;
assert.strictEqual(markerAssistantCount, 1, "MNote 可见豆包回复应只有一条");
const sessionId = String(run.sessionId);
const bindingRows = sqliteJson(
`SELECT mnote_session_id, remote_conversation_id, status FROM ai_external_conversation_bindings WHERE user_id='${actorId}' AND mnote_session_id='${sessionId}' AND provider='doubao-web' ORDER BY updated_at DESC LIMIT 1;`,
[],
);
assert(bindingRows && bindingRows.length === 1, "SQLite 应保存豆包远端会话绑定");
assert.strictEqual(bindingRows[0].status, "active", "删除前 binding 应为 active");
const remoteConversationId = bindingRows[0].remote_conversation_id;
assert(remoteConversationId, "binding 应包含 remote_conversation_id");
providerCaptures.afterMessage = await captureDoubaoPage(doubaoPage, {
name: "doubao-after-message",
url: doubaoConversationUrl(remoteConversationId),
remoteConversationId,
});
const doubaoMessageStats = await readDoubaoConversationStats(doubaoPage, { marker, prompt });
screenshots.doubaoAfterMessage = providerCaptures.afterMessage.screenshot;
assert(
providerCaptures.afterMessage.pageUrl.includes(remoteConversationId),
"豆包截图应定位到本轮远端 conversation_id",
);
assert(
providerCaptures.afterMessage.visibleText.includes(marker),
"豆包本轮远端会话页面应显示 marker",
);
assert.strictEqual(
providerCaptures.afterMessage.conversationRowCount,
1,
"豆包删除前左侧历史列表应存在本轮 conversation 行",
);
assert.strictEqual(
doubaoMessageStats.userPromptCount,
1,
"豆包网页端本轮用户消息应只有一条",
);
assert.strictEqual(
doubaoMessageStats.userMarkerCount,
1,
"豆包网页端不应把豆包回复再次作为用户消息发送",
);
assert.strictEqual(
doubaoMessageStats.assistantMarkerCount,
1,
"豆包网页端本轮助手回复应只有一条",
);
await installDoubaoFetchProbe(doubaoPage);
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="history"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-session-row="${sessionId}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
page.once("dialog", async (dialog) => {
await dialog.accept();
});
await page.locator(`[data-page-ai-session-delete="${sessionId}"]`).click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(id) => !document.querySelector(`[data-page-ai-session-row="${CSS.escape(id)}"]`),
sessionId,
{ timeout: UI_TIMEOUT_MS },
);
screenshots.afterDelete = await saveScreenshot(page, "after-delete");
await page.waitForTimeout(1000);
const logAfterDelete = await readDoubaoFetchLog(doubaoPage);
const deleteCalls = logAfterDelete.filter((entry) => entry.url.includes("/im/conversation/batch_del_user_conv"));
assert.strictEqual(deleteCalls.length, 1, "豆包端本轮只能收到一次会话删除请求");
assert(deleteCalls[0].body.includes(remoteConversationId), "豆包删除请求应包含绑定的远端 conversation_id");
assert(deleteResponses.length >= 1, "MNote 应发出历史会话 DELETE 请求");
assert.strictEqual(deleteResponses.at(-1).status, 200, "MNote 历史会话 DELETE 应成功");
const deleteBody = JSON.parse(deleteResponses.at(-1).body || "{}");
assert.strictEqual(
deleteBody?.result?.providerConversationDelete?.response?.result?.providerDeleteMode,
"doubao_sidebar_menu",
"豆包远端删除必须走左侧会话三点菜单 + 确认弹窗路径",
);
providerCaptures.afterDelete = await captureDoubaoPage(doubaoPage, {
name: "doubao-after-delete",
url: "https://www.doubao.com/chat/",
remoteConversationId,
});
screenshots.doubaoAfterDelete = providerCaptures.afterDelete.screenshot;
assert(
!providerCaptures.afterDelete.visibleText.includes(marker),
"豆包删除后聊天入口不应继续显示本轮 marker",
);
assert.strictEqual(
providerCaptures.afterDelete.conversationRowCount,
0,
"豆包删除后左侧历史列表不应继续存在本轮 conversation 行",
);
const deletedRows = sqliteJson(
`SELECT mnote_session_id, remote_conversation_id, status, metadata_json FROM ai_external_conversation_bindings WHERE user_id='${actorId}' AND mnote_session_id='${sessionId}' AND provider='doubao-web' ORDER BY updated_at DESC LIMIT 1;`,
[],
);
assert(deletedRows && deletedRows.length === 1, "删除后 binding 仍应可审计");
assert.strictEqual(deletedRows[0].status, "remote_deleted", "删除后 binding 应标记 remote_deleted");
fs.writeFileSync(
RESULT_PATH,
`${JSON.stringify({
ok: true,
marker,
sessionId,
remoteConversationId,
samanthaCompletionCallCount: completionCalls.length,
doubaoMessageStats,
deleteCallCount: deleteCalls.length,
deleteResponse: deleteResponses.at(-1),
bindingBefore: bindingRows[0],
bindingAfter: deletedRows[0],
screenshots,
providerCaptures,
}, null, 2)}\n`,
"utf8",
);
} catch (error) {
caughtError = error;
screenshots.failure = await saveScreenshot(page, "failure").catch(() => "");
providerCaptures.failure = await captureDoubaoPage(doubaoPage, {
name: "doubao-failure",
url: "",
}).catch((captureError) => ({
screenshot: "",
requestedUrl: "",
pageUrl: "",
title: "",
navigationError: captureError instanceof Error ? captureError.message : String(captureError),
visibleText: "",
}));
if (providerCaptures.failure.screenshot) {
screenshots.doubaoFailure = providerCaptures.failure.screenshot;
}
fs.writeFileSync(
RESULT_PATH,
`${JSON.stringify({
ok: false,
marker,
error: error instanceof Error ? error.stack || error.message : String(error),
runRequests,
deleteResponses,
doubaoFetchLog: await readDoubaoFetchLog(doubaoPage).catch(() => []),
screenshots,
providerCaptures,
}, null, 2)}\n`,
"utf8",
);
} finally {
await browser.close().catch(() => {});
await doubaoBrowser.close().catch(() => {});
}
if (caughtError) throw caughtError;
console.log(JSON.stringify(JSON.parse(fs.readFileSync(RESULT_PATH, "utf8")), null, 2));
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,512 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const PROVIDERS = {
deepseek: {
taskName: "task513-chatonly-deepseek-sync-smoke",
title: "DeepSeek ChatOnly Sync",
agentProfileId: "shared_deepseek_chat",
chipText: "ChatOnly / DeepSeek",
expectedProvider: "deepseek-web",
gatewayLog: "/home/lix/.openclaw-mnote-deepseek-chat/logs/gateway.out",
sendLogPattern: /\[DeepSeekWebClient\] Sending chat completion request/g,
configPath: "/home/lix/.openclaw-mnote-deepseek-chat/openclaw.json",
},
gemini: {
taskName: "task513-chatonly-gemini-sync-smoke",
title: "Gemini ChatOnly Sync",
agentProfileId: "shared_gemini_chat",
chipText: "ChatOnly / Gemini",
expectedProvider: "gemini-web",
gatewayLog: "/home/lix/.openclaw-mnote-gemini-chat/logs/gateway.out",
sendLogPattern: /\[Gemini Web Browser\] DOM: typed message and pressed Enter/g,
cdpUrl: process.env.MNOTE_GEMINI_CDP_URL || "http://127.0.0.1:9232",
},
};
const providerKey = process.argv[2] || process.env.MNOTE_CHATONLY_PROVIDER || "deepseek";
const provider = PROVIDERS[providerKey];
if (!provider) {
throw new Error(`未知 provider: ${providerKey}`);
}
const OUTPUT_DIR = path.join(process.cwd(), "tmp", provider.taskName);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const CHROMIUM_EXECUTABLE_PATH =
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ||
[
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/snap/bin/chromium",
"/usr/bin/chromium",
].find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function sqliteJson(sql, fallback = null) {
try {
const raw = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
}
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function grantWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai","markdown_edit"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
function logSize(logPath) {
try {
return fs.statSync(logPath).size;
} catch {
return 0;
}
}
function readLogSince(logPath, offset) {
try {
const fd = fs.openSync(logPath, "r");
const stat = fs.fstatSync(fd);
const start = offset > stat.size ? 0 : offset;
const buffer = Buffer.alloc(stat.size - start);
fs.readSync(fd, buffer, 0, buffer.length, start);
fs.closeSync(fd);
return buffer.toString("utf8");
} catch {
return "";
}
}
function countMatches(text, pattern) {
return Array.from(String(text || "").matchAll(pattern)).length;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function parseCookieString(cookieString, domain) {
return String(cookieString || "")
.split(";")
.filter((cookie) => cookie.trim().includes("="))
.map((cookie) => {
const [name, ...valueParts] = cookie.trim().split("=");
return {
name: name.trim(),
value: valueParts.join("=").trim(),
domain,
path: "/",
};
})
.filter((cookie) => cookie.name);
}
function deepseekAuth() {
const config = JSON.parse(fs.readFileSync(provider.configPath, "utf8"));
return JSON.parse(config.models.providers["deepseek-web"].apiKey || "{}");
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function captureDeepseek(remoteConversationId, name) {
const auth = deepseekAuth();
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
userAgent: auth.userAgent,
});
await context.addCookies(parseCookieString(auth.cookie, ".deepseek.com"));
if (auth.bearer) {
await context.addInitScript((token) => {
localStorage.setItem("userToken", JSON.stringify({ value: token, __version: "0" }));
}, auth.bearer);
}
const page = await context.newPage();
if (auth.bearer) {
await page.route("https://chat.deepseek.com/api/**", (route) => {
route.continue({
headers: {
...route.request().headers(),
authorization: `Bearer ${auth.bearer}`,
},
});
});
}
const url = `https://chat.deepseek.com/a/chat/s/${remoteConversationId}`;
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => {});
await page.waitForTimeout(2500);
const screenshot = await saveScreenshot(page, name);
const title = await page.title().catch(() => "");
const visibleText = await page.locator("body").innerText({ timeout: 5000 }).catch(() => "");
const conversationLinkCount = await page
.locator(`a[href*="/a/chat/s/${remoteConversationId}"]`)
.count()
.catch(() => -1);
await browser.close().catch(() => {});
return {
screenshot,
url,
title,
conversationLinkCount,
conversationMissingText: visibleText.includes("该对话不存在"),
visibleText: visibleText.slice(0, 1000),
};
}
async function firstGeminiPage(cdpBrowser, remoteConversationId) {
const targetUrl = String(remoteConversationId || "");
for (const context of cdpBrowser.contexts()) {
const exact = context.pages().find((candidate) => candidate.url().split("#")[0] === targetUrl);
if (exact) return exact;
const gemini = context.pages().find((candidate) => candidate.url().includes("gemini.google.com"));
if (gemini) return gemini;
}
const context = cdpBrowser.contexts()[0] || await cdpBrowser.newContext();
return await context.newPage();
}
async function captureGemini(remoteConversationId, name) {
const browser = await chromium.connectOverCDP(provider.cdpUrl);
const page = await firstGeminiPage(browser, remoteConversationId);
const targetUrl = String(remoteConversationId || "");
if (targetUrl && page.url().split("#")[0] !== targetUrl) {
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => {});
await page.waitForTimeout(1500);
}
await page.bringToFront().catch(() => {});
await page.waitForTimeout(1000);
const screenshot = await saveScreenshot(page, name);
const title = await page.title().catch(() => "");
const visibleText = await page.locator("body").innerText({ timeout: 5000 }).catch(() => "");
const conversationLinkCount = await page.evaluate((target) => {
let targetPath = "";
try {
targetPath = new URL(target).pathname;
} catch {
return -1;
}
return Array.from(document.querySelectorAll("a[href]")).filter((anchor) => {
try {
return new URL(anchor.href, location.href).pathname === targetPath;
} catch {
return false;
}
}).length;
}, targetUrl).catch(() => -1);
await browser.close().catch(() => {});
return {
screenshot,
url: page.url(),
title,
conversationLinkCount,
visibleText: visibleText.slice(0, 1000),
};
}
async function captureProvider(remoteConversationId, name) {
if (providerKey === "deepseek") return await captureDeepseek(remoteConversationId, name);
return await captureGemini(remoteConversationId, name);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const root = fs.mkdtempSync(path.join(os.tmpdir(), `mnote-task513-${providerKey}-`));
const rootUri = fileUrl(root);
const workspaceId = `local-ws:${actorId}:task513-${providerKey}-${suffix}`;
const relativePath = `${providerKey}-ChatOnlySync.md`;
const marker = `MNOTE_CHATONLY_SYNC_${suffix}`;
const screenshots = {};
const providerCaptures = {};
const runRequests = [];
const deleteResponses = [];
const gatewayLogOffset = logSize(provider.gatewayLog);
let caughtError = null;
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, relativePath), [`# ${provider.title}`, "", marker, ""].join("\n"), "utf8");
grantWorkspaceAccess({
actorId,
workspaceId,
root,
rootUri,
grantId: `grant_task513_${providerKey}_${suffix}`,
});
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await page.route("**/api/hermes/client/runs", async (route) => {
runRequests.push(JSON.parse(route.request().postData() || "{}"));
await route.continue();
});
page.on("response", async (response) => {
const url = response.url();
const request = response.request();
if (request.method() === "DELETE" && url.includes("/api/hermes/client/sessions/")) {
deleteResponses.push({
url,
status: response.status(),
body: await response.text().catch(() => ""),
});
}
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="new-session"]').click({
timeout: UI_TIMEOUT_MS,
});
await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-agent-id="chat_only"][data-page-ai-profile-id="${provider.agentProfileId}"]`).click({
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
(expected) => document.querySelector("[data-page-ai-agent-chip]")?.textContent?.includes(expected),
provider.chipText,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator("[data-page-ai-input]").fill(`请只回复:${marker}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(expectedMarker) => {
const assistantText = Array.from(document.querySelectorAll(".wolai-page-ai-message--assistant"))
.map((node) => node.textContent || "")
.join("\n");
return assistantText.includes(expectedMarker);
},
marker,
{ timeout: 180_000 },
);
await page.waitForFunction(
() => (document.querySelector("[data-page-ai-run-status]")?.textContent || "").includes("完成"),
null,
{ timeout: 180_000 },
).catch(() => {});
screenshots.afterMessage = await saveScreenshot(page, "after-message");
assert.strictEqual(runRequests.length, 1, "MNote 本轮应只创建一个 run");
const run = runRequests[0];
assert.strictEqual(run.agentId, "chat_only", "应使用 ChatOnly agent");
assert.strictEqual(run.profileId, provider.agentProfileId, `应使用 ${provider.title} profile`);
assert.strictEqual(run.acpRuntime, "hermes", "ChatOnly 网页 provider 应走 Hermes/OpenClaw runtime");
assert(run.sessionId, "run payload 应包含 MNote sessionId");
const visibleAssistantTexts = await page.locator(".wolai-page-ai-message--assistant").allTextContents();
const markerAssistantCount = visibleAssistantTexts.filter((text) => text.includes(marker)).length;
assert.strictEqual(markerAssistantCount, 1, "MNote 可见 provider 回复应只有一条");
const sessionId = String(run.sessionId);
const bindingRows = sqliteJson(
`SELECT mnote_session_id, remote_conversation_id, status FROM ai_external_conversation_bindings WHERE user_id=${sqlQuote(actorId)} AND mnote_session_id=${sqlQuote(sessionId)} AND provider=${sqlQuote(provider.expectedProvider)} ORDER BY updated_at DESC LIMIT 1;`,
[],
);
assert(bindingRows && bindingRows.length === 1, "SQLite 应保存远端会话绑定");
assert.strictEqual(bindingRows[0].status, "active", "删除前 binding 应为 active");
const remoteConversationId = bindingRows[0].remote_conversation_id;
assert(remoteConversationId, "binding 应包含 remote_conversation_id");
providerCaptures.afterMessage = await captureProvider(remoteConversationId, `${providerKey}-after-message`);
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="history"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-session-row="${sessionId}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
page.once("dialog", async (dialog) => {
await dialog.accept();
});
await page.locator(`[data-page-ai-session-delete="${sessionId}"]`).click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(id) => !document.querySelector(`[data-page-ai-session-row="${CSS.escape(id)}"]`),
sessionId,
{ timeout: UI_TIMEOUT_MS },
);
screenshots.afterDelete = await saveScreenshot(page, "after-delete");
assert(deleteResponses.length >= 1, "MNote 应发出历史会话 DELETE 请求");
assert.strictEqual(deleteResponses.at(-1).status, 200, "MNote 历史会话 DELETE 应成功");
const deleteBody = JSON.parse(deleteResponses.at(-1).body || "{}");
assert.strictEqual(
deleteBody?.result?.providerConversationDelete?.status,
"remote_deleted",
"provider 删除应返回 remote_deleted",
);
const providerDeleteMode = deleteBody?.result?.providerConversationDelete?.response?.result?.providerDeleteMode;
if (providerKey === "deepseek") {
assert.strictEqual(
providerDeleteMode,
"deepseek_chat_session_delete_api",
"DeepSeek 删除应明确走 chat_session_delete_api 并由网页复核",
);
} else {
assert.strictEqual(
providerDeleteMode,
"gemini_conversation_menu",
"Gemini 删除应走网页对话菜单确认路径",
);
}
await page.waitForTimeout(1500);
providerCaptures.afterDelete = await captureProvider(remoteConversationId, `${providerKey}-after-delete`);
assert.strictEqual(
providerCaptures.afterDelete.conversationLinkCount,
0,
"provider 删除后网页历史中不应继续存在本轮远端会话链接",
);
const deletedRows = sqliteJson(
`SELECT mnote_session_id, remote_conversation_id, status, metadata_json FROM ai_external_conversation_bindings WHERE user_id=${sqlQuote(actorId)} AND mnote_session_id=${sqlQuote(sessionId)} AND provider=${sqlQuote(provider.expectedProvider)} ORDER BY updated_at DESC LIMIT 1;`,
[],
);
assert(deletedRows && deletedRows.length === 1, "删除后 binding 仍应可审计");
assert.strictEqual(deletedRows[0].status, "remote_deleted", "删除后 binding 应标记 remote_deleted");
const providerLog = readLogSince(provider.gatewayLog, gatewayLogOffset);
let providerSendCount = countMatches(providerLog, provider.sendLogPattern);
if (providerKey === "gemini" && providerSendCount === 0) {
const fullProviderLog = readLogSince(provider.gatewayLog, 0);
providerSendCount = countMatches(fullProviderLog, new RegExp(escapeRegExp(marker), "g"));
}
assert.strictEqual(providerSendCount, 1, "provider 本轮只能收到一次发送动作");
fs.writeFileSync(
RESULT_PATH,
`${JSON.stringify({
ok: true,
provider: providerKey,
marker,
sessionId,
remoteConversationId,
providerSendCount,
deleteResponse: deleteResponses.at(-1),
bindingBefore: bindingRows[0],
bindingAfter: deletedRows[0],
screenshots,
providerCaptures,
}, null, 2)}\n`,
"utf8",
);
} catch (error) {
caughtError = error;
screenshots.failure = await saveScreenshot(page, "failure").catch(() => "");
fs.writeFileSync(
RESULT_PATH,
`${JSON.stringify({
ok: false,
provider: providerKey,
marker,
error: error instanceof Error ? error.stack || error.message : String(error),
runRequests,
deleteResponses,
screenshots,
gatewayLog: readLogSince(provider.gatewayLog, gatewayLogOffset).slice(-8000),
}, null, 2)}\n`,
"utf8",
);
} finally {
await browser.close().catch(() => {});
}
if (caughtError) throw caughtError;
console.log(JSON.stringify(JSON.parse(fs.readFileSync(RESULT_PATH, "utf8")), null, 2));
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,141 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const { spawn } = require("node:child_process");
const net = require("node:net");
const { chromium } = require("playwright");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_UI_TIMEOUT_MS || 45000);
function findFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const port = server.address().port;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
async function waitForGateway(baseUrl, timeoutMs = 120000) {
const deadline = Date.now() + timeoutMs;
let lastError = "";
while (Date.now() < deadline) {
try {
const response = await fetch(`${baseUrl}/`, {
headers: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
},
});
if (response.status === 200 || response.status === 303) return;
lastError = `${response.status} ${await response.text().catch(() => "")}`;
} catch (error) {
lastError = error && error.message ? error.message : String(error);
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`mnote-web 未就绪: ${lastError}`);
}
function startGateway(port, devHot) {
const env = {
...process.env,
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ALLOW_DEV_FIXTURES: "1",
MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
};
delete env.MNOTE_WEB_DEV_HOT_RELOAD;
if (devHot) env.MNOTE_WEB_DEV_HOT_RELOAD = "1";
const child = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: "/mnt/Data1T/mnote/rust",
env,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
child.stdout.on("data", () => {});
return {
child,
stderr: () => stderr,
};
}
async function stopGateway(gateway) {
if (!gateway || gateway.child.killed) return;
gateway.child.kill("SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 500));
if (!gateway.child.killed) gateway.child.kill("SIGKILL");
}
async function verifyCase(devHot) {
const port = await findFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const gateway = startGateway(port, devHot);
try {
await waitForGateway(baseUrl);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
extraHTTPHeaders: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const hotReloadRequests = [];
const networkFailures = [];
const consoleErrors = [];
page.on("request", (request) => {
if (request.url().includes("/api/dev/hot-reload")) hotReloadRequests.push(request.url());
});
page.on("requestfailed", (request) => {
networkFailures.push({ url: request.url(), failure: request.failure()?.errorText || "" });
});
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
await page.goto(`${baseUrl}/`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('body[data-mnote-shell="workspace"]').waitFor({ state: "attached", timeout: UI_TIMEOUT_MS });
await page.waitForTimeout(1800);
const html = await page.content();
const shell = await page.locator("body").getAttribute("data-mnote-shell");
const devHotAttr = await page.locator("html").getAttribute("data-mnote-dev-hot-reload").catch(() => null);
await browser.close();
return {
devHot,
baseUrl,
shell,
scriptHasDevHot: html.includes("sidebar-tree-runtime.js?devHot="),
hotReloadRequestCount: hotReloadRequests.length,
hotReloadAttr: devHotAttr || "",
networkFailures,
consoleErrors,
};
} finally {
await stopGateway(gateway);
}
}
async function main() {
const normal = await verifyCase(false);
const hot = await verifyCase(true);
assert.equal(normal.shell, "workspace", JSON.stringify(normal));
assert.equal(normal.scriptHasDevHot, false, JSON.stringify(normal));
assert.equal(normal.hotReloadRequestCount, 0, JSON.stringify(normal));
assert.equal(hot.shell, "workspace", JSON.stringify(hot));
assert.equal(hot.scriptHasDevHot, true, JSON.stringify(hot));
assert(hot.hotReloadRequestCount >= 1, JSON.stringify(hot));
assert.equal(hot.hotReloadAttr, "enabled", JSON.stringify(hot));
console.log(JSON.stringify({ ok: true, task: "task514-sidebar-dev-hot-reload-gating-smoke", normal, hot }, null, 2));
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,188 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
async function requestJson(path, init = {}) {
const response = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
"content-type": "application/json",
"x-mnote-actor-id": "user_1",
...(init.headers || {}),
},
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch (_) {
payload = { raw: text };
}
return {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
payload,
};
}
function callPayload(sessionId, args, toolName = "mnote.onlyoffice.sheet.set_value") {
return {
toolName,
workspaceId: "ws_demo",
documentId: "doc_1",
actorId: "user_1",
sessionId,
runId: `${sessionId}-run`,
toolCallId: `${sessionId}-call`,
traceId: `${sessionId}-trace`,
idempotencyKey: `${sessionId}-idem`,
dryRun: true,
capabilityScope: ["office.write"],
args,
};
}
async function main() {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const bridgeSessionId = `mnote-oo-http-${suffix}`;
const bridgeToken = `token-${suffix}`;
const register = await requestJson("/api/onlyoffice/bridge/session", {
method: "POST",
body: JSON.stringify({
sessionId: bridgeSessionId,
token: bridgeToken,
editorType: "cell",
documentId: "doc_http",
assetId: "asset_http_allowed",
fileType: "xlsx",
}),
});
assert.equal(register.status, 200, JSON.stringify(register));
assert.equal(register.payload.sessionId, bridgeSessionId);
const implicit = await requestJson("/api/hermes/tools/mnote/call", {
method: "POST",
body: JSON.stringify(callPayload("sess_http_implicit", {
address: "A1",
value: "after",
})),
});
assert.equal(implicit.status, 400, JSON.stringify(implicit));
assert.equal(implicit.headers["x-error-code"], "mnote_onlyoffice_session_explicit_required");
const forbidden = await requestJson("/api/hermes/tools/mnote/call", {
method: "POST",
body: JSON.stringify(callPayload("sess_http_forbidden", {
onlyofficeSessionId: bridgeSessionId,
address: "A1",
value: "after",
aiAccessScope: {
permissionLevel: "read_write",
allowedResourceIds: ["asset_other"],
},
})),
});
assert.equal(forbidden.status, 403, JSON.stringify(forbidden));
assert.equal(forbidden.headers["x-error-code"], "mnote_onlyoffice_resource_scope_forbidden");
const missingScope = await requestJson("/api/hermes/tools/mnote/call", {
method: "POST",
body: JSON.stringify(callPayload("sess_http_missing_scope", {
onlyofficeSessionId: bridgeSessionId,
address: "A1",
value: "after",
})),
});
assert.equal(missingScope.status, 403, JSON.stringify(missingScope));
assert.equal(missingScope.headers["x-error-code"], "mnote_onlyoffice_resource_scope_required");
const allowed = await requestJson("/api/hermes/tools/mnote/call", {
method: "POST",
body: JSON.stringify(callPayload("sess_http_allowed", {
onlyofficeSessionId: bridgeSessionId,
address: "A1",
value: "after",
aiAccessScope: {
permissionLevel: "read_write",
allowedResourceIds: ["asset_http_allowed"],
},
})),
});
assert.equal(allowed.status, 200, JSON.stringify(allowed));
assert.equal(allowed.payload.result.schema, "mnote.onlyoffice.action_plan.v1");
assert.equal(allowed.payload.result.sessionId, bridgeSessionId);
assert.equal(allowed.payload.result.action, "sheet.set_value");
assert.equal(allowed.payload.audit.effect, "dry_run");
const currentImplicit = await requestJson("/api/hermes/tools/mnote/call", {
method: "POST",
body: JSON.stringify(callPayload("sess_http_current_implicit", {
aiAccessScope: {
permissionLevel: "read",
allowedResourceIds: ["asset_http_allowed"],
},
}, "mnote.onlyoffice.session.current")),
});
assert.equal(currentImplicit.status, 400, JSON.stringify(currentImplicit));
assert.equal(currentImplicit.headers["x-error-code"], "mnote_onlyoffice_session_explicit_required");
const currentForbidden = await requestJson("/api/hermes/tools/mnote/call", {
method: "POST",
body: JSON.stringify(callPayload("sess_http_current_forbidden", {
onlyofficeSessionId: bridgeSessionId,
aiAccessScope: {
permissionLevel: "read",
allowedResourceIds: ["asset_other"],
},
}, "mnote.onlyoffice.session.current")),
});
assert.equal(currentForbidden.status, 403, JSON.stringify(currentForbidden));
assert.equal(currentForbidden.headers["x-error-code"], "mnote_onlyoffice_resource_scope_forbidden");
const currentAllowed = await requestJson("/api/hermes/tools/mnote/call", {
method: "POST",
body: JSON.stringify(callPayload("sess_http_current_allowed", {
onlyofficeSessionId: bridgeSessionId,
aiAccessScope: {
permissionLevel: "read",
allowedResourceIds: ["asset_http_allowed"],
},
}, "mnote.onlyoffice.session.current")),
});
assert.equal(currentAllowed.status, 200, JSON.stringify(currentAllowed));
assert.equal(currentAllowed.payload.result.schema, "mnote.onlyoffice.session.v1");
assert.equal(currentAllowed.payload.result.session.sessionId, bridgeSessionId);
assert.equal(currentAllowed.payload.result.session.assetId, "asset_http_allowed");
console.log(JSON.stringify({
ok: true,
task: "task515-onlyoffice-live-scope-http-smoke",
baseUrl: BASE_URL,
bridgeSessionId,
implicit: { status: implicit.status, code: implicit.headers["x-error-code"] },
forbidden: { status: forbidden.status, code: forbidden.headers["x-error-code"] },
missingScope: { status: missingScope.status, code: missingScope.headers["x-error-code"] },
allowed: {
status: allowed.status,
schema: allowed.payload.result.schema,
action: allowed.payload.result.action,
audit: allowed.payload.audit.effect,
},
currentImplicit: { status: currentImplicit.status, code: currentImplicit.headers["x-error-code"] },
currentForbidden: { status: currentForbidden.status, code: currentForbidden.headers["x-error-code"] },
currentAllowed: {
status: currentAllowed.status,
schema: currentAllowed.payload.result.schema,
sessionId: currentAllowed.payload.result.session.sessionId,
assetId: currentAllowed.payload.result.session.assetId,
},
}, null, 2));
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,194 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_UI_TIMEOUT_MS || 45000);
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const consoleErrors = [];
const networkFailures = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("requestfailed", (request) => {
networkFailures.push({ url: request.url(), failure: request.failure()?.errorText || "" });
});
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const result = await page.evaluate(async () => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const sessionA = `mnote-oo-browser-a-${suffix}`;
const sessionB = `mnote-oo-browser-b-${suffix}`;
const tokenA = `token-a-${suffix}`;
const tokenB = `token-b-${suffix}`;
async function postJson(path, payload) {
const response = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
const body = await response.json().catch(() => null);
return { status: response.status, body };
}
async function getJson(path) {
const response = await fetch(path);
const body = response.status === 204 ? null : await response.json().catch(() => null);
return { status: response.status, body };
}
const registeredA = await postJson("/api/onlyoffice/bridge/session", {
sessionId: sessionA,
token: tokenA,
editorType: "word",
documentId: "doc_a",
assetId: "asset_a",
fileType: "docx",
docKey: "doc_key_a",
pageOrigin: location.origin,
});
const registeredB = await postJson("/api/onlyoffice/bridge/session", {
sessionId: sessionB,
token: tokenB,
editorType: "word",
documentId: "doc_b",
assetId: "asset_b",
fileType: "docx",
docKey: "doc_key_b",
pageOrigin: location.origin,
});
const wrongToken = await getJson(`/api/onlyoffice/bridge/commands/next?sessionId=${encodeURIComponent(sessionA)}&token=${encodeURIComponent(tokenB)}&timeoutMs=1`);
const commandA = await postJson("/api/onlyoffice/bridge/commands", {
sessionId: sessionA,
token: tokenA,
action: "document.insert_text",
payload: { text: "A" },
});
const commandB = await postJson("/api/onlyoffice/bridge/commands", {
sessionId: sessionB,
token: tokenB,
action: "document.insert_text",
payload: { text: "B" },
});
const nextA = await getJson(`/api/onlyoffice/bridge/commands/next?sessionId=${encodeURIComponent(sessionA)}&token=${encodeURIComponent(tokenA)}&timeoutMs=1000`);
const nextB = await getJson(`/api/onlyoffice/bridge/commands/next?sessionId=${encodeURIComponent(sessionB)}&token=${encodeURIComponent(tokenB)}&timeoutMs=1000`);
const postedA = await postJson("/api/onlyoffice/bridge/results", {
sessionId: sessionA,
token: tokenA,
id: nextA.body && nextA.body.id,
ok: true,
result: { text: "result-a" },
});
const postedB = await postJson("/api/onlyoffice/bridge/results", {
sessionId: sessionB,
token: tokenB,
id: nextB.body && nextB.body.id,
ok: true,
result: { text: "result-b" },
});
const resultA = await getJson(`/api/onlyoffice/bridge/results?sessionId=${encodeURIComponent(sessionA)}&token=${encodeURIComponent(tokenA)}&commandId=${encodeURIComponent(nextA.body && nextA.body.id)}&timeoutMs=1000`);
const resultB = await getJson(`/api/onlyoffice/bridge/results?sessionId=${encodeURIComponent(sessionB)}&token=${encodeURIComponent(tokenB)}&commandId=${encodeURIComponent(nextB.body && nextB.body.id)}&timeoutMs=1000`);
const sessions = await getJson("/api/onlyoffice/bridge/sessions");
const currentBeforeClose = await getJson("/api/onlyoffice/bridge/session/current");
const closeA = await postJson("/api/onlyoffice/bridge/session/close", {
sessionId: sessionA,
token: tokenA,
});
const nextAAfterClose = await getJson(`/api/onlyoffice/bridge/commands/next?sessionId=${encodeURIComponent(sessionA)}&token=${encodeURIComponent(tokenA)}&timeoutMs=1`);
const sessionsAfterClose = await getJson("/api/onlyoffice/bridge/sessions");
return {
sessionA,
sessionB,
registeredA,
registeredB,
wrongToken,
commandA,
commandB,
nextA,
nextB,
postedA,
postedB,
resultA,
resultB,
sessions,
currentBeforeClose,
closeA,
nextAAfterClose,
sessionsAfterClose,
};
});
assert.equal(result.registeredA.status, 200, JSON.stringify(result.registeredA));
assert.equal(result.registeredB.status, 200, JSON.stringify(result.registeredB));
assert.equal(result.registeredA.body.docKey, "doc_key_a");
assert.equal(result.registeredB.body.docKey, "doc_key_b");
assert.equal(result.registeredA.body.pageOrigin, BASE_URL);
assert.equal(result.registeredB.body.pageOrigin, BASE_URL);
assert.equal(result.wrongToken.status, 401, JSON.stringify(result.wrongToken));
assert.equal(result.commandA.status, 200, JSON.stringify(result.commandA));
assert.equal(result.commandB.status, 200, JSON.stringify(result.commandB));
assert.equal(result.nextA.status, 200, JSON.stringify(result.nextA));
assert.equal(result.nextB.status, 200, JSON.stringify(result.nextB));
assert.equal(result.nextA.body.action, "document.insert_text");
assert.equal(result.nextB.body.action, "document.insert_text");
assert.equal(result.nextA.body.payload.text, "A");
assert.equal(result.nextB.body.payload.text, "B");
assert.notEqual(result.nextA.body.id, result.nextB.body.id);
assert.equal(result.postedA.status, 200, JSON.stringify(result.postedA));
assert.equal(result.postedB.status, 200, JSON.stringify(result.postedB));
assert.equal(result.resultA.status, 200, JSON.stringify(result.resultA));
assert.equal(result.resultB.status, 200, JSON.stringify(result.resultB));
assert.equal(result.resultA.body.result.text, "result-a");
assert.equal(result.resultB.body.result.text, "result-b");
const sessionARecord = result.sessions.body.sessions.find((session) => session.sessionId === result.sessionA);
const sessionBRecord = result.sessions.body.sessions.find((session) => session.sessionId === result.sessionB);
assert(sessionARecord, JSON.stringify(result.sessions));
assert(sessionBRecord, JSON.stringify(result.sessions));
assert.equal(sessionARecord.docKey, "doc_key_a");
assert.equal(sessionBRecord.pageOrigin, BASE_URL);
assert.equal(result.currentBeforeClose.status, 200, JSON.stringify(result.currentBeforeClose));
assert(result.currentBeforeClose.body.session, JSON.stringify(result.currentBeforeClose));
assert.equal(result.closeA.status, 200, JSON.stringify(result.closeA));
assert.equal(result.closeA.body.closed, true, JSON.stringify(result.closeA));
assert.equal(result.nextAAfterClose.status, 401, JSON.stringify(result.nextAAfterClose));
assert(!result.sessionsAfterClose.body.sessions.some((session) => session.sessionId === result.sessionA));
assert(result.sessionsAfterClose.body.sessions.some((session) => session.sessionId === result.sessionB));
await browser.close();
console.log(JSON.stringify({
ok: true,
task: "task516-onlyoffice-bridge-multisession-browser-smoke",
baseUrl: BASE_URL,
sessionA: result.sessionA,
sessionB: result.sessionB,
wrongToken: { status: result.wrongToken.status, code: result.wrongToken.body && result.wrongToken.body.code },
nextA: { id: result.nextA.body.id, text: result.nextA.body.payload.text },
nextB: { id: result.nextB.body.id, text: result.nextB.body.payload.text },
resultA: result.resultA.body.result,
resultB: result.resultB.body.result,
currentBeforeClose: {
status: result.currentBeforeClose.status,
sessionId: result.currentBeforeClose.body.session && result.currentBeforeClose.body.session.sessionId,
},
closeA: result.closeA.body,
consoleErrors,
networkFailures,
}, null, 2));
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,229 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const { spawn } = require("node:child_process");
const net = require("node:net");
const { chromium } = require("playwright");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_UI_TIMEOUT_MS || 45000);
function findFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const port = server.address().port;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
async function waitForGateway(baseUrl, timeoutMs = 120000) {
const deadline = Date.now() + timeoutMs;
let lastError = "";
while (Date.now() < deadline) {
try {
const response = await fetch(`${baseUrl}/`);
if (response.status === 200 || response.status === 303) return;
lastError = `${response.status} ${await response.text().catch(() => "")}`;
} catch (error) {
lastError = error && error.message ? error.message : String(error);
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`mnote-web 未就绪: ${lastError}`);
}
function startGateway(port) {
const env = {
...process.env,
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ALLOW_DEV_FIXTURES: "1",
MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
};
const child = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: "/mnt/Data1T/mnote/rust",
env,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
child.stdout.on("data", () => {});
return {
child,
stderr: () => stderr,
};
}
async function stopGateway(gateway) {
if (!gateway || gateway.child.killed) return;
gateway.child.kill("SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 500));
if (!gateway.child.killed) gateway.child.kill("SIGKILL");
}
async function requestJson(baseUrl, path, init = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: {
"content-type": "application/json",
...(init.headers || {}),
},
});
const body = response.status === 204 ? null : await response.json().catch(() => null);
return { status: response.status, body };
}
async function enqueueCommand(baseUrl, sessionId, token, action, payload) {
const response = await requestJson(baseUrl, "/api/onlyoffice/bridge/commands", {
method: "POST",
body: JSON.stringify({ sessionId, token, action, payload }),
});
assert.equal(response.status, 200, JSON.stringify(response));
assert(response.body && response.body.command && response.body.command.id, JSON.stringify(response));
return response.body.command.id;
}
async function readResult(baseUrl, sessionId, token, commandId) {
const query = new URLSearchParams({
sessionId,
token,
commandId,
timeoutMs: "5000",
});
const response = await requestJson(baseUrl, `/api/onlyoffice/bridge/results?${query.toString()}`);
assert.equal(response.status, 200, JSON.stringify(response));
assert.equal(response.body && response.body.ok, true, JSON.stringify(response));
return response.body.result;
}
async function waitForSession(baseUrl, sessionId, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
let lastResponse = null;
while (Date.now() < deadline) {
lastResponse = await requestJson(baseUrl, "/api/onlyoffice/bridge/sessions");
if (lastResponse.status === 200 && lastResponse.body && Array.isArray(lastResponse.body.sessions)) {
const found = lastResponse.body.sessions.find((session) => session.sessionId === sessionId);
if (found) return { response: lastResponse, session: found };
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`bridge session 未注册: ${JSON.stringify(lastResponse)}`);
}
async function main() {
const port = await findFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const gateway = startGateway(port);
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const sessionId = `mnote-oo-plugin-direct-${suffix}`;
const token = `token-${suffix}`;
const consoleErrors = [];
const networkFailures = [];
try {
await waitForGateway(baseUrl);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("requestfailed", (request) => {
networkFailures.push({ url: request.url(), failure: request.failure()?.errorText || "" });
});
await page.route("**/onlyoffice-server/sdkjs-plugins/v1/plugins.js", async (route) => {
await route.fulfill({
contentType: "application/javascript; charset=utf-8",
body: `
window.__MNOTE_ONLYOFFICE_PLUGIN_MOCK__ = { executeCalls: [], commandCalls: [] };
window.Asc = {
scope: {},
plugin: {
info: { editorType: "word" },
executeMethod(name, args, callback) {
window.__MNOTE_ONLYOFFICE_PLUGIN_MOCK__.executeCalls.push({ name, args });
const value = name === "GetSelectedText"
? "mock selected text"
: name === "PasteText"
? true
: name === "ConvertDocument"
? "# mock document"
: null;
callback(value);
},
callCommand(func, _close, _calc, callback) {
window.__MNOTE_ONLYOFFICE_PLUGIN_MOCK__.commandCalls.push({ scope: window.Asc.scope.mnoteBridge || {} });
callback(func());
},
init: null,
button: null
}
};
`,
});
});
const pluginUrl = new URL(`${baseUrl}/api/onlyoffice/bridge/plugin/index`);
pluginUrl.searchParams.set("sessionId", sessionId);
pluginUrl.searchParams.set("token", token);
pluginUrl.searchParams.set("apiBase", baseUrl);
pluginUrl.searchParams.set("documentId", "doc_plugin_direct");
pluginUrl.searchParams.set("assetId", "asset_plugin_direct");
pluginUrl.searchParams.set("fileType", "docx");
pluginUrl.searchParams.set("docKey", "doc_key_plugin_direct");
pluginUrl.searchParams.set("pageOrigin", baseUrl);
await page.goto(pluginUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.evaluate(() => window.Asc.plugin.init());
const { session: registeredSession } = await waitForSession(baseUrl, sessionId);
assert.equal(registeredSession.docKey, "doc_key_plugin_direct", JSON.stringify(registeredSession));
assert.equal(registeredSession.pageOrigin, baseUrl, JSON.stringify(registeredSession));
const wrongToken = await requestJson(
baseUrl,
`/api/onlyoffice/bridge/commands/next?sessionId=${encodeURIComponent(sessionId)}&token=wrong-${encodeURIComponent(token)}&timeoutMs=1`,
);
assert.equal(wrongToken.status, 401, JSON.stringify(wrongToken));
const selectionCommandId = await enqueueCommand(baseUrl, sessionId, token, "selection.get", {});
const selection = await readResult(baseUrl, sessionId, token, selectionCommandId);
assert.equal(selection.editorType, "word", JSON.stringify(selection));
assert.equal(selection.text, "mock selected text", JSON.stringify(selection));
const insertCommandId = await enqueueCommand(baseUrl, sessionId, token, "document.insert_text", { text: "hello" });
const inserted = await readResult(baseUrl, sessionId, token, insertCommandId);
assert.equal(inserted.value, true, JSON.stringify(inserted));
const mockState = await page.evaluate(() => window.__MNOTE_ONLYOFFICE_PLUGIN_MOCK__);
assert(mockState.executeCalls.some((call) => call.name === "GetSelectedText"), JSON.stringify(mockState));
assert(mockState.executeCalls.some((call) => call.name === "PasteText"), JSON.stringify(mockState));
await browser.close();
console.log(JSON.stringify({
ok: true,
task: "task517-onlyoffice-bridge-plugin-direct-smoke",
baseUrl,
sessionId,
wrongToken: { status: wrongToken.status, code: wrongToken.body && wrongToken.body.code },
registeredSession,
selection,
inserted,
executeCalls: mockState.executeCalls.map((call) => call.name),
consoleErrors,
networkFailures,
}, null, 2));
} finally {
await stopGateway(gateway);
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,471 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task518-onlyoffice-real-iframe-session-scope-smoke";
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 120_000);
const PROBE_DOCX_PATH = process.env.MNOTE_ONLYOFFICE_PROBE_DOCX
|| "/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const SCREENSHOT_DIR = path.join(OUT_DIR, "screenshots");
const RESULT_PATH = path.join(OUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localOfficeFileUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/api/local-folder/files/open`);
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("path", relativePath);
return url.toString();
}
function onlyofficeUrl(root, relativePath, assetId) {
const url = new URL(`${BASE_URL}/onlyoffice`);
url.searchParams.set("fileUrl", localOfficeFileUrl(root, relativePath));
url.searchParams.set("fileName", path.basename(relativePath));
url.searchParams.set("fileType", "docx");
url.searchParams.set("assetId", assetId);
url.searchParams.set("documentId", "local-md:Page~2FPage.md");
url.searchParams.set("mode", "edit");
return url.toString();
}
function bridgeToolPayload(sessionId, assetId, allowedResourceIds, toolName = "mnote.onlyoffice.document.insert_text", options = {}) {
return {
toolName,
workspaceId: "local-ws:task518",
documentId: "local-md:Page~2FPage.md",
actorId: "user_1",
sessionId: `task518-${Date.now()}-${Math.random().toString(36).slice(2)}`,
runId: "task518-run",
toolCallId: "task518-call",
traceId: "task518-trace",
idempotencyKey: `task518-${Date.now()}-${Math.random().toString(36).slice(2)}`,
dryRun: options.dryRun !== false,
capabilityScope: ["office.write"],
args: {
onlyofficeSessionId: sessionId,
text: options.text || "task518 dry run",
aiAccessScope: {
permissionLevel: "read_write",
allowedResourceIds,
},
},
};
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname !== "/auth", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
}
async function waitForOfficeReady(page, label) {
await page.waitForFunction(
() => {
const debug = window.__MNOTE_ONLYOFFICE_DEBUG__ || null;
const frameCount = document.querySelectorAll("#onlyoffice-frame iframe, #onlyoffice-frame canvas").length;
return Boolean(debug && debug.bridgeSessionId && debug.bridgeToken)
&& Boolean(window.__MNOTE_ONLYOFFICE_EDITOR__)
&& (Boolean(window.__MNOTE_ONLYOFFICE_READY__) || frameCount > 0);
},
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.screenshot({ path: path.join(SCREENSHOT_DIR, `${label}.png`), fullPage: true });
return await page.evaluate(() => ({
url: location.href,
ready: Boolean(window.__MNOTE_ONLYOFFICE_READY__),
editorExists: Boolean(window.__MNOTE_ONLYOFFICE_EDITOR__),
frameCount: document.querySelectorAll("#onlyoffice-frame iframe, #onlyoffice-frame canvas").length,
errorVisible: document.getElementById("onlyoffice-error")?.getAttribute("data-visible") || "",
debug: window.__MNOTE_ONLYOFFICE_DEBUG__ || null,
errorLog: Array.isArray(window.__MNOTE_ONLYOFFICE_ERRLOG__) ? window.__MNOTE_ONLYOFFICE_ERRLOG__.slice() : [],
}));
}
async function readBridgeSessions(context) {
const response = await context.request.fetch(`${BASE_URL}/api/onlyoffice/bridge/sessions`, {
method: "GET",
timeout: UI_TIMEOUT_MS,
});
const body = await response.json().catch(async () => ({ raw: await response.text() }));
return {
status: response.status(),
body,
};
}
async function waitForBridgeSession(context, sessionId) {
const deadline = Date.now() + UI_TIMEOUT_MS;
let last = null;
while (Date.now() < deadline) {
last = await readBridgeSessions(context);
if (
last.status === 200
&& Array.isArray(last.body?.sessions)
&& last.body.sessions.some((session) => session.sessionId === sessionId)
) {
return last;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`bridge session 未注册: ${sessionId}; last=${JSON.stringify(last)}`);
}
async function runBridgeSelection(page) {
return await page.evaluate(async () => {
return await window.__MNOTE_ONLYOFFICE_BRIDGE__.run("selection.get", {}, 30_000);
});
}
async function runBridgeExport(page, format = "html") {
return await page.evaluate(async (selectedFormat) => {
return await window.__MNOTE_ONLYOFFICE_BRIDGE__.run("document.export", { format: selectedFormat }, 30_000);
}, format);
}
async function postToolCall(context, payload) {
const response = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-actor-id": "user_1",
},
data: payload,
timeout: UI_TIMEOUT_MS,
});
const body = await response.json().catch(async () => ({ raw: await response.text() }));
return {
status: response.status(),
headers: response.headers(),
body,
};
}
function classifyOnlyOfficeSignals(result, states, selections, scopeChecks) {
const bridgeTranslation404 = result.httpErrors.filter((entry) => (
/\/api\/onlyoffice\/bridge\/plugin\/index\/translations\//.test(entry.url)
&& entry.status === 404
));
const mnoteBridgePluginHttpErrors = result.httpErrors.filter((entry) => (
/\/api\/onlyoffice\/bridge\/plugin\//.test(entry.url)
&& !/\/api\/onlyoffice\/bridge\/plugin\/index\/translations\//.test(entry.url)
));
const builtinPluginNoise = result.httpErrors.filter((entry) => (
/\/sdkjs-plugins\/|custom.?assistant|annotation/i.test(entry.url)
));
const bridgeConsoleNoise = result.consoleErrors.filter((entry) => (
/\/api\/onlyoffice\/bridge\/plugin\/index\/translations\//.test(entry.text)
|| /Failed to load resource: the server responded with a status of 404/i.test(entry.text)
));
const errorCodeMinus18 = [
...result.consoleErrors.map((entry) => entry.text || ""),
...states.flatMap((state) => Array.isArray(state.errorLog) ? state.errorLog.map((item) => JSON.stringify(item)) : []),
].some((text) => /errorCode["'=:\s-]*-18|\b-18\b/.test(text));
const mainDocumentFailures = [
...result.networkFailures.filter((entry) => (
!/\/api\/onlyoffice\/bridge\/commands\/next/.test(entry.url)
)),
...result.httpErrors.filter((entry) => {
if (/\/api\/onlyoffice\/bridge\/plugin\/index\/translations\//.test(entry.url)) return false;
if (/\/sdkjs-plugins\/|custom.?assistant|annotation/i.test(entry.url)) return false;
if (/\/api\/onlyoffice\/bridge\/plugin\//.test(entry.url)) return false;
if (/\/api\/onlyoffice\/bridge\/commands\/next/.test(entry.url)) return false;
return /\/onlyoffice-server\/|\/api\/onlyoffice\/proxy|\/api\/local-folder\/files\/open/.test(entry.url);
}),
...states.filter((state) => state.errorVisible || !state.editorExists || !state.ready).map((state) => ({
url: state.url,
ready: state.ready,
editorExists: state.editorExists,
errorVisible: state.errorVisible,
})),
];
return {
mnoteBridgePlugin: {
configUrls: states.map((state) => state.debug?.bridgePluginConfigUrl || ""),
httpErrors: mnoteBridgePluginHttpErrors,
sessionsRegistered: states.every((state) => (
result.bridgeSessionsFinal?.body?.sessions || []
).some((session) => session.sessionId === state.debug?.bridgeSessionId)),
commandLoopOk: scopeChecks?.allowedBDryRun?.status === 200
&& scopeChecks?.allowedBWrite?.status === 200,
},
bridgeTranslationNoise: {
count: bridgeTranslation404.length,
urls: Array.from(new Set(bridgeTranslation404.map((entry) => entry.url))),
},
builtinPluginNoise: {
count: builtinPluginNoise.length,
urls: Array.from(new Set(builtinPluginNoise.map((entry) => entry.url))),
},
mainDocument: {
ready: mainDocumentFailures.length === 0 && selections.every((selection) => selection?.editorType === "word"),
failures: mainDocumentFailures,
errorCodeMinus18,
},
consoleNoise: {
bridgeTranslationOrGeneric404Count: bridgeConsoleNoise.length,
},
};
}
async function main() {
assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测 docx: ${PROBE_DOCX_PATH}`);
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task518-onlyoffice-"));
fs.mkdirSync(path.join(root, "Page"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote-placeholder"), "task518\n", "utf8");
fs.copyFileSync(PROBE_DOCX_PATH, path.join(root, "Page", "office-a.docx"));
fs.copyFileSync(PROBE_DOCX_PATH, path.join(root, "Page", "office-b.docx"));
const consoleErrors = [];
const networkFailures = [];
const httpErrors = [];
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
executablePath: CHROMIUM_EXECUTABLE_PATH,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const authPage = await context.newPage();
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
root,
screenshots: [],
consoleErrors,
networkFailures,
httpErrors,
};
try {
await quickLogin(authPage);
await authPage.close();
const pageA = await context.newPage();
const pageADuplicate = await context.newPage();
const pageB = await context.newPage();
for (const page of [pageA, pageADuplicate, pageB]) {
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push({ url: page.url(), text: message.text() });
});
page.on("requestfailed", (request) => {
networkFailures.push({ url: request.url(), failure: request.failure()?.errorText || "" });
});
page.on("response", (response) => {
if (response.status() >= 400) {
httpErrors.push({ url: response.url(), status: response.status() });
}
});
}
const assetA = "local-file:Page/office-a.docx";
const assetB = "local-file:Page/office-b.docx";
await pageA.goto(onlyofficeUrl(root, "Page/office-a.docx", assetA), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
await pageADuplicate.goto(onlyofficeUrl(root, "Page/office-a.docx", assetA), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
await pageB.goto(onlyofficeUrl(root, "Page/office-b.docx", assetB), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
const stateA = await waitForOfficeReady(pageA, "office-a");
const stateADuplicate = await waitForOfficeReady(pageADuplicate, "office-a-duplicate");
const stateB = await waitForOfficeReady(pageB, "office-b");
result.screenshots.push(path.join(SCREENSHOT_DIR, "office-a.png"));
result.screenshots.push(path.join(SCREENSHOT_DIR, "office-a-duplicate.png"));
result.screenshots.push(path.join(SCREENSHOT_DIR, "office-b.png"));
assert.equal(stateA.errorVisible, "", `A 页面不应显示错误: ${JSON.stringify(stateA)}`);
assert.equal(stateADuplicate.errorVisible, "", `A duplicate 页面不应显示错误: ${JSON.stringify(stateADuplicate)}`);
assert.equal(stateB.errorVisible, "", `B 页面不应显示错误: ${JSON.stringify(stateB)}`);
assert(stateA.debug.bridgeSessionId !== stateB.debug.bridgeSessionId, `A/B sessionId 不应相同: ${JSON.stringify({ a: stateA.debug, b: stateB.debug })}`);
assert(
stateA.debug.bridgeSessionId !== stateADuplicate.debug.bridgeSessionId,
`同一 Office 文档双 tab sessionId 不应相同: ${JSON.stringify({ a: stateA.debug, duplicate: stateADuplicate.debug })}`,
);
assert.equal(stateA.debug.docKey, stateADuplicate.debug.docKey, `同一 Office 文档双 tab 应共享 docKey 但使用不同 session salt: ${JSON.stringify({ a: stateA.debug, duplicate: stateADuplicate.debug })}`);
assert.equal(stateA.debug.assetId, assetA, `A debug assetId 不匹配: ${JSON.stringify(stateA.debug)}`);
assert.equal(stateADuplicate.debug.assetId, assetA, `A duplicate debug assetId 不匹配: ${JSON.stringify(stateADuplicate.debug)}`);
assert.equal(stateB.debug.assetId, assetB, `B debug assetId 不匹配: ${JSON.stringify(stateB.debug)}`);
result.officeAInitial = stateA;
result.officeADuplicateInitial = stateADuplicate;
result.officeBInitial = stateB;
result.bridgeSessionsAfterReady = await readBridgeSessions(context);
await waitForBridgeSession(context, stateA.debug.bridgeSessionId);
await waitForBridgeSession(context, stateADuplicate.debug.bridgeSessionId);
await waitForBridgeSession(context, stateB.debug.bridgeSessionId);
result.bridgeSessionsFinal = await readBridgeSessions(context);
const selectionA = await runBridgeSelection(pageA);
const selectionADuplicate = await runBridgeSelection(pageADuplicate);
const selectionB = await runBridgeSelection(pageB);
assert.equal(selectionA.editorType, "word", `A selection 应来自 word editor: ${JSON.stringify(selectionA)}`);
assert.equal(selectionADuplicate.editorType, "word", `A duplicate selection 应来自 word editor: ${JSON.stringify(selectionADuplicate)}`);
assert.equal(selectionB.editorType, "word", `B selection 应来自 word editor: ${JSON.stringify(selectionB)}`);
const forbiddenBFromScopeA = await postToolCall(
context,
bridgeToolPayload(stateB.debug.bridgeSessionId, assetB, [assetA, `resource:onlyoffice:${stateA.debug.documentId}:${assetA}`]),
);
assert.equal(forbiddenBFromScopeA.status, 403, JSON.stringify(forbiddenBFromScopeA));
assert.equal(forbiddenBFromScopeA.headers["x-error-code"], "mnote_onlyoffice_resource_scope_forbidden");
const allowedBDryRun = await postToolCall(
context,
bridgeToolPayload(stateB.debug.bridgeSessionId, assetB, [assetB, `resource:onlyoffice:${stateB.debug.documentId}:${assetB}`]),
);
assert.equal(allowedBDryRun.status, 200, JSON.stringify(allowedBDryRun));
assert.equal(allowedBDryRun.body.result.schema, "mnote.onlyoffice.action_plan.v1");
assert.equal(allowedBDryRun.body.result.sessionId, stateB.debug.bridgeSessionId);
const writeMarker = `task518-write-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const allowedBWrite = await postToolCall(
context,
bridgeToolPayload(
stateB.debug.bridgeSessionId,
assetB,
[assetB, `resource:onlyoffice:${stateB.debug.documentId}:${assetB}`],
"mnote.onlyoffice.document.insert_text",
{ dryRun: false, text: writeMarker },
),
);
assert.equal(allowedBWrite.status, 200, JSON.stringify(allowedBWrite));
assert.equal(allowedBWrite.body.result.schema, "mnote.onlyoffice.action_result.v1");
assert.equal(allowedBWrite.body.result.sessionId, stateB.debug.bridgeSessionId);
await pageA.screenshot({ path: path.join(SCREENSHOT_DIR, "office-a-after-write.png"), fullPage: true });
await pageB.screenshot({ path: path.join(SCREENSHOT_DIR, "office-b-after-write.png"), fullPage: true });
result.screenshots.push(path.join(SCREENSHOT_DIR, "office-a-after-write.png"));
result.screenshots.push(path.join(SCREENSHOT_DIR, "office-b-after-write.png"));
const exportAAfterWrite = await runBridgeExport(pageA, "html");
const exportBAfterWrite = await runBridgeExport(pageB, "html");
const exportAText = String(exportAAfterWrite?.content || exportAAfterWrite?.result?.content || "");
const exportBText = String(exportBAfterWrite?.content || exportBAfterWrite?.result?.content || "");
assert(
exportBText.includes(writeMarker),
`B 导出内容应包含写入标记 ${writeMarker}: ${JSON.stringify(exportBAfterWrite).slice(0, 1000)}`,
);
assert(
!exportAText.includes(writeMarker),
`A 导出内容不应包含 B 写入标记 ${writeMarker}: ${JSON.stringify(exportAAfterWrite).slice(0, 1000)}`,
);
const scopeChecks = {
forbiddenBFromScopeA: {
status: forbiddenBFromScopeA.status,
code: forbiddenBFromScopeA.headers["x-error-code"],
},
allowedBDryRun: {
status: allowedBDryRun.status,
schema: allowedBDryRun.body.result.schema,
sessionId: allowedBDryRun.body.result.sessionId,
},
allowedBWrite: {
status: allowedBWrite.status,
schema: allowedBWrite.body.result.schema,
sessionId: allowedBWrite.body.result.sessionId,
commandId: allowedBWrite.body.result.commandId,
},
exportAfterWrite: {
writeMarker,
aContainsMarker: exportAText.includes(writeMarker),
bContainsMarker: exportBText.includes(writeMarker),
},
};
result.bridgeSessionsFinal = await readBridgeSessions(context);
const errorClassification = classifyOnlyOfficeSignals(
result,
[stateA, stateADuplicate, stateB],
[selectionA, selectionADuplicate, selectionB],
scopeChecks,
);
assert.equal(
errorClassification.mnoteBridgePlugin.httpErrors.length,
0,
`MNote bridge 插件 config/index 失败不能归为普通噪音: ${JSON.stringify(errorClassification.mnoteBridgePlugin.httpErrors)}`,
);
assert.equal(
errorClassification.mnoteBridgePlugin.sessionsRegistered,
true,
`MNote bridge 插件应完成 session 注册: ${JSON.stringify(errorClassification.mnoteBridgePlugin)}`,
);
assert.equal(
errorClassification.mnoteBridgePlugin.commandLoopOk,
true,
`MNote bridge 插件 command loop 应可用: ${JSON.stringify(errorClassification.mnoteBridgePlugin)}`,
);
assert.equal(
errorClassification.mainDocument.errorCodeMinus18,
false,
`errorCode=-18 必须作为主文档失败暴露: ${JSON.stringify(errorClassification.mainDocument)}`,
);
assert.equal(
errorClassification.mainDocument.ready,
true,
`主文档失败不能被插件噪音吞掉: ${JSON.stringify(errorClassification.mainDocument)}`,
);
Object.assign(result, {
ok: true,
errorClassification,
officeA: {
sessionId: stateA.debug.bridgeSessionId,
assetId: stateA.debug.assetId,
docKey: stateA.debug.docKey,
frameCount: stateA.frameCount,
selection: selectionA,
},
officeADuplicate: {
sessionId: stateADuplicate.debug.bridgeSessionId,
assetId: stateADuplicate.debug.assetId,
docKey: stateADuplicate.debug.docKey,
frameCount: stateADuplicate.frameCount,
selection: selectionADuplicate,
},
officeB: {
sessionId: stateB.debug.bridgeSessionId,
assetId: stateB.debug.assetId,
docKey: stateB.debug.docKey,
frameCount: stateB.frameCount,
selection: selectionB,
},
scopeChecks,
});
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
result.error = error && error.stack ? error.stack : String(error);
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
throw error;
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,296 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task520-page-ai-raw-resource-target-smoke";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
async function quickLogin(page, request) {
await ensureAuthenticated(page, request);
}
async function waitFiletreeRow(page, relativePath) {
return await page.waitForFunction(
(expectedRelativePath) => Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.some((row) => row.getAttribute("data-local-relative-path") === expectedRelativePath),
relativePath,
{ timeout: UI_TIMEOUT_MS },
);
}
async function clickFiletreeOpen(page, relativePath) {
const handle = await page.waitForFunction(
(expectedRelativePath) => {
const row = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.find((candidate) => candidate.getAttribute("data-local-relative-path") === expectedRelativePath);
return row?.querySelector('[data-rust-action="open"], .tree-link') || null;
},
relativePath,
{ timeout: UI_TIMEOUT_MS },
);
await handle.asElement().click();
}
async function expandFiletreeFolder(page, relativePath) {
await waitFiletreeRow(page, relativePath);
const expanded = await page.evaluate((expectedRelativePath) => {
const row = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.find((candidate) => candidate.getAttribute("data-local-relative-path") === expectedRelativePath);
return row?.getAttribute("aria-expanded") === "true";
}, relativePath);
if (!expanded) {
await clickFiletreeOpen(page, relativePath);
}
await page.waitForFunction(
(expectedRelativePath) => {
const row = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.find((candidate) => candidate.getAttribute("data-local-relative-path") === expectedRelativePath);
return row?.getAttribute("aria-expanded") === "true";
},
relativePath,
{ timeout: UI_TIMEOUT_MS },
);
}
async function saveScreenshot(page, name) {
const target = path.join(OUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task520`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task520-raw-target-"));
const rootUri = fileUrl(root);
const pagePath = "Page.md";
const rawPath = "Page/notes.txt";
const documentId = localMdDocumentId(pagePath);
const captured = [];
let caughtError = null;
fs.mkdirSync(path.join(root, "Page"), { recursive: true });
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, pagePath), "# Page\n\nTask520 page\n", "utf8");
fs.writeFileSync(path.join(root, rawPath), "Task520 raw resource target\n", "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
grants: [{
id: "grant_task520",
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "write",
recursive: true,
capabilities: ["ai", "markdown_edit"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
captured.push({ kind: "ui-preferences", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
});
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, gateway: { ok: true }, profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true } }),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, tools: [] }) });
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, agentId: "reasonix", profiles: [] }),
});
});
await page.route("**/api/hermes/client/profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, active: "mnoteai", profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }] }),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, sessionId: "mnote_task520", title: "task520", traceId: "trace_task520" }),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, sessionId: "mnote_task520", runId: "run_task520", events: [], traceId: "trace_run_task520" }),
});
});
await page.route("**/api/hermes/client/events/*", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body: `data: ${JSON.stringify({ event: "message.delta", run_id: "run_task520", delta: "Task520 response" })}\n\n`
+ `data: ${JSON.stringify({ event: "run.completed", run_id: "run_task520", output: "Task520 response" })}\n\n`,
});
});
await quickLogin(page, context.request);
const response = await page.goto(documentUrl(root, pagePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await expandFiletreeFolder(page, "Page");
await waitFiletreeRow(page, rawPath);
await clickFiletreeOpen(page, rawPath);
await page.waitForFunction(
(expectedPath) => {
const snapshot = window.__mnoteDocumentPaneRuntime?.getOpenEditorsSnapshot?.() || window.__mnoteOpenEditorsSnapshot || null;
return (snapshot?.resourceEditors || []).some((entry) => entry?.path === expectedPath && entry?.active === true);
},
rawPath,
{ timeout: UI_TIMEOUT_MS },
);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const targetChip = page.locator("[data-page-ai-target-chip]");
await targetChip.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const chipText = (await targetChip.innerText({ timeout: UI_TIMEOUT_MS })).trim();
assert(chipText.includes("notes.txt"), `raw resource 打开后 target chip 应指向 notes.txt: ${chipText}`);
await page.locator("[data-page-ai-input]").fill("Task520 raw target", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task520 response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const runs = captured.filter((item) => item.kind === "run");
assert(runs.length >= 1, "未捕获 Page AI run payload");
const runBody = JSON.parse(runs[runs.length - 1].body || "{}");
const activeEditorRef = runBody.contextRefs?.find((item) => item.kind === "active_editor");
assert(activeEditorRef, `run payload 应包含 active_editor contextRef: ${JSON.stringify(runBody.contextRefs)}`);
assert.strictEqual(activeEditorRef.relativePath, rawPath, `active_editor 应指向 raw resource: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.resourceKind, "attachment", `raw resource contextRef 应保留 attachment resourceKind: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.objectIdentity, "resource:file:" + rootUri + ":" + rawPath, `active_editor objectIdentity 不应退化为 [object Object]: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(runBody.targetPackage?.schema, "mnote.agent_target_package.v1", "run payload 应携带 targetPackage");
assert.strictEqual(runBody.targetPackage?.primaryTargetId, "resource:file:" + rootUri + ":" + rawPath, `targetPackage 应冻结 raw resource objectIdentity: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.objectIdentity, "resource:file:" + rootUri + ":" + rawPath, `targetPackage objectIdentity 不应退化为 [object Object]: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.resourceKind, "attachment", `targetPackage 应保留 raw resource kind: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.relativePath, rawPath, `targetPackage currentFile 应指向 raw resource: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.objectIdentity, "resource:file:" + rootUri + ":" + rawPath, `currentFile objectIdentity 不应退化为 [object Object]: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
assert(runBody.targetPackage?.allowedFiles?.includes(rawPath), `allowedFiles 应只包含 raw resource: ${JSON.stringify(runBody.targetPackage?.allowedFiles)}`);
const screenshot = await saveScreenshot(page, "01-raw-resource-target");
const result = { ok: true, task: TASK, baseUrl: BASE_URL, root, rootUri, documentId, rawPath, screenshot, captured };
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
fs.rmSync(root, { recursive: true, force: true });
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
@@ -0,0 +1,99 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const RUNTIME_PATH = path.join(
process.cwd(),
"rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js",
);
function loadConversionRuntime() {
const source = fs
.readFileSync(RUNTIME_PATH, "utf8")
.replace(/\bexport const\s+([A-Za-z0-9_]+)\s*=/g, "const $1 =");
const sandbox = { console };
vm.runInNewContext(
`${source}\n;globalThis.__mnoteExports = { pageBodyTiptapDocumentSource, pageBodyTiptapDocument };`,
sandbox,
{ filename: RUNTIME_PATH },
);
return sandbox.__mnoteExports;
}
function paragraphText(doc) {
return doc?.content?.[0]?.content?.[0]?.text || "";
}
const {
pageBodyTiptapDocumentSource,
pageBodyTiptapDocument,
} = loadConversionRuntime();
const blockDocument = {
type: "doc",
content: [{
type: "paragraph",
attrs: { blockId: "block-truth" },
content: [{ type: "text", text: "Block truth" }],
}],
};
const legacyContent = [{
id: "legacy-1",
type: "paragraph",
content: [{ type: "text", text: "Legacy fallback" }],
}];
const localWithBlockAndLegacy = {
projectionSource: "local_markdown.content",
blockDocument,
content: legacyContent,
};
assert.equal(
pageBodyTiptapDocumentSource(localWithBlockAndLegacy),
"page_aggregate.block_document",
);
assert.equal(
paragraphText(pageBodyTiptapDocument(localWithBlockAndLegacy)),
"Block truth",
);
const localLegacyOnly = {
projectionSource: "local_markdown.content",
content: legacyContent,
};
assert.equal(
pageBodyTiptapDocumentSource(localLegacyOnly),
"local_markdown.content",
);
assert.equal(
paragraphText(pageBodyTiptapDocument(localLegacyOnly)),
"Legacy fallback",
);
const compatLegacyOnly = {
content: legacyContent,
};
assert.equal(
pageBodyTiptapDocumentSource(compatLegacyOnly),
"compat.legacy_content",
);
assert.equal(
paragraphText(pageBodyTiptapDocument(compatLegacyOnly)),
"Legacy fallback",
);
assert.equal(
pageBodyTiptapDocumentSource({}, "Fallback text"),
"degraded.fallback_text",
);
assert.equal(
paragraphText(pageBodyTiptapDocument({}, "Fallback text")),
"Fallback text",
);
console.log("task522 page aggregate compat fallback contract passed");
@@ -0,0 +1,416 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task523-page-ai-onlyoffice-real-target-session-smoke";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const SCREENSHOT_DIR = path.join(OUT_DIR, "screenshots");
const RESULT_PATH = path.join(OUT_DIR, "result.json");
const PROBE_DOCX_PATH = process.env.MNOTE_ONLYOFFICE_PROBE_DOCX
|| "/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function localOfficeFileUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/api/local-folder/files/open`);
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("path", relativePath);
return url.toString();
}
function onlyofficeUrl(root, pageRelativePath, officeRelativePath, assetId) {
const url = new URL(`${BASE_URL}/onlyoffice`);
url.searchParams.set("fileUrl", localOfficeFileUrl(root, officeRelativePath));
url.searchParams.set("fileName", path.basename(officeRelativePath));
url.searchParams.set("fileType", "docx");
url.searchParams.set("assetId", assetId);
url.searchParams.set("documentId", localMdDocumentId(pageRelativePath));
url.searchParams.set("mode", "edit");
return url.toString();
}
async function saveScreenshot(page, name) {
const target = path.join(SCREENSHOT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: true });
return target;
}
async function waitForOfficeSnapshot(page, objectIdentity) {
return await page.waitForFunction(
(targetId) => {
const runtime = window.__mnoteDocumentPaneRuntime;
if (!runtime || typeof runtime.getOpenEditorsSnapshot !== "function") return null;
const snapshot = runtime.getOpenEditorsSnapshot();
const resources = Array.isArray(snapshot && snapshot.resourceEditors) ? snapshot.resourceEditors : [];
const entry = resources.find((item) => item && item.objectIdentity === targetId);
if (!entry || !entry.bridgeSessionReady || !(entry.onlyofficeSessionId || entry.bridgeSessionId)) return null;
return entry;
},
objectIdentity,
{ timeout: UI_TIMEOUT_MS },
);
}
async function main() {
assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测 docx: ${PROBE_DOCX_PATH}`);
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task523`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task523-onlyoffice-target-"));
const rootUri = fileUrl(root);
const pageRelativePath = "Page/Page.md";
const officeRelativePath = "Page/office-a.docx";
const documentId = localMdDocumentId(pageRelativePath);
const assetId = `local-file:${officeRelativePath}`;
const objectIdentity = `resource:office:${documentId}:${assetId}`;
const captured = [];
const consoleErrors = [];
const networkFailures = [];
const httpErrors = [];
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
root,
documentId,
officeRelativePath,
objectIdentity,
screenshots: [],
consoleErrors,
networkFailures,
httpErrors,
};
fs.mkdirSync(path.join(root, "Page"), { recursive: true });
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(
path.join(root, pageRelativePath),
["# Page", "", `Task523 ${suffix}`, ""].join("\n"),
"utf8",
);
fs.copyFileSync(PROBE_DOCX_PATH, path.join(root, officeRelativePath));
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("requestfailed", (request) => {
networkFailures.push({ url: request.url(), failure: request.failure()?.errorText || "" });
});
page.on("response", (response) => {
if (response.status() >= 400) {
httpErrors.push({ url: response.url(), status: response.status() });
}
});
try {
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
controlPlane: "sqlite",
grants: [{
id: `grant_task523_${suffix}`,
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "write",
recursive: true,
capabilities: ["ai", "markdown_edit", "office.write"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
captured.push({ kind: "ui-preferences", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
});
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
gateway: { ok: true, status: "mocked" },
profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true },
suggestions: [],
}),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, tools: [] }),
});
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
agentId: "reasonix",
profiles: [
{ profileId: "shared_doubao_chat", kind: "shared", displayName: "豆包 Chat", baseProfile: "doubao-chat", isolatedProfile: "openclaw-doubao-chat", canRun: true, canManageSkills: false, canManageConfig: false, readonly: true },
{ profileId: "usr_task523_default", kind: "personal", displayName: "我的 Hermes", baseProfile: "default", isolatedProfile: "mnote-u-task523-default", canRun: true, canManageSkills: true, canManageConfig: true },
],
}),
});
});
await page.route("**/api/hermes/client/profiles", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
active: "mnoteai",
profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }],
}),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: `mnote_task523_${suffix}`,
title: "task523",
traceId: `trace_task523_session_${suffix}`,
persistence: "local_ai_session_jsonl",
sessionStorage: "local_private",
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: `mnote_task523_${suffix}`,
runId: `run_task523_${suffix}`,
events: [],
traceId: `trace_task523_run_${suffix}`,
}),
});
});
await page.route("**/api/hermes/client/events/*", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body:
`data: ${JSON.stringify({ event: "message.delta", run_id: `run_task523_${suffix}`, delta: "Task523 response" })}\n\n` +
`data: ${JSON.stringify({ event: "run.completed", run_id: `run_task523_${suffix}`, output: "Task523 response" })}\n\n`,
});
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, pageRelativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const officeHref = onlyofficeUrl(root, pageRelativePath, officeRelativePath, assetId);
const openResult = await page.evaluate(async ({ objectIdentity, assetId, documentId, workspaceId, rootUri, officeRelativePath, officeHref }) => {
const runtime = window.__mnoteDocumentPaneRuntime;
if (!runtime || typeof runtime.openResourceInActiveTab !== "function") {
throw new Error("缺少 openResourceInActiveTab runtime");
}
return await runtime.openResourceInActiveTab({
objectIdentity,
assetId,
title: "Task523 Office",
fileName: "office-a.docx",
kind: "office",
editorKind: "office",
href: officeHref,
officeUrl: officeHref,
documentId,
workspaceId,
rootUri,
sourceKind: "local_folder",
path: officeRelativePath,
workspacePath: {
schema: "mnote.workspace_path.v1",
workspaceId,
sourceKind: "local_folder",
rootUri,
relativePath: officeRelativePath,
documentId,
objectIdentity,
assetId,
resourceKind: "only_office",
},
paneRole: "primary",
});
}, { objectIdentity, assetId, documentId, workspaceId, rootUri, officeRelativePath, officeHref });
assert.strictEqual(openResult, true, "openResourceInActiveTab 应成功打开 Office resource tab");
const officeHandle = await waitForOfficeSnapshot(page, objectIdentity);
const officeSnapshot = await officeHandle.jsonValue();
const onlyofficeSessionId = String(officeSnapshot.onlyofficeSessionId || officeSnapshot.bridgeSessionId || "").trim();
assert(onlyofficeSessionId, `Office snapshot 必须携带 bridge session: ${JSON.stringify(officeSnapshot)}`);
assert.strictEqual(officeSnapshot.bridgeSessionReady, true, "Office snapshot 应标记 bridgeSessionReady");
assert.strictEqual(officeSnapshot.assetId, assetId, `Office snapshot assetId 不匹配: ${JSON.stringify(officeSnapshot)}`);
assert.strictEqual(officeSnapshot.bridgeAssetId, assetId, `Office iframe debug assetId 应透传到 snapshot: ${JSON.stringify(officeSnapshot)}`);
result.officeSnapshot = officeSnapshot;
result.screenshots.push(await saveScreenshot(page, "00-office-resource-tab-ready"));
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const targetButton = page.locator("[data-page-ai-target-button]");
await targetButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await targetButton.click({ timeout: UI_TIMEOUT_MS });
const targetPopover = page.locator("[data-page-ai-target-popover]");
await targetPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await targetPopover.locator(`[data-page-ai-target-option="${objectIdentity}"]`).click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector("[data-page-ai-target-chip]")?.textContent || "").includes("Task523 Office"),
null,
{ timeout: UI_TIMEOUT_MS },
);
result.screenshots.push(await saveScreenshot(page, "01-page-ai-office-target-selected"));
await page.locator("[data-page-ai-input]").fill(`Task523 OnlyOffice target ${suffix}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task523 response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const runPayloads = captured.filter((item) => item.kind === "run");
assert(runPayloads.length >= 1, "未捕获 Page AI run payload");
const runBody = JSON.parse(runPayloads[runPayloads.length - 1].body);
const targetPackage = runBody.targetPackage || {};
const packageTarget = Array.isArray(targetPackage.targets)
? targetPackage.targets.find((target) => target.targetId === objectIdentity)
: null;
assert.strictEqual(runBody.editorTarget?.targetId, objectIdentity, `editorTarget 应冻结 Office target: ${JSON.stringify(runBody.editorTarget)}`);
assert.strictEqual(runBody.editorTarget?.resourceKind, "only_office", `editorTarget 应归一为 only_office: ${JSON.stringify(runBody.editorTarget)}`);
assert.strictEqual(runBody.editorTarget?.onlyofficeSessionId, onlyofficeSessionId, `editorTarget 应携带 iframe live session: ${JSON.stringify(runBody.editorTarget)}`);
assert.strictEqual(targetPackage.schema, "mnote.agent_target_package.v1", "targetPackage schema 不匹配");
assert.strictEqual(targetPackage.primaryTargetId, objectIdentity, `targetPackage 应冻结 Office target: ${JSON.stringify(targetPackage)}`);
assert.strictEqual(targetPackage.onlyofficeSessionId, onlyofficeSessionId, `targetPackage 顶层应携带 live session: ${JSON.stringify(targetPackage)}`);
assert(packageTarget, `targetPackage.targets 应包含 Office target: ${JSON.stringify(targetPackage)}`);
assert.strictEqual(packageTarget.resourceKind, "only_office", `targetPackage target 应归一为 only_office: ${JSON.stringify(packageTarget)}`);
assert.strictEqual(packageTarget.relativePath, officeRelativePath, `targetPackage target 应携带 Office 相对路径: ${JSON.stringify(packageTarget)}`);
assert.strictEqual(packageTarget.assetId, assetId, `targetPackage target 应携带 assetId: ${JSON.stringify(packageTarget)}`);
assert.strictEqual(packageTarget.onlyofficeSessionId, onlyofficeSessionId, `targetPackage target 应携带 live session: ${JSON.stringify(packageTarget)}`);
assert(
Array.isArray(targetPackage.allowedFiles) && targetPackage.allowedFiles.includes(officeRelativePath),
`targetPackage.allowedFiles 应只按选中 Office target 授权: ${JSON.stringify(targetPackage)}`,
);
assert.strictEqual(
httpErrors.filter((item) => item.url.includes("/api/documents/buffer-state")).length,
0,
`Office target 不应触发 Markdown buffer-state 查询: ${JSON.stringify(httpErrors)}`,
);
Object.assign(result, {
ok: true,
onlyofficeSessionId,
runBody: {
agentId: runBody.agentId,
editorTarget: runBody.editorTarget,
targetPackage,
},
});
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
result.error = error && error.stack ? error.stack : String(error);
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
throw error;
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,331 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const PROBE_DOCX_PATH = process.env.MNOTE_ONLYOFFICE_PROBE_DOCX
|| "/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
const OUT_DIR = path.join(process.cwd(), "tmp", "task524-workspace-object-identity-matrix-smoke");
const RESULT_PATH = path.join(OUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname !== "/auth", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
}
async function filetreeRowState(page, relativePath) {
return await page.evaluate((targetRelativePath) => {
const row = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
.find((candidate) => candidate instanceof HTMLElement && candidate.getAttribute("data-local-relative-path") === targetRelativePath);
if (!(row instanceof HTMLElement)) {
return null;
}
const params = new URL(window.location.href).searchParams;
const workspacePath = window.__mnoteFileTreeRuntime?.readWorkspacePathFromRow?.(row, {
currentSourceKind: () => params.get("sourceKind") || document.body.dataset.mnoteSourceKind || "local_folder",
currentRootUri: () => params.get("rootUri") || document.body.dataset.mnoteRootUri || "",
resolveWorkspaceId: () => document.body.dataset.workspaceId || "",
rowTitle: (candidate) => candidate.textContent || "",
}) || null;
const objectIdentityRaw = row.getAttribute("data-object-identity") || "";
let objectIdentity = null;
try {
objectIdentity = objectIdentityRaw ? JSON.parse(objectIdentityRaw) : null;
} catch {
objectIdentity = objectIdentityRaw;
}
return {
rowId: row.getAttribute("data-row-id") || "",
rowKind: row.getAttribute("data-row-kind") || "",
documentId: row.getAttribute("data-document-id") || "",
assetId: row.getAttribute("data-asset-id") || "",
relativePath: row.getAttribute("data-local-relative-path") || "",
objectIdentity,
workspacePath,
expanded: row.getAttribute("aria-expanded") || "",
};
}, relativePath);
}
async function clickFiletreeOpen(page, relativePath) {
const row = page.locator(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${relativePath.replace(/"/g, '\\"')}"]`).first();
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await row.locator('[data-rust-action="open"]').click({ timeout: UI_TIMEOUT_MS });
}
async function waitFiletreeRow(page, relativePath) {
const row = page.locator(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${relativePath.replace(/"/g, '\\"')}"]`).first();
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function expandFiletreeFolder(page, relativePath) {
const row = page.locator(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${relativePath.replace(/"/g, '\\"')}"]`).first();
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const expanded = await row.getAttribute("aria-expanded");
if (expanded === "true") return;
await row.locator('[data-rust-action="open"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(targetRelativePath) => {
const candidate = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
.find((row) => row instanceof HTMLElement && row.getAttribute("data-local-relative-path") === targetRelativePath);
return candidate && candidate.getAttribute("aria-expanded") === "true";
},
relativePath,
{ timeout: UI_TIMEOUT_MS },
);
}
async function activeSnapshotEntry(page, expectedRelativePath, expectedKind) {
return await page.waitForFunction(
({ expectedRelativePath, expectedKind }) => {
const snapshot = window.__mnoteOpenEditorsSnapshot || null;
const liveSnapshot = window.__mnoteDocumentPaneRuntime?.getOpenEditorsSnapshot?.() || snapshot;
const resources = Array.isArray(liveSnapshot?.resourceEditors) ? liveSnapshot.resourceEditors : [];
const entries = Array.isArray(liveSnapshot?.editors) ? liveSnapshot.editors.concat(resources) : resources;
const activeObjectIdentity = String(liveSnapshot?.activeObjectIdentity || "");
const active = entries.find((entry) =>
entry
&& (entry.active === true || entry.objectIdentity === activeObjectIdentity)
&& (!expectedRelativePath || entry.workspacePath?.relativePath === expectedRelativePath || entry.path === expectedRelativePath)
&& (!expectedKind || entry.kind === expectedKind || entry.editorKind === expectedKind || entry.workspacePath?.resourceKind === expectedKind)
);
return active || null;
},
{ expectedRelativePath, expectedKind },
{ timeout: UI_TIMEOUT_MS },
);
}
function assertWorkspacePathMatchesRow(label, rowState, snapshotEntry, expected) {
assert(rowState, `${label}: 缺少 FileTree row state`);
assert(rowState.workspacePath, `${label}: FileTree row 缺少 workspacePath: ${JSON.stringify(rowState)}`);
assert(snapshotEntry, `${label}: 缺少 OpenEditorsSnapshot entry`);
assert.equal(rowState.workspacePath.schema, "mnote.workspace_path.v1", `${label}: row workspacePath schema`);
assert.equal(snapshotEntry.workspacePath?.schema, "mnote.workspace_path.v1", `${label}: snapshot workspacePath schema`);
assert.equal(rowState.workspacePath.relativePath, expected.relativePath, `${label}: row relativePath`);
assert.equal(snapshotEntry.workspacePath?.relativePath, expected.relativePath, `${label}: snapshot relativePath`);
assert.equal(snapshotEntry.workspacePath?.sourceKind, rowState.workspacePath.sourceKind, `${label}: sourceKind 应一致`);
assert.equal(snapshotEntry.workspacePath?.rootUri, rowState.workspacePath.rootUri, `${label}: rootUri 应一致`);
assert.equal(snapshotEntry.workspacePath?.documentId, rowState.workspacePath.documentId, `${label}: documentId 应一致`);
if (expected.assetId) {
assert.equal(rowState.workspacePath.assetId, expected.assetId, `${label}: row assetId`);
assert.equal(snapshotEntry.workspacePath?.assetId, expected.assetId, `${label}: snapshot assetId`);
}
if (expected.resourceKind) {
assert.equal(rowState.workspacePath.resourceKind, expected.resourceKind, `${label}: row resourceKind`);
assert.equal(snapshotEntry.workspacePath?.resourceKind, expected.resourceKind, `${label}: resourceKind`);
}
if (expected.objectKind) {
assert.equal(rowState.workspacePath.objectIdentity?.objectKind, expected.objectKind, `${label}: row objectKind`);
assert.equal(snapshotEntry.workspacePath?.objectIdentity?.objectKind, expected.objectKind, `${label}: snapshot objectKind`);
}
if (rowState.workspacePath.objectIdentity && typeof rowState.workspacePath.objectIdentity === "object"
&& snapshotEntry.workspacePath?.objectIdentity && typeof snapshotEntry.workspacePath.objectIdentity === "object") {
assert.deepStrictEqual(
snapshotEntry.workspacePath.objectIdentity,
rowState.workspacePath.objectIdentity,
`${label}: snapshot objectIdentity 应与 row objectIdentity 对齐`,
);
}
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task524`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task524-identity-"));
const rootUri = fileUrl(root);
fs.mkdirSync(path.join(root, "Page"), { recursive: true });
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, "Page.md"), "# Page\n\nTask524 page\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "Plan.md"), "# Plan\n\nTask524 plan\n", "utf8");
fs.writeFileSync(path.join(root, "Page", "notes.txt"), "Task524 text resource\n", "utf8");
fs.writeFileSync(path.join(root, "Page", "map.mindmap.json"), JSON.stringify({
root: { data: { text: "Task524 Mindmap" }, children: [] },
}, null, 2), "utf8");
if (fs.existsSync(PROBE_DOCX_PATH)) {
fs.copyFileSync(PROBE_DOCX_PATH, path.join(root, "Page", "office.docx"));
} else {
fs.writeFileSync(path.join(root, "Page", "office.docx"), "task524 office probe\n", "utf8");
}
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const result = {
ok: false,
task: "task524-workspace-object-identity-matrix-smoke",
baseUrl: BASE_URL,
root,
rootUri,
checks: [],
};
try {
await quickLogin(page);
await page.goto(documentUrl(root, "Page.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(() => Boolean(window.__mnoteFileTreeRuntime?.readWorkspacePathFromRow), null, {
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(() => Boolean(window.__mnoteOpenEditorsSnapshot), null, {
timeout: UI_TIMEOUT_MS,
});
await page.evaluate(() => {
window.__task524IdentityEvents = [];
window.addEventListener("tree.filetree.open", (event) => {
window.__task524IdentityEvents.push({ type: "tree.filetree.open", detail: event.detail || null });
});
window.addEventListener("tree.asset.open", (event) => {
window.__task524IdentityEvents.push({ type: "tree.asset.open", detail: event.detail || null });
});
});
const pageRow = await filetreeRowState(page, "Page.md");
const pageEntry = await page.evaluate(() => {
const snapshot = window.__mnoteDocumentPaneRuntime?.getOpenEditorsSnapshot?.() || window.__mnoteOpenEditorsSnapshot || null;
return snapshot?.groups?.primary?.editors?.find((entry) => entry.kind === "page" && entry.documentId === "local-md:Page.md") || null;
});
assertWorkspacePathMatchesRow("page", pageRow, pageEntry, {
relativePath: "Page.md",
resourceKind: "page",
objectKind: "page",
});
result.checks.push({ kind: "page", row: pageRow, snapshot: pageEntry });
const folderRow = await filetreeRowState(page, "docs");
assert(folderRow, "folder: 缺少 docs row");
assert.equal(folderRow.workspacePath?.schema, "mnote.workspace_path.v1", "folder row workspacePath schema");
assert.equal(folderRow.workspacePath?.relativePath, "docs", "folder row relativePath");
assert.equal(folderRow.workspacePath?.documentId, "local-dir:docs", "folder row documentId");
assert.equal(folderRow.workspacePath?.objectIdentity?.objectKind, "index", "folder row objectKind");
const beforeFolderUrl = page.url();
await clickFiletreeOpen(page, "docs");
await page.waitForFunction(() => {
const row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="docs"]');
return row && row.getAttribute("aria-expanded") === "true";
}, null, { timeout: UI_TIMEOUT_MS });
assert.equal(page.url(), beforeFolderUrl, "FileTree folder open 只应展开,不应改 URL");
result.checks.push({ kind: "folder", row: folderRow });
await expandFiletreeFolder(page, "Page");
const resources = [
{ label: "text", relativePath: "Page/notes.txt", kind: "text", resourceKind: "attachment", objectKind: "attachment" },
{ label: "mindmap", relativePath: "Page/map.mindmap.json", kind: "mindmap", resourceKind: "mindmap", objectKind: "mindmap" },
{ label: "office", relativePath: "Page/office.docx", kind: "office", resourceKind: "only_office", objectKind: "only_office" },
];
for (const resource of resources) {
await waitFiletreeRow(page, resource.relativePath);
const rowState = await filetreeRowState(page, resource.relativePath);
assert(rowState, `${resource.label}: 缺少 FileTree row`);
await clickFiletreeOpen(page, resource.relativePath);
const entryHandle = await activeSnapshotEntry(page, resource.relativePath, resource.kind);
const entry = await entryHandle.jsonValue();
assertWorkspacePathMatchesRow(resource.label, rowState, entry, {
relativePath: resource.relativePath,
assetId: rowState.workspacePath.assetId,
resourceKind: resource.resourceKind,
objectKind: resource.objectKind,
});
assert.equal(entry.kind, resource.kind, `${resource.label}: editor kind`);
const eventDetail = await page.evaluate((relativePath) => {
const events = Array.isArray(window.__task524IdentityEvents) ? window.__task524IdentityEvents : [];
return [...events].reverse().find((event) => event?.detail?.workspacePath?.relativePath === relativePath) || null;
}, resource.relativePath);
assert(eventDetail, `${resource.label}: 应捕获真实 tree.asset.open 事件`);
assert.equal(eventDetail.detail.workspacePath?.sourceKind, rowState.workspacePath.sourceKind, `${resource.label}: event sourceKind`);
assert.equal(eventDetail.detail.workspacePath?.rootUri, rowState.workspacePath.rootUri, `${resource.label}: event rootUri`);
assert.equal(eventDetail.detail.workspacePath?.documentId, rowState.workspacePath.documentId, `${resource.label}: event documentId`);
assert.equal(eventDetail.detail.workspacePath?.assetId, rowState.workspacePath.assetId, `${resource.label}: event assetId`);
assert.deepStrictEqual(eventDetail.detail.workspacePath?.objectIdentity, rowState.workspacePath.objectIdentity, `${resource.label}: event objectIdentity`);
const url = new URL(page.url());
const resourceTab = url.searchParams.get("resourceTab") || "";
assert(
resourceTab === `primary::${entry.objectIdentity}`,
`${resource.label}: URL resourceTab 应等于 active snapshot objectIdentity: ${JSON.stringify({ resourceTab, entry })}`,
);
const activeTabIdentity = await page.evaluate(() => {
const activeTab = document.querySelector(".mnote-main-tab.is-active");
return activeTab?.getAttribute("data-mnote-object-identity") || "";
});
if (activeTabIdentity) {
assert.equal(activeTabIdentity, entry.objectIdentity, `${resource.label}: active tab identity`);
}
result.checks.push({ kind: resource.label, row: rowState, snapshot: entry, resourceTab, activeTabIdentity });
}
result.ok = true;
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
result.error = error && error.stack ? error.stack : String(error);
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
throw error;
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,299 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task525-page-ai-mindmap-resource-target-smoke";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
async function waitFiletreeRow(page, relativePath) {
await page.waitForFunction(
(expectedRelativePath) => Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.some((row) => row.getAttribute("data-local-relative-path") === expectedRelativePath),
relativePath,
{ timeout: UI_TIMEOUT_MS },
);
}
async function clickFiletreeOpen(page, relativePath) {
const handle = await page.waitForFunction(
(expectedRelativePath) => {
const row = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.find((candidate) => candidate.getAttribute("data-local-relative-path") === expectedRelativePath);
return row?.querySelector('[data-rust-action="open"], .tree-link') || null;
},
relativePath,
{ timeout: UI_TIMEOUT_MS },
);
await handle.asElement().click();
}
async function expandFiletreeFolder(page, relativePath) {
await waitFiletreeRow(page, relativePath);
const expanded = await page.evaluate((expectedRelativePath) => {
const row = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.find((candidate) => candidate.getAttribute("data-local-relative-path") === expectedRelativePath);
return row?.getAttribute("aria-expanded") === "true";
}, relativePath);
if (!expanded) {
await clickFiletreeOpen(page, relativePath);
}
await page.waitForFunction(
(expectedRelativePath) => {
const row = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.find((candidate) => candidate.getAttribute("data-local-relative-path") === expectedRelativePath);
return row?.getAttribute("aria-expanded") === "true";
},
relativePath,
{ timeout: UI_TIMEOUT_MS },
);
}
async function saveScreenshot(page, name) {
const target = path.join(OUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task525`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task525-mindmap-target-"));
const rootUri = fileUrl(root);
const pagePath = "Page/Page.md";
const mindmapPath = "Page/map.mindmap.json";
const documentId = localMdDocumentId(pagePath);
const assetId = `local-file:${mindmapPath}`;
const expectedTargetId = `resource:mindmap:${documentId}:${assetId}`;
const captured = [];
let caughtError = null;
fs.mkdirSync(path.join(root, "Page"), { recursive: true });
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, pagePath), "# Page\n\n[思维导图](map.mindmap.json)\n", "utf8");
fs.writeFileSync(
path.join(root, mindmapPath),
`${JSON.stringify({ data: { text: "Task525 mindmap" }, children: [] }, null, 2)}\n`,
"utf8",
);
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
grants: [{
id: "grant_task525",
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "write",
recursive: true,
capabilities: ["ai", "markdown_edit"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
captured.push({ kind: "ui-preferences", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
});
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, gateway: { ok: true }, profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true } }),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, tools: [] }) });
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, agentId: "reasonix", profiles: [] }),
});
});
await page.route("**/api/hermes/client/profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, active: "mnoteai", profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }] }),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, sessionId: "mnote_task525", title: "task525", traceId: "trace_task525" }),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, sessionId: "mnote_task525", runId: "run_task525", events: [], traceId: "trace_run_task525" }),
});
});
await page.route("**/api/hermes/client/events/*", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body: `data: ${JSON.stringify({ event: "message.delta", run_id: "run_task525", delta: "Task525 response" })}\n\n`
+ `data: ${JSON.stringify({ event: "run.completed", run_id: "run_task525", output: "Task525 response" })}\n\n`,
});
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, pagePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await expandFiletreeFolder(page, "Page");
await waitFiletreeRow(page, mindmapPath);
await clickFiletreeOpen(page, mindmapPath);
await page.waitForFunction(
() => {
return Boolean(document.querySelector('[data-testid="mnote-mindmap-editor-root"]'))
&& (document.body?.innerText || "").includes("map.mindmap.json");
},
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const targetChip = page.locator("[data-page-ai-target-chip]");
await targetChip.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const chipText = (await targetChip.innerText({ timeout: UI_TIMEOUT_MS })).trim();
assert(chipText.includes("map.mindmap.json"), `mindmap resource 打开后 target chip 应指向 map.mindmap.json: ${chipText}`);
await page.locator("[data-page-ai-input]").fill("Task525 mindmap target", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task525 response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const runs = captured.filter((item) => item.kind === "run");
assert(runs.length >= 1, "未捕获 Page AI run payload");
const runBody = JSON.parse(runs[runs.length - 1].body || "{}");
const activeEditorRef = runBody.contextRefs?.find((item) => item.kind === "active_editor");
assert(activeEditorRef, `run payload 应包含 active_editor contextRef: ${JSON.stringify(runBody.contextRefs)}`);
assert.strictEqual(activeEditorRef.resourceKind, "mindmap", `active_editor 应标记 mindmap resourceKind: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.assetId, assetId, `active_editor 应携带 mindmap assetId: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.objectIdentity, expectedTargetId, `active_editor objectIdentity 不应退化: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.relativePath, mindmapPath, `active_editor 应携带 mindmap relativePath: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(runBody.targetPackage?.schema, "mnote.agent_target_package.v1", "run payload 应携带 targetPackage");
assert.strictEqual(runBody.targetPackage?.primaryTargetId, expectedTargetId, `targetPackage 应冻结 mindmap target: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.objectIdentity, expectedTargetId, `targetPackage objectIdentity 不应退化: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.resourceKind, "mindmap", `targetPackage 应保留 mindmap resourceKind: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.relativePath, mindmapPath, `targetPackage currentFile 应指向 mindmap resource: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.objectIdentity, expectedTargetId, `currentFile objectIdentity 不应退化: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
assert(runBody.targetPackage?.allowedFiles?.includes(mindmapPath), `allowedFiles 应包含 mindmap resource: ${JSON.stringify(runBody.targetPackage?.allowedFiles)}`);
const screenshot = await saveScreenshot(page, "01-mindmap-resource-target");
const result = { ok: true, task: TASK, baseUrl: BASE_URL, root, rootUri, documentId, mindmapPath, assetId, expectedTargetId, screenshot, captured };
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
fs.rmSync(root, { recursive: true, force: true });
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
@@ -0,0 +1,262 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const { ensureAuthenticated, UI_TIMEOUT_MS } = require("./tree-shell-smoke-helpers");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const TASK = "task526-local-folder-ocr-api-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
function fileUrl(localPath) {
return `file://${localPath.split(path.sep).map((part, index) => (
index === 0 ? "" : encodeURIComponent(part)
)).join("/")}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${Buffer.from(relativePath, "utf8")
.toString("hex")
.replace(/../g, (hex) => {
const code = Number.parseInt(hex, 16);
const ch = String.fromCharCode(code);
return /[A-Za-z0-9._-]/.test(ch) ? ch : `~${hex.toUpperCase()}`;
})}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task526`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "ocr"],
}, null, 2)}\n`,
"utf8",
);
}
async function writeResult(payload) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function ensureDocumentVisible(page, root, relativePath) {
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
if (new URL(page.url()).pathname === "/auth") {
const quickLogin = page.getByRole("button", { name: "测试账号快速登录" });
await quickLogin.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await quickLogin.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname !== "/auth", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
}
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task526-ocr-"));
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task526`;
const relativePath = "docs/Page.md";
const documentId = localMdDocumentId(relativePath);
const sourceRootRelativePath = "docs/Page.assets/photo.png";
const ocrToken = "TASK526_OCR_TOKEN";
writeWorkspaceManifest(root, actorId);
fs.mkdirSync(path.join(root, "docs", "Page.assets"), { recursive: true });
fs.writeFileSync(path.join(root, relativePath), "# OCR Page\n\n![photo](./Page.assets/photo.png)\n", "utf8");
fs.writeFileSync(path.join(root, sourceRootRelativePath), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]));
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const screenshots = {};
try {
await ensureAuthenticated(page, context.request);
await ensureDocumentVisible(page, root, relativePath);
screenshots.page = path.join(OUTPUT_DIR, "01-page.png");
await page.screenshot({ path: screenshots.page, fullPage: true });
const result = await page.evaluate(async ({ rootUri, documentId, sourceRootRelativePath, workspaceId, ocrToken }) => {
async function jsonFetch(pathname, init = {}) {
const response = await fetch(pathname, {
...init,
headers: {
"content-type": "application/json",
...(init.headers || {}),
},
});
const payload = await response.json().catch(() => null);
return { status: response.status, payload };
}
const create = await jsonFetch("/api/local-folder/ocr/jobs", {
method: "POST",
body: JSON.stringify({
rootUri,
documentId,
sourceRootRelativePath,
provider: "mock",
mockMarkdown: `# OCR Result\n\n${ocrToken} browser smoke text`,
}),
});
const ocrRootRelativePath = create.payload?.job?.ocrRootRelativePath || "";
const read = await jsonFetch(`/api/local-folder/ocr/read?rootUri=${encodeURIComponent(rootUri)}&ocrRootRelativePath=${encodeURIComponent(ocrRootRelativePath)}`, {
method: "GET",
headers: {},
});
const status = await jsonFetch(`/api/local-folder/ocr/status?rootUri=${encodeURIComponent(rootUri)}&sourceRootRelativePath=${encodeURIComponent(sourceRootRelativePath)}`, {
method: "GET",
headers: {},
});
const jobs = await jsonFetch(`/api/local-folder/ocr/jobs?rootUri=${encodeURIComponent(rootUri)}`, {
method: "GET",
headers: {},
});
const withoutOcr = await jsonFetch("/api/search/documents", {
method: "POST",
body: JSON.stringify({
workspaceId,
sourceKind: "local_folder",
rootUri,
query: ocrToken,
limit: 5,
filters: { includeOcr: false },
}),
});
const withOcr = await jsonFetch("/api/search/documents", {
method: "POST",
body: JSON.stringify({
workspaceId,
sourceKind: "local_folder",
rootUri,
query: ocrToken,
limit: 5,
filters: { includeOcr: true },
}),
});
const insert = await jsonFetch("/api/local-folder/ocr/insert", {
method: "POST",
body: JSON.stringify({
rootUri,
documentId,
ocrRootRelativePath,
mode: "link",
}),
});
return { create, read, status, jobs, withoutOcr, withOcr, insert, ocrRootRelativePath };
}, {
rootUri: fileUrl(root),
documentId,
sourceRootRelativePath,
workspaceId,
ocrToken,
});
assert.equal(result.create.status, 200, `OCR create failed: ${JSON.stringify(result.create)}`);
assert.equal(result.create.payload.job.status, "done", `OCR job should be done: ${JSON.stringify(result.create.payload)}`);
assert(result.ocrRootRelativePath.endsWith(".ocr.md"), `OCR path invalid: ${result.ocrRootRelativePath}`);
assert.equal(result.read.status, 200, `OCR read failed: ${JSON.stringify(result.read)}`);
assert(result.read.payload.markdown.includes(ocrToken), "OCR read should include mock OCR text");
assert.equal(result.status.payload.job.ocrRootRelativePath, result.ocrRootRelativePath, "status should return OCR sidecar path");
assert(result.jobs.payload.jobs.some((job) => job.ocrRootRelativePath === result.ocrRootRelativePath), "jobs list should include OCR job");
assert.equal(result.withoutOcr.payload.results.length, 0, `includeOcr=false should not match OCR text: ${JSON.stringify(result.withoutOcr.payload)}`);
const ocrResult = result.withOcr.payload.results.find((item) => item.hasOcr === true);
assert(ocrResult, `includeOcr=true should return owner page OCR result: ${JSON.stringify(result.withOcr.payload)}`);
assert.equal(ocrResult.documentId, documentId, "OCR search result should point to owner document");
assert.equal(ocrResult.ocrEvidence.ocrRootRelativePath, result.ocrRootRelativePath, "OCR evidence should include sidecar path");
assert.equal(result.insert.status, 200, `OCR insert failed: ${JSON.stringify(result.insert)}`);
const ownerMarkdown = fs.readFileSync(path.join(root, relativePath), "utf8");
assert(ownerMarkdown.includes(`[OCRphoto.png](`), `owner markdown should include explicit OCR link:\n${ownerMarkdown}`);
await page.waitForFunction(() => Boolean(window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab), null, {
timeout: UI_TIMEOUT_MS,
});
const openResourceResult = await page.evaluate(async ({ rootUri, documentId, ocrRootRelativePath, workspaceId }) => {
const runtime = window.__mnoteDocumentPaneRuntime;
if (!runtime || typeof runtime.openResourceInActiveTab !== "function") {
throw new Error("缺少 openResourceInActiveTab runtime");
}
const title = ocrRootRelativePath.split("/").filter(Boolean).pop() || "OCR";
return await runtime.openResourceInActiveTab({
kind: "markdown",
title,
path: ocrRootRelativePath,
objectIdentity: `local-ocr:${ocrRootRelativePath}`,
assetId: `local-ocr:${ocrRootRelativePath}`,
documentId,
ownerDocumentId: documentId,
workspaceId,
sourceKind: "local_folder",
rootUri,
resourceKind: "markdown",
});
}, {
rootUri: fileUrl(root),
documentId,
ocrRootRelativePath: result.ocrRootRelativePath,
workspaceId,
});
assert.equal(openResourceResult, true, "OCR sidecar resource tab should open");
await page.locator('[data-mnote-resource-tab-panel][data-resource-kind="markdown"] [data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
screenshots.ocrResource = path.join(OUTPUT_DIR, "02-ocr-resource-tab.png");
await page.screenshot({ path: screenshots.ocrResource, fullPage: true });
await writeResult({
ok: true,
task: TASK,
root,
documentId,
ocrRootRelativePath: result.ocrRootRelativePath,
screenshots,
});
console.log(JSON.stringify({
ok: true,
task: TASK,
root,
documentId,
ocrRootRelativePath: result.ocrRootRelativePath,
screenshots,
}, null, 2));
} catch (error) {
screenshots.failure = path.join(OUTPUT_DIR, "failure.png");
await page.screenshot({ path: screenshots.failure, fullPage: true }).catch(() => undefined);
await writeResult({
ok: false,
task: TASK,
error: error instanceof Error ? error.message : String(error),
root,
documentId,
screenshots,
});
throw error;
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});