feat: finish rust web dual pane document shell

This commit is contained in:
lix-2026
2026-05-08 23:15:00 +08:00
parent 83805f9254
commit 3d5e0c9d5a
16 changed files with 3776 additions and 761 deletions
+731
View File
@@ -0,0 +1,731 @@
#!/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_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
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: "/mnt/Data1T/mnote/rust",
env: buildFixtureEnv(port),
stdio: ["ignore", "pipe", "pipe"],
});
}
function createLocalFolderFixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-dual-pane-"));
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}"]`;
}
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 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(({ paneSelector, editorSelector }) => {
const pane = document.querySelector(paneSelector);
const editor = document.querySelector(editorSelector);
const findScrollable = (start) => {
let current = start;
while (current instanceof HTMLElement) {
if (current.scrollHeight > current.clientHeight + 8) {
return current;
}
current = current.parentElement;
}
return null;
};
const target = findScrollable(editor) || findScrollable(pane);
if (!(target instanceof HTMLElement)) {
return null;
}
return {
scrollTop: target.scrollTop,
scrollHeight: target.scrollHeight,
clientHeight: target.clientHeight,
};
}, {
paneSelector: paneSelector(role),
editorSelector: paneEditorSelector(role),
});
}
async function setPaneScrollTop(page, role, top) {
return await page.evaluate(({ paneSelector, editorSelector, topValue }) => {
const pane = document.querySelector(paneSelector);
const editor = document.querySelector(editorSelector);
const findScrollable = (start) => {
let current = start;
while (current instanceof HTMLElement) {
if (current.scrollHeight > current.clientHeight + 8) {
return current;
}
current = current.parentElement;
}
return null;
};
const target = findScrollable(editor) || findScrollable(pane);
if (!(target instanceof HTMLElement)) {
return null;
}
target.scrollTop = topValue;
return {
scrollTop: target.scrollTop,
scrollHeight: target.scrollHeight,
clientHeight: target.clientHeight,
};
}, {
paneSelector: paneSelector(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 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();
});
}
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");
}
function isTreeEventRequest(record) {
return record.method === "GET" && record.url.includes("/api/tree/events");
}
function isLocalFolderEventRequest(record) {
return record.method === "GET" && record.url.includes("/api/local-folder/events");
}
async function runFixturePhase(page, baseUrl, requests) {
const url = `${baseUrl}/documents/doc_1?workspaceId=ws_demo&secondaryDocumentId=doc_1`;
const openIndex = requests.length;
await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForDualPaneReady(page);
await waitForRequestCount(requests, openIndex, isTreeEventRequest, 1, "tree EventSource 建连");
await page.waitForTimeout(800);
assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 1, "双 pane fixture 页面不应建立第二条 tree EventSource");
const closeButton = page.locator('[data-mnote-pane-close="secondary"]').first();
await closeButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
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 waitForPaneStatus(page, "primary", "saved");
await waitForPaneStatus(page, "secondary", "saved");
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 waitForPaneStatus(page, "primary", "saved");
await waitForPaneStatus(page, "secondary", "saved");
await page.waitForTimeout(800);
assert.equal(countRequests(requests, secondarySaveIndex, isSaveRequest), 1, "同 session 双 view 次 pane 输入后应只触发一次保存请求");
assert.equal(await readActivePaneRole(page), "secondary", "secondary 输入后焦点不应被同步到 primary");
const reloadIndex = requests.length;
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForDualPaneReady(page);
await waitForRequestCount(requests, reloadIndex, isTreeEventRequest, 1, "reload 后 tree EventSource 建连");
await page.waitForTimeout(800);
assert.equal(countRequests(requests, reloadIndex, isTreeEventRequest), 1, "reload 后双 pane 仍应只建立一条 tree EventSource");
const fixtureSnapshot = await readDocumentSessionSnapshot(page);
assert.equal(fixtureSnapshot.sessionCount, 1, "同文档双开时应只复用一个 session");
assert.equal(fixtureSnapshot.sessions[0]?.viewCount, 2, "同文档双开时单 session 应挂两个 view");
const navigationIndex = requests.length;
await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Fixture Other" }).first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((nextUrl) => decodeURIComponent(nextUrl.pathname).endsWith("/documents/doc_other"), {
timeout: UI_TIMEOUT_MS,
waitUntil: "domcontentloaded",
});
await waitForDualPaneReady(page);
await waitForRequestCount(requests, navigationIndex, isTreeEventRequest, 1, "sidebar 导航后的 tree EventSource 建连");
await page.waitForTimeout(800);
assert.equal(countRequests(requests, navigationIndex, isTreeEventRequest), 1, "sidebar 导航后当前页面仍应只建立一条 tree EventSource");
const navigatedFixtureUrl = new URL(page.url());
assert.equal(
navigatedFixtureUrl.searchParams.get("secondaryDocumentId"),
"doc_1",
"fixture sidebar 导航后 secondaryDocumentId 应保持原值",
);
}
async function runLocalFolderPhase(page, baseUrl, requests, fixture) {
const differentDocUrl = `${baseUrl}/documents/${encodeURIComponent(fixture.documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(fixture.rootUri)}&secondaryDocumentId=${encodeURIComponent(fixture.sideDocumentId)}&secondarySourceKind=local_folder&secondaryRootUri=${encodeURIComponent(fixture.rootUri)}`;
const differentDocIndex = requests.length;
await page.goto(differentDocUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForDualPaneReady(page);
await waitForRequestCount(requests, differentDocIndex, isLocalFolderEventRequest, 1, "不同文档 local folder EventSource 建连");
await page.waitForTimeout(800);
assert.equal(countRequests(requests, differentDocIndex, isLocalFolderEventRequest), 1, "同 rootUri 不同文档双 pane 也应只复用一条 local-folder EventSource");
const differentDocSnapshot = await readDocumentSessionSnapshot(page);
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",
);
await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Local Third" }).first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((nextUrl) => decodeURIComponent(nextUrl.pathname).endsWith(`/documents/${fixture.thirdDocumentId}`), {
timeout: UI_TIMEOUT_MS,
waitUntil: "domcontentloaded",
});
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 waitForRequestCount(requests, openIndex, isLocalFolderEventRequest, 1, "local folder EventSource 建连");
await page.waitForTimeout(800);
assert.equal(countRequests(requests, openIndex, isLocalFolderEventRequest), 1, "同 rootUri 双 pane 不应建立第二条 local-folder EventSource");
assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 0, "local folder 页面不应建立 tree EventSource");
const reloadIndex = requests.length;
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForDualPaneReady(page);
await waitForRequestCount(requests, reloadIndex, isLocalFolderEventRequest, 1, "reload 后 local folder EventSource 建连");
await page.waitForTimeout(800);
assert.equal(countRequests(requests, reloadIndex, isLocalFolderEventRequest), 1, "reload 后同 rootUri 双 pane 仍应只建立一条 local-folder EventSource");
const sameDocSnapshot = await readDocumentSessionSnapshot(page);
assert.equal(sameDocSnapshot.sessionCount, 1, "同文档 local folder 双开时应只复用一个 session");
assert.equal(sameDocSnapshot.sessions[0]?.viewCount, 2, "同文档 local folder 双开时单 session 应挂两个 view");
const viewportBefore = await setViewportScroll(page, 260);
assert((viewportBefore?.y || 0) >= 200, "本地双栏页面应可滚动到可观察位置");
const 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 viewportAfter = await readViewportScroll(page);
assert(
Math.abs((viewportAfter?.y || 0) - (viewportBefore?.y || 0)) < 40,
"远端 replaceContent 后共享页面滚动不应被重置",
);
const savedMarkdown = fs.readFileSync(fixture.readmePath, "utf8");
assert(savedMarkdown.includes(externalText), "外部写盘未落到本地 Markdown 文件");
await page.locator('[data-mnote-pane-close="secondary"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((nextUrl) => !nextUrl.searchParams.has("secondaryDocumentId"), {
timeout: UI_TIMEOUT_MS,
waitUntil: "commit",
});
await waitForSinglePaneReady(page);
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 标题不应闪成“无标题”");
}
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 = [];
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" });
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,
});
});
let caughtError = null;
try {
if (!useExistingServer) {
await runFixturePhase(page, baseUrl, requests);
}
await runLocalFolderPhase(page, baseUrl, requests, localFixture);
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,
},
},
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);
});
}