@@ -10180,6 +10584,27 @@ pub fn PageLayout(
mod tests {
use super::{SIDEBAR_TREE_JS, TREE_LIVE_CONTROLLER_JS};
+ fn js_function_body(source: &str, name: &str) -> String {
+ let marker = format!("function {name}(");
+ let start = source.find(&marker).expect("js function exists");
+ let rest = &source[start..];
+ let brace = rest.find('{').expect("js function body starts");
+ let mut depth = 0usize;
+ let mut end = None;
+ for (offset, ch) in rest[brace..].char_indices() {
+ if ch == '{' {
+ depth += 1;
+ } else if ch == '}' {
+ depth -= 1;
+ if depth == 0 {
+ end = Some(brace + offset + 1);
+ break;
+ }
+ }
+ }
+ rest[..end.expect("js function body ends")].to_string()
+ }
+
#[test]
fn sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions() {
assert!(SIDEBAR_TREE_JS.contains("mnoteNavigationInFlight"));
@@ -10255,6 +10680,10 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains("switchToCloudWorkspace"));
assert!(SIDEBAR_TREE_JS.contains("mnote-switch-cloud-workspace"));
assert!(SIDEBAR_TREE_JS.contains("mnote-recent-local-root"));
+ assert!(SIDEBAR_TREE_JS.contains("mnote-local-folder-authorized-roots"));
+ assert!(SIDEBAR_TREE_JS.contains("mnote-local-folder-authorized-root"));
+ assert!(SIDEBAR_TREE_JS.contains("已授权文件夹"));
+ assert!(SIDEBAR_TREE_JS.contains("fetch('/api/user/access-policy'"));
}
#[test]
@@ -10412,8 +10841,23 @@ mod tests {
#[test]
fn sidebar_upload_runtime_routes_local_markdown_assets_to_local_folder() {
+ let current_document_function = js_function_body(SIDEBAR_TREE_JS, "currentDocumentId");
+ let upload_function = js_function_body(SIDEBAR_TREE_JS, "uploadFileToMediaAsset");
assert!(SIDEBAR_TREE_JS.contains("currentSourceKind() === 'local_folder'"));
assert!(SIDEBAR_TREE_JS.contains("/api/local-folder/assets/upload"));
+ assert!(
+ upload_function.contains("var rootUri = currentRootUri();"),
+ "本地上传必须复用 currentRootUri(),否则 / 页面由 body dataset 提供 rootUri 时 .md 上传会失败"
+ );
+ assert!(
+ current_document_function.contains("params.get('pageId')"),
+ "SQLite/local-first 根入口可能通过 pageId 或 DOM 暴露当前页面,不能只解析 /documents/:id"
+ );
+ assert!(
+ current_document_function.contains("data-pane-document-id")
+ && current_document_function.contains("data-document-id"),
+ "主编辑器上传应能从当前文档 DOM 回退解析 documentId"
+ );
assert!(SIDEBAR_TREE_JS.contains("localForm.append('rootUri', rootUri)"));
assert!(SIDEBAR_TREE_JS.contains("localForm.append('documentId', documentId)"));
assert!(SIDEBAR_TREE_JS.contains("isLocalUploadedAsset(asset)"));
@@ -10422,6 +10866,11 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains("fileTreeIconKindForFileName(title)"));
assert!(SIDEBAR_TREE_JS.contains("refreshLocalFolderSidebarSnapshot"));
assert!(SIDEBAR_TREE_JS.contains("wolai:local-assets-changed"));
+ assert!(SIDEBAR_TREE_JS.contains("function resolveEditorUploadContext"));
+ assert!(SIDEBAR_TREE_JS.contains("__mnoteLastEditorUploadRoot"));
+ assert!(SIDEBAR_TREE_JS.contains("data-pane-role=\"primary\""));
+ assert!(SIDEBAR_TREE_JS.contains("insertUploadedAssetIntoEditor(localPayload.asset, editorRootFromUploadOptions(options))"));
+ assert!(SIDEBAR_TREE_JS.contains("editorRoot: uploadContext.root"));
}
#[test]
@@ -10444,6 +10893,47 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains("isLocalAsset ? onlyOfficeUrl"));
}
+ #[test]
+ fn sidebar_local_folder_authorized_root_normalizes_plain_path_root_uri() {
+ assert!(SIDEBAR_TREE_JS.contains("function normalizeGrantedLocalFolderRootUri(grant)"));
+ assert!(SIDEBAR_TREE_JS.contains("if (rootPath) return pathToFileRootUri(rootPath);"));
+ assert!(SIDEBAR_TREE_JS.contains("if (rootUri) return pathToFileRootUri(rootUri);"));
+ assert!(
+ SIDEBAR_TREE_JS.contains("var rootUri = normalizeGrantedLocalFolderRootUri(grant);")
+ );
+ assert!(SIDEBAR_TREE_JS.contains("function isDefaultWorkspaceAutoGrant(grant)"));
+ assert!(SIDEBAR_TREE_JS.contains("if (isDefaultWorkspaceAutoGrant(grant)) return;"));
+ assert!(SIDEBAR_TREE_JS.contains("function recentLocalRootLabel(rootUri)"));
+ assert!(SIDEBAR_TREE_JS.contains("button.textContent = recentLocalRootLabel(rootUri);"));
+ assert!(
+ !SIDEBAR_TREE_JS.contains("button.textContent = rootUri.replace(/^file:\\/\\//, '')")
+ );
+ }
+
+ #[test]
+ fn sidebar_runtime_supports_resizable_filetree_and_hover_titles() {
+ assert!(SIDEBAR_TREE_JS.contains("function installWorkspaceSidebarResizer()"));
+ assert!(SIDEBAR_TREE_JS.contains("data-mnote-sidebar-resizer"));
+ assert!(SIDEBAR_TREE_JS.contains("mnote.workspace.sidebarWidth.v1"));
+ assert!(SIDEBAR_TREE_JS.contains("shell.style.setProperty('--mnote-sidebar-width'"));
+ assert!(SIDEBAR_TREE_JS.contains("installWorkspaceSidebarResizer();"));
+ assert!(SIDEBAR_TREE_JS.contains(r#"" title="' + escapeHtml(title) + '""#));
+ assert!(SIDEBAR_TREE_JS
+ .contains(r#"class="tree-link-title" title="' + escapeHtml(title) + '""#));
+ }
+
+ #[test]
+ fn sidebar_runtime_opens_local_pdf_assets_in_browser_tab_by_default() {
+ assert!(SIDEBAR_TREE_JS.contains("function shouldOpenLocalResourceInNewWindow(fileName)"));
+ assert!(SIDEBAR_TREE_JS.contains("return ext === 'pdf';"));
+ assert!(SIDEBAR_TREE_JS.contains("var shouldOpenInNewWindow = forceNewWindow || shouldOpenLocalResourceInNewWindow(localFileName);"));
+ assert!(SIDEBAR_TREE_JS
+ .contains("if (!shouldOpenInNewWindow && await openLocalResourceInActiveTab({"));
+ assert!(SIDEBAR_TREE_JS.contains(
+ "openTarget: e.shiftKey || e.ctrlKey || e.metaKey ? 'new-window' : 'active-tab'"
+ ));
+ }
+
#[test]
fn sidebar_tree_runtime_contains_dev_hot_reload_client() {
assert!(SIDEBAR_TREE_JS.contains("installMnoteDevHotReload"));
@@ -10628,6 +11118,29 @@ mod tests {
assert!(table_loop.contains("/api/tables/"));
}
+ #[test]
+ fn sidebar_filetree_asset_open_uses_owner_document_id() {
+ assert!(
+ SIDEBAR_TREE_JS.contains(
+ "var ownerDocumentId = fileRow.getAttribute('data-owner-document-id') || documentId;"
+ ),
+ "打开资源行时必须优先使用资源 owner document,不能用当前页面 documentId"
+ );
+ assert!(
+ SIDEBAR_TREE_JS.contains("documentId: ownerDocumentId || null"),
+ "tree.asset.open detail 应携带资源 owner document"
+ );
+ assert!(
+ SIDEBAR_TREE_JS.contains("data-owner-document-id=\"' + escapeHtml(ownerDocumentId)"),
+ "SSR filetree 行应输出 data-owner-document-id"
+ );
+ assert!(
+ SIDEBAR_TREE_JS
+ .contains("return 'local-md:' + bundleName + '~2F' + bundleName + '.md';"),
+ "local_folder bundle 资源应从 local-file 路径推导 owner markdown document"
+ );
+ }
+
#[test]
fn sidebar_tree_runtime_keeps_pdf_and_code_assets_out_of_onlyoffice() {
assert!(SIDEBAR_TREE_JS.contains("function inferOnlyOfficeFileType"));
diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs
index 68dd916a..1f842ca1 100644
--- a/rust/crates/mnote-web/src/ssr/styles.rs
+++ b/rust/crates/mnote-web/src/ssr/styles.rs
@@ -232,7 +232,8 @@ a:hover {
}
.mnote-shell[data-mnote-sidebar-collapsed="true"] .mnote-sidebar,
-.mnote-shell[data-mnote-sidebar-collapsed="true"] .wolai-sidebar {
+.mnote-shell[data-mnote-sidebar-collapsed="true"] .wolai-sidebar,
+.mnote-shell[data-mnote-sidebar-collapsed="true"] .mnote-sidebar-resizer {
display: none;
}
@@ -240,13 +241,38 @@ a:hover {
--wolai-bg-sidebar: #F7F7F6;
--wolai-bg-selected: #F8E6E7;
--wolai-accent-red: #E0525B;
+ --mnote-sidebar-width: 288px;
}
.wolai-sidebar {
- width: 288px;
+ width: var(--mnote-sidebar-width);
background: var(--wolai-bg-sidebar);
}
+.mnote-sidebar-resizer {
+ display: block;
+ width: 6px;
+ min-height: 100vh;
+ cursor: col-resize;
+ position: relative;
+ flex: 0 0 auto;
+}
+
+.mnote-sidebar-resizer::before {
+ content: "";
+ position: absolute;
+ left: 2px;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ background: rgba(27, 28, 28, 0.08);
+}
+
+.mnote-sidebar-resizer:hover::before,
+html[data-mnote-sidebar-resizing="true"] .mnote-sidebar-resizer::before {
+ background: rgba(37, 99, 235, 0.42);
+}
+
.wolai-sidebar-header {
height: 52px;
gap: 10px;
@@ -345,6 +371,33 @@ a:hover {
color: #1D4ED8;
}
+.mnote-local-folder-dialog__recent {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+}
+
+.mnote-local-folder-dialog__recent button {
+ min-width: 0;
+ max-width: 100%;
+ border: 1px solid rgba(27, 28, 28, 0.12);
+ border-radius: 6px;
+ background: #fff;
+ padding: 7px 9px;
+ color: var(--wolai-text-primary);
+ font-size: 13px;
+ line-height: 1.4;
+ text-align: left;
+ cursor: pointer;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.mnote-local-folder-dialog__recent button:hover {
+ background: var(--wolai-bg-hover);
+}
+
.mnote-account-menu{inset-inline-start:auto;right:8px;top:calc(100% - 10px);width:194px}.mnote-account-menu__item{display:flex;align-items:center;gap:10px}.mnote-account-menu__logout{color:#C2410C}.mnote-account-menu__logout:hover{background:#FFF7ED}.mnote-account-menu__error{padding:6px 8px 2px;color:#B91C1C;font-size:12px}.mnote-profile-dialog{position:fixed;inset:0;z-index:1200;display:grid;place-items:center}.mnote-profile-dialog__backdrop{position:absolute;inset:0;background:rgba(15,23,42,.22)}.mnote-profile-dialog__panel{position:relative;width:min(440px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;border:1px solid var(--wolai-border);border-radius:8px;background:#fff;box-shadow:0 18px 54px rgba(15,23,42,.18);padding:20px}.mnote-profile-dialog__header,.mnote-profile-dialog__identity,.mnote-profile-dialog__list>div,.mnote-profile-dialog__list dd{display:flex;align-items:center}.mnote-profile-dialog__header{justify-content:space-between;margin-bottom:18px}.mnote-profile-dialog__eyebrow,.mnote-profile-dialog__identity span,.mnote-profile-dialog__list dt{color:var(--wolai-text-secondary);font-size:13px}.mnote-profile-dialog__header h2{font-size:20px}.mnote-profile-dialog__close,.mnote-profile-dialog__list button{border:0;border-radius:6px;background:transparent;color:var(--wolai-text-secondary);cursor:pointer}.mnote-profile-dialog__close{width:32px;height:32px}.mnote-profile-dialog__identity{gap:12px;padding:12px;border-radius:8px;background:var(--wolai-bg-sidebar)}.mnote-profile-dialog__avatar{width:36px;height:36px;display:grid;place-items:center;border-radius:6px;background:#D6545D;color:#fff;font-weight:650}.mnote-profile-dialog__list{margin-top:16px}.mnote-profile-dialog__list>div{justify-content:space-between;gap:16px;padding:11px 0;border-bottom:1px solid var(--wolai-border)}.mnote-profile-dialog__list dd{min-width:0;gap:8px;max-width:280px;font-size:13px;text-align:right;overflow-wrap:anywhere}.mnote-profile-dialog__list code{font-family:"JetBrains Mono","SFMono-Regular",Consolas,monospace;font-size:12px;white-space:normal}
.wolai-quick-actions {
@@ -792,10 +845,15 @@ a:hover {
.mnote-content {
flex: 1;
+ min-height: 0;
overflow-y: auto;
padding: 0;
}
+.mnote-content:has(.document-workspace) {
+ overflow: hidden;
+}
+
/* ===== 首页 ===== */
.mnote-home {
max-width: 720px;
@@ -1311,7 +1369,7 @@ body {
.mnote-sidebar,
.wolai-sidebar {
- width: 248px;
+ width: var(--mnote-sidebar-width, 248px);
background: var(--atelier-sidebar);
border-right: 0;
box-shadow: none;
@@ -1798,7 +1856,8 @@ body {
font-size: 18px;
}
-.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row {
+.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row,
+.document-shell .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"] {
display: inline-flex !important;
align-items: center !important;
gap: 7px !important;
@@ -1812,7 +1871,8 @@ body {
text-decoration: none !important;
}
-.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before {
+.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before,
+.document-shell .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]::before {
content: "" !important;
display: inline-block !important;
width: 18px !important;
@@ -1900,7 +1960,7 @@ body {
background: var(--atelier-document);
}
-.mnote-admin-policy-page{width:min(1080px,calc(100vw - 64px));margin:40px auto 64px}.mnote-admin-policy-modal{position:fixed;inset:0;z-index:1200;display:grid;place-items:center}.mnote-admin-policy-modal__backdrop{position:absolute;inset:0;background:rgba(15,23,42,.22)}.mnote-admin-policy-modal__panel{position:relative;width:min(920px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;border:1px solid var(--wolai-border);border-radius:8px;background:#fff;box-shadow:0 18px 54px rgba(15,23,42,.18);padding:20px}.mnote-admin-policy-modal__close{position:absolute;right:14px;top:14px;width:32px;height:32px;border:0;border-radius:6px;background:transparent;color:var(--wolai-text-secondary);cursor:pointer}.mnote-admin-policy-dialog__content{padding-right:28px}.mnote-admin-policy-header{margin-bottom:20px}.mnote-admin-policy-eyebrow{color:var(--wolai-text-secondary);font-size:13px}.mnote-admin-policy-header h1,.mnote-admin-policy-header h2{font-size:22px;font-weight:650}.mnote-admin-policy-header p,.mnote-admin-policy-note{color:var(--wolai-text-secondary);font-size:14px}.mnote-admin-policy-summary,.mnote-admin-policy-panel,.mnote-admin-policy-form{border:1px solid var(--wolai-border);border-radius:8px;background:#fff}.mnote-admin-policy-summary{display:grid;grid-template-columns:1fr 2fr;gap:16px;padding:16px;margin-bottom:16px}.mnote-admin-policy-summary-label{display:block;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-summary code,.mnote-admin-policy-json{white-space:pre-wrap;overflow-wrap:anywhere;font-family:"JetBrains Mono","SFMono-Regular",Consolas,monospace;font-size:12px}.mnote-admin-policy-panel,.mnote-admin-policy-form{padding:16px;margin-bottom:16px}.mnote-admin-policy-panel-header{display:flex;justify-content:space-between;gap:12px;margin-bottom:12px}.mnote-admin-policy-grid{display:grid;grid-template-columns:minmax(260px,.8fr) minmax(0,1.4fr);gap:16px}.mnote-admin-policy-form{display:flex;flex-direction:column;gap:12px}.mnote-admin-policy-form label{display:flex;flex-direction:column;gap:6px;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-form input,.mnote-admin-policy-form select{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 8px;background:#fff}.mnote-admin-policy-form button,.mnote-admin-policy-panel button,.mnote-admin-policy-row button{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 12px;background:var(--wolai-bg-sidebar);cursor:pointer}.mnote-admin-policy-row button{margin-top:8px;color:#B91C1C}.mnote-admin-policy-json{min-height:48px;max-height:320px;overflow:auto;border-radius:6px;background:var(--wolai-bg-sidebar);padding:10px}.mnote-admin-policy-debug{margin-top:12px}.mnote-admin-policy-table{display:grid;gap:8px}.mnote-admin-policy-row{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(120px,.5fr) minmax(0,.8fr);gap:12px;border:1px solid var(--wolai-border);border-radius:8px;padding:12px}.mnote-admin-policy-row strong,.mnote-admin-policy-row span{display:block;min-width:0}.mnote-admin-policy-row span{color:var(--wolai-text-secondary);font-size:12px;overflow-wrap:anywhere}.mnote-admin-policy-badge{display:inline-flex!important;margin:2px 4px 2px 0;border-radius:999px;padding:2px 8px;background:var(--wolai-bg-sidebar);font-size:12px}.mnote-admin-policy-badge[data-value="write"]{background:#DCFCE7}.mnote-admin-policy-badge[data-value="read"]{background:#DBEAFE}.mnote-admin-policy-badge[data-value="已撤销"]{background:#FEE2E2}.mnote-admin-policy-empty{border:1px dashed var(--wolai-border);border-radius:8px;padding:18px;color:var(--wolai-text-secondary);background:var(--wolai-bg-sidebar);font-size:13px}@media(max-width:960px){.mnote-admin-policy-summary,.mnote-admin-policy-grid,.mnote-admin-policy-row{grid-template-columns:1fr}.mnote-admin-policy-dialog__content{padding-right:0}}
+.mnote-admin-policy-page{width:min(1080px,calc(100vw - 64px));margin:40px auto 64px}.mnote-admin-policy-modal{position:fixed;inset:0;z-index:1200;display:grid;place-items:center}.mnote-admin-policy-modal__backdrop{position:absolute;inset:0;background:rgba(15,23,42,.22)}.mnote-admin-policy-modal__panel{position:relative;width:min(920px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;border:1px solid var(--wolai-border);border-radius:8px;background:#fff;box-shadow:0 18px 54px rgba(15,23,42,.18);padding:20px}.mnote-admin-policy-modal__close{position:absolute;right:14px;top:14px;width:32px;height:32px;border:0;border-radius:6px;background:transparent;color:var(--wolai-text-secondary);cursor:pointer}.mnote-admin-policy-dialog__content{padding-right:28px}.mnote-admin-policy-header{margin-bottom:20px}.mnote-admin-policy-eyebrow{color:var(--wolai-text-secondary);font-size:13px}.mnote-admin-policy-header h1,.mnote-admin-policy-header h2{font-size:22px;font-weight:650}.mnote-admin-policy-header p,.mnote-admin-policy-note{color:var(--wolai-text-secondary);font-size:14px}.mnote-admin-policy-summary,.mnote-admin-policy-panel,.mnote-admin-policy-form{border:1px solid var(--wolai-border);border-radius:8px;background:#fff}.mnote-admin-policy-summary{display:grid;grid-template-columns:1fr 2fr;gap:16px;padding:16px;margin-bottom:16px}.mnote-admin-policy-summary-label{display:block;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-summary code,.mnote-admin-policy-json{white-space:pre-wrap;overflow-wrap:anywhere;font-family:"JetBrains Mono","SFMono-Regular",Consolas,monospace;font-size:12px}.mnote-admin-policy-panel,.mnote-admin-policy-form{padding:16px;margin-bottom:16px}.mnote-admin-policy-panel-header{display:flex;justify-content:space-between;gap:12px;margin-bottom:12px}.mnote-admin-policy-grid{display:grid;grid-template-columns:minmax(260px,.8fr) minmax(0,1.4fr);gap:16px}.mnote-admin-policy-form{display:flex;flex-direction:column;gap:12px}.mnote-admin-policy-form label{display:flex;flex-direction:column;gap:6px;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-form input,.mnote-admin-policy-form select{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 8px;background:#fff}.mnote-admin-policy-form button,.mnote-admin-policy-panel button,.mnote-admin-policy-row button{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 12px;background:var(--wolai-bg-sidebar);cursor:pointer}.mnote-admin-policy-row button{margin-top:8px;color:#B91C1C}.mnote-admin-policy-json{min-height:48px;max-height:320px;overflow:auto;border-radius:6px;background:var(--wolai-bg-sidebar);padding:10px}.mnote-admin-policy-debug{margin-top:12px}.mnote-admin-policy-table{display:grid;gap:8px}.mnote-admin-policy-row{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(120px,.5fr) minmax(0,.8fr);gap:12px;border:1px solid var(--wolai-border);border-radius:8px;padding:12px}.mnote-admin-policy-row>div{min-width:0}.mnote-admin-policy-row strong,.mnote-admin-policy-root-link{overflow-wrap:anywhere}.mnote-admin-policy-row strong,.mnote-admin-policy-root-link,.mnote-admin-policy-row span{display:block;min-width:0}.mnote-admin-policy-root-link{color:var(--wolai-text-primary);font-weight:650;text-decoration:none}.mnote-admin-policy-root-link:hover{text-decoration:underline}.mnote-admin-policy-row span{color:var(--wolai-text-secondary);font-size:12px;overflow-wrap:anywhere}.mnote-admin-policy-badge{display:inline-flex!important;margin:2px 4px 2px 0;border-radius:999px;padding:2px 8px;background:var(--wolai-bg-sidebar);font-size:12px}.mnote-admin-policy-badge[data-value="write"]{background:#DCFCE7}.mnote-admin-policy-badge[data-value="read"]{background:#DBEAFE}.mnote-admin-policy-badge[data-value="已撤销"]{background:#FEE2E2}.mnote-admin-policy-empty{border:1px dashed var(--wolai-border);border-radius:8px;padding:18px;color:var(--wolai-text-secondary);background:var(--wolai-bg-sidebar);font-size:13px}@media(max-width:960px){.mnote-admin-policy-summary,.mnote-admin-policy-grid,.mnote-admin-policy-row{grid-template-columns:1fr}.mnote-admin-policy-dialog__content{padding-right:0}}
.mnote-trash-workbench {
width: min(860px, calc(100vw - 64px));
@@ -2217,9 +2277,12 @@ body {
--mnote-secondary-pane-resizer-width: 6px;
display: grid;
grid-template-columns: minmax(0, 1fr);
- align-items: start;
+ align-items: stretch;
width: 100%;
+ height: 100%;
min-width: 0;
+ min-height: 0;
+ overflow: hidden;
}
.document-workspace[data-has-secondary-pane="true"] {
@@ -2228,6 +2291,28 @@ body {
.document-main-editor-group {
min-width: 0;
+ min-height: 0;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+
+.mnote-main-tab-panels {
+ min-width: 0;
+ min-height: 0;
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+}
+
+.mnote-main-tab-panels > [data-mnote-page-tab-panel] {
+ min-width: 0;
+ min-height: 0;
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
}
.mnote-main-tab-strip {
@@ -2376,8 +2461,20 @@ body {
display: none !important;
}
+.mnote-resource-tab-host {
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+.mnote-resource-tab-panel-root {
+ min-height: 0;
+ height: 100%;
+}
+
.mnote-resource-tab-panel {
min-height: calc(100vh - 76px);
+ height: 100%;
+ min-width: 0;
}
.mnote-resource-tab-frame,
@@ -2439,6 +2536,39 @@ body {
color: var(--color-basic-600, #9B9A97);
}
+.mnote-resource-tab-mindmap-shell {
+ width: 100%;
+ max-width: none;
+ height: 100%;
+ min-height: calc(100vh - 80px);
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.mnote-resource-tab-mindmap-root {
+ width: 100%;
+ height: 100%;
+ min-height: calc(100vh - 80px);
+ overflow: hidden;
+ flex: 1 1 auto;
+}
+
+.document-pane[data-pane-role="secondary"] .mnote-resource-tab-mindmap-shell,
+[data-testid="mnote-secondary-editor-tab-host"] .mnote-resource-tab-mindmap-shell {
+ width: 100%;
+ padding: 0;
+}
+
+.mnote-resource-tab-mindmap-shell [data-testid="mnote-mindmap-editor-root"] {
+ width: 100%;
+ height: 100%;
+ min-height: calc(100vh - 80px);
+ overflow: hidden;
+}
+
.mnote-resource-tab-editor-root {
min-height: calc(100vh - 112px);
}
@@ -2689,6 +2819,10 @@ body {
min-height: 320px;
}
+[data-editor-host-kind="leptos_tiptap_island"] {
+ min-height: 320px;
+}
+
#mnote-leptos-tiptap-island-editor-root .editor-surface {
border: 0 !important;
box-shadow: none !important;
@@ -2696,6 +2830,13 @@ body {
padding: 0 !important;
}
+[data-editor-host-kind="leptos_tiptap_island"] .editor-surface {
+ border: 0 !important;
+ box-shadow: none !important;
+ background: transparent !important;
+ padding: 0 !important;
+}
+
#mnote-leptos-tiptap-island-editor-root .ProseMirror,
.ProseMirror {
color: var(--atelier-text);
@@ -2711,6 +2852,13 @@ body {
padding: 0 !important;
}
+[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror {
+ width: 100% !important;
+ min-height: 280px;
+ margin: 0 !important;
+ padding: 0 !important;
+}
+
#mnote-leptos-tiptap-island-editor-root .block-handle-shell {
left: -32px !important;
z-index: 80;
@@ -2733,17 +2881,32 @@ body {
margin: 0 0 8px;
}
+[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror p {
+ margin: 0 0 8px;
+}
+
#mnote-leptos-tiptap-island-editor-root .ProseMirror ul,
#mnote-leptos-tiptap-island-editor-root .ProseMirror ol {
margin: 4px 0 8px;
padding-left: 1.5em;
}
+[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ul,
+[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ol {
+ margin: 4px 0 8px;
+ padding-left: 1.5em;
+}
+
#mnote-leptos-tiptap-island-editor-root .ProseMirror li {
margin: 2px 0;
padding-left: 2px;
}
+[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror li {
+ margin: 2px 0;
+ padding-left: 2px;
+}
+
#mnote-leptos-tiptap-island-editor-root .ProseMirror blockquote {
margin: 8px 0;
padding: 2px 0 2px 14px;
@@ -2751,6 +2914,13 @@ body {
color: #5F5B56;
}
+[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror blockquote {
+ margin: 8px 0;
+ padding: 2px 0 2px 14px;
+ border-left: 3px solid #D9D6D0;
+ color: #5F5B56;
+}
+
#mnote-leptos-tiptap-island-editor-root .ProseMirror input[type="checkbox"] {
width: 16px;
height: 16px;
@@ -2759,6 +2929,14 @@ body {
accent-color: #2EA44F;
}
+[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ margin: 0 8px 0 0;
+ vertical-align: -3px;
+ accent-color: #2EA44F;
+}
+
#mnote-leptos-tiptap-island-editor-root .ProseMirror h1,
#mnote-leptos-tiptap-island-editor-root .ProseMirror h2,
#mnote-leptos-tiptap-island-editor-root .ProseMirror h3 {
@@ -2766,6 +2944,13 @@ body {
letter-spacing: 0;
}
+[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h1,
+[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h2,
+[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h3 {
+ line-height: 1.2;
+ letter-spacing: 0;
+}
+
.mnote-workspace-active-state {
padding-top: 10px;
}
@@ -2846,6 +3031,13 @@ body {
line-height: 22px;
}
+.document-shell[data-page-small-text="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror,
+.document-shell[data-page-small-text="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror p,
+.document-shell[data-page-small-text="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror li {
+ font-size: 15px;
+ line-height: 22px;
+}
+
.document-shell[data-layout-density="compact"] #mnote-leptos-tiptap-island-editor-root .ProseMirror p,
.document-shell[data-layout-density="compact"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ul,
.document-shell[data-layout-density="compact"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ol,
@@ -2853,6 +3045,13 @@ body {
margin-bottom: 4px;
}
+.document-shell[data-layout-density="compact"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror p,
+.document-shell[data-layout-density="compact"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ul,
+.document-shell[data-layout-density="compact"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ol,
+.document-shell[data-layout-density="compact"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror blockquote {
+ margin-bottom: 4px;
+}
+
.document-shell[data-layout-density="spacious"] #mnote-leptos-tiptap-island-editor-root .ProseMirror p,
.document-shell[data-layout-density="spacious"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ul,
.document-shell[data-layout-density="spacious"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ol,
@@ -2860,20 +3059,39 @@ body {
margin-bottom: 14px;
}
+.document-shell[data-layout-density="spacious"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror p,
+.document-shell[data-layout-density="spacious"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ul,
+.document-shell[data-layout-density="spacious"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ol,
+.document-shell[data-layout-density="spacious"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror blockquote {
+ margin-bottom: 14px;
+}
+
.document-shell[data-page-font="song"] .document-title-input,
.document-shell[data-page-font="song"] #mnote-leptos-tiptap-island-editor-root .ProseMirror {
font-family: "Noto Serif SC", "Songti SC", serif;
}
+.document-shell[data-page-font="song"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror {
+ font-family: "Noto Serif SC", "Songti SC", serif;
+}
+
.document-shell[data-page-font="kai"] .document-title-input,
.document-shell[data-page-font="kai"] #mnote-leptos-tiptap-island-editor-root .ProseMirror {
font-family: "STKaiti", "KaiTi", serif;
}
+.document-shell[data-page-font="kai"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror {
+ font-family: "STKaiti", "KaiTi", serif;
+}
+
.document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror {
counter-reset: mnote-heading;
}
+.document-shell[data-page-show-heading-numbers="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror {
+ counter-reset: mnote-heading;
+}
+
.document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror h1::before,
.document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror h2::before,
.document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror h3::before {
@@ -2883,6 +3101,15 @@ body {
font-weight: 500;
}
+.document-shell[data-page-show-heading-numbers="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h1::before,
+.document-shell[data-page-show-heading-numbers="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h2::before,
+.document-shell[data-page-show-heading-numbers="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h3::before {
+ counter-increment: mnote-heading;
+ content: counter(mnote-heading) ". ";
+ color: #8B8782;
+ font-weight: 500;
+}
+
.wolai-page-settings-popover {
position: fixed;
top: 52px;
@@ -4025,6 +4252,10 @@ button.wolai-page-ai-message-text {
width: 220px;
}
+ .mnote-sidebar-resizer {
+ display: none;
+ }
+
.wolai-topbar {
padding: 0 12px;
}
@@ -4091,6 +4322,10 @@ button.wolai-page-ai-message-text {
box-shadow: none;
}
+ .mnote-sidebar-resizer {
+ display: none;
+ }
+
.document-shell {
padding: 36px 20px 112px;
}
@@ -4141,6 +4376,9 @@ mod tests {
assert!(MNOTE_CSS.contains("#mnote-editor-island"));
assert!(MNOTE_CSS.contains("#mnote-search-island"));
assert!(MNOTE_CSS.contains("#mnote-mindmap-island"));
+ assert!(MNOTE_CSS.contains("--mnote-sidebar-width"));
+ assert!(MNOTE_CSS.contains(".mnote-sidebar-resizer"));
+ assert!(MNOTE_CSS.contains(".mnote-local-folder-dialog__recent button"));
}
#[test]
@@ -4176,6 +4414,15 @@ mod tests {
assert!(MNOTE_CSS.contains("::-webkit-scrollbar-thumb"));
}
+ #[test]
+ fn admin_policy_rows_wrap_long_grant_values() {
+ assert!(MNOTE_CSS.contains(
+ ".mnote-admin-policy-row strong,.mnote-admin-policy-root-link{overflow-wrap:anywhere"
+ ));
+ assert!(MNOTE_CSS.contains(".mnote-admin-policy-row>div{min-width:0"));
+ assert!(MNOTE_CSS.contains(".mnote-admin-policy-root-link:hover"));
+ }
+
#[test]
fn mnote_css_contains_prosemirror_styles() {
assert!(MNOTE_CSS.contains(".ProseMirror"));
diff --git a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs
index 6d1eedd6..5eb231f6 100644
--- a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs
+++ b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs
@@ -90,7 +90,7 @@ fn render_filetree_row(
};
let owner_document_id = row.document_id.as_deref().unwrap_or_default();
html.push_str(&format!(
- r#"
{toggle_html}
{create_action_html}
"#,
+ r#"{toggle_html}
{create_action_html}
"#,
node_id = escape_html(&row.node_id),
aria_level = row.depth + 1,
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
@@ -217,6 +217,8 @@ mod tests {
assert!(html.contains("data-object-identity=\"{"objectKind":"mindmap""));
assert!(html.contains("tree-children"));
assert!(html.contains("首页 <安全>"));
+ assert!(html.contains("title=\"首页 <安全>\""));
+ assert!(html.contains("title=\"思维导图.json\""));
assert!(html.contains("data-selected=\"true\""));
}
}
diff --git a/scripts/task165-rust-web-dual-pane-smoke.js b/scripts/task165-rust-web-dual-pane-smoke.js
index 7971d137..48028f50 100644
--- a/scripts/task165-rust-web-dual-pane-smoke.js
+++ b/scripts/task165-rust-web-dual-pane-smoke.js
@@ -115,7 +115,7 @@ function buildFixtureEnv(port) {
function startGateway(port) {
return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
- cwd: "/mnt/Data1T/mnote/rust",
+ cwd: path.resolve(__dirname, "..", "rust"),
env: buildFixtureEnv(port),
stdio: ["ignore", "pipe", "pipe"],
});
@@ -123,6 +123,21 @@ function startGateway(port) {
function createLocalFolderFixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-dual-pane-"));
+ const mnoteDir = path.join(root, ".mnote");
+ fs.mkdirSync(mnoteDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(mnoteDir, "workspace.json"),
+ JSON.stringify(
+ {
+ workspaceId: "local-ws:dual-pane-smoke",
+ ownerId: "user_real",
+ capabilities: ["local_files", "markdown_edit", "asset_upload"],
+ },
+ null,
+ 2,
+ ),
+ "utf8",
+ );
const readmePath = path.join(root, "README.md");
const sidePath = path.join(root, "side.md");
const thirdPath = path.join(root, "third.md");
@@ -184,6 +199,10 @@ function paneSelector(role) {
return `.document-pane[data-pane-role="${role}"]`;
}
+function paneScrollHostSelector(role) {
+ return `.document-main-editor-group[data-pane-role="${role}"]`;
+}
+
async function waitForDualPaneReady(page) {
await page.waitForFunction(
({ primarySelector, secondarySelector, primaryRootSelector, secondaryRootSelector }) => {
@@ -294,6 +313,31 @@ async function waitForPaneStatus(page, role, status) {
);
}
+async function waitForPaneStatusWithSnapshot(page, role, status, label) {
+ try {
+ await waitForPaneStatus(page, role, status);
+ } catch (error) {
+ const snapshot = await readDocumentSessionSnapshot(page).catch((snapshotError) => ({
+ error: snapshotError && snapshotError.stack ? snapshotError.stack : String(snapshotError),
+ }));
+ const paneState = await page.evaluate(({ rootSelector, paneSelector }) => {
+ const root = document.querySelector(rootSelector);
+ const pane = document.querySelector(paneSelector);
+ return {
+ status: root?.getAttribute("data-runtime-editor-status") || "",
+ error: root?.getAttribute("data-runtime-editor-error") || "",
+ panelText: pane?.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
+ };
+ }, {
+ rootSelector: paneRootSelector(role),
+ paneSelector: paneSelector(role),
+ }).catch((stateError) => ({
+ error: stateError && stateError.stack ? stateError.stack : String(stateError),
+ }));
+ throw new Error(`${label} 等待 ${role}=${status} 失败: pane=${JSON.stringify(paneState)} sessions=${JSON.stringify(snapshot)}`, { cause: error });
+ }
+}
+
async function readPaneText(page, role) {
return await page.evaluate((selector) => {
const editor = document.querySelector(selector);
@@ -309,20 +353,24 @@ async function readActivePaneRole(page) {
}
async function readPaneScrollState(page, role) {
- return await page.evaluate(({ paneSelector, editorSelector }) => {
- const pane = document.querySelector(paneSelector);
+ return await page.evaluate(({ scrollHostSelector, editorSelector }) => {
+ const scrollHost = document.querySelector(scrollHostSelector);
const editor = document.querySelector(editorSelector);
- const findScrollable = (start) => {
+ const findScrollable = (start, boundary) => {
let current = start;
while (current instanceof HTMLElement) {
- if (current.scrollHeight > current.clientHeight + 8) {
+ const overflowY = window.getComputedStyle(current).overflowY;
+ if (current.scrollHeight > current.clientHeight + 8 && /auto|scroll|overlay/.test(overflowY)) {
return current;
}
+ if (current === boundary) {
+ break;
+ }
current = current.parentElement;
}
return null;
};
- const target = findScrollable(editor) || findScrollable(pane);
+ const target = scrollHost instanceof HTMLElement ? (findScrollable(editor, scrollHost) || findScrollable(scrollHost, scrollHost)) : null;
if (!(target instanceof HTMLElement)) {
return null;
}
@@ -332,26 +380,30 @@ async function readPaneScrollState(page, role) {
clientHeight: target.clientHeight,
};
}, {
- paneSelector: paneSelector(role),
+ scrollHostSelector: paneScrollHostSelector(role),
editorSelector: paneEditorSelector(role),
});
}
async function setPaneScrollTop(page, role, top) {
- return await page.evaluate(({ paneSelector, editorSelector, topValue }) => {
- const pane = document.querySelector(paneSelector);
+ return await page.evaluate(({ scrollHostSelector, editorSelector, topValue }) => {
+ const scrollHost = document.querySelector(scrollHostSelector);
const editor = document.querySelector(editorSelector);
- const findScrollable = (start) => {
+ const findScrollable = (start, boundary) => {
let current = start;
while (current instanceof HTMLElement) {
- if (current.scrollHeight > current.clientHeight + 8) {
+ const overflowY = window.getComputedStyle(current).overflowY;
+ if (current.scrollHeight > current.clientHeight + 8 && /auto|scroll|overlay/.test(overflowY)) {
return current;
}
+ if (current === boundary) {
+ break;
+ }
current = current.parentElement;
}
return null;
};
- const target = findScrollable(editor) || findScrollable(pane);
+ const target = scrollHost instanceof HTMLElement ? (findScrollable(editor, scrollHost) || findScrollable(scrollHost, scrollHost)) : null;
if (!(target instanceof HTMLElement)) {
return null;
}
@@ -362,7 +414,7 @@ async function setPaneScrollTop(page, role, top) {
clientHeight: target.clientHeight,
};
}, {
- paneSelector: paneSelector(role),
+ scrollHostSelector: paneScrollHostSelector(role),
editorSelector: paneEditorSelector(role),
topValue: top,
});
@@ -379,6 +431,11 @@ async function setViewportScroll(page, top) {
}, top);
}
+async function clickSidebarRowOpen(page, text) {
+ const row = page.getByTestId("wolai-sidebar-row").filter({ hasText: text }).first();
+ await row.locator(".tree-link").first().click({ timeout: UI_TIMEOUT_MS });
+}
+
async function waitForRequestCount(requests, startIndex, predicate, expected, label) {
const deadline = Date.now() + UI_TIMEOUT_MS;
while (Date.now() < deadline) {
@@ -401,6 +458,100 @@ async function readDocumentSessionSnapshot(page) {
});
}
+async function waitForTreeLiveConnected(page, label) {
+ await page.waitForFunction(
+ () => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected",
+ {},
+ { timeout: UI_TIMEOUT_MS },
+ );
+ const snapshot = await readTreeLiveSnapshot(page);
+ assert(snapshot.transport, `${label} 应暴露 tree live transport`);
+ assert(snapshot.sourceKind, `${label} 应持有 tree live source`);
+ return snapshot;
+}
+
+async function readTreeLiveSnapshot(page) {
+ return await page.evaluate(() => {
+ const source = window.__mnoteTreeLiveEventSource || null;
+ if (!window.__mnoteSmokeTreeLiveSourceIds) {
+ window.__mnoteSmokeTreeLiveSourceIds = new WeakMap();
+ window.__mnoteSmokeTreeLiveNextSourceId = 1;
+ }
+ let sourceId = "";
+ if (source && typeof source === "object") {
+ if (!window.__mnoteSmokeTreeLiveSourceIds.has(source)) {
+ window.__mnoteSmokeTreeLiveSourceIds.set(source, window.__mnoteSmokeTreeLiveNextSourceId++);
+ }
+ sourceId = String(window.__mnoteSmokeTreeLiveSourceIds.get(source) || "");
+ }
+ return {
+ status: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
+ transport: document.documentElement.getAttribute("data-mnote-tree-live-transport") || "",
+ sourceKind: source
+ ? (typeof WebSocket !== "undefined" && source instanceof WebSocket
+ ? "websocket"
+ : (typeof EventSource !== "undefined" && source instanceof EventSource ? "eventsource" : "unknown"))
+ : "",
+ sourceId,
+ url: source && typeof source.url === "string" ? source.url : "",
+ readyState: source && typeof source.readyState === "number" ? source.readyState : null,
+ };
+ });
+}
+
+function requestUrl(record) {
+ try {
+ return new URL(record.url);
+ } catch {
+ return null;
+ }
+}
+
+function summarizeLocalFolderEventRequests(requests, startIndex) {
+ const summary = {
+ total: 0,
+ documentChannel: 0,
+ treeLive: 0,
+ byRootUri: {},
+ };
+ for (const record of requests.slice(startIndex)) {
+ if (record.method !== "GET" || !record.url.includes("/api/local-folder/events")) continue;
+ const url = requestUrl(record);
+ const rootUri = url?.searchParams.get("rootUri") || "";
+ const phase = url?.searchParams.get("treeLive") === "true" ? "treeLive" : "documentChannel";
+ summary.total += 1;
+ summary[phase] += 1;
+ if (!summary.byRootUri[rootUri]) {
+ summary.byRootUri[rootUri] = { total: 0, documentChannel: 0, treeLive: 0 };
+ }
+ summary.byRootUri[rootUri].total += 1;
+ summary.byRootUri[rootUri][phase] += 1;
+ }
+ return summary;
+}
+
+function recordLocalFolderEventDiagnostics(diagnostics, label, requests, startIndex, snapshot) {
+ diagnostics.localFolderEventPhases.push({
+ label,
+ requests: summarizeLocalFolderEventRequests(requests, startIndex),
+ snapshot,
+ });
+}
+
+async function waitForLocalFolderChannelCount(page, expected, label) {
+ const deadline = Date.now() + UI_TIMEOUT_MS;
+ let snapshot = null;
+ while (Date.now() < deadline) {
+ snapshot = await readDocumentSessionSnapshot(page);
+ if (snapshot.localFolderChannelCount === expected) {
+ return snapshot;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ assert.equal(snapshot?.localFolderChannelCount, expected, label);
+ return snapshot;
+}
+
async function readNoReloadProbe(page) {
return await page.evaluate(({ primaryRootSelector, secondaryRootSelector }) => ({
pagehideCount: Number(window.sessionStorage?.getItem("__mnoteSmokePagehideCount") || "0"),
@@ -425,25 +576,31 @@ function countRequests(requests, startIndex, predicate) {
}
function isSaveRequest(record) {
- return record.method === "POST" && record.url.includes("/api/documents/save");
+ return record.method === "POST"
+ && (record.url.includes("/api/documents/save") || record.url.includes("/api/page-body/write"));
}
function isTreeEventRequest(record) {
- return record.method === "GET" && record.url.includes("/api/tree/events");
+ return (record.method === "GET" && record.url.includes("/api/tree/events"))
+ || (record.method === "WS" && record.url.includes("/api/realtime/ws"));
}
function isLocalFolderEventRequest(record) {
- return record.method === "GET" && record.url.includes("/api/local-folder/events");
+ if (record.method !== "GET" || !record.url.includes("/api/local-folder/events")) return false;
+ try {
+ const url = new URL(record.url);
+ return url.searchParams.get("treeLive") !== "true";
+ } catch {
+ return !record.url.includes("treeLive=true");
+ }
}
async function runFixturePhase(page, baseUrl, requests) {
const url = `${baseUrl}/documents/doc_1?workspaceId=ws_demo&secondaryDocumentId=doc_1`;
- const openIndex = requests.length;
await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForDualPaneReady(page);
- await waitForRequestCount(requests, openIndex, isTreeEventRequest, 1, "tree EventSource 建连");
+ await waitForTreeLiveConnected(page, "fixture tree live 建连");
await page.waitForTimeout(800);
- assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 1, "双 pane fixture 页面不应建立第二条 tree EventSource");
const closeButton = page.locator('[data-mnote-pane-close="secondary"]').first();
await closeButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -453,8 +610,8 @@ async function runFixturePhase(page, baseUrl, requests) {
await typePaneText(page, "primary", primaryToSecondary);
await waitForPaneText(page, "primary", primaryToSecondary);
await waitForPaneText(page, "secondary", primaryToSecondary);
- await waitForPaneStatus(page, "primary", "saved");
- await waitForPaneStatus(page, "secondary", "saved");
+ await waitForPaneStatusWithSnapshot(page, "primary", "saved", "cross-doc primary save");
+ await waitForPaneStatusWithSnapshot(page, "secondary", "saved", "cross-doc secondary save");
await page.waitForTimeout(800);
assert.equal(countRequests(requests, primarySaveIndex, isSaveRequest), 1, "同 session 双 view 主 pane 输入后应只触发一次保存请求");
assert.equal(await readActivePaneRole(page), "primary", "primary 输入后焦点不应被同步到 secondary");
@@ -464,26 +621,24 @@ async function runFixturePhase(page, baseUrl, requests) {
await typePaneText(page, "secondary", secondaryToPrimary);
await waitForPaneText(page, "primary", secondaryToPrimary);
await waitForPaneText(page, "secondary", secondaryToPrimary);
- await waitForPaneStatus(page, "primary", "saved");
- await waitForPaneStatus(page, "secondary", "saved");
+ await waitForPaneStatusWithSnapshot(page, "primary", "saved", "different-doc primary save");
+ await waitForPaneStatusWithSnapshot(page, "secondary", "saved", "different-doc secondary save");
await page.waitForTimeout(800);
assert.equal(countRequests(requests, secondarySaveIndex, isSaveRequest), 1, "同 session 双 view 次 pane 输入后应只触发一次保存请求");
assert.equal(await readActivePaneRole(page), "secondary", "secondary 输入后焦点不应被同步到 primary");
- const reloadIndex = requests.length;
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForDualPaneReady(page);
- await waitForRequestCount(requests, reloadIndex, isTreeEventRequest, 1, "reload 后 tree EventSource 建连");
+ await waitForTreeLiveConnected(page, "reload 后 tree live 建连");
await page.waitForTimeout(800);
- assert.equal(countRequests(requests, reloadIndex, isTreeEventRequest), 1, "reload 后双 pane 仍应只建立一条 tree EventSource");
const fixtureSnapshot = await readDocumentSessionSnapshot(page);
assert.equal(fixtureSnapshot.sessionCount, 1, "同文档双开时应只复用一个 session");
assert.equal(fixtureSnapshot.sessions[0]?.viewCount, 2, "同文档双开时单 session 应挂两个 view");
- const navigationIndex = requests.length;
const beforeFixtureNavigation = await readNoReloadProbe(page);
+ const beforeTreeLiveNavigation = await readTreeLiveSnapshot(page);
assert(beforeFixtureNavigation.secondaryMountId, "fixture 导航前应已挂载 secondary editor");
- await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Fixture Other" }).first().click({ timeout: UI_TIMEOUT_MS });
+ await clickSidebarRowOpen(page, "Fixture Other");
await waitForDocumentPath(page, "doc_other");
await waitForDualPaneReady(page);
await page.waitForTimeout(800);
@@ -498,7 +653,12 @@ async function runFixturePhase(page, baseUrl, requests) {
beforeFixtureNavigation.secondaryMountId,
"fixture sidebar 导航不应重挂 secondary editor",
);
- assert.equal(countRequests(requests, navigationIndex, isTreeEventRequest), 0, "fixture sidebar 导航不应重建 tree EventSource");
+ const afterTreeLiveNavigation = await readTreeLiveSnapshot(page);
+ assert.equal(
+ afterTreeLiveNavigation.sourceId,
+ beforeTreeLiveNavigation.sourceId,
+ "fixture sidebar 导航不应重建 tree live source",
+ );
const navigatedFixtureUrl = new URL(page.url());
assert.equal(
navigatedFixtureUrl.searchParams.get("secondaryDocumentId"),
@@ -507,15 +667,18 @@ async function runFixturePhase(page, baseUrl, requests) {
);
}
-async function runLocalFolderPhase(page, baseUrl, requests, fixture) {
+async function runLocalFolderPhase(page, baseUrl, requests, fixture, diagnostics) {
const differentDocUrl = `${baseUrl}/documents/${encodeURIComponent(fixture.documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(fixture.rootUri)}&secondaryDocumentId=${encodeURIComponent(fixture.sideDocumentId)}&secondarySourceKind=local_folder&secondaryRootUri=${encodeURIComponent(fixture.rootUri)}`;
const differentDocIndex = requests.length;
await page.goto(differentDocUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForDualPaneReady(page);
- await waitForRequestCount(requests, differentDocIndex, isLocalFolderEventRequest, 1, "不同文档 local folder EventSource 建连");
await page.waitForTimeout(800);
- assert.equal(countRequests(requests, differentDocIndex, isLocalFolderEventRequest), 1, "同 rootUri 不同文档双 pane 也应只复用一条 local-folder EventSource");
- const differentDocSnapshot = await readDocumentSessionSnapshot(page);
+ const differentDocSnapshot = await waitForLocalFolderChannelCount(
+ page,
+ 1,
+ "同 rootUri 不同文档双开时应只复用一个 local-folder channel",
+ );
+ recordLocalFolderEventDiagnostics(diagnostics, "different-doc-open", requests, differentDocIndex, differentDocSnapshot);
assert.equal(differentDocSnapshot.sessionCount, 2, "不同文档双开时应建立两个 session");
assert.equal(differentDocSnapshot.localFolderChannelCount, 1, "同 rootUri 不同文档双开时应只复用一个 local-folder channel");
assert.deepEqual(
@@ -523,7 +686,49 @@ async function runLocalFolderPhase(page, baseUrl, requests, fixture) {
[fixture.documentId, fixture.sideDocumentId].sort(),
"不同文档双开时 session 应分别归属到两个 documentId",
);
- await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Local Third" }).first().click({ timeout: UI_TIMEOUT_MS });
+
+ const crossDocPrimaryText = `cross-doc-primary-${Date.now().toString().slice(-6)}`;
+ const crossDocSecondaryText = `cross-doc-secondary-${(Date.now() + 1).toString().slice(-6)}`;
+ const crossDocPrimarySaveIndex = requests.length;
+ await typePaneText(page, "primary", crossDocPrimaryText);
+ await waitForPaneText(page, "primary", crossDocPrimaryText);
+ await waitForPaneStatusWithSnapshot(page, "primary", "saved", "same-doc primary save");
+ await page.waitForTimeout(800);
+ assert.equal(
+ countRequests(requests, crossDocPrimarySaveIndex, isSaveRequest),
+ 1,
+ "同 rootUri 不同文档时 primary pane 输入后应只触发一次保存请求",
+ );
+ const afterPrimaryCrossDocSnapshot = await readDocumentSessionSnapshot(page);
+ const afterPrimarySecondarySession = afterPrimaryCrossDocSnapshot.sessions.find((item) => item.documentId === fixture.sideDocumentId);
+ assert(afterPrimarySecondarySession, "primary save 后 secondary session 应存在");
+ assert.equal(
+ afterPrimarySecondarySession.status,
+ "saved",
+ `primary save 后 secondary session 不应被误判冲突: ${JSON.stringify(afterPrimarySecondarySession)}`,
+ );
+
+ const crossDocSecondarySaveIndex = requests.length;
+ await typePaneText(page, "secondary", crossDocSecondaryText);
+ await waitForPaneText(page, "secondary", crossDocSecondaryText);
+ await waitForPaneStatusWithSnapshot(page, "secondary", "saved", "same-doc secondary save");
+ await page.waitForTimeout(800);
+ assert.equal(
+ countRequests(requests, crossDocSecondarySaveIndex, isSaveRequest),
+ 1,
+ "同 rootUri 不同文档时 secondary pane 输入后应只触发一次保存请求",
+ );
+
+ const crossDocSnapshot = await readDocumentSessionSnapshot(page);
+ const primarySession = crossDocSnapshot.sessions.find((item) => item.documentId === fixture.documentId);
+ const secondarySession = crossDocSnapshot.sessions.find((item) => item.documentId === fixture.sideDocumentId);
+ assert(primarySession, "cross-doc primary session 应存在");
+ assert(secondarySession, "cross-doc secondary session 应存在");
+ assert.equal(primarySession.status, "saved", `cross-doc primary session 不应进入冲突态: ${JSON.stringify(primarySession)}`);
+ assert.notEqual(secondarySession.status, "external-change-conflict", `cross-doc secondary session 不应进入冲突态: ${JSON.stringify(secondarySession)}`);
+ assert.notEqual(secondarySession.dirtyState, "ExternalModified", `cross-doc secondary session 不应被误判为外部修改: ${JSON.stringify(secondarySession)}`);
+
+ await clickSidebarRowOpen(page, "Local Third");
await waitForDocumentPath(page, fixture.thirdDocumentId);
await waitForDualPaneReady(page);
const navigatedUrl = new URL(page.url());
@@ -561,23 +766,33 @@ async function runLocalFolderPhase(page, baseUrl, requests, fixture) {
const openIndex = requests.length;
await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForDualPaneReady(page);
- await waitForRequestCount(requests, openIndex, isLocalFolderEventRequest, 1, "local folder EventSource 建连");
await page.waitForTimeout(800);
- assert.equal(countRequests(requests, openIndex, isLocalFolderEventRequest), 1, "同 rootUri 双 pane 不应建立第二条 local-folder EventSource");
+ const sameDocOpenSnapshot = await waitForLocalFolderChannelCount(page, 1, "同 rootUri 双 pane 应只保留一个 local-folder channel");
+ recordLocalFolderEventDiagnostics(diagnostics, "same-doc-open", requests, openIndex, sameDocOpenSnapshot);
assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 0, "local folder 页面不应建立 tree EventSource");
const reloadIndex = requests.length;
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForDualPaneReady(page);
- await waitForRequestCount(requests, reloadIndex, isLocalFolderEventRequest, 1, "reload 后 local folder EventSource 建连");
await page.waitForTimeout(800);
- assert.equal(countRequests(requests, reloadIndex, isLocalFolderEventRequest), 1, "reload 后同 rootUri 双 pane 仍应只建立一条 local-folder EventSource");
- const sameDocSnapshot = await readDocumentSessionSnapshot(page);
+ const sameDocSnapshot = await waitForLocalFolderChannelCount(page, 1, "reload 后同 rootUri 双 pane 仍应只保留一个 local-folder channel");
+ recordLocalFolderEventDiagnostics(diagnostics, "same-doc-reload", requests, reloadIndex, sameDocSnapshot);
assert.equal(sameDocSnapshot.sessionCount, 1, "同文档 local folder 双开时应只复用一个 session");
assert.equal(sameDocSnapshot.sessions[0]?.viewCount, 2, "同文档 local folder 双开时单 session 应挂两个 view");
- const viewportBefore = await setViewportScroll(page, 260);
- assert((viewportBefore?.y || 0) >= 200, "本地双栏页面应可滚动到可观察位置");
+ const primaryScrollBefore = await readPaneScrollState(page, "primary");
+ const secondaryScrollBefore = await readPaneScrollState(page, "secondary");
+ assert(primaryScrollBefore && primaryScrollBefore.scrollHeight > primaryScrollBefore.clientHeight, `primary pane 应有独立滚动容器: ${JSON.stringify(primaryScrollBefore)}`);
+ assert(secondaryScrollBefore && secondaryScrollBefore.scrollHeight > secondaryScrollBefore.clientHeight, `secondary pane 应有独立滚动容器: ${JSON.stringify(secondaryScrollBefore)}`);
+ const primaryScrollAfter = await setPaneScrollTop(page, "primary", 260);
+ const secondaryScrollAfter = await setPaneScrollTop(page, "secondary", 40);
+ assert((primaryScrollAfter?.scrollTop || 0) > 120, `primary pane 内滚动应生效: ${JSON.stringify(primaryScrollAfter)}`);
+ assert((secondaryScrollAfter?.scrollTop || 0) < 120, `secondary pane 内滚动应独立于 primary: ${JSON.stringify(secondaryScrollAfter)}`);
+ const primaryScrollFinal = await readPaneScrollState(page, "primary");
+ assert((primaryScrollFinal?.scrollTop || 0) > 120, `secondary pane 滚动不应重置 primary pane: ${JSON.stringify(primaryScrollFinal)}`);
+ const primaryScrollBeforeSync = primaryScrollFinal;
+ const secondaryScrollBeforeSync = await readPaneScrollState(page, "secondary");
+
const externalText = `external-sync-${Date.now().toString().slice(-6)}`;
fs.writeFileSync(
fixture.readmePath,
@@ -597,10 +812,15 @@ async function runLocalFolderPhase(page, baseUrl, requests, fixture) {
await waitForPaneStatus(page, "primary", "synced-external-change");
await waitForPaneStatus(page, "secondary", "synced-external-change");
await page.waitForTimeout(800);
- const viewportAfter = await readViewportScroll(page);
+ const primaryScrollAfterSync = await readPaneScrollState(page, "primary");
+ const secondaryScrollAfterSync = await readPaneScrollState(page, "secondary");
assert(
- Math.abs((viewportAfter?.y || 0) - (viewportBefore?.y || 0)) < 40,
- "远端 replaceContent 后共享页面滚动不应被重置",
+ Math.abs((primaryScrollAfterSync?.scrollTop || 0) - (primaryScrollBeforeSync?.scrollTop || 0)) < 40,
+ `远端 replaceContent 后 primary pane 滚动不应被重置: before=${JSON.stringify(primaryScrollBeforeSync)} after=${JSON.stringify(primaryScrollAfterSync)}`,
+ );
+ assert(
+ Math.abs((secondaryScrollAfterSync?.scrollTop || 0) - (secondaryScrollBeforeSync?.scrollTop || 0)) < 40,
+ `远端 replaceContent 后 secondary pane 滚动不应被重置: before=${JSON.stringify(secondaryScrollBeforeSync)} after=${JSON.stringify(secondaryScrollAfterSync)}`,
);
const savedMarkdown = fs.readFileSync(fixture.readmePath, "utf8");
@@ -681,6 +901,7 @@ async function main() {
const gateway = useExistingServer ? null : startGateway(port);
const localFixture = createLocalFolderFixture();
const requests = [];
+ const diagnostics = { localFolderEventPhases: [] };
let stderr = "";
let stdout = "";
@@ -694,7 +915,14 @@ async function main() {
await waitForGateway(baseUrl);
const browser = await chromium.launch({ headless: true });
- const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
+ const context = await browser.newContext({
+ viewport: { width: 1440, height: 960 },
+ locale: "zh-CN",
+ extraHTTPHeaders: {
+ "x-mnote-actor-id": "user_real",
+ "x-mnote-actor-type": "user",
+ },
+ });
await context.addInitScript(() => {
const key = "__mnoteSmokePagehideCount";
window.addEventListener("pagehide", () => {
@@ -718,12 +946,20 @@ async function main() {
});
});
+ page.on("websocket", (socket) => {
+ requests.push({
+ url: socket.url(),
+ method: "WS",
+ payload: null,
+ });
+ });
+
let caughtError = null;
try {
if (!useExistingServer) {
await runFixturePhase(page, baseUrl, requests);
}
- await runLocalFolderPhase(page, baseUrl, requests, localFixture);
+ await runLocalFolderPhase(page, baseUrl, requests, localFixture, diagnostics);
await runSinglePaneTitlePhase(page, baseUrl, localFixture);
console.log(
JSON.stringify(
@@ -737,6 +973,7 @@ async function main() {
treeEventRequests: requests.filter(isTreeEventRequest).length,
localFolderEventRequests: requests.filter(isLocalFolderEventRequest).length,
},
+ diagnostics,
},
null,
2,
diff --git a/scripts/task443-filetree-mindmap-click-active-row-smoke.js b/scripts/task443-filetree-mindmap-click-active-row-smoke.js
index 57f93678..babb0050 100644
--- a/scripts/task443-filetree-mindmap-click-active-row-smoke.js
+++ b/scripts/task443-filetree-mindmap-click-active-row-smoke.js
@@ -1,41 +1,105 @@
#!/usr/bin/env node
"use strict";
-const fs = require("node:fs/promises");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const fsp = require("node:fs/promises");
+const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
-const {
- BASE_URL,
- UI_TIMEOUT_MS,
- assert,
- cleanupDocuments,
- createTempDocument,
- ensureAuthenticated,
- openDocument,
- openFilesystemView,
- renameDocument,
- requestJson,
-} = require("./tree-shell-smoke-helpers");
+
+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 || 30_000);
+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));
const TASK = "task443-filetree-mindmap-click-active-row-smoke";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUT_DIR, "result.json");
+const ACTOR_ID = "user_real";
-async function writeResult(result) {
- await fs.mkdir(OUT_DIR, { recursive: true });
- await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
+function fileUrl(localPath) {
+ return `file://${localPath}`;
}
-async function createMindmap(request, workspaceId, documentId, mindmapId, title) {
- return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
- method: "POST",
- data: {
- commandName: "mindmaps.put",
- workspaceId,
- createOnly: true,
- data: { root: { data: { text: title }, children: [] } },
- },
- });
+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) {
+ fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
+ fs.writeFileSync(
+ path.join(root, ".mnote", "workspace.json"),
+ `${JSON.stringify({
+ workspaceId: `local-ws:${ACTOR_ID}:task443`,
+ ownerId: ACTOR_ID,
+ createdAt: new Date().toISOString(),
+ capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"],
+ }, null, 2)}\n`,
+ "utf8",
+ );
+}
+
+function writeMindmapFixture(root, stamp) {
+ const pageDir = path.join(root, "Task443");
+ fs.mkdirSync(pageDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(pageDir, "Task443.md"),
+ [
+ "---",
+ `title: TEST-443-mindmap-active-${stamp}`,
+ "---",
+ "",
+ "# TEST 443",
+ "",
+ `[TEST-443-mind-${stamp}](map-${stamp}.mindmap.json)`,
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+ fs.writeFileSync(
+ path.join(pageDir, `map-${stamp}.mindmap.json`),
+ `${JSON.stringify({ data: { uid: "root", text: `TEST-443-mind-${stamp}` }, children: [] }, null, 2)}\n`,
+ "utf8",
+ );
+ return {
+ relativePath: "Task443/Task443.md",
+ documentId: localMdDocumentId("Task443/Task443.md"),
+ mindmapFileName: `map-${stamp}.mindmap.json`,
+ };
+}
+
+async function writeResult(result) {
+ await fsp.mkdir(OUT_DIR, { recursive: true });
+ await fsp.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
+}
+
+async function waitForMindmapRow(page, mindmapFileName) {
+ await page.waitForFunction((fileName) => {
+ return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
+ .some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName));
+ }, mindmapFileName, { timeout: UI_TIMEOUT_MS });
+}
+
+async function clickMindmapRow(page, mindmapFileName) {
+ await page.evaluate((fileName) => {
+ const row = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
+ .find((candidate) => (candidate.getAttribute("data-asset-id") || "").includes(fileName));
+ const link = row?.querySelector(".tree-link");
+ if (!(link instanceof HTMLElement)) {
+ throw new Error(`找不到 mindmap 文件行: ${fileName}`);
+ }
+ link.click();
+ }, mindmapFileName);
}
async function readSelectedFileTreeRows(page) {
@@ -44,6 +108,7 @@ async function readSelectedFileTreeRows(page) {
rowId: row.getAttribute("data-row-id") || "",
rowKind: row.getAttribute("data-row-kind") || "",
docId: row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "",
+ ownerDocumentId: row.getAttribute("data-owner-document-id") || "",
assetId: row.getAttribute("data-asset-id") || "",
objectIdentity: row.getAttribute("data-object-identity") || "",
title: (row.textContent || "").trim().slice(0, 160),
@@ -52,9 +117,23 @@ async function readSelectedFileTreeRows(page) {
}
(async () => {
- await fs.mkdir(OUT_DIR, { recursive: true });
- const browser = await chromium.launch({ headless: true });
- const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
+ await fsp.mkdir(OUT_DIR, { recursive: true });
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task443-local-mindmap-"));
+ const stamp = Date.now().toString().slice(-8);
+ writeWorkspaceManifest(root);
+ const fixture = writeMindmapFixture(root, stamp);
+
+ const browser = await chromium.launch({
+ headless: true,
+ ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
+ });
+ const context = await browser.newContext({
+ viewport: { width: 1440, height: 900 },
+ extraHTTPHeaders: {
+ "x-mnote-actor-id": ACTOR_ID,
+ "x-mnote-actor-type": "user",
+ },
+ });
const page = await context.newPage();
const navEvents = [];
const network = [];
@@ -68,10 +147,9 @@ async function readSelectedFileTreeRows(page) {
}
});
- let doc = null;
const result = {
baseUrl: BASE_URL,
- fixture: {},
+ fixture: { root, ...fixture },
beforeSelectedRows: [],
afterSelectedRows: [],
navEvents,
@@ -80,35 +158,29 @@ async function readSelectedFileTreeRows(page) {
};
try {
- await ensureAuthenticated(page, context.request);
- doc = await createTempDocument(context.request, null);
- const stamp = Date.now().toString().slice(-8);
- const title = `TEST-443-mindmap-active-${stamp}`;
- await renameDocument(context.request, doc.workspaceId, doc.documentId, title);
- const mindmapId = `mindmap_443_active_${stamp}`;
- await createMindmap(context.request, doc.workspaceId, doc.documentId, mindmapId, `TEST-443-mind-${stamp}`);
- result.fixture = { ...doc, title, mindmapId };
-
- await openDocument(page, doc.workspaceId, doc.documentId);
- await openFilesystemView(page);
- await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"]`, { timeout: UI_TIMEOUT_MS });
- result.beforeSelectedRows = await readSelectedFileTreeRows(page);
-
- await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
- await page.waitForFunction(
- ({ documentId, mindmapId }) => {
- const url = new URL(window.location.href);
- if (!url.pathname.includes(`/documents/${encodeURIComponent(documentId)}`)) return false;
- const rt = url.searchParams.get("resourceTab") || "";
- if (!rt) return false;
- return decodeURIComponent(rt).includes(`resource:mindmap:${documentId}:${mindmapId}`);
- },
- { documentId: doc.documentId, mindmapId },
- { timeout: UI_TIMEOUT_MS },
- );
- await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"][data-selected="true"]`, {
+ await page.goto(documentUrl(root, fixture.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,
});
+ await waitForMindmapRow(page, fixture.mindmapFileName);
+ result.beforeSelectedRows = await readSelectedFileTreeRows(page);
+
+ await clickMindmapRow(page, fixture.mindmapFileName);
+ await page.waitForFunction(
+ ({ documentId, fileName }) => {
+ const url = new URL(window.location.href);
+ if (!url.pathname.includes(`/documents/${encodeURIComponent(documentId)}`)) return false;
+ const rt = decodeURIComponent(url.searchParams.get("resourceTab") || "");
+ return rt.includes("resource:mindmap:") && rt.includes(documentId) && rt.includes(fileName);
+ },
+ { documentId: fixture.documentId, fileName: fixture.mindmapFileName },
+ { timeout: UI_TIMEOUT_MS },
+ );
+ await page.waitForFunction((fileName) => {
+ return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]'))
+ .some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName));
+ }, fixture.mindmapFileName, { timeout: UI_TIMEOUT_MS });
result.afterUrl = page.url();
result.afterSelectedRows = await readSelectedFileTreeRows(page);
const screenshotPath = path.join(OUT_DIR, "after-mindmap-click.png");
@@ -116,11 +188,11 @@ async function readSelectedFileTreeRows(page) {
result.screenshots.push(screenshotPath);
assert(
- result.afterSelectedRows.some((row) => row.rowId === `asset:${mindmapId}` && row.assetId === mindmapId),
+ result.afterSelectedRows.some((row) => row.assetId.includes(fixture.mindmapFileName)),
`点击 mindmap 文件行后应保持 asset row 选中: ${JSON.stringify(result.afterSelectedRows)}`,
);
assert(
- !result.afterSelectedRows.some((row) => row.rowId === `doc:${doc.documentId}`),
+ !result.afterSelectedRows.some((row) => row.rowId === `doc:${fixture.documentId}`),
`点击 mindmap 文件行后不应闪回父页面行选中: ${JSON.stringify(result.afterSelectedRows)}`,
);
@@ -135,8 +207,8 @@ async function readSelectedFileTreeRows(page) {
});
throw error;
} finally {
- if (doc) await cleanupDocuments(context.request, [doc.documentId]).catch(() => null);
await browser.close().catch(() => null);
+ fs.rmSync(root, { recursive: true, force: true });
}
})().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
diff --git a/scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js b/scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js
index 23e3ad16..cee71327 100644
--- a/scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js
+++ b/scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js
@@ -1,49 +1,154 @@
#!/usr/bin/env node
"use strict";
-const fs = require("node:fs/promises");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const fsp = require("node:fs/promises");
+const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
-const {
- BASE_URL,
- UI_TIMEOUT_MS,
- assert,
- cleanupDocuments,
- createTempDocument,
- ensureAuthenticated,
- openDocument,
- openFilesystemView,
- renameDocument,
- requestJson,
-} = require("./tree-shell-smoke-helpers");
+
+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 || 30_000);
+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));
const TASK = "task445-filetree-mindmap-switch-no-flicker-smoke";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUT_DIR, "result.json");
+const ACTOR_ID = "user_real";
+
+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) {
+ fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
+ fs.writeFileSync(
+ path.join(root, ".mnote", "workspace.json"),
+ `${JSON.stringify({
+ workspaceId: `local-ws:${ACTOR_ID}:task445`,
+ ownerId: ACTOR_ID,
+ createdAt: new Date().toISOString(),
+ capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"],
+ }, null, 2)}\n`,
+ "utf8",
+ );
+}
+
+function writePage(root, relativePath, title, bodyLines) {
+ const fullPath = path.join(root, relativePath);
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+ fs.writeFileSync(
+ fullPath,
+ ["---", `title: ${title}`, "---", "", ...bodyLines, ""].join("\n"),
+ "utf8",
+ );
+}
+
+function writeMindmapFixture(root, stamp) {
+ const rootRelativePath = "Task445Root/Task445Root.md";
+ const otherRelativePath = "Task445Other.md";
+ const mindmapFileName = `map-${stamp}.mindmap.json`;
+ writePage(root, rootRelativePath, `TEST-445-root-${stamp}`, [
+ "# TEST 445 Root",
+ "",
+ `[TEST-445-mind-${stamp}](${mindmapFileName})`,
+ ]);
+ writePage(root, otherRelativePath, `TEST-445-other-${stamp}`, [
+ "# TEST 445 Other",
+ "",
+ "用于验证从另一个页面点击资源行时仍回到资源 owner document。",
+ ]);
+ fs.writeFileSync(
+ path.join(root, "Task445Root", mindmapFileName),
+ `${JSON.stringify({ data: { uid: "root", text: `TEST-445-mind-${stamp}` }, children: [] }, null, 2)}\n`,
+ "utf8",
+ );
+ return {
+ rootRelativePath,
+ otherRelativePath,
+ documentId: localMdDocumentId(rootRelativePath),
+ otherDocumentId: localMdDocumentId(otherRelativePath),
+ mindmapFileName,
+ };
+}
async function writeResult(result) {
- await fs.mkdir(OUT_DIR, { recursive: true });
- await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
+ await fsp.mkdir(OUT_DIR, { recursive: true });
+ await fsp.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
-async function createMindmap(request, workspaceId, documentId, mindmapId, title) {
- return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
- method: "POST",
- data: {
- commandName: "mindmaps.put",
- workspaceId,
- createOnly: true,
- data: { root: { data: { text: title }, children: [] } },
- },
- });
+async function waitForMindmapRow(page, mindmapFileName) {
+ await page.waitForFunction((fileName) => {
+ return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
+ .some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName));
+ }, mindmapFileName, { timeout: UI_TIMEOUT_MS });
}
-async function readFileTreeState(page, documentId, mindmapId) {
+async function clickMindmapRow(page, mindmapFileName) {
+ await page.evaluate((fileName) => {
+ const row = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
+ .find((candidate) => (candidate.getAttribute("data-asset-id") || "").includes(fileName));
+ const link = row?.querySelector(".tree-link");
+ if (!(link instanceof HTMLElement)) {
+ throw new Error(`找不到 mindmap 文件行: ${fileName}`);
+ }
+ link.click();
+ }, mindmapFileName);
+}
+
+async function clickDocumentRow(page, documentId) {
+ await page.evaluate((docId) => {
+ const row = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
+ .find((candidate) => (
+ (candidate.getAttribute("data-document-id") || candidate.getAttribute("data-doc-id") || "") === docId
+ ));
+ const link = row?.querySelector(".tree-link");
+ if (!(link instanceof HTMLElement)) {
+ throw new Error(`找不到文档行: ${docId}`);
+ }
+ link.click();
+ }, documentId);
+}
+
+async function readAllFileTreeRows(page) {
+ return await page.evaluate(() =>
+ Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')).map((row) => ({
+ rowId: row.getAttribute("data-row-id") || "",
+ rowKind: row.getAttribute("data-row-kind") || "",
+ nodeId: row.getAttribute("data-node-id") || "",
+ documentId: row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "",
+ ownerDocumentId: row.getAttribute("data-owner-document-id") || "",
+ assetId: row.getAttribute("data-asset-id") || "",
+ title: (row.textContent || "").trim().slice(0, 160),
+ selected: row.getAttribute("data-selected") || "",
+ })),
+ );
+}
+
+async function readFileTreeState(page, documentId, mindmapFileName) {
return await page.evaluate(
- ({ documentId: docId, mindmapId: mapId }) => {
+ ({ documentId: docId, mindmapFileName: fileName }) => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
- const pageRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(docId)}"]`);
- const mindmapRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${CSS.escape(mapId)}"]`);
+ const rows = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'));
+ const pageRow = rows.find((row) => (
+ (row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "") === docId
+ ));
+ const mindmapRow = rows.find((row) => (row.getAttribute("data-asset-id") || "").includes(fileName));
const titleNode = mindmapRow?.querySelector(":scope > .tree-link > .tree-link-title");
return {
fileRootStable: Boolean(fileRoot && fileRoot === window.__task445FileRoot),
@@ -52,20 +157,81 @@ async function readFileTreeState(page, documentId, mindmapId) {
pageSelected: pageRow instanceof HTMLElement ? pageRow.getAttribute("data-selected") || "" : "",
mindmapSelected: mindmapRow instanceof HTMLElement ? mindmapRow.getAttribute("data-selected") || "" : "",
mindmapTitle: titleNode instanceof HTMLElement ? (titleNode.textContent || "").trim() : "",
+ mindmapAssetId: mindmapRow instanceof HTMLElement ? mindmapRow.getAttribute("data-asset-id") || "" : "",
+ mindmapOwnerDocumentId: mindmapRow instanceof HTMLElement ? mindmapRow.getAttribute("data-owner-document-id") || "" : "",
objectEditor:
document.querySelector("[data-mnote-object-editor]")?.getAttribute("data-mnote-object-editor") ||
document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-mnote-object-editor") ||
"",
};
},
- { documentId, mindmapId },
+ { documentId, mindmapFileName },
);
}
+async function waitForMindmapResourceTab(page, documentId, mindmapFileName) {
+ await page.waitForFunction(
+ ({ docId, fileName }) => {
+ const url = new URL(window.location.href);
+ if (!url.pathname.includes(`/documents/${encodeURIComponent(docId)}`)) return false;
+ const rt = decodeURIComponent(url.searchParams.get("resourceTab") || "");
+ return rt.includes("resource:mindmap:") && rt.includes(docId) && rt.includes(fileName);
+ },
+ { docId: documentId, fileName: mindmapFileName },
+ { timeout: UI_TIMEOUT_MS },
+ );
+}
+
+async function readMindmapTabLayout(page) {
+ return await page.evaluate(() => {
+ const host = document.querySelector('[data-mnote-resource-tab-host][data-pane-role="primary"]');
+ const panel = document.querySelector('.mnote-resource-tab-panel[data-pane-role="primary"][data-resource-kind="mindmap"]:not([hidden])');
+ const shell = panel?.querySelector('.mnote-resource-tab-mindmap-shell');
+ const root = panel?.querySelector('[data-testid="mnote-mindmap-editor-root"]');
+ const rectOf = (node) => {
+ if (!(node instanceof HTMLElement)) return null;
+ const rect = node.getBoundingClientRect();
+ return {
+ width: Math.round(rect.width),
+ height: Math.round(rect.height),
+ left: Math.round(rect.left),
+ right: Math.round(rect.right),
+ scrollWidth: node.scrollWidth,
+ clientWidth: node.clientWidth,
+ scrollHeight: node.scrollHeight,
+ clientHeight: node.clientHeight,
+ };
+ };
+ return {
+ hostHidden: host instanceof HTMLElement ? host.hidden : true,
+ host: rectOf(host),
+ panel: rectOf(panel),
+ shell: rectOf(shell),
+ root: rectOf(root),
+ shellOverflow: shell instanceof HTMLElement ? getComputedStyle(shell).overflow : "",
+ rootOverflow: root instanceof HTMLElement ? getComputedStyle(root).overflow : "",
+ };
+ });
+}
+
(async () => {
- await fs.mkdir(OUT_DIR, { recursive: true });
- const browser = await chromium.launch({ headless: true });
- const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
+ await fsp.mkdir(OUT_DIR, { recursive: true });
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task445-local-mindmap-"));
+ const stamp = Date.now().toString().slice(-8);
+ writeWorkspaceManifest(root);
+ const fixture = writeMindmapFixture(root, stamp);
+
+ const browser = await chromium.launch({
+ headless: true,
+ ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
+ });
+ const context = await browser.newContext({
+ viewport: { width: 1440, height: 900 },
+ extraHTTPHeaders: {
+ "x-mnote-actor-id": ACTOR_ID,
+ "x-mnote-actor-type": "user",
+ },
+ });
const page = await context.newPage();
const navEvents = [];
const consoleMessages = [];
@@ -81,10 +247,9 @@ async function readFileTreeState(page, documentId, mindmapId) {
consoleMessages.push({ type: "pageerror", text: error instanceof Error ? error.stack || error.message : String(error) });
});
- const createdDocuments = [];
const result = {
baseUrl: BASE_URL,
- fixture: {},
+ fixture: { root, ...fixture },
before: null,
afterMindmap: null,
afterPage: null,
@@ -99,80 +264,76 @@ async function readFileTreeState(page, documentId, mindmapId) {
};
try {
- await ensureAuthenticated(page, context.request);
- const doc = await createTempDocument(context.request, null);
- createdDocuments.push(doc.documentId);
- const otherDoc = await createTempDocument(context.request, null);
- createdDocuments.push(otherDoc.documentId);
- const stamp = Date.now().toString().slice(-8);
- await renameDocument(context.request, doc.workspaceId, doc.documentId, `TEST-445-root-${stamp}`);
- await renameDocument(context.request, otherDoc.workspaceId, otherDoc.documentId, `TEST-445-other-${stamp}`);
- const mindmapId = `mindmap_445_${stamp}`;
- await createMindmap(context.request, doc.workspaceId, doc.documentId, mindmapId, `TEST-445-mind-${stamp}`);
- result.fixture = { ...doc, otherDocumentId: otherDoc.documentId, mindmapId };
-
- await openDocument(page, doc.workspaceId, doc.documentId);
- await openFilesystemView(page);
- await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"]`, { timeout: UI_TIMEOUT_MS });
+ await page.goto(documentUrl(root, fixture.rootRelativePath), { 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,
+ });
+ await waitForMindmapRow(page, fixture.mindmapFileName);
await page.evaluate(() => {
window.__task445FileRoot = document.getElementById("sidebar-file-tree-root");
});
- result.before = await readFileTreeState(page, doc.documentId, mindmapId);
+ result.before = await readFileTreeState(page, fixture.documentId, fixture.mindmapFileName);
if (/^mindmap-mindmap[_-]/i.test(result.before.mindmapTitle)) {
recordFailure("mindmap_title_double_technical_prefix", result.before.mindmapTitle);
}
- if (result.before.mindmapTitle.length > 24) {
+ if (result.before.mindmapTitle.length > 32) {
recordFailure("mindmap_title_too_long", result.before.mindmapTitle);
}
- await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
- await page.waitForFunction(
- ({ documentId, mindmapId }) => {
- const url = new URL(window.location.href);
- if (!url.pathname.includes(`/documents/${encodeURIComponent(documentId)}`)) return false;
- const rt = url.searchParams.get("resourceTab") || "";
- if (!rt) return false;
- return decodeURIComponent(rt).includes(`resource:mindmap:${documentId}:${mindmapId}`);
- },
- { documentId: doc.documentId, mindmapId },
- { timeout: UI_TIMEOUT_MS },
- );
- await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"][data-selected="true"]`, {
- timeout: UI_TIMEOUT_MS,
- });
- result.afterMindmap = await readFileTreeState(page, doc.documentId, mindmapId);
+ await clickMindmapRow(page, fixture.mindmapFileName);
+ await waitForMindmapResourceTab(page, fixture.documentId, fixture.mindmapFileName);
+ await page.waitForFunction((fileName) => {
+ return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]'))
+ .some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName));
+ }, fixture.mindmapFileName, { timeout: UI_TIMEOUT_MS });
+ result.afterMindmap = await readFileTreeState(page, fixture.documentId, fixture.mindmapFileName);
if (!result.afterMindmap.fileRootStable) recordFailure("mindmap_click_replaced_sidebar_root", result.afterMindmap);
+ result.rowsBeforeOtherClick = await readAllFileTreeRows(page);
- await page.click(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${otherDoc.documentId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
- await page.waitForURL((url) => url.pathname.includes(`/documents/${encodeURIComponent(otherDoc.documentId)}`), { timeout: UI_TIMEOUT_MS });
- await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${otherDoc.documentId}"][data-selected="true"]`, {
- timeout: UI_TIMEOUT_MS,
- });
- result.afterPage = await readFileTreeState(page, otherDoc.documentId, mindmapId);
+ await clickDocumentRow(page, fixture.otherDocumentId);
+ await page.waitForURL((url) => url.pathname.includes(`/documents/${encodeURIComponent(fixture.otherDocumentId)}`), { timeout: UI_TIMEOUT_MS });
+ await page.waitForFunction((docId) => {
+ return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]'))
+ .some((row) => (row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "") === docId);
+ }, fixture.otherDocumentId, { timeout: UI_TIMEOUT_MS });
+ result.afterPage = await readFileTreeState(page, fixture.otherDocumentId, fixture.mindmapFileName);
if (!result.afterPage.fileRootStable) recordFailure("page_click_after_mindmap_replaced_sidebar_root", result.afterPage);
- await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
- await page.waitForFunction(
- ({ mindmapId, docId }) => {
- const url = new URL(window.location.href);
- if (!url.pathname.startsWith("/documents/")) return false;
- const rt = url.searchParams.get("resourceTab") || "";
- if (!rt) return false;
- return decodeURIComponent(rt).includes(`resource:mindmap:${docId}:${mindmapId}`);
- },
- { mindmapId, docId: doc.documentId },
- { timeout: UI_TIMEOUT_MS },
- );
- await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"][data-selected="true"]`, {
- timeout: UI_TIMEOUT_MS,
- });
- result.afterMindmapAgain = await readFileTreeState(page, doc.documentId, mindmapId);
+ await clickMindmapRow(page, fixture.mindmapFileName);
+ await waitForMindmapResourceTab(page, fixture.documentId, fixture.mindmapFileName);
+ await page.waitForFunction((fileName) => {
+ return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]'))
+ .some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName));
+ }, fixture.mindmapFileName, { timeout: UI_TIMEOUT_MS });
+ result.afterMindmapAgain = await readFileTreeState(page, fixture.documentId, fixture.mindmapFileName);
+ result.mindmapLayout = await readMindmapTabLayout(page);
if (!result.afterMindmapAgain.fileRootStable) recordFailure("mindmap_click_again_replaced_sidebar_root", result.afterMindmapAgain);
+ if (result.mindmapLayout.hostHidden) recordFailure("mindmap_resource_host_hidden", result.mindmapLayout);
+ if (!result.mindmapLayout.shell || !result.mindmapLayout.root) recordFailure("mindmap_resource_shell_or_root_missing", result.mindmapLayout);
+ if ((result.mindmapLayout.shell?.width || 0) < 320 || (result.mindmapLayout.root?.width || 0) < 320) {
+ recordFailure("mindmap_resource_width_too_small", result.mindmapLayout);
+ }
+ if ((result.mindmapLayout.shell?.clientWidth || 0) + 4 < (result.mindmapLayout.shell?.scrollWidth || 0)) {
+ recordFailure("mindmap_resource_horizontal_overflow", result.mindmapLayout);
+ }
+ if ((result.mindmapLayout.root?.height || 0) < 600) {
+ recordFailure("mindmap_resource_height_too_small", result.mindmapLayout);
+ }
+ if (!/hidden/.test(result.mindmapLayout.shellOverflow) || !/hidden/.test(result.mindmapLayout.rootOverflow)) {
+ recordFailure("mindmap_resource_overflow_not_clipped", result.mindmapLayout);
+ }
+ if (!page.url().includes(`/documents/${encodeURIComponent(fixture.documentId)}`)) {
+ recordFailure("mindmap_click_again_used_wrong_owner_document", {
+ expectedDocumentId: fixture.documentId,
+ actualUrl: page.url(),
+ });
+ }
const screenshotPath = path.join(OUT_DIR, "after-mindmap-again.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshots.push(screenshotPath);
- assert(result.failures.length === 0, `task445 failures: ${JSON.stringify(result.failures, null, 2)}`);
+ assert.equal(result.failures.length, 0, `task445 failures: ${JSON.stringify(result.failures, null, 2)}`);
await writeResult({ ...result, ok: true, finalUrl: page.url() });
console.log(`ok ${TASK} ${RESULT_PATH}`);
} catch (error) {
@@ -184,8 +345,8 @@ async function readFileTreeState(page, documentId, mindmapId) {
});
throw error;
} finally {
- if (createdDocuments.length) await cleanupDocuments(context.request, createdDocuments).catch(() => null);
await browser.close().catch(() => null);
+ fs.rmSync(root, { recursive: true, force: true });
}
})().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
diff --git a/scripts/task453-local-folder-page-ai-changed-files-smoke.js b/scripts/task453-local-folder-page-ai-changed-files-smoke.js
index 416e17b9..a4989e0f 100644
--- a/scripts/task453-local-folder-page-ai-changed-files-smoke.js
+++ b/scripts/task453-local-folder-page-ai-changed-files-smoke.js
@@ -11,6 +11,9 @@ const {
UI_TIMEOUT_MS,
} = require("./tree-shell-smoke-helpers");
+const TASK = "task453-local-folder-page-ai-changed-files-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));
@@ -19,6 +22,10 @@ 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(
@@ -33,16 +40,76 @@ function writeWorkspaceManifest(root, ownerId, workspaceId) {
);
}
+async function fetchPageAggregate(page, documentId, rootUri) {
+ return await page.evaluate(async ({ id, uri }) => {
+ const url = new URL(`/api/page-aggregate/${encodeURIComponent(id)}`, window.location.origin);
+ url.searchParams.set("sourceKind", "local_folder");
+ url.searchParams.set("rootUri", uri);
+ const response = await fetch(url.toString(), { headers: { accept: "application/json" } });
+ return {
+ ok: response.ok,
+ status: response.status,
+ payload: await response.json().catch(() => null),
+ };
+ }, { id: documentId, uri: rootUri });
+}
+
+async function waitForEditorText(page, expected) {
+ await page.waitForFunction(
+ (text) => {
+ const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
+ return (editor?.textContent || "").includes(text);
+ },
+ expected,
+ { timeout: UI_TIMEOUT_MS },
+ );
+}
+
+async function typeDirtyText(page, text) {
+ const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
+ await editor.click({ timeout: UI_TIMEOUT_MS });
+ await page.keyboard.type(text, { delay: 8 });
+ await waitForEditorText(page, text.trim());
+}
+
+async function waitForEditorStatus(page, status) {
+ await page.waitForFunction(
+ (expected) => {
+ const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
+ return root?.getAttribute("data-runtime-editor-status") === expected;
+ },
+ status,
+ { timeout: UI_TIMEOUT_MS },
+ );
+}
+
+async function conflictEnvelope(page, documentId) {
+ return await page.evaluate((docId) => {
+ const snapshot = window.__mnoteDebugDocumentSessions?.snapshot?.();
+ if (!snapshot) return null;
+ const session = snapshot.sessions.find((item) => item.documentId === docId);
+ return session?.lastExternalConflictEnvelope || null;
+ }, documentId);
+}
+
async function main() {
+ fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-ai-changed-files-"));
- const documentId = "local-md:README.md";
+ const documentId = localMdDocumentId("README.md");
+ const dirtyDocumentId = localMdDocumentId("Dirty.md");
const actorId = "user_real";
const sessionId = `mnote_local_ai_changed_${suffix}`;
const runId = `run_local_ai_changed_${suffix}`;
+ const dirtySessionId = `mnote_local_ai_dirty_${suffix}`;
+ const dirtyRunId = `run_local_ai_dirty_${suffix}`;
const marker = `LOCAL-AI-CHANGED-FILES-${suffix}`;
+ const dirtyMarker = `LOCAL-AI-DIRTY-FILES-${suffix}`;
+ const dirtyLocalToken = `LOCAL-UNSAVED-DIRTY-${suffix}`;
const readmePath = path.join(root, "README.md");
+ const dirtyPath = path.join(root, "Dirty.md");
const captured = [];
+ let currentScenario = "clean";
const browser = await chromium.launch({
headless: true,
@@ -61,11 +128,34 @@ async function main() {
const workspaceId = `local-ws:${actorId}:task453`;
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(readmePath, `# Local AI Changed Files\n初始内容 ${suffix}\n`, "utf8");
+ fs.writeFileSync(dirtyPath, `# Dirty AI Changed Files\n初始 dirty 内容 ${suffix}\n`, "utf8");
const rootUri = fileUrl(root);
+ const scenarioConfig = () => currentScenario === "dirty"
+ ? {
+ sessionId: dirtySessionId,
+ runId: dirtyRunId,
+ documentId: dirtyDocumentId,
+ filePath: dirtyPath,
+ relativePath: "Dirty.md",
+ marker: dirtyMarker,
+ message: "已修改本地 Dirty。",
+ }
+ : {
+ sessionId,
+ runId,
+ documentId,
+ filePath: readmePath,
+ relativePath: "README.md",
+ marker,
+ message: "已修改本地 README。",
+ };
await page.route("**/api/ai-agent/run", async (route) => {
throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
});
+ await page.route("**/api/documents/save", async (route) => {
+ throw new Error(`local-first AI smoke 不应请求 compat /api/documents/save: ${route.request().url()}`);
+ });
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
@@ -97,13 +187,14 @@ async function main() {
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
+ const scenario = scenarioConfig();
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,
+ sessionId: scenario.sessionId,
title: "本地 changed files",
traceId: `trace_local_changed_${suffix}`,
persistence: "local_ai_session_jsonl",
@@ -111,35 +202,37 @@ async function main() {
}),
});
});
- await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => {
+ await page.route("**/api/hermes/client/sessions/*/resume", async (route) => {
+ const scenario = scenarioConfig();
captured.push({ kind: "session-resume", method: route.request().method(), body: "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
- sessionId,
- session: { sessionId, messages: [] },
+ sessionId: scenario.sessionId,
+ session: { sessionId: scenario.sessionId, messages: [] },
runtime: {
- sessionId,
- runId,
+ sessionId: scenario.sessionId,
+ runId: scenario.runId,
status: "completed",
profile: "reasonix",
- documentId,
+ documentId: scenario.documentId,
traceId: `trace_local_changed_resume_${suffix}`,
},
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
+ const scenario = scenarioConfig();
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,
- runId,
+ sessionId: scenario.sessionId,
+ runId: scenario.runId,
events: [],
traceId: `trace_local_changed_run_${suffix}`,
persistence: "local_ai_session_jsonl",
@@ -147,28 +240,29 @@ async function main() {
}),
});
});
- await page.route(`**/api/hermes/client/events/${runId}`, async (route) => {
+ await page.route("**/api/hermes/client/events/*", async (route) => {
+ const scenario = scenarioConfig();
captured.push({ kind: "events", method: route.request().method(), body: "" });
- fs.appendFileSync(readmePath, `\nAI 写入标记:${marker}\n`, "utf8");
+ fs.appendFileSync(scenario.filePath, `\nAI 写入标记:${scenario.marker}\n`, "utf8");
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body:
- `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "已修改本地 README。" })}\n\n` +
+ `data: ${JSON.stringify({ event: "message.delta", run_id: scenario.runId, session_id: scenario.sessionId, delta: scenario.message })}\n\n` +
`data: ${JSON.stringify({
event: "run.completed",
- run_id: runId,
- session_id: sessionId,
- output: "已修改本地 README。",
+ run_id: scenario.runId,
+ session_id: scenario.sessionId,
+ output: scenario.message,
agentAudit: {
- eventId: `audit_local_changed_${suffix}`,
+ eventId: `audit_local_changed_${currentScenario}_${suffix}`,
rootUri,
diffSummary: "1 changed file(s)",
changedFiles: [
{
- path: "README.md",
+ path: scenario.relativePath,
changeType: "modified",
- summary: `追加 ${marker}`,
+ summary: `追加 ${scenario.marker}`,
},
],
},
@@ -217,19 +311,112 @@ async function main() {
),
`本地 AI changed files 工具卡未显示 README.md 与 diff 摘要: ${JSON.stringify(cards)}`,
);
- assert(fs.readFileSync(readmePath, "utf8").includes(marker), "本地 README.md 未写入 smoke 标记");
- assert(captured.some((entry) => entry.kind === "run"), "未捕获 page AI run 请求");
- console.log(
- JSON.stringify({
- ok: true,
- root,
- documentId,
- sessionId,
- runId,
- marker,
- capturedKinds: captured.map((entry) => entry.kind),
- }, null, 2),
+ const diskText = fs.readFileSync(readmePath, "utf8");
+ assert(diskText.includes(marker), "本地 README.md 未写入 smoke 标记");
+ const aggregate = await fetchPageAggregate(page, documentId, rootUri);
+ assert.equal(aggregate.ok, true, `Page Aggregate 应能读取 local_folder 文档: ${JSON.stringify(aggregate)}`);
+ assert(
+ JSON.stringify(aggregate.payload || {}).includes(marker),
+ `Page Aggregate 应读回 AI 写入标记: ${JSON.stringify(aggregate)}`,
);
+ await waitForEditorText(page, marker);
+ const editorState = await page.evaluate(() => {
+ const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
+ const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
+ const conflictPanel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
+ return {
+ status: root?.getAttribute("data-runtime-editor-status") || "",
+ text: editor?.textContent || "",
+ conflictVisible: Boolean(conflictPanel && conflictPanel.getClientRects().length > 0),
+ };
+ });
+ assert.notEqual(editorState.status, "external-change-conflict", `clean AI 写入不应触发冲突态: ${JSON.stringify(editorState)}`);
+ assert.equal(editorState.conflictVisible, false, `clean AI 写入不应显示冲突面板: ${JSON.stringify(editorState)}`);
+ assert(captured.some((entry) => entry.kind === "run"), "未捕获 page AI run 请求");
+ const runBody = JSON.parse(captured.find((entry) => entry.kind === "run")?.body || "{}");
+ assert.equal(runBody.documentId, documentId, `Hermes run 应携带 local documentId: ${JSON.stringify(runBody)}`);
+ assert.equal(runBody.sourceKind, "local_folder", `Hermes run 应携带 local_folder sourceKind: ${JSON.stringify(runBody)}`);
+ assert.equal(runBody.rootUri, rootUri, `Hermes run 应携带 rootUri: ${JSON.stringify(runBody)}`);
+ const cleanCapturedKinds = captured.map((entry) => entry.kind);
+
+ currentScenario = "dirty";
+ captured.length = 0;
+ const dirtyUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(dirtyDocumentId)}`);
+ dirtyUrl.searchParams.set("sourceKind", "local_folder");
+ dirtyUrl.searchParams.set("rootUri", rootUri);
+ await page.goto(dirtyUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
+ await waitForEditorText(page, "Dirty AI Changed Files");
+ await typeDirtyText(page, ` ${dirtyLocalToken}`);
+ await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
+ await page.locator("[data-page-ai-input]").fill(`请修改 Dirty 并记录 changed files ${dirtyMarker}`, { timeout: UI_TIMEOUT_MS });
+ await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
+ await page.waitForFunction(
+ () => {
+ const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
+ return drawerText.includes("agent.changed_files") && drawerText.includes("Dirty.md");
+ },
+ null,
+ { timeout: UI_TIMEOUT_MS },
+ );
+ await waitForEditorStatus(page, "external-change-conflict");
+ await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
+ await page.waitForFunction(
+ (expected) => {
+ const panel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
+ return (panel?.textContent || "").includes(expected);
+ },
+ `agent run ${dirtyRunId}`,
+ { timeout: UI_TIMEOUT_MS },
+ );
+ const envelope = await conflictEnvelope(page, dirtyDocumentId);
+ assert(envelope, "dirty AI 写入应生成冲突信封");
+ assert("externalActor" in envelope, `冲突信封应包含 externalActor: ${JSON.stringify(envelope)}`);
+ assert("dirtyState" in envelope, `冲突信封应包含 dirtyState: ${JSON.stringify(envelope)}`);
+ assert("bufferFileVersion" in envelope, `冲突信封应包含 bufferFileVersion: ${JSON.stringify(envelope)}`);
+ await page.locator('[data-testid="mnote-conflict-open-diff"]').click({ timeout: UI_TIMEOUT_MS });
+ await page.locator('[data-testid="mnote-conflict-diff-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
+ await page.waitForFunction(
+ ({ localToken, aiToken }) => {
+ const panel = document.querySelector('[data-testid="mnote-conflict-diff-panel"]');
+ const text = panel?.textContent || "";
+ return text.includes(localToken) && text.includes(aiToken);
+ },
+ { localToken: dirtyLocalToken, aiToken: dirtyMarker },
+ { timeout: UI_TIMEOUT_MS },
+ );
+ const diffText = await page.locator('[data-testid="mnote-conflict-diff-panel"]').innerText({ timeout: UI_TIMEOUT_MS });
+ assert(diffText.includes(dirtyLocalToken), `dirty diff 应包含本地未保存内容: ${diffText}`);
+ assert(diffText.includes(dirtyMarker), `dirty diff 应包含 AI 写盘内容: ${diffText}`);
+ const dirtyAggregate = await fetchPageAggregate(page, dirtyDocumentId, rootUri);
+ assert(
+ JSON.stringify(dirtyAggregate.payload || {}).includes(dirtyMarker),
+ `dirty Page Aggregate 应读回 AI 写入标记: ${JSON.stringify(dirtyAggregate)}`,
+ );
+ const dirtyRunBody = JSON.parse(captured.find((entry) => entry.kind === "run")?.body || "{}");
+ assert.equal(dirtyRunBody.documentId, dirtyDocumentId, `dirty Hermes run 应携带 local documentId: ${JSON.stringify(dirtyRunBody)}`);
+ assert.equal(dirtyRunBody.sourceKind, "local_folder", `dirty Hermes run 应携带 local_folder sourceKind: ${JSON.stringify(dirtyRunBody)}`);
+ assert.equal(dirtyRunBody.rootUri, rootUri, `dirty Hermes run 应携带 rootUri: ${JSON.stringify(dirtyRunBody)}`);
+
+ const result = {
+ ok: true,
+ root,
+ documentId,
+ sessionId,
+ runId,
+ marker,
+ aggregateRevision: aggregate.payload?.result?.body?.revision ?? aggregate.payload?.body?.revision ?? null,
+ editorStatus: editorState.status,
+ dirtyDocumentId,
+ dirtyRunId,
+ dirtyMarker,
+ dirtyConflictStatus: "external-change-conflict",
+ dirtyEnvelope: envelope,
+ capturedKinds: cleanCapturedKinds,
+ dirtyCapturedKinds: captured.map((entry) => entry.kind),
+ resultPath: RESULT_PATH,
+ };
+ fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
+ console.log(JSON.stringify(result, null, 2));
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
diff --git a/scripts/task459-local-markdown-attachment-tab-smoke.js b/scripts/task459-local-markdown-attachment-tab-smoke.js
index 247ba32d..391465b2 100644
--- a/scripts/task459-local-markdown-attachment-tab-smoke.js
+++ b/scripts/task459-local-markdown-attachment-tab-smoke.js
@@ -53,6 +53,62 @@ async function quickLogin(page) {
}
}
+async function activatePrimaryPageTab(page) {
+ const pageTab = page.locator('[data-mnote-main-tab="page"][data-pane-role="primary"]').first();
+ if (await pageTab.count()) {
+ await pageTab.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
+ }
+ await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
+ state: "visible",
+ timeout: UI_TIMEOUT_MS,
+ });
+}
+
+async function uploadAttachmentViaPrimarySlash(page, fileName, markdown) {
+ await activatePrimaryPageTab(page);
+ const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
+ await editor.click({ timeout: UI_TIMEOUT_MS });
+ await page.keyboard.press("End").catch(() => undefined);
+ await page.keyboard.type("/");
+ const item = page
+ .locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]')
+ .first();
+ await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
+ const [fileChooser] = await Promise.all([
+ page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
+ item.click({ timeout: UI_TIMEOUT_MS }),
+ ]);
+ await fileChooser.setFiles({
+ name: fileName,
+ mimeType: "text/markdown",
+ buffer: Buffer.from(markdown, "utf8"),
+ });
+ await page.waitForFunction(
+ (name) => {
+ const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
+ return editor instanceof HTMLElement && (editor.textContent || "").includes(name);
+ },
+ fileName,
+ { timeout: UI_TIMEOUT_MS },
+ );
+}
+
+async function clickPrimaryAttachmentAndExpectTab(page, fileName) {
+ await activatePrimaryPageTab(page);
+ const link = page
+ .locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]', {
+ hasText: fileName,
+ })
+ .first();
+ await link.click({ timeout: UI_TIMEOUT_MS });
+ await page.locator('.mnote-main-tab.is-active[data-pane-role="primary"][data-mnote-tab-kind="markdown"]', {
+ hasText: fileName,
+ }).waitFor({
+ state: "visible",
+ timeout: UI_TIMEOUT_MS,
+ });
+}
+
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task459-md-attachment-"));
const relativePath = "README.md";
@@ -100,7 +156,8 @@ async function main() {
await link.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const node = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"][href*="README%2Fresource-note.md"]');
- return node && node.classList.contains("mnote-uploaded-attachment-row") && node.getAttribute("data-mnote-attachment-link") === "true";
+ return node instanceof HTMLAnchorElement
+ && (node.classList.contains("mnote-uploaded-attachment-row") || getComputedStyle(node).display === "inline-flex");
}, null, { timeout: UI_TIMEOUT_MS });
await link.click({ timeout: UI_TIMEOUT_MS });
@@ -142,6 +199,13 @@ async function main() {
});
await resourceEditor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
+ await uploadAttachmentViaPrimarySlash(page, "uploaded-one.md", "# Uploaded One\n\n第一个上传附件\n");
+ await clickPrimaryAttachmentAndExpectTab(page, "uploaded-one.md");
+ await uploadAttachmentViaPrimarySlash(page, "uploaded-two.md", "# Uploaded Two\n\n第二个上传附件\n");
+ await clickPrimaryAttachmentAndExpectTab(page, "uploaded-one.md");
+ await clickPrimaryAttachmentAndExpectTab(page, "uploaded-two.md");
+ assert.equal(popups.length, 0, `连续上传两个 MD 附件后点击不应打开浏览器新窗口,实际 popup=${popups.length}`);
+
console.log(JSON.stringify({ ok: true, root, documentId, popups: popups.length }, null, 2));
} finally {
await context.close().catch(() => undefined);
diff --git a/scripts/task472-side-target-secondary-pane-smoke.js b/scripts/task472-side-target-secondary-pane-smoke.js
index d948f3f0..921a4ce7 100644
--- a/scripts/task472-side-target-secondary-pane-smoke.js
+++ b/scripts/task472-side-target-secondary-pane-smoke.js
@@ -77,6 +77,47 @@ async function uploadLocalAsset(page, root, documentId, fileName, mimeType, byte
);
}
+async function uploadAttachmentViaSecondarySlash(page, fileName, markdown, action) {
+ const editor = page
+ .locator('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror')
+ .first();
+ await editor.click({ timeout: UI_TIMEOUT_MS });
+ await page.keyboard.press("End").catch(() => undefined);
+ await page.keyboard.type("/");
+ const item = page
+ .locator('.mnote-resource-tab-panel[data-pane-role="secondary"] [data-testid="slash-item-upload-attachment"]')
+ .first();
+ await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
+ const [fileChooser] = await Promise.all([
+ page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
+ item.click({ timeout: UI_TIMEOUT_MS }),
+ ]);
+ await fileChooser.setFiles({
+ name: fileName,
+ mimeType: "text/markdown",
+ buffer: Buffer.from(markdown, "utf8"),
+ });
+ await page.waitForFunction(
+ ({ name }) => {
+ const editor = document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror');
+ return (editor instanceof HTMLElement && (editor.textContent || "").includes(name))
+ || document.documentElement.getAttribute("data-mnote-last-upload-inserted") === "false";
+ },
+ { name: fileName },
+ { timeout: UI_TIMEOUT_MS },
+ );
+ const inserted = await page.evaluate((name) => {
+ const editor = document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror');
+ return {
+ hasText: editor instanceof HTMLElement && (editor.textContent || "").includes(name),
+ inserted: document.documentElement.getAttribute("data-mnote-last-upload-inserted") || "",
+ error: document.documentElement.getAttribute("data-mnote-last-upload-insert-error") || "",
+ text: editor?.textContent || "",
+ };
+ }, fileName);
+ assert(inserted.hasText, `${action} 上传后未插入 secondary 编辑器:${JSON.stringify(inserted)}`);
+}
+
async function waitForPrimaryReady(page) {
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
state: "visible",
@@ -104,7 +145,7 @@ async function readSideTargetState(page) {
return await page.evaluate(() => {
const url = new URL(window.location.href);
const pane = document.querySelector('.document-pane[data-pane-role="secondary"]');
- const activeTab = document.querySelector(".mnote-main-tab.is-active");
+ const activeTab = document.querySelector('.mnote-main-tab.is-active[data-pane-role="primary"]');
const placeholder = document.querySelector("[data-mnote-side-target-placeholder=\"true\"]");
return {
resourceTab: url.searchParams.get("resourceTab") || "",
@@ -116,6 +157,13 @@ async function readSideTargetState(page) {
secondarySideTarget: pane?.getAttribute("data-mnote-side-target") || "",
activeTabKind: activeTab?.getAttribute("data-mnote-tab-kind") || "",
activeTabText: activeTab?.textContent || "",
+ secondaryActiveTabKind: document.querySelector('.mnote-main-tab.is-active[data-pane-role="secondary"]')?.getAttribute("data-mnote-tab-kind") || "",
+ secondaryActiveTabText: document.querySelector('.mnote-main-tab.is-active[data-pane-role="secondary"]')?.textContent || "",
+ secondaryResourceText: document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror')?.textContent || "",
+ primaryVisibleEditorText: document.querySelector('.document-pane[data-pane-role="primary"] .mnote-resource-tab-panel:not([hidden]) .ProseMirror, .document-pane[data-pane-role="primary"] [data-mnote-page-tab-panel]:not([hidden]) .ProseMirror')?.textContent || "",
+ secondaryOfficeFrameSrc: document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) iframe.mnote-resource-tab-frame')?.getAttribute("src") || "",
+ primarySlashVisible: Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] [data-testid="mnote-leptos-tiptap-slash-menu"]')).some((node) => node instanceof HTMLElement && getComputedStyle(node).display !== "none"),
+ secondarySlashVisible: Array.from(document.querySelectorAll('.document-pane[data-pane-role="secondary"] [data-testid="mnote-leptos-tiptap-slash-menu"]')).some((node) => node instanceof HTMLElement && getComputedStyle(node).display !== "none"),
placeholderText: placeholder?.textContent || "",
unsupportedFlag: document.documentElement.getAttribute("data-mnote-side-target-unsupported") || "",
popupCount: window.__mnoteSideTargetPopupCount || 0,
@@ -123,6 +171,53 @@ async function readSideTargetState(page) {
});
}
+async function waitForDiskTextContains(filePath, expectedNames) {
+ const deadline = Date.now() + UI_TIMEOUT_MS;
+ while (Date.now() < deadline) {
+ const text = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
+ if (expectedNames.every((name) => text.includes(name))) return text;
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ }
+ const text = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
+ throw new Error(`等待资源文件保存超时: ${filePath} text=${JSON.stringify(text)}`);
+}
+
+async function waitForSecondaryAttachmentLinks(page, expectedNames) {
+ await page.waitForFunction(
+ ({ names }) => {
+ const editor = document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror');
+ if (!(editor instanceof HTMLElement)) return false;
+ return names.every((name) => {
+ const link = Array.from(editor.querySelectorAll("a[href]"))
+ .find((node) => (node.textContent || "").includes(name));
+ if (!(link instanceof HTMLAnchorElement)) return false;
+ const href = link.getAttribute("href") || "";
+ const className = link.getAttribute("class") || "";
+ const styledAsAttachment = getComputedStyle(link).display === "inline-flex";
+ const enhancedAsAttachment = link.getAttribute("data-mnote-attachment-link") === "true"
+ || className.includes("mnote-uploaded-attachment-row");
+ return href.includes("/api/local-folder/files/open")
+ && (styledAsAttachment || enhancedAsAttachment);
+ });
+ },
+ { names: expectedNames },
+ { timeout: UI_TIMEOUT_MS },
+ );
+}
+
+async function clickSecondaryAttachment(page, fileName) {
+ await page.evaluate((name) => {
+ const editor = document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror');
+ const link = Array.from(editor?.querySelectorAll('a[href*="/api/local-folder/files/open"], a[data-mnote-attachment-link="true"]') || [])
+ .find((node) => (node.textContent || "").includes(name));
+ if (!(link instanceof HTMLAnchorElement)) {
+ throw new Error(`secondary_attachment_link_missing:${name}`);
+ }
+ link.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
+ link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
+ }, fileName);
+}
+
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task472-side-target-"));
const relativePath = "README.md";
@@ -156,7 +251,6 @@ async function main() {
};
});
const page = await context.newPage();
-
try {
await quickLogin(page);
await page.goto(documentUrl(root, relativePath, firstSideRelativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
@@ -172,6 +266,15 @@ async function main() {
Buffer.from("# Resource\n\n资源正文\n", "utf8"),
"attachment",
);
+ const officeAsset = await uploadLocalAsset(
+ page,
+ root,
+ documentId,
+ "side-target-office.docx",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ Buffer.from("task472 secondary office probe", "utf8"),
+ "attachment",
+ );
await page.evaluate(({ asset, documentId }) => {
window.dispatchEvent(new CustomEvent("tree.asset.open", {
@@ -227,16 +330,196 @@ async function main() {
},
}));
}, { asset, documentId });
- await page.waitForFunction(() => document.querySelector("[data-mnote-side-target-placeholder=\"true\"]"), {}, { timeout: UI_TIMEOUT_MS });
+ await page.locator('.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]').waitFor({
+ state: "visible",
+ timeout: UI_TIMEOUT_MS,
+ });
+ await page.locator('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror').first().waitFor({
+ state: "visible",
+ timeout: UI_TIMEOUT_MS,
+ });
const afterResourceSideOpen = await readSideTargetState(page);
- assert.equal(afterResourceSideOpen.secondaryDocumentId, null, `资源 openTarget=side placeholder 不应保留旧 secondaryDocumentId: ${JSON.stringify(afterResourceSideOpen)}`);
- assert.equal(afterResourceSideOpen.secondarySideTarget, "unsupported-resource", `资源 openTarget=side 应标记 unsupported side target: ${JSON.stringify(afterResourceSideOpen)}`);
- assert.match(afterResourceSideOpen.placeholderText, /暂不支持在侧栏打开此资源/, `资源 side placeholder 应可观测: ${JSON.stringify(afterResourceSideOpen)}`);
- assert(afterResourceSideOpen.resourceTab.includes("side-target-resource.md"), `资源 side placeholder 不应清空 active resource tab URL: ${JSON.stringify(afterResourceSideOpen)}`);
- assert.equal(afterResourceSideOpen.activeTabKind, "markdown", `资源 side placeholder 不应切走 active resource tab: ${JSON.stringify(afterResourceSideOpen)}`);
+ assert.equal(afterResourceSideOpen.secondaryDocumentId, null, `资源 openTarget=side 不应保留旧 secondaryDocumentId: ${JSON.stringify(afterResourceSideOpen)}`);
+ assert.equal(afterResourceSideOpen.secondaryActiveTabKind, "markdown", `资源 openTarget=side 应在 secondary 资源标签打开 markdown: ${JSON.stringify(afterResourceSideOpen)}`);
+ assert(afterResourceSideOpen.secondaryActiveTabText.includes("side-target-resource.md"), `secondary 资源标签标题应可观测: ${JSON.stringify(afterResourceSideOpen)}`);
+ assert(afterResourceSideOpen.secondaryResourceText.includes("资源正文"), `secondary 资源标签应渲染附件内容: ${JSON.stringify(afterResourceSideOpen)}`);
+ assert(afterResourceSideOpen.resourceTab.includes("side-target-resource.md"), `资源 side open 不应清空 primary active resource tab URL: ${JSON.stringify(afterResourceSideOpen)}`);
+ assert.equal(afterResourceSideOpen.activeTabKind, "markdown", `资源 side open 不应切走 primary active resource tab: ${JSON.stringify(afterResourceSideOpen)}`);
assert.equal(afterResourceSideOpen.popupCount, 0, `资源 openTarget=side 不应误开新窗口: ${JSON.stringify(afterResourceSideOpen)}`);
+ const secondaryResourceEditor = page.locator('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror').first();
+ await secondaryResourceEditor.click({ timeout: UI_TIMEOUT_MS });
+ await page.waitForTimeout(150);
+ const afterSecondaryFirstClick = await readSideTargetState(page);
+ assert.equal(afterSecondaryFirstClick.primarySlashVisible, false, `secondary 首次点击资源正文不应让 primary 菜单闪现: ${JSON.stringify(afterSecondaryFirstClick)}`);
- console.log(JSON.stringify({ ok: true, root, assetId: asset.id }, null, 2));
+ await uploadAttachmentViaSecondarySlash(
+ page,
+ "secondary-real-upload-1.md",
+ "# Upload One\n\n第一个真实上传\n",
+ "secondary 第一个真实 md",
+ );
+ await page.waitForTimeout(250);
+ const afterFirstRealSecondaryUpload = await readSideTargetState(page);
+ if (!afterFirstRealSecondaryUpload.secondaryResourceText.includes("secondary-real-upload-1.md")) {
+ const debug = await page.evaluate(() => {
+ const root = document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) [data-testid="mnote-leptos-tiptap-island-editor-root"]');
+ const editor = root?.querySelector(".editor-surface .ProseMirror");
+ return {
+ rootKind: root instanceof HTMLElement ? root.getAttribute("data-editor-host-kind") : "",
+ rootDocumentId: root instanceof HTMLElement ? root.getAttribute("data-document-id") : "",
+ rootWorkspaceId: root instanceof HTMLElement ? root.getAttribute("data-workspace-id") : "",
+ rootStatus: root instanceof HTMLElement ? root.getAttribute("data-runtime-editor-status") : "",
+ hasEditorHandle: Boolean(editor?.editor?.chain),
+ lastRootKind: window.__mnoteLastEditorUploadRoot instanceof HTMLElement ? window.__mnoteLastEditorUploadRoot.getAttribute("data-editor-host-kind") : "",
+ lastRootPane: window.__mnoteLastEditorUploadRoot instanceof HTMLElement ? window.__mnoteLastEditorUploadRoot.getAttribute("data-pane-role") : "",
+ visibleResourceEditors: Array.from(document.querySelectorAll('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror')).map((node) => ({
+ text: node.textContent,
+ hasEditorHandle: Boolean(node.editor?.chain),
+ })),
+ };
+ });
+ throw new Error(`secondary 真实上传第一个 md 未插入 secondary 资源编辑器,debug=${JSON.stringify(debug)} state=${JSON.stringify(afterFirstRealSecondaryUpload)}`);
+ }
+ assert(afterFirstRealSecondaryUpload.secondaryResourceText.includes("secondary-real-upload-1.md"), `secondary 真实上传第一个 md 应插入 secondary 资源编辑器: ${JSON.stringify(afterFirstRealSecondaryUpload)}`);
+ assert(!afterFirstRealSecondaryUpload.primaryVisibleEditorText.includes("secondary-real-upload-1.md"), `secondary 真实上传第一个 md 不应插入 primary 编辑器: ${JSON.stringify(afterFirstRealSecondaryUpload)}`);
+ assert.equal(afterFirstRealSecondaryUpload.primarySlashVisible, false, `secondary 真实上传第一个 md 后 primary 菜单不应可见: ${JSON.stringify(afterFirstRealSecondaryUpload)}`);
+
+ await uploadAttachmentViaSecondarySlash(
+ page,
+ "secondary-real-upload-2.md",
+ "# Upload Two\n\n第二个真实上传\n",
+ "secondary 第二个真实 md",
+ );
+ await page.waitForTimeout(250);
+ const afterSecondRealSecondaryUpload = await readSideTargetState(page);
+ assert(afterSecondRealSecondaryUpload.secondaryResourceText.includes("secondary-real-upload-2.md"), `secondary 真实上传第二个 md 应插入 secondary 资源编辑器: ${JSON.stringify(afterSecondRealSecondaryUpload)}`);
+ assert(!afterSecondRealSecondaryUpload.primaryVisibleEditorText.includes("secondary-real-upload-2.md"), `secondary 真实上传第二个 md 不应插入 primary 编辑器: ${JSON.stringify(afterSecondRealSecondaryUpload)}`);
+ assert.equal(afterSecondRealSecondaryUpload.primarySlashVisible, false, `secondary 真实上传第二个 md 后 primary 菜单不应可见: ${JSON.stringify(afterSecondRealSecondaryUpload)}`);
+
+ const sideResourceDiskPath = path.join(root, "README", "side-target-resource.md");
+ await waitForDiskTextContains(sideResourceDiskPath, [
+ "secondary-real-upload-1.md",
+ "secondary-real-upload-2.md",
+ ]);
+ await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
+ await waitForPrimaryReady(page);
+ await page.evaluate(({ asset, documentId }) => {
+ window.dispatchEvent(new CustomEvent("tree.asset.open", {
+ detail: {
+ assetId: asset.id,
+ documentId,
+ title: asset.file_name || "side-target-resource.md",
+ assetType: asset.asset_type || "attachment",
+ openTarget: "side",
+ },
+ }));
+ }, { asset, documentId });
+ await page.locator('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror').first().waitFor({
+ state: "visible",
+ timeout: UI_TIMEOUT_MS,
+ });
+ await waitForSecondaryAttachmentLinks(page, [
+ "secondary-real-upload-1.md",
+ "secondary-real-upload-2.md",
+ ]);
+ await clickSecondaryAttachment(page, "secondary-real-upload-1.md");
+ await page.locator('.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]').waitFor({
+ state: "visible",
+ timeout: UI_TIMEOUT_MS,
+ });
+ const afterReloadFirstAttachmentClick = await readSideTargetState(page);
+ assert(afterReloadFirstAttachmentClick.secondaryActiveTabText.includes("secondary-real-upload-1.md"), `刷新后第一个 md 附件应在 secondary tab 打开: ${JSON.stringify(afterReloadFirstAttachmentClick)}`);
+ assert.equal(afterReloadFirstAttachmentClick.popupCount, 0, `刷新后第一个 md 附件不应新开窗口: ${JSON.stringify(afterReloadFirstAttachmentClick)}`);
+ await page.evaluate(({ asset, documentId }) => {
+ window.dispatchEvent(new CustomEvent("tree.asset.open", {
+ detail: {
+ assetId: asset.id,
+ documentId,
+ title: asset.file_name || "side-target-resource.md",
+ assetType: asset.asset_type || "attachment",
+ openTarget: "side",
+ },
+ }));
+ }, { asset, documentId });
+ await waitForSecondaryAttachmentLinks(page, [
+ "secondary-real-upload-1.md",
+ "secondary-real-upload-2.md",
+ ]);
+ await clickSecondaryAttachment(page, "secondary-real-upload-2.md");
+ await page.locator('.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]').waitFor({
+ state: "visible",
+ timeout: UI_TIMEOUT_MS,
+ });
+ const afterReloadSecondAttachmentClick = await readSideTargetState(page);
+ assert(afterReloadSecondAttachmentClick.secondaryActiveTabText.includes("secondary-real-upload-2.md"), `刷新后第二个 md 附件应在 secondary tab 打开: ${JSON.stringify(afterReloadSecondAttachmentClick)}`);
+ assert.equal(afterReloadSecondAttachmentClick.popupCount, 0, `刷新后第二个 md 附件不应新开窗口: ${JSON.stringify(afterReloadSecondAttachmentClick)}`);
+
+ fs.writeFileSync(path.join(root, "Second-resource.md"), "# Second Resource\n\n第二个资源正文\n", "utf8");
+ const secondAsset = await uploadLocalAsset(
+ page,
+ root,
+ documentId,
+ "Second-resource.md",
+ "text/markdown",
+ Buffer.from("# Second Resource\n\n第二个资源正文\n", "utf8"),
+ "attachment",
+ );
+ await page.evaluate(({ secondAsset, documentId }) => {
+ window.dispatchEvent(new CustomEvent("tree.asset.open", {
+ detail: {
+ assetId: secondAsset.id,
+ documentId,
+ title: secondAsset.file_name || "Second-resource.md",
+ assetType: secondAsset.asset_type || "attachment",
+ openTarget: "side",
+ },
+ }));
+ }, { secondAsset, documentId });
+ await page.locator('.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]').waitFor({
+ state: "visible",
+ timeout: UI_TIMEOUT_MS,
+ });
+ await page.locator('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror').first().waitFor({
+ state: "visible",
+ timeout: UI_TIMEOUT_MS,
+ });
+ const afterSecondResourceSideOpen = await readSideTargetState(page);
+ assert(afterSecondResourceSideOpen.secondaryActiveTabText.includes("Second-resource.md"), `secondary 第二个 md 资源应切到新标签: ${JSON.stringify(afterSecondResourceSideOpen)}`);
+ assert(afterSecondResourceSideOpen.secondaryResourceText.includes("第二个资源正文"), `secondary 第二个 md 资源应渲染新正文: ${JSON.stringify(afterSecondResourceSideOpen)}`);
+ assert.equal(afterSecondResourceSideOpen.secondaryActiveTabKind, "markdown", `secondary 第二个 md 资源不应回到 primary: ${JSON.stringify(afterSecondResourceSideOpen)}`);
+ assert.equal(afterSecondResourceSideOpen.popupCount, 0, `secondary 第二个 md 资源不应误开新窗口: ${JSON.stringify(afterSecondResourceSideOpen)}`);
+
+ await page.evaluate(({ officeAsset, documentId }) => {
+ window.dispatchEvent(new CustomEvent("tree.asset.open", {
+ detail: {
+ assetId: officeAsset.id,
+ documentId,
+ title: officeAsset.file_name || "side-target-office.docx",
+ assetType: officeAsset.asset_type || "attachment",
+ openTarget: "side",
+ },
+ }));
+ }, { officeAsset, documentId });
+ await page.locator('.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="office"]').waitFor({
+ state: "visible",
+ timeout: UI_TIMEOUT_MS,
+ });
+ await page.locator('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) iframe.mnote-resource-tab-frame').first().waitFor({
+ state: "visible",
+ timeout: UI_TIMEOUT_MS,
+ });
+ const afterOfficeSideOpen = await readSideTargetState(page);
+ assert.equal(afterOfficeSideOpen.secondaryDocumentId, null, `Office openTarget=side 不应恢复 secondaryDocumentId: ${JSON.stringify(afterOfficeSideOpen)}`);
+ assert.equal(afterOfficeSideOpen.secondaryActiveTabKind, "office", `Office openTarget=side 应在 secondary 资源标签打开 office: ${JSON.stringify(afterOfficeSideOpen)}`);
+ assert(afterOfficeSideOpen.secondaryActiveTabText.includes("side-target-office.docx"), `secondary Office 标签标题应可观测: ${JSON.stringify(afterOfficeSideOpen)}`);
+ assert(afterOfficeSideOpen.secondaryOfficeFrameSrc, `secondary Office 应创建 iframe: ${JSON.stringify(afterOfficeSideOpen)}`);
+ const secondaryOfficeFrameUrl = new URL(afterOfficeSideOpen.secondaryOfficeFrameSrc, BASE_URL);
+ assert.equal(secondaryOfficeFrameUrl.pathname, "/onlyoffice", `secondary Office iframe 应指向 /onlyoffice: ${JSON.stringify(afterOfficeSideOpen)}`);
+ assert.equal(secondaryOfficeFrameUrl.searchParams.get("assetId"), officeAsset.id, `secondary Office iframe 应携带 assetId: ${JSON.stringify(afterOfficeSideOpen)}`);
+ assert.equal(secondaryOfficeFrameUrl.searchParams.get("mode"), "view", `secondary Office iframe 应使用 view 模式: ${JSON.stringify(afterOfficeSideOpen)}`);
+ assert.equal(afterOfficeSideOpen.popupCount, 0, `Office openTarget=side 不应误开新窗口: ${JSON.stringify(afterOfficeSideOpen)}`);
+
+ console.log(JSON.stringify({ ok: true, root, assetId: asset.id, officeAssetId: officeAsset.id }, null, 2));
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);