diff --git a/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md b/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md index 6879b257..86d9699f 100644 --- a/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md +++ b/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md @@ -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 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`。 +- 2026-05-25:Batch 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-25:Batch 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。 diff --git a/design/05-editor-mainline/process/5-29-editor-runtime-followup-checklist-v1.md b/design/05-editor-mainline/process/5-29-editor-runtime-followup-checklist-v1.md index 80b2954b..4af42f5a 100644 --- a/design/05-editor-mainline/process/5-29-editor-runtime-followup-checklist-v1.md +++ b/design/05-editor-mainline/process/5-29-editor-runtime-followup-checklist-v1.md @@ -155,3 +155,8 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据 - 2026-05-25:Batch O 继续收口 overlay open helper:Codex 主控把 `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 编排。 - 已验证:`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-25:Batch 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`。 diff --git a/rust/crates/mnote-web/browser/filetree-runtime.js b/rust/crates/mnote-web/browser/filetree-runtime.js index 5b86993d..8e50e645 100644 --- a/rust/crates/mnote-web/browser/filetree-runtime.js +++ b/rust/crates/mnote-web/browser/filetree-runtime.js @@ -214,6 +214,17 @@ function groupRowsByParent(rows) { 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) { if (!(row instanceof HTMLElement)) return; var node = row.closest('.tree-node'); @@ -351,6 +362,7 @@ window.__mnoteFileTreeRuntime = { titleOf: titleOf, fileWorkspaceRelativePath: fileWorkspaceRelativePath, groupRowsByParent: groupRowsByParent, + replaceSidebarTreeFromDocument: replaceSidebarTreeFromDocument, fileTreeRowKind: fileTreeRowKind, fileTreeRowDocumentId: fileTreeRowDocumentId, fileTreeRowAssetId: fileTreeRowAssetId, diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 192cb864..3553bdd5 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -2414,6 +2414,8 @@ const SIDEBAR_TREE_JS: &str = r##" } function replaceSidebarTreeFromDocument(nextDocument, rootId) { + var runtimeFn = fileTreeRuntimeFunction('replaceSidebarTreeFromDocument'); + if (runtimeFn) return runtimeFn(nextDocument, rootId); var current = document.getElementById(rootId); var next = nextDocument ? nextDocument.getElementById(rootId) : null; 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('fileWorkspaceRelativePath')")); 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"); assert!( document_id_body.contains("data-document-id") && document_id_body.contains("data-doc-id"), "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] @@ -11121,8 +11132,11 @@ mod tests { assert!(FILETREE_RUNTIME_JS.contains("function titleOf")); assert!(FILETREE_RUNTIME_JS.contains("function fileWorkspaceRelativePath")); 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("groupRowsByParent: groupRowsByParent")); + assert!(FILETREE_RUNTIME_JS + .contains("replaceSidebarTreeFromDocument: replaceSidebarTreeFromDocument")); assert!(FILETREE_RUNTIME_JS .contains("uploadIntent: String(detail.uploadIntent || 'filetree.folder.drop')")); assert!(FILETREE_RUNTIME_JS diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js index 86518b31..f17699e6 100644 --- a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js +++ b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js @@ -1284,7 +1284,7 @@ function __wbg_get_imports() { return ret; }, __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); return ret; }, diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm index 21268355..5622963f 100644 Binary files a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm and b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm differ diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs index 69eae8fc..1e949e4c 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs @@ -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 web_sys::{window, EventTarget}; pub(crate) struct HandleCorridorGeometry { 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 { + 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)] mod tests { use super::*; diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 2e8ebbd1..cbb108b0 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -22,8 +22,8 @@ use web_sys::{ use editor_runtime::attachment_upload::dispatch_editor_upload_request; use editor_runtime::block_dnd::{ - drop_indicator_top, drop_placement_from_block_point, pointer_in_handle_corridor_geometry, - HandleCorridorGeometry, + drop_indicator_top, drop_placement_from_block_point, event_target_from_point, + pointer_in_handle_corridor_geometry, HandleCorridorGeometry, }; use editor_runtime::block_menu_overlay; 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 { - let target = window() - .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(); + let target = event_target_from_point(client_x, client_y)?; drop_indicator_from_target(target, client_y) } diff --git a/scripts/task487-local-folder-tree-live-consumer-smoke.js b/scripts/task487-local-folder-tree-live-consumer-smoke.js index 5350b4cb..f34d1d68 100644 --- a/scripts/task487-local-folder-tree-live-consumer-smoke.js +++ b/scripts/task487-local-folder-tree-live-consumer-smoke.js @@ -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 OUTPUT_DIR = path.join(process.cwd(), "tmp", "task487-local-folder-tree-live-consumer-smoke"); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); -const SCREENSHOT_BEFORE_PATH = path.join(OUTPUT_DIR, "page-tree-before-create.png"); -const SCREENSHOT_AFTER_PATH = path.join(OUTPUT_DIR, "page-tree-after-create.png"); +const SCREENSHOT_PAGE_TREE_LOADED_PATH = path.join(OUTPUT_DIR, "page-tree-loaded.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 || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"] .find((candidate) => fs.existsSync(candidate)); @@ -75,21 +77,52 @@ function writeWorkspaceManifest(root, ownerId) { /** * 读取 上的所有 data-mnote-tree-live-* 属性和 - * data-mnote-local-folder-watch-applied,返回一个状态对象。 + * data-mnote-local-folder-watch-applied,并补充当前树 DOM 证据。 */ -async function readLiveDomState(page) { - return page.evaluate(() => { +async function readLiveDomState(page, evidence = {}) { + return page.evaluate((options) => { 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 { + label: options.label || "", treeLiveTransport: html.getAttribute("data-mnote-tree-live-transport") || "", treeLiveStatus: html.getAttribute("data-mnote-tree-live-status") || "", treeLiveApplied: html.getAttribute("data-mnote-tree-live-applied") || "", treeLiveRevision: html.getAttribute("data-mnote-tree-live-revision") || "", treeLiveApplyError: html.getAttribute("data-mnote-tree-live-apply-error") || "", localFolderWatchApplied: html.getAttribute("data-mnote-local-folder-watch-applied") || "", + pageTreeRowCount: pageTreeRows.length, + fileTreeRowCount: fileTreeRows.length, + targetNodeId, + targetNodePresent, + targetRowSelector, + targetRowPresent, 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 = []; - let domStateBefore = null; - let domStateAfterCreate = null; - let domStateAfterDelete = null; + let pageTreeLoadedState = null; + let pageTreeAfterCreateState = null; + let pageTreeAfterDeleteState = null; + let filetreeLoadedState = null; + let filetreeAfterCreateState = null; + let filetreeAfterDeleteState = null; + let pageTreeNavigationEvidence = null; + let filetreeNavigationEvidence = null; let fatalError = null; try { @@ -173,72 +211,77 @@ async function run() { navigationEvents.length = 0; // 记录加载后 DOM 状态 - domStateBefore = await readLiveDomState(page); + pageTreeLoadedState = await readLiveDomState(page, { label: "pageTreeLoaded" }); // 断言 1: transport 不应为 local-folder-static assertions.push(check( "data-mnote-tree-live-transport !== 'local-folder-static'", - domStateBefore.treeLiveTransport !== "local-folder-static", + pageTreeLoadedState.treeLiveTransport !== "local-folder-static", '非 local-folder-static(期望 "local-folder-events" 或等价)', - domStateBefore.treeLiveTransport, + pageTreeLoadedState.treeLiveTransport, )); // 断言 2: status 不应为 static assertions.push(check( "data-mnote-tree-live-status 不为 static", - domStateBefore.treeLiveStatus !== "static", + pageTreeLoadedState.treeLiveStatus !== "static", "connected / connecting / disabled 等非 static 值", - domStateBefore.treeLiveStatus, + pageTreeLoadedState.treeLiveStatus, )); // 断言 3: 有 transport 属性(不是空) assertions.push(check( "data-mnote-tree-live-transport 非空", - domStateBefore.treeLiveTransport !== "", + pageTreeLoadedState.treeLiveTransport !== "", "非空字符串", - domStateBefore.treeLiveTransport, + pageTreeLoadedState.treeLiveTransport, )); // 截图: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 // 先确认页面稳定 await page.waitForTimeout(200); - domStateBefore = await readLiveDomState(page); + pageTreeLoadedState = await readLiveDomState(page, { label: "pageTreeLoadedAfterStabilize" }); // 创建外部文件 const createdFileRel = "docs/live-consumer-test.md"; + const createdFileDocumentId = localMdDocumentId(createdFileRel); fs.writeFileSync(path.join(root, createdFileRel), "# Live Consumer Test\n", "utf8"); // 等待 page tree row 出现(当前通过 polling 机制) - await waitForPageTreeNode(page, localMdDocumentId(createdFileRel)).catch(() => {}); + await waitForPageTreeNode(page, createdFileDocumentId).catch(() => {}); // 等待一小段时间让 tree live consumer 有机会触发 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 - const appliedOk = domStateAfterCreate.treeLiveApplied === "snapshot" - || domStateAfterCreate.treeLiveApplied === "resync"; + const appliedOk = pageTreeAfterCreateState.treeLiveApplied === "snapshot" + || pageTreeAfterCreateState.treeLiveApplied === "resync"; assertions.push(check( "外部创建后 data-mnote-tree-live-applied 为 snapshot|resync", appliedOk, "snapshot 或 resync", - domStateAfterCreate.treeLiveApplied, + pageTreeAfterCreateState.treeLiveApplied, )); // 断言 5: watch-applied 不再是唯一证据 // 即 treeLiveApplied 存在(非空)或 transport 不是 local-folder-static // 如果 watch-applied 是唯一标记而 treeLiveApplied 为空,则不合格 - const hasTreeLiveEvidence = domStateAfterCreate.treeLiveApplied !== "" - || domStateAfterCreate.treeLiveTransport !== "local-folder-static"; + const hasTreeLiveEvidence = pageTreeAfterCreateState.treeLiveApplied !== "" + || pageTreeAfterCreateState.treeLiveTransport !== "local-folder-static"; assertions.push(check( "存在 tree live consumer 证据(非仅 local-folder-watch-applied)", hasTreeLiveEvidence, "treeLiveApplied 非空 或 transport !== local-folder-static", - `treeLiveApplied="${domStateAfterCreate.treeLiveApplied}" transport="${domStateAfterCreate.treeLiveTransport}"`, + `treeLiveApplied="${pageTreeAfterCreateState.treeLiveApplied}" transport="${pageTreeAfterCreateState.treeLiveTransport}"`, )); // 断言 6: 外部文件删除后同样有 tree live applied 痕迹 @@ -250,19 +293,28 @@ async function run() { { timeout: UI_TIMEOUT_MS }, ).catch(() => {}); 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" - || domStateAfterDelete.treeLiveApplied === "resync"; + const appliedDelOk = pageTreeAfterDeleteState.treeLiveApplied === "snapshot" + || pageTreeAfterDeleteState.treeLiveApplied === "resync"; assertions.push(check( "外部删除后 data-mnote-tree-live-applied 为 snapshot|resync", appliedDelOk, "snapshot 或 resync", - domStateAfterDelete.treeLiveApplied, + pageTreeAfterDeleteState.treeLiveApplied, )); - // 截图:外部创建后 - await page.screenshot({ path: SCREENSHOT_AFTER_PATH, fullPage: false }); + pageTreeNavigationEvidence = navigationEvidence("PageTree 外部创建/删除阶段无主框架导航或 reload", navigationEvents); + assertions.push(check( + pageTreeNavigationEvidence.label, + pageTreeNavigationEvidence.pass, + "navigationEvents.length === 0", + pageTreeNavigationEvidence.count, + )); // ── Phase 2: FileTree 视图 ── 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); navigationEvents.length = 0; - const filetreeState = await readLiveDomState(page); + filetreeLoadedState = await readLiveDomState(page, { label: "filetreeLoaded" }); assertions.push(check( "FileTree: transport !== 'local-folder-static'", - filetreeState.treeLiveTransport !== "local-folder-static", + filetreeLoadedState.treeLiveTransport !== "local-folder-static", "非 local-folder-static", - filetreeState.treeLiveTransport, + filetreeLoadedState.treeLiveTransport, )); assertions.push(check( "FileTree: status 不为 static", - filetreeState.treeLiveStatus !== "static", + filetreeLoadedState.treeLiveStatus !== "static", "非 static", - filetreeState.treeLiveStatus, + filetreeLoadedState.treeLiveStatus, )); // 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 同模式) - await page.locator(`.tree-row[data-row-id="local:markdown:docs/filetree-live-test.md"]`).waitFor({ + await page.locator(filetreeCreatedRowSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }).catch(() => {}); 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( "FileTree 外部创建后 tree-live-applied 为 snapshot|resync", - filetreeAfterCreate.treeLiveApplied === "snapshot" || filetreeAfterCreate.treeLiveApplied === "resync", + filetreeAfterCreateState.treeLiveApplied === "snapshot" || filetreeAfterCreateState.treeLiveApplied === "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) { @@ -325,13 +410,27 @@ async function run() { ? "✅ 所有断言通过 — local_folder 已收敛到 tree live consumer" : "🔴 RED — 一个或多个断言失败,local_folder 仍使用 local-folder-static / polling 主链", assertions, - domStateBefore, - domStateAfterCreate, - domStateAfterDelete, + domStateBefore: pageTreeLoadedState, + domStateAfterCreate: pageTreeAfterCreateState, + 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), screenshots: { - pageTreeLoaded: SCREENSHOT_BEFORE_PATH, - afterExternalCreate: SCREENSHOT_AFTER_PATH, + pageTreeLoaded: SCREENSHOT_PAGE_TREE_LOADED_PATH, + pageTreeAfterExternalCreate: SCREENSHOT_PAGE_TREE_AFTER_CREATE_PATH, + pageTreeAfterExternalDelete: SCREENSHOT_PAGE_TREE_AFTER_DELETE_PATH, + filetreeAfterExternalCreate: SCREENSHOT_FILETREE_AFTER_CREATE_PATH, }, fatalError, timestamp: new Date().toISOString(),