#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { spawn } = require("node:child_process"); const { chromium } = require("playwright"); const { findFreePort, waitForGateway } = require("./task114-rust-web-gateway-entry-smoke.js"); const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000); const TYPE_DELAY_MS = Number(process.env.MNOTE_SMOKE_TYPE_DELAY_MS || 20); function buildFixtureEnv(port) { return { ...process.env, MNOTE_WEB_ALLOW_DEV_FIXTURES: "1", MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000", MNOTE_WEB_QUERY_FIXTURES_JSON: JSON.stringify({ "documents:getMeta": { id: "doc_1", workspace_id: "ws_demo", title: "双栏验收页面", updated_at: "2026-05-08T10:00:00Z", can_edit: true, word_count: 10, character_count: 32, block_count: 1, }, "documents:getContent": { title: "双栏验收页面", content: [ { id: "block_1", type: "paragraph", content: [{ type: "text", text: "fixture init" }], }, ], revision: 7, conflict_detection_key: "doc_1:7", pageSubtree: { rootNodeId: "doc_1", outline: [] }, }, "sidebar:datasetList": { active_workspace_id: "ws_demo", workspaces: [{ id: "ws_demo", name: "双栏空间" }], documents: [ { id: "doc_1", workspace_id: "ws_demo", title: "双栏验收页面", parent_id: null, sort_order: 0, is_starred: false, is_template: false, created_at: "2026-05-08T10:00:00Z", updated_at: "2026-05-08T10:00:00Z", }, { id: "doc_other", workspace_id: "ws_demo", title: "Fixture Other", parent_id: null, sort_order: 1, is_starred: false, is_template: false, created_at: "2026-05-08T10:00:00Z", updated_at: "2026-05-08T10:00:00Z", }, ], trashed_documents: [], media_assets: [], trashed_media_assets: [], mindmap_assets: [], trashed_mindmap_assets: [], table_assets: [], trashed_table_assets: [], mindmap_docs: [], mindmap_asset_children: {}, }, "bridgeLogs:listWorkspaceOverview": { workspace_id: "ws_demo", command_logs: [], domain_events: [], next_cursor: null, has_more: false, filters: { command_status: null, event_status: null, target_page_id: null, target_block_id: null, aggregate_type: null, aggregate_id: null, }, generated_at: "2026-05-08T10:00:00Z", }, }), MNOTE_WEB_MUTATION_FIXTURES_JSON: JSON.stringify({ "documents:updateContent": { ok: true, updated_at: "2026-05-08T10:00:10Z", revision: 8, conflict_detection_key: "doc_1:8", }, "documents:updateTitle": { ok: true, title: "双栏验收页面", }, }), }; } function startGateway(port) { return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], { cwd: path.resolve(__dirname, "..", "rust"), env: buildFixtureEnv(port), stdio: ["ignore", "pipe", "pipe"], }); } 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"); const readmeParagraphs = Array.from({ length: 80 }, (_, index) => `local init line ${index + 1}`); const seed = [ "---", "title: Local Dual Pane", "---", "", ...readmeParagraphs, "", ].join("\n"); fs.writeFileSync(readmePath, seed, "utf8"); fs.writeFileSync( sidePath, [ "---", "title: Local Side", "---", "", "side init", "", ].join("\n"), "utf8", ); fs.writeFileSync( thirdPath, [ "---", "title: Local Third", "---", "", "third init", "", ].join("\n"), "utf8", ); return { root, rootUri: `file://${root}`, readmePath, sidePath, thirdPath, documentId: "local-md:README.md", sideDocumentId: "local-md:side.md", thirdDocumentId: "local-md:third.md", }; } function paneEditorSelector(role) { return `.document-pane[data-pane-role="${role}"] .editor-surface .ProseMirror[contenteditable="true"]`; } function paneRootSelector(role) { return `.document-pane[data-pane-role="${role}"] [data-testid="mnote-leptos-tiptap-island-editor-root"]`; } 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 }) => { const primaryEditor = document.querySelector(primarySelector); const secondaryEditor = document.querySelector(secondarySelector); const primaryRoot = document.querySelector(primaryRootSelector); const secondaryRoot = document.querySelector(secondaryRootSelector); const ready = (editor, root) => editor instanceof HTMLElement && editor.isContentEditable && root instanceof HTMLElement && root.getAttribute("data-runtime-editor-status") !== "error"; return ready(primaryEditor, primaryRoot) && ready(secondaryEditor, secondaryRoot); }, { primarySelector: paneEditorSelector("primary"), secondarySelector: paneEditorSelector("secondary"), primaryRootSelector: paneRootSelector("primary"), secondaryRootSelector: paneRootSelector("secondary"), }, { timeout: UI_TIMEOUT_MS }, ); } async function waitForSinglePaneReady(page) { await page.waitForFunction( ({ primarySelector, primaryRootSelector, secondaryPaneSelector }) => { const primaryEditor = document.querySelector(primarySelector); const primaryRoot = document.querySelector(primaryRootSelector); const secondaryPane = document.querySelector(secondaryPaneSelector); const secondaryVisible = secondaryPane instanceof HTMLElement && !secondaryPane.hasAttribute("hidden") && secondaryPane.getAttribute("data-pane-visible") !== "false"; return ( primaryEditor instanceof HTMLElement && primaryEditor.isContentEditable && primaryRoot instanceof HTMLElement && primaryRoot.getAttribute("data-runtime-editor-status") !== "error" && !secondaryVisible ); }, { primarySelector: paneEditorSelector("primary"), primaryRootSelector: paneRootSelector("primary"), secondaryPaneSelector: paneSelector("secondary"), }, { timeout: UI_TIMEOUT_MS }, ); } async function typePaneText(page, role, text) { const editor = page.locator(paneEditorSelector(role)).first(); await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await editor.click({ timeout: UI_TIMEOUT_MS }); await page.keyboard.press("Control+a"); await page.keyboard.press("Backspace"); await page.keyboard.type(text, { delay: TYPE_DELAY_MS }); } async function appendPaneText(page, role, text) { const editor = page.locator(paneEditorSelector(role)).first(); await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await editor.click({ timeout: UI_TIMEOUT_MS }); await page.keyboard.press("Control+End"); await page.keyboard.type(text, { delay: TYPE_DELAY_MS }); } async function waitForPaneText(page, role, text) { await page.waitForFunction( ({ selector, expected }) => { const editor = document.querySelector(selector); return (editor?.textContent || "").includes(expected); }, { selector: paneEditorSelector(role), expected: text, }, { timeout: UI_TIMEOUT_MS }, ); } async function readPrimaryTitleState(page) { return await page.evaluate(() => { const primaryInput = document.querySelector('.document-pane[data-pane-role="primary"] [data-page-title-input="true"]'); const primaryMeta = document.querySelector('.document-pane[data-pane-role="primary"] [data-page-title-current="true"]'); const breadcrumb = document.querySelector('.wolai-breadcrumb-current [data-page-title-current="true"]'); return { documentTitle: document.title || "", inputValue: primaryInput instanceof HTMLTextAreaElement ? primaryInput.value || "" : "", metaTitle: primaryMeta instanceof HTMLElement ? primaryMeta.textContent || "" : "", breadcrumbTitle: breadcrumb instanceof HTMLElement ? breadcrumb.textContent || "" : "", }; }); } async function waitForPaneStatus(page, role, status) { await page.waitForFunction( ({ selector, expected }) => { const root = document.querySelector(selector); return root?.getAttribute("data-runtime-editor-status") === expected; }, { selector: paneRootSelector(role), expected: status, }, { timeout: UI_TIMEOUT_MS }, ); } 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); return editor?.textContent || ""; }, paneEditorSelector(role)); } async function readActivePaneRole(page) { return await page.evaluate(() => { const active = document.activeElement; return active?.closest?.(".document-pane")?.getAttribute?.("data-pane-role") || null; }); } async function readPaneScrollState(page, role) { return await page.evaluate(({ scrollHostSelector, editorSelector }) => { const scrollHost = document.querySelector(scrollHostSelector); const editor = document.querySelector(editorSelector); const findScrollable = (start, boundary) => { let current = start; while (current instanceof HTMLElement) { 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 = scrollHost instanceof HTMLElement ? (findScrollable(editor, scrollHost) || findScrollable(scrollHost, scrollHost)) : null; if (!(target instanceof HTMLElement)) { return null; } return { scrollTop: target.scrollTop, scrollHeight: target.scrollHeight, clientHeight: target.clientHeight, }; }, { scrollHostSelector: paneScrollHostSelector(role), editorSelector: paneEditorSelector(role), }); } async function setPaneScrollTop(page, role, top) { return await page.evaluate(({ scrollHostSelector, editorSelector, topValue }) => { const scrollHost = document.querySelector(scrollHostSelector); const editor = document.querySelector(editorSelector); const findScrollable = (start, boundary) => { let current = start; while (current instanceof HTMLElement) { 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 = scrollHost instanceof HTMLElement ? (findScrollable(editor, scrollHost) || findScrollable(scrollHost, scrollHost)) : null; if (!(target instanceof HTMLElement)) { return null; } target.scrollTop = topValue; return { scrollTop: target.scrollTop, scrollHeight: target.scrollHeight, clientHeight: target.clientHeight, }; }, { scrollHostSelector: paneScrollHostSelector(role), editorSelector: paneEditorSelector(role), topValue: top, }); } async function readViewportScroll(page) { return await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY })); } async function setViewportScroll(page, top) { return await page.evaluate((topValue) => { window.scrollTo({ top: topValue, left: 0, behavior: "auto" }); return { x: window.scrollX, y: window.scrollY }; }, 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) { const count = requests.slice(startIndex).filter(predicate).length; if (count >= expected) { return count; } await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error(`${label} 超时,期望至少 ${expected} 条`); } async function readDocumentSessionSnapshot(page) { return await page.evaluate(() => { const debug = window.__mnoteDebugDocumentSessions; if (!debug || typeof debug.snapshot !== "function") { throw new Error("缺少文档 session debug 快照"); } return debug.snapshot(); }); } 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"), primaryMountId: document.querySelector(primaryRootSelector)?.getAttribute("data-runtime-mount-id") || "", secondaryMountId: document.querySelector(secondaryRootSelector)?.getAttribute("data-runtime-mount-id") || "", }), { primaryRootSelector: paneRootSelector("primary"), secondaryRootSelector: paneRootSelector("secondary"), }); } async function waitForDocumentPath(page, documentId) { await page.waitForFunction( (expectedDocumentId) => decodeURIComponent(window.location.pathname).endsWith(`/documents/${expectedDocumentId}`), documentId, { timeout: UI_TIMEOUT_MS }, ); } function countRequests(requests, startIndex, predicate) { return requests.slice(startIndex).filter(predicate).length; } function isSaveRequest(record) { 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")) || (record.method === "WS" && record.url.includes("/api/realtime/ws")); } function isLocalFolderEventRequest(record) { 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`; await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForDualPaneReady(page); await waitForTreeLiveConnected(page, "fixture tree live 建连"); await page.waitForTimeout(800); const closeButton = page.locator('[data-mnote-pane-close="secondary"]').first(); await closeButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const primaryToSecondary = `fixture-primary-${Date.now().toString().slice(-6)}`; const primarySaveIndex = requests.length; await typePaneText(page, "primary", primaryToSecondary); await waitForPaneText(page, "primary", primaryToSecondary); await waitForPaneText(page, "secondary", primaryToSecondary); 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"); const secondaryToPrimary = `fixture-secondary-${(Date.now() + 1).toString().slice(-6)}`; const secondarySaveIndex = requests.length; await typePaneText(page, "secondary", secondaryToPrimary); await waitForPaneText(page, "primary", secondaryToPrimary); await waitForPaneText(page, "secondary", secondaryToPrimary); 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"); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForDualPaneReady(page); await waitForTreeLiveConnected(page, "reload 后 tree live 建连"); await page.waitForTimeout(800); const fixtureSnapshot = await readDocumentSessionSnapshot(page); assert.equal(fixtureSnapshot.sessionCount, 1, "同文档双开时应只复用一个 session"); assert.equal(fixtureSnapshot.sessions[0]?.viewCount, 2, "同文档双开时单 session 应挂两个 view"); const beforeFixtureNavigation = await readNoReloadProbe(page); const beforeTreeLiveNavigation = await readTreeLiveSnapshot(page); assert(beforeFixtureNavigation.secondaryMountId, "fixture 导航前应已挂载 secondary editor"); await clickSidebarRowOpen(page, "Fixture Other"); await waitForDocumentPath(page, "doc_other"); await waitForDualPaneReady(page); await page.waitForTimeout(800); const afterFixtureNavigation = await readNoReloadProbe(page); assert.equal( afterFixtureNavigation.pagehideCount, beforeFixtureNavigation.pagehideCount, "fixture sidebar 导航不应触发整页 pagehide", ); assert.equal( afterFixtureNavigation.secondaryMountId, beforeFixtureNavigation.secondaryMountId, "fixture sidebar 导航不应重挂 secondary editor", ); 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"), "doc_1", "fixture sidebar 导航后 secondaryDocumentId 应保持原值", ); } 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 page.waitForTimeout(800); 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( differentDocSnapshot.sessions.map((item) => item.documentId).sort(), [fixture.documentId, fixture.sideDocumentId].sort(), "不同文档双开时 session 应分别归属到两个 documentId", ); 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()); assert.equal( navigatedUrl.searchParams.get("secondaryDocumentId"), fixture.sideDocumentId, "左侧普通导航后 secondaryDocumentId 应保持原值", ); assert.equal( navigatedUrl.searchParams.get("secondarySourceKind"), "local_folder", "左侧普通导航后 secondarySourceKind 应保持 local_folder", ); assert.equal( navigatedUrl.searchParams.get("secondaryRootUri"), fixture.rootUri, "左侧普通导航后 secondaryRootUri 应保持原值", ); const primaryTextAfterNavigation = await readPaneText(page, "primary"); const secondaryTextAfterNavigation = await readPaneText(page, "secondary"); assert(primaryTextAfterNavigation.includes("third init"), "左侧普通导航后 primary pane 应切到新文档"); assert(secondaryTextAfterNavigation.includes("side init"), "左侧普通导航后 secondary pane 应保持原文档"); const topbarTitle = await page.locator(".wolai-topbar [data-page-title-current='true']").first().textContent(); assert((topbarTitle || "").includes("Local Third"), "左侧普通导航后共享 topbar 标题应切到 primary 文档"); const activeThirdRow = page.getByTestId("wolai-sidebar-row").filter({ hasText: "Local Third" }).first(); const activeState = await activeThirdRow.getAttribute("data-active"); const selectedState = await activeThirdRow.getAttribute("data-selected"); assert( activeState === "true" || selectedState === "true", "左侧普通导航后共享侧栏 active/selected 状态应切到新的 primary 文档", ); const url = `${baseUrl}/documents/${encodeURIComponent(fixture.documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(fixture.rootUri)}&secondaryDocumentId=${encodeURIComponent(fixture.documentId)}&secondarySourceKind=local_folder&secondaryRootUri=${encodeURIComponent(fixture.rootUri)}`; const openIndex = requests.length; await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForDualPaneReady(page); await page.waitForTimeout(800); 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 page.waitForTimeout(800); 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 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, [ "---", "title: Local Dual Pane", "---", "", ...Array.from({ length: 80 }, (_, index) => `local init line ${index + 1}`), externalText, "", ].join("\n"), "utf8", ); await waitForPaneText(page, "primary", externalText); await waitForPaneText(page, "secondary", externalText); await waitForPaneStatus(page, "primary", "synced-external-change"); await waitForPaneStatus(page, "secondary", "synced-external-change"); await page.waitForTimeout(800); const primaryScrollAfterSync = await readPaneScrollState(page, "primary"); const secondaryScrollAfterSync = await readPaneScrollState(page, "secondary"); assert( 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"); assert(savedMarkdown.includes(externalText), "外部写盘未落到本地 Markdown 文件"); const closeBefore = await readNoReloadProbe(page); await page.locator('[data-mnote-pane-close="secondary"]').first().click({ timeout: UI_TIMEOUT_MS }); await page.waitForFunction(() => !new URL(window.location.href).searchParams.has("secondaryDocumentId"), {}, { timeout: UI_TIMEOUT_MS }); await waitForSinglePaneReady(page); const closeAfter = await readNoReloadProbe(page); assert.equal(closeAfter.pagehideCount, closeBefore.pagehideCount, "关闭 secondary 不应触发整页 pagehide"); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForSinglePaneReady(page); const finalUrl = new URL(page.url()); assert(!finalUrl.searchParams.has("secondaryDocumentId"), "关闭 secondary 后 reload 不应恢复 secondaryDocumentId"); assert(!finalUrl.searchParams.has("secondarySourceKind"), "关闭 secondary 后 reload 不应恢复 secondarySourceKind"); assert(!finalUrl.searchParams.has("secondaryRootUri"), "关闭 secondary 后 reload 不应恢复 secondaryRootUri"); await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForDualPaneReady(page); await page.evaluate(() => { document.querySelector('.document-pane[data-pane-role="secondary"]')?.remove(); }); await page.waitForTimeout(300); const oneViewSnapshot = await readDocumentSessionSnapshot(page); assert.equal(oneViewSnapshot.sessionCount, 1, "移除一个 pane 后 session 不应提前释放"); assert.equal(oneViewSnapshot.sessions[0]?.viewCount, 1, "移除一个 pane 后同 session 应只剩一个 view"); await page.evaluate(() => { document.querySelector('.document-pane[data-pane-role="primary"]')?.remove(); }); await page.waitForTimeout(1500); const releasedSnapshot = await readDocumentSessionSnapshot(page); assert.equal(releasedSnapshot.sessionCount, 0, "最后一个 view 卸载后应延迟释放 session"); assert.equal(releasedSnapshot.localFolderChannelCount, 0, "最后一个 view 卸载后应回收 local-folder channel"); } async function runSinglePaneTitlePhase(page, baseUrl, fixture) { const url = `${baseUrl}/documents/${encodeURIComponent(fixture.documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(fixture.rootUri)}`; await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForSinglePaneReady(page); const expectedTitle = "Local Dual Pane"; const beforeClick = await readPrimaryTitleState(page); assert.equal(beforeClick.documentTitle.trim(), expectedTitle, "单页本地文档首屏 document.title 不应被隐藏 secondary 覆盖成“无标题”"); assert.equal(beforeClick.inputValue.trim(), expectedTitle, "单页本地文档首屏标题输入框不应被隐藏 secondary 覆盖成“无标题”"); assert.equal(beforeClick.metaTitle.trim(), expectedTitle, "单页本地文档首屏页头标题不应被隐藏 secondary 覆盖成“无标题”"); assert.equal(beforeClick.breadcrumbTitle.trim(), expectedTitle, "单页本地文档首屏 breadcrumb 标题不应被隐藏 secondary 覆盖成“无标题”"); const titleInput = page.locator('.document-pane[data-pane-role="primary"] [data-page-title-input="true"]').first(); await titleInput.click({ timeout: UI_TIMEOUT_MS }); await page.waitForTimeout(250); const afterClick = await readPrimaryTitleState(page); assert.equal(afterClick.documentTitle.trim(), expectedTitle, "点击标题后 document.title 不应闪成“无标题”"); assert.equal(afterClick.inputValue.trim(), expectedTitle, "点击标题后输入框标题不应闪成“无标题”"); assert.equal(afterClick.metaTitle.trim(), expectedTitle, "点击标题后页头标题不应闪成“无标题”"); assert.equal(afterClick.breadcrumbTitle.trim(), expectedTitle, "点击标题后 breadcrumb 标题不应闪成“无标题”"); const beforeOpenRight = await readNoReloadProbe(page); assert(beforeOpenRight.primaryMountId, "打开 secondary 前应已挂载 primary editor"); await page.evaluate((documentId) => { window.dispatchEvent(new CustomEvent("tree.page.open-right", { detail: { documentId } })); }, fixture.sideDocumentId); await page.waitForFunction(() => new URL(window.location.href).searchParams.has("secondaryDocumentId"), {}, { timeout: UI_TIMEOUT_MS }); await waitForDualPaneReady(page); const afterOpenRight = await readNoReloadProbe(page); assert.equal(afterOpenRight.pagehideCount, beforeOpenRight.pagehideCount, "打开 secondary 不应触发整页 pagehide"); assert.equal(afterOpenRight.primaryMountId, beforeOpenRight.primaryMountId, "打开 secondary 不应重挂 primary editor"); const openRightUrl = new URL(page.url()); assert.equal(openRightUrl.searchParams.get("secondaryDocumentId"), fixture.sideDocumentId, "打开 secondary 后 URL 应写入 secondaryDocumentId"); assert.equal(openRightUrl.searchParams.get("secondarySourceKind"), "local_folder", "打开 secondary 后 URL 应保留 secondarySourceKind"); assert.equal(openRightUrl.searchParams.get("secondaryRootUri"), fixture.rootUri, "打开 secondary 后 URL 应保留 secondaryRootUri"); } async function main() { const externalBaseUrl = String(process.env.MNOTE_UI_BASE_URL || "").trim(); const useExistingServer = externalBaseUrl.length > 0; const port = useExistingServer ? null : await findFreePort(); const baseUrl = useExistingServer ? externalBaseUrl : `http://127.0.0.1:${port}`; const gateway = useExistingServer ? null : startGateway(port); const localFixture = createLocalFolderFixture(); const requests = []; const diagnostics = { localFolderEventPhases: [] }; let stderr = ""; let stdout = ""; gateway?.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); gateway?.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); }); await waitForGateway(baseUrl); const browser = await chromium.launch({ headless: true }); 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", () => { const current = Number(window.sessionStorage?.getItem(key) || "0"); window.sessionStorage?.setItem(key, String(current + 1)); }); }); const page = await context.newPage(); page.on("request", (request) => { let payload = null; try { payload = request.postDataJSON?.() ?? null; } catch { payload = null; } requests.push({ url: request.url(), method: request.method(), payload, }); }); 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, diagnostics); await runSinglePaneTitlePhase(page, baseUrl, localFixture); console.log( JSON.stringify( { ok: true, task: "task165-rust-web-dual-pane-smoke", baseUrl, serverMode: useExistingServer ? "existing" : "spawned", requestSummary: { saveRequests: requests.filter(isSaveRequest).length, treeEventRequests: requests.filter(isTreeEventRequest).length, localFolderEventRequests: requests.filter(isLocalFolderEventRequest).length, }, diagnostics, }, null, 2, ), ); } catch (error) { caughtError = error; } finally { await page.close().catch(() => undefined); await context.close().catch(() => undefined); await browser.close().catch(() => undefined); gateway?.kill("SIGTERM"); if (gateway) { setTimeout(() => { if (!gateway.killed) { gateway.kill("SIGKILL"); } }, 2000).unref(); } fs.rmSync(localFixture.root, { recursive: true, force: true }); if (caughtError) { const debug = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n"); if (debug) { process.stderr.write(`${debug}\n`); } } } if (caughtError) { throw caughtError; } } if (require.main === module) { main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exit(1); }); }