收口 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:
@@ -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 Aggregate:local-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 Aggregate:local-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 payload;raw 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 bridge:Sidebar 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 smoke;Page 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/` 做历史对照
|
||||
|
||||
原则:
|
||||
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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\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(`[OCR:photo.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);
|
||||
});
|
||||
Reference in New Issue
Block a user