refactor: extract dnd target and sidebar helpers

This commit is contained in:
lix-2026
2026-05-25 04:16:35 +08:00
parent 32adfb38a1
commit 1e097a9597
9 changed files with 201 additions and 57 deletions
@@ -154,3 +154,12 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据
- Worker C`filetree-runtime.js` 新增并导出 `readProjection``readSidebarDataset``readDatasetProjection``projectionItems``hasProjectionItems``nodeIdOf``rowIdOf``parentIdOf``titleOf``fileWorkspaceRelativePath``groupRowsByParent``layout.rs` 对应 inline fallback 优先委托 `fileTreeRuntimeFunction(...)`,未迁 `refreshLocalFolderSidebarSnapshot` / `startLocalFolderSidebarWatch` / fetch / post-render shell 副作用 / HTML renderer。 - Worker C`filetree-runtime.js` 新增并导出 `readProjection``readSidebarDataset``readDatasetProjection``projectionItems``hasProjectionItems``nodeIdOf``rowIdOf``parentIdOf``titleOf``fileWorkspaceRelativePath``groupRowsByParent``layout.rs` 对应 inline fallback 优先委托 `fileTreeRuntimeFunction(...)`,未迁 `refreshLocalFolderSidebarSnapshot` / `startLocalFolderSidebarWatch` / fetch / post-render shell 副作用 / HTML renderer。
- Worker D`task435-local-folder-watch-no-reload-smoke.js` 新增 `waitForLocalFolderWatchProjectionApplied`,每个外部文件变化步骤前清理旧标记,步骤内显式等待 `data-mnote-local-folder-watch-applied="projection"`,未放宽 no-reload 断言;Codex 同步把初始 `Stable Page` 等待修正为当前 file tree 标题 `stable` - Worker D`task435-local-folder-watch-no-reload-smoke.js` 新增 `waitForLocalFolderWatchProjectionApplied`,每个外部文件变化步骤前清理旧标记,步骤内显式等待 `data-mnote-local-folder-watch-applied="projection"`,未放宽 no-reload 断言;Codex 同步把初始 `Stable Page` 等待修正为当前 file tree 标题 `stable`
- 已验证:`node --check rust/crates/mnote-web/browser/filetree-runtime.js``node --check scripts/task435-local-folder-watch-no-reload-smoke.js``cargo fmt --manifest-path rust/Cargo.toml --all --check``cargo check --manifest-path rust/Cargo.toml -p mnote-web``cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree_runtime_contains_row_accessor_helpers -- --test-threads=1``cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime_helpers_are_externalized_with_inline_fallback -- --test-threads=1``cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_polls_local_folder_without_browser_reload -- --test-threads=1``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task435-local-folder-watch-no-reload-smoke.js``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js` - 已验证:`node --check rust/crates/mnote-web/browser/filetree-runtime.js``node --check scripts/task435-local-folder-watch-no-reload-smoke.js``cargo fmt --manifest-path rust/Cargo.toml --all --check``cargo check --manifest-path rust/Cargo.toml -p mnote-web``cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree_runtime_contains_row_accessor_helpers -- --test-threads=1``cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime_helpers_are_externalized_with_inline_fallback -- --test-threads=1``cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_polls_local_folder_without_browser_reload -- --test-threads=1``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task435-local-folder-watch-no-reload-smoke.js``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`
- 2026-05-25Batch O Worker C 使用独立 worktree `/mnt/Data1T/mnote-worktrees/0525-o-worker-c-3-20-replace-sidebar-helper`Codex 已读取 subagent handoff、worktree diff 和验证证据,并选择性移植。
- 修改:`filetree-runtime.js` 新增并导出 `replaceSidebarTreeFromDocument(nextDocument, rootId)``layout.rs` 的 inline fallback 优先委托 `fileTreeRuntimeFunction('replaceSidebarTreeFromDocument')`,仍保留 `innerHTML` 和 attribute copy fallback。
- 未迁:`refreshLocalFolderSidebarSnapshot`、fetch URL 构造、watcher、副作用和 HTML renderer。
- Worker 验证:`node --check rust/crates/mnote-web/browser/filetree-runtime.js``cargo fmt --manifest-path rust/Cargo.toml --all --check``cargo check --manifest-path rust/Cargo.toml -p mnote-web``cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree_runtime -- --test-threads=1` 通过。
- Codex 主控验证:`node --check rust/crates/mnote-web/browser/filetree-runtime.js``cargo check --manifest-path rust/Cargo.toml -p mnote-web``cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree_runtime -- --test-threads=1``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task435-local-folder-watch-no-reload-smoke.js`
- 2026-05-25Batch O Worker D 使用独立 worktree `/mnt/Data1T/mnote-worktrees/0525-o-worker-d-3-20-live-smoke-assertions`Codex 已读取 subagent handoff、worktree diff 和验证证据,并选择性移植。
- 修改:`task487-local-folder-tree-live-consumer-smoke.js` 保持 RED smoke 退出 0 语义,增加 staged DOM 状态、PageTree/FileTree 外部创建/删除后的目标节点证据、navigation evidence 和更多截图路径。
- Worker 验证:`node --check scripts/task487-local-folder-tree-live-consumer-smoke.js`、限定 `git diff --check` 通过;因当时 3000 未启动,未运行真实浏览器 smoke。
- Codex 主控验证:`node --check scripts/task487-local-folder-tree-live-consumer-smoke.js``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task487-local-folder-tree-live-consumer-smoke.js`。结果仍为 RED`transport=local-folder-events`、PageTree/FileTree 外部变更阶段 `navigationEvents=0`,但 `treeLiveApplied` 仍为空,说明 live consumer 证据仍未收敛到 snapshot/resync。
@@ -155,3 +155,8 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据
- 2026-05-25Batch O 继续收口 overlay open helperCodex 主控把 `open_slash_menu_overlay``open_image_toolbar_overlay``open_block_menu_overlay` 迁入 `editor_runtime/overlays.rs`。这三者只复用 `close_editor_floating_overlays` 并设置 Leptos signals`open_block_menu_overlay` 仅读取 `block_menu_open` 判定是否重新打开。 - 2026-05-25Batch O 继续收口 overlay open helperCodex 主控把 `open_slash_menu_overlay``open_image_toolbar_overlay``open_block_menu_overlay` 迁入 `editor_runtime/overlays.rs`。这三者只复用 `close_editor_floating_overlays` 并设置 Leptos signals`open_block_menu_overlay` 仅读取 `block_menu_open` 判定是否重新打开。
- 未迁:`try_sync_editor_overlay_state``on_selection_change` 和各调用点 DOM / selection bridge 逻辑,原因是仍属于 editor shell 编排。 - 未迁:`try_sync_editor_overlay_state``on_selection_change` 和各调用点 DOM / selection bridge 逻辑,原因是仍属于 editor shell 编排。
- 已验证:`cargo fmt --manifest-path rust/Cargo.toml --all --check``cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml`、wasm release build、`wasm-bindgen``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task489-block-menu-delete-undo-smoke.js``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task488-local-attachment-link-delete-undo-smoke.js` - 已验证:`cargo fmt --manifest-path rust/Cargo.toml --all --check``cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml`、wasm release build、`wasm-bindgen``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task489-block-menu-delete-undo-smoke.js``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task488-local-attachment-link-delete-undo-smoke.js`
- 2026-05-25Batch O Worker B 使用独立 worktree `/mnt/Data1T/mnote-worktrees/0525-o-worker-b-5-29-dnd-target-helper`Codex 已读取 subagent handoff、worktree diff 和验证证据,并选择性移植。
- 修改:`editor_runtime/block_dnd.rs` 新增 `event_target_from_point(client_x, client_y)`,封装 `document.element_from_point``EventTarget` 的窄 DOM lookup`lib.rs``drop_indicator_from_point` 只委托该 helper 后继续调用 `drop_indicator_from_target`
- 未迁:`drop_indicator_from_target`、拖拽 listener、Leptos signal、`editor_stage_element` / `editor_root_element` 查询和 block reorder 行为仍留在 shell。
- Worker 验证:`cargo fmt --manifest-path rust/Cargo.toml --all --check``git diff --check``rustfmt --edition 2021 --check rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs` 通过;`cargo check -p leptos-tiptap-spike` 因包名不匹配失败,主控改用正确 manifest 复核。
- Codex 主控验证:`cargo fmt --manifest-path rust/Cargo.toml --all --check``cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml`、wasm release build、`wasm-bindgen``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task489-block-menu-delete-undo-smoke.js``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task488-local-attachment-link-delete-undo-smoke.js`
@@ -214,6 +214,17 @@ function groupRowsByParent(rows) {
return grouped; return grouped;
} }
function replaceSidebarTreeFromDocument(nextDocument, rootId) {
var current = document.getElementById(rootId);
var next = nextDocument ? nextDocument.getElementById(rootId) : null;
if (!(current instanceof HTMLElement) || !(next instanceof HTMLElement)) return false;
current.innerHTML = next.innerHTML;
Array.from(next.attributes || []).forEach(function(attr) {
current.setAttribute(attr.name, attr.value);
});
return true;
}
function revealFileTreeRow(row) { function revealFileTreeRow(row) {
if (!(row instanceof HTMLElement)) return; if (!(row instanceof HTMLElement)) return;
var node = row.closest('.tree-node'); var node = row.closest('.tree-node');
@@ -351,6 +362,7 @@ window.__mnoteFileTreeRuntime = {
titleOf: titleOf, titleOf: titleOf,
fileWorkspaceRelativePath: fileWorkspaceRelativePath, fileWorkspaceRelativePath: fileWorkspaceRelativePath,
groupRowsByParent: groupRowsByParent, groupRowsByParent: groupRowsByParent,
replaceSidebarTreeFromDocument: replaceSidebarTreeFromDocument,
fileTreeRowKind: fileTreeRowKind, fileTreeRowKind: fileTreeRowKind,
fileTreeRowDocumentId: fileTreeRowDocumentId, fileTreeRowDocumentId: fileTreeRowDocumentId,
fileTreeRowAssetId: fileTreeRowAssetId, fileTreeRowAssetId: fileTreeRowAssetId,
@@ -2414,6 +2414,8 @@ const SIDEBAR_TREE_JS: &str = r##"
} }
function replaceSidebarTreeFromDocument(nextDocument, rootId) { function replaceSidebarTreeFromDocument(nextDocument, rootId) {
var runtimeFn = fileTreeRuntimeFunction('replaceSidebarTreeFromDocument');
if (runtimeFn) return runtimeFn(nextDocument, rootId);
var current = document.getElementById(rootId); var current = document.getElementById(rootId);
var next = nextDocument ? nextDocument.getElementById(rootId) : null; var next = nextDocument ? nextDocument.getElementById(rootId) : null;
if (!(current instanceof HTMLElement) || !(next instanceof HTMLElement)) return false; if (!(current instanceof HTMLElement) || !(next instanceof HTMLElement)) return false;
@@ -11087,12 +11089,21 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('titleOf')")); assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('titleOf')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileWorkspaceRelativePath')")); assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileWorkspaceRelativePath')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('groupRowsByParent')")); assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('groupRowsByParent')"));
assert!(
SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('replaceSidebarTreeFromDocument')")
);
let document_id_body = js_function_body(SIDEBAR_TREE_JS, "fileTreeRowDocumentId"); let document_id_body = js_function_body(SIDEBAR_TREE_JS, "fileTreeRowDocumentId");
assert!( assert!(
document_id_body.contains("data-document-id") document_id_body.contains("data-document-id")
&& document_id_body.contains("data-doc-id"), && document_id_body.contains("data-doc-id"),
"inline fallback 必须保留旧 data attribute 解析" "inline fallback 必须保留旧 data attribute 解析"
); );
let replace_body = js_function_body(SIDEBAR_TREE_JS, "replaceSidebarTreeFromDocument");
assert!(
replace_body.contains("current.innerHTML = next.innerHTML")
&& replace_body.contains("current.setAttribute(attr.name, attr.value)"),
"inline fallback 必须保留 DOM copy 行为"
);
} }
#[test] #[test]
@@ -11121,8 +11132,11 @@ mod tests {
assert!(FILETREE_RUNTIME_JS.contains("function titleOf")); assert!(FILETREE_RUNTIME_JS.contains("function titleOf"));
assert!(FILETREE_RUNTIME_JS.contains("function fileWorkspaceRelativePath")); assert!(FILETREE_RUNTIME_JS.contains("function fileWorkspaceRelativePath"));
assert!(FILETREE_RUNTIME_JS.contains("function groupRowsByParent")); assert!(FILETREE_RUNTIME_JS.contains("function groupRowsByParent"));
assert!(FILETREE_RUNTIME_JS.contains("function replaceSidebarTreeFromDocument"));
assert!(FILETREE_RUNTIME_JS.contains("appendUploadedAssetRow: appendUploadedAssetRow")); assert!(FILETREE_RUNTIME_JS.contains("appendUploadedAssetRow: appendUploadedAssetRow"));
assert!(FILETREE_RUNTIME_JS.contains("groupRowsByParent: groupRowsByParent")); assert!(FILETREE_RUNTIME_JS.contains("groupRowsByParent: groupRowsByParent"));
assert!(FILETREE_RUNTIME_JS
.contains("replaceSidebarTreeFromDocument: replaceSidebarTreeFromDocument"));
assert!(FILETREE_RUNTIME_JS assert!(FILETREE_RUNTIME_JS
.contains("uploadIntent: String(detail.uploadIntent || 'filetree.folder.drop')")); .contains("uploadIntent: String(detail.uploadIntent || 'filetree.folder.drop')"));
assert!(FILETREE_RUNTIME_JS assert!(FILETREE_RUNTIME_JS
@@ -1284,7 +1284,7 @@ function __wbg_get_imports() {
return ret; return ret;
}, },
__wbindgen_cast_0000000000000006: function(arg0, arg1) { __wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 883, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 736, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf);
return ret; return ret;
}, },
@@ -1,9 +1,10 @@
//! Block handle 拖拽命中的纯几何 helper。 //! Block handle 拖拽命中的 runtime helper。
//! //!
//! 本模块不查询 DOM、不处理 Leptos signal,也不分发 editor command //! 本模块只承接拖拽命中所需的窄 helper,不处理 Leptos signal
//! 调用方负责传入已经从页面中读取的坐标 //! 也不分发 editor command;调用方负责保留编辑器状态编排
use crate::editor_runtime::block_hover_state::DropPlacement; use crate::editor_runtime::block_hover_state::DropPlacement;
use web_sys::{window, EventTarget};
pub(crate) struct HandleCorridorGeometry { pub(crate) struct HandleCorridorGeometry {
pub(crate) stage_left: f64, pub(crate) stage_left: f64,
@@ -56,6 +57,13 @@ pub(crate) fn drop_indicator_top(
} }
} }
pub(crate) fn event_target_from_point(client_x: i32, client_y: i32) -> Option<EventTarget> {
window()
.and_then(|win| win.document())
.and_then(|document| document.element_from_point(client_x as f32, client_y as f32))
.map(Into::into)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+3 -6
View File
@@ -22,8 +22,8 @@ use web_sys::{
use editor_runtime::attachment_upload::dispatch_editor_upload_request; use editor_runtime::attachment_upload::dispatch_editor_upload_request;
use editor_runtime::block_dnd::{ use editor_runtime::block_dnd::{
drop_indicator_top, drop_placement_from_block_point, pointer_in_handle_corridor_geometry, drop_indicator_top, drop_placement_from_block_point, event_target_from_point,
HandleCorridorGeometry, pointer_in_handle_corridor_geometry, HandleCorridorGeometry,
}; };
use editor_runtime::block_menu_overlay; use editor_runtime::block_menu_overlay;
use editor_runtime::block_menu_legacy_html::{ use editor_runtime::block_menu_legacy_html::{
@@ -5430,10 +5430,7 @@ fn drop_indicator_from_target(
} }
fn drop_indicator_from_point(client_x: i32, client_y: i32) -> Option<DropIndicatorState> { fn drop_indicator_from_point(client_x: i32, client_y: i32) -> Option<DropIndicatorState> {
let target = window() let target = event_target_from_point(client_x, client_y)?;
.and_then(|win| win.document())
.and_then(|document| document.element_from_point(client_x as f32, client_y as f32))?;
let target: web_sys::EventTarget = target.into();
drop_indicator_from_target(target, client_y) drop_indicator_from_target(target, client_y)
} }
@@ -28,8 +28,10 @@ const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task487-local-folder-tree-live-consumer-smoke"); const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task487-local-folder-tree-live-consumer-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_BEFORE_PATH = path.join(OUTPUT_DIR, "page-tree-before-create.png"); const SCREENSHOT_PAGE_TREE_LOADED_PATH = path.join(OUTPUT_DIR, "page-tree-loaded.png");
const SCREENSHOT_AFTER_PATH = path.join(OUTPUT_DIR, "page-tree-after-create.png"); const SCREENSHOT_PAGE_TREE_AFTER_CREATE_PATH = path.join(OUTPUT_DIR, "page-tree-after-create.png");
const SCREENSHOT_PAGE_TREE_AFTER_DELETE_PATH = path.join(OUTPUT_DIR, "page-tree-after-delete.png");
const SCREENSHOT_FILETREE_AFTER_CREATE_PATH = path.join(OUTPUT_DIR, "filetree-after-create.png");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"] || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate)); .find((candidate) => fs.existsSync(candidate));
@@ -75,21 +77,52 @@ function writeWorkspaceManifest(root, ownerId) {
/** /**
* 读取 <html> 上的所有 data-mnote-tree-live-* 属性和 * 读取 <html> 上的所有 data-mnote-tree-live-* 属性和
* data-mnote-local-folder-watch-applied返回一个状态对象 * data-mnote-local-folder-watch-applied并补充当前树 DOM 证据
*/ */
async function readLiveDomState(page) { async function readLiveDomState(page, evidence = {}) {
return page.evaluate(() => { return page.evaluate((options) => {
const html = document.documentElement; const html = document.documentElement;
const pageTreeRows = Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]"));
const fileTreeRows = Array.from(document.querySelectorAll(".tree-row[data-row-id]"));
const targetNodeId = options.targetNodeId || "";
const targetRowSelector = options.targetRowSelector || "";
const targetNodePresent = targetNodeId
? pageTreeRows.some((row) => row.getAttribute("data-node-id") === targetNodeId)
: null;
const targetRowPresent = targetRowSelector
? !!document.querySelector(targetRowSelector)
: null;
return { return {
label: options.label || "",
treeLiveTransport: html.getAttribute("data-mnote-tree-live-transport") || "", treeLiveTransport: html.getAttribute("data-mnote-tree-live-transport") || "",
treeLiveStatus: html.getAttribute("data-mnote-tree-live-status") || "", treeLiveStatus: html.getAttribute("data-mnote-tree-live-status") || "",
treeLiveApplied: html.getAttribute("data-mnote-tree-live-applied") || "", treeLiveApplied: html.getAttribute("data-mnote-tree-live-applied") || "",
treeLiveRevision: html.getAttribute("data-mnote-tree-live-revision") || "", treeLiveRevision: html.getAttribute("data-mnote-tree-live-revision") || "",
treeLiveApplyError: html.getAttribute("data-mnote-tree-live-apply-error") || "", treeLiveApplyError: html.getAttribute("data-mnote-tree-live-apply-error") || "",
localFolderWatchApplied: html.getAttribute("data-mnote-local-folder-watch-applied") || "", localFolderWatchApplied: html.getAttribute("data-mnote-local-folder-watch-applied") || "",
pageTreeRowCount: pageTreeRows.length,
fileTreeRowCount: fileTreeRows.length,
targetNodeId,
targetNodePresent,
targetRowSelector,
targetRowPresent,
url: window.location.href, url: window.location.href,
}; };
}); }, evidence);
}
/**
* 记录一个阶段内是否发生主框架导航。RED smoke 不因失败退出非 0
* 但需要把 reload / full navigation 作为明确证据写入 result.json。
*/
function navigationEvidence(label, events) {
return {
label,
pass: events.length === 0,
count: events.length,
events: events.slice(0, 20),
};
} }
/** /**
@@ -144,9 +177,14 @@ async function run() {
}); });
const assertions = []; const assertions = [];
let domStateBefore = null; let pageTreeLoadedState = null;
let domStateAfterCreate = null; let pageTreeAfterCreateState = null;
let domStateAfterDelete = null; let pageTreeAfterDeleteState = null;
let filetreeLoadedState = null;
let filetreeAfterCreateState = null;
let filetreeAfterDeleteState = null;
let pageTreeNavigationEvidence = null;
let filetreeNavigationEvidence = null;
let fatalError = null; let fatalError = null;
try { try {
@@ -173,72 +211,77 @@ async function run() {
navigationEvents.length = 0; navigationEvents.length = 0;
// 记录加载后 DOM 状态 // 记录加载后 DOM 状态
domStateBefore = await readLiveDomState(page); pageTreeLoadedState = await readLiveDomState(page, { label: "pageTreeLoaded" });
// 断言 1: transport 不应为 local-folder-static // 断言 1: transport 不应为 local-folder-static
assertions.push(check( assertions.push(check(
"data-mnote-tree-live-transport !== 'local-folder-static'", "data-mnote-tree-live-transport !== 'local-folder-static'",
domStateBefore.treeLiveTransport !== "local-folder-static", pageTreeLoadedState.treeLiveTransport !== "local-folder-static",
'非 local-folder-static(期望 "local-folder-events" 或等价)', '非 local-folder-static(期望 "local-folder-events" 或等价)',
domStateBefore.treeLiveTransport, pageTreeLoadedState.treeLiveTransport,
)); ));
// 断言 2: status 不应为 static // 断言 2: status 不应为 static
assertions.push(check( assertions.push(check(
"data-mnote-tree-live-status 不为 static", "data-mnote-tree-live-status 不为 static",
domStateBefore.treeLiveStatus !== "static", pageTreeLoadedState.treeLiveStatus !== "static",
"connected / connecting / disabled 等非 static 值", "connected / connecting / disabled 等非 static 值",
domStateBefore.treeLiveStatus, pageTreeLoadedState.treeLiveStatus,
)); ));
// 断言 3: 有 transport 属性(不是空) // 断言 3: 有 transport 属性(不是空)
assertions.push(check( assertions.push(check(
"data-mnote-tree-live-transport 非空", "data-mnote-tree-live-transport 非空",
domStateBefore.treeLiveTransport !== "", pageTreeLoadedState.treeLiveTransport !== "",
"非空字符串", "非空字符串",
domStateBefore.treeLiveTransport, pageTreeLoadedState.treeLiveTransport,
)); ));
// 截图:page tree 加载后状态 // 截图:page tree 加载后状态
await page.screenshot({ path: SCREENSHOT_BEFORE_PATH, fullPage: false }); await page.screenshot({ path: SCREENSHOT_PAGE_TREE_LOADED_PATH, fullPage: false });
// 断言 4: 外部文件创建后,tree live applied 应为 snapshot 或 resync // 断言 4: 外部文件创建后,tree live applied 应为 snapshot 或 resync
// 先确认页面稳定 // 先确认页面稳定
await page.waitForTimeout(200); await page.waitForTimeout(200);
domStateBefore = await readLiveDomState(page); pageTreeLoadedState = await readLiveDomState(page, { label: "pageTreeLoadedAfterStabilize" });
// 创建外部文件 // 创建外部文件
const createdFileRel = "docs/live-consumer-test.md"; const createdFileRel = "docs/live-consumer-test.md";
const createdFileDocumentId = localMdDocumentId(createdFileRel);
fs.writeFileSync(path.join(root, createdFileRel), "# Live Consumer Test\n", "utf8"); fs.writeFileSync(path.join(root, createdFileRel), "# Live Consumer Test\n", "utf8");
// 等待 page tree row 出现(当前通过 polling 机制) // 等待 page tree row 出现(当前通过 polling 机制)
await waitForPageTreeNode(page, localMdDocumentId(createdFileRel)).catch(() => {}); await waitForPageTreeNode(page, createdFileDocumentId).catch(() => {});
// 等待一小段时间让 tree live consumer 有机会触发 // 等待一小段时间让 tree live consumer 有机会触发
await page.waitForTimeout(400); await page.waitForTimeout(400);
domStateAfterCreate = await readLiveDomState(page); pageTreeAfterCreateState = await readLiveDomState(page, {
label: "pageTreeAfterExternalCreate",
targetNodeId: createdFileDocumentId,
});
await page.screenshot({ path: SCREENSHOT_PAGE_TREE_AFTER_CREATE_PATH, fullPage: false });
// 断言 4: applied 应为 snapshot 或 resync // 断言 4: applied 应为 snapshot 或 resync
const appliedOk = domStateAfterCreate.treeLiveApplied === "snapshot" const appliedOk = pageTreeAfterCreateState.treeLiveApplied === "snapshot"
|| domStateAfterCreate.treeLiveApplied === "resync"; || pageTreeAfterCreateState.treeLiveApplied === "resync";
assertions.push(check( assertions.push(check(
"外部创建后 data-mnote-tree-live-applied 为 snapshot|resync", "外部创建后 data-mnote-tree-live-applied 为 snapshot|resync",
appliedOk, appliedOk,
"snapshot 或 resync", "snapshot 或 resync",
domStateAfterCreate.treeLiveApplied, pageTreeAfterCreateState.treeLiveApplied,
)); ));
// 断言 5: watch-applied 不再是唯一证据 // 断言 5: watch-applied 不再是唯一证据
// 即 treeLiveApplied 存在(非空)或 transport 不是 local-folder-static // 即 treeLiveApplied 存在(非空)或 transport 不是 local-folder-static
// 如果 watch-applied 是唯一标记而 treeLiveApplied 为空,则不合格 // 如果 watch-applied 是唯一标记而 treeLiveApplied 为空,则不合格
const hasTreeLiveEvidence = domStateAfterCreate.treeLiveApplied !== "" const hasTreeLiveEvidence = pageTreeAfterCreateState.treeLiveApplied !== ""
|| domStateAfterCreate.treeLiveTransport !== "local-folder-static"; || pageTreeAfterCreateState.treeLiveTransport !== "local-folder-static";
assertions.push(check( assertions.push(check(
"存在 tree live consumer 证据(非仅 local-folder-watch-applied", "存在 tree live consumer 证据(非仅 local-folder-watch-applied",
hasTreeLiveEvidence, hasTreeLiveEvidence,
"treeLiveApplied 非空 或 transport !== local-folder-static", "treeLiveApplied 非空 或 transport !== local-folder-static",
`treeLiveApplied="${domStateAfterCreate.treeLiveApplied}" transport="${domStateAfterCreate.treeLiveTransport}"`, `treeLiveApplied="${pageTreeAfterCreateState.treeLiveApplied}" transport="${pageTreeAfterCreateState.treeLiveTransport}"`,
)); ));
// 断言 6: 外部文件删除后同样有 tree live applied 痕迹 // 断言 6: 外部文件删除后同样有 tree live applied 痕迹
@@ -250,19 +293,28 @@ async function run() {
{ timeout: UI_TIMEOUT_MS }, { timeout: UI_TIMEOUT_MS },
).catch(() => {}); ).catch(() => {});
await page.waitForTimeout(400); await page.waitForTimeout(400);
domStateAfterDelete = await readLiveDomState(page); pageTreeAfterDeleteState = await readLiveDomState(page, {
label: "pageTreeAfterExternalDelete",
targetNodeId: createdFileDocumentId,
});
await page.screenshot({ path: SCREENSHOT_PAGE_TREE_AFTER_DELETE_PATH, fullPage: false });
const appliedDelOk = domStateAfterDelete.treeLiveApplied === "snapshot" const appliedDelOk = pageTreeAfterDeleteState.treeLiveApplied === "snapshot"
|| domStateAfterDelete.treeLiveApplied === "resync"; || pageTreeAfterDeleteState.treeLiveApplied === "resync";
assertions.push(check( assertions.push(check(
"外部删除后 data-mnote-tree-live-applied 为 snapshot|resync", "外部删除后 data-mnote-tree-live-applied 为 snapshot|resync",
appliedDelOk, appliedDelOk,
"snapshot 或 resync", "snapshot 或 resync",
domStateAfterDelete.treeLiveApplied, pageTreeAfterDeleteState.treeLiveApplied,
)); ));
// 截图:外部创建后 pageTreeNavigationEvidence = navigationEvidence("PageTree 外部创建/删除阶段无主框架导航或 reload", navigationEvents);
await page.screenshot({ path: SCREENSHOT_AFTER_PATH, fullPage: false }); assertions.push(check(
pageTreeNavigationEvidence.label,
pageTreeNavigationEvidence.pass,
"navigationEvents.length === 0",
pageTreeNavigationEvidence.count,
));
// ── Phase 2: FileTree 视图 ── // ── Phase 2: FileTree 视图 ──
await page.goto(documentUrl(root, "README.md", "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.goto(documentUrl(root, "README.md", "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
@@ -277,38 +329,71 @@ async function run() {
await page.waitForTimeout(300); await page.waitForTimeout(300);
navigationEvents.length = 0; navigationEvents.length = 0;
const filetreeState = await readLiveDomState(page); filetreeLoadedState = await readLiveDomState(page, { label: "filetreeLoaded" });
assertions.push(check( assertions.push(check(
"FileTree: transport !== 'local-folder-static'", "FileTree: transport !== 'local-folder-static'",
filetreeState.treeLiveTransport !== "local-folder-static", filetreeLoadedState.treeLiveTransport !== "local-folder-static",
"非 local-folder-static", "非 local-folder-static",
filetreeState.treeLiveTransport, filetreeLoadedState.treeLiveTransport,
)); ));
assertions.push(check( assertions.push(check(
"FileTree: status 不为 static", "FileTree: status 不为 static",
filetreeState.treeLiveStatus !== "static", filetreeLoadedState.treeLiveStatus !== "static",
"非 static", "非 static",
filetreeState.treeLiveStatus, filetreeLoadedState.treeLiveStatus,
)); ));
// FileTree 外部创建 // FileTree 外部创建
fs.writeFileSync(path.join(root, "docs", "filetree-live-test.md"), "# FileTree Live\n", "utf8"); const filetreeCreatedRel = "docs/filetree-live-test.md";
const filetreeCreatedRowSelector = `.tree-row[data-row-id="local:markdown:${filetreeCreatedRel}"]`;
fs.writeFileSync(path.join(root, filetreeCreatedRel), "# FileTree Live\n", "utf8");
// 等待 filetree row 出现(与 task435 同模式) // 等待 filetree row 出现(与 task435 同模式)
await page.locator(`.tree-row[data-row-id="local:markdown:docs/filetree-live-test.md"]`).waitFor({ await page.locator(filetreeCreatedRowSelector).waitFor({
state: "visible", state: "visible",
timeout: UI_TIMEOUT_MS, timeout: UI_TIMEOUT_MS,
}).catch(() => {}); }).catch(() => {});
await page.waitForTimeout(400); await page.waitForTimeout(400);
const filetreeAfterCreate = await readLiveDomState(page); filetreeAfterCreateState = await readLiveDomState(page, {
label: "filetreeAfterExternalCreate",
targetRowSelector: filetreeCreatedRowSelector,
});
await page.screenshot({ path: SCREENSHOT_FILETREE_AFTER_CREATE_PATH, fullPage: false });
assertions.push(check( assertions.push(check(
"FileTree 外部创建后 tree-live-applied 为 snapshot|resync", "FileTree 外部创建后 tree-live-applied 为 snapshot|resync",
filetreeAfterCreate.treeLiveApplied === "snapshot" || filetreeAfterCreate.treeLiveApplied === "resync", filetreeAfterCreateState.treeLiveApplied === "snapshot" || filetreeAfterCreateState.treeLiveApplied === "resync",
"snapshot 或 resync", "snapshot 或 resync",
filetreeAfterCreate.treeLiveApplied, filetreeAfterCreateState.treeLiveApplied,
));
fs.rmSync(path.join(root, filetreeCreatedRel));
await page.locator(filetreeCreatedRowSelector).waitFor({
state: "detached",
timeout: UI_TIMEOUT_MS,
}).catch(() => {});
await page.waitForTimeout(400);
filetreeAfterDeleteState = await readLiveDomState(page, {
label: "filetreeAfterExternalDelete",
targetRowSelector: filetreeCreatedRowSelector,
});
assertions.push(check(
"FileTree 外部删除后 tree-live-applied 为 snapshot|resync",
filetreeAfterDeleteState.treeLiveApplied === "snapshot" || filetreeAfterDeleteState.treeLiveApplied === "resync",
"snapshot 或 resync",
filetreeAfterDeleteState.treeLiveApplied,
));
filetreeNavigationEvidence = navigationEvidence("FileTree 外部创建/删除阶段无主框架导航或 reload", navigationEvents);
assertions.push(check(
filetreeNavigationEvidence.label,
filetreeNavigationEvidence.pass,
"navigationEvents.length === 0",
filetreeNavigationEvidence.count,
)); ));
} catch (err) { } catch (err) {
@@ -325,13 +410,27 @@ async function run() {
? "✅ 所有断言通过 — local_folder 已收敛到 tree live consumer" ? "✅ 所有断言通过 — local_folder 已收敛到 tree live consumer"
: "🔴 RED — 一个或多个断言失败,local_folder 仍使用 local-folder-static / polling 主链", : "🔴 RED — 一个或多个断言失败,local_folder 仍使用 local-folder-static / polling 主链",
assertions, assertions,
domStateBefore, domStateBefore: pageTreeLoadedState,
domStateAfterCreate, domStateAfterCreate: pageTreeAfterCreateState,
domStateAfterDelete, domStateAfterDelete: pageTreeAfterDeleteState,
stagedDomStates: {
pageTreeLoaded: pageTreeLoadedState,
pageTreeAfterExternalCreate: pageTreeAfterCreateState,
pageTreeAfterExternalDelete: pageTreeAfterDeleteState,
filetreeLoaded: filetreeLoadedState,
filetreeAfterExternalCreate: filetreeAfterCreateState,
filetreeAfterExternalDelete: filetreeAfterDeleteState,
},
navigationEvidence: {
pageTreeExternalMutations: pageTreeNavigationEvidence,
filetreeExternalMutations: filetreeNavigationEvidence,
},
navigationEvents: navigationEvents.slice(0, 20), navigationEvents: navigationEvents.slice(0, 20),
screenshots: { screenshots: {
pageTreeLoaded: SCREENSHOT_BEFORE_PATH, pageTreeLoaded: SCREENSHOT_PAGE_TREE_LOADED_PATH,
afterExternalCreate: SCREENSHOT_AFTER_PATH, pageTreeAfterExternalCreate: SCREENSHOT_PAGE_TREE_AFTER_CREATE_PATH,
pageTreeAfterExternalDelete: SCREENSHOT_PAGE_TREE_AFTER_DELETE_PATH,
filetreeAfterExternalCreate: SCREENSHOT_FILETREE_AFTER_CREATE_PATH,
}, },
fatalError, fatalError,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),