chore: align local-first control plane and editor fixes
- wire SQLite control-plane access/session paths into Rust web local-folder routes - preserve local Markdown attachment semantics across upload, reload, and secondary-pane resource tabs - refresh design governance docs, Reasonix task templates, and bug records - retire root .mcp.json local MCP config
This commit is contained in:
@@ -115,7 +115,7 @@ function buildFixtureEnv(port) {
|
||||
|
||||
function startGateway(port) {
|
||||
return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: "/mnt/Data1T/mnote/rust",
|
||||
cwd: path.resolve(__dirname, "..", "rust"),
|
||||
env: buildFixtureEnv(port),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
@@ -123,6 +123,21 @@ function startGateway(port) {
|
||||
|
||||
function createLocalFolderFixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-dual-pane-"));
|
||||
const mnoteDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(mnoteDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(mnoteDir, "workspace.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
workspaceId: "local-ws:dual-pane-smoke",
|
||||
ownerId: "user_real",
|
||||
capabilities: ["local_files", "markdown_edit", "asset_upload"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const readmePath = path.join(root, "README.md");
|
||||
const sidePath = path.join(root, "side.md");
|
||||
const thirdPath = path.join(root, "third.md");
|
||||
@@ -184,6 +199,10 @@ function paneSelector(role) {
|
||||
return `.document-pane[data-pane-role="${role}"]`;
|
||||
}
|
||||
|
||||
function paneScrollHostSelector(role) {
|
||||
return `.document-main-editor-group[data-pane-role="${role}"]`;
|
||||
}
|
||||
|
||||
async function waitForDualPaneReady(page) {
|
||||
await page.waitForFunction(
|
||||
({ primarySelector, secondarySelector, primaryRootSelector, secondaryRootSelector }) => {
|
||||
@@ -294,6 +313,31 @@ async function waitForPaneStatus(page, role, status) {
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForPaneStatusWithSnapshot(page, role, status, label) {
|
||||
try {
|
||||
await waitForPaneStatus(page, role, status);
|
||||
} catch (error) {
|
||||
const snapshot = await readDocumentSessionSnapshot(page).catch((snapshotError) => ({
|
||||
error: snapshotError && snapshotError.stack ? snapshotError.stack : String(snapshotError),
|
||||
}));
|
||||
const paneState = await page.evaluate(({ rootSelector, paneSelector }) => {
|
||||
const root = document.querySelector(rootSelector);
|
||||
const pane = document.querySelector(paneSelector);
|
||||
return {
|
||||
status: root?.getAttribute("data-runtime-editor-status") || "",
|
||||
error: root?.getAttribute("data-runtime-editor-error") || "",
|
||||
panelText: pane?.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
|
||||
};
|
||||
}, {
|
||||
rootSelector: paneRootSelector(role),
|
||||
paneSelector: paneSelector(role),
|
||||
}).catch((stateError) => ({
|
||||
error: stateError && stateError.stack ? stateError.stack : String(stateError),
|
||||
}));
|
||||
throw new Error(`${label} 等待 ${role}=${status} 失败: pane=${JSON.stringify(paneState)} sessions=${JSON.stringify(snapshot)}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async function readPaneText(page, role) {
|
||||
return await page.evaluate((selector) => {
|
||||
const editor = document.querySelector(selector);
|
||||
@@ -309,20 +353,24 @@ async function readActivePaneRole(page) {
|
||||
}
|
||||
|
||||
async function readPaneScrollState(page, role) {
|
||||
return await page.evaluate(({ paneSelector, editorSelector }) => {
|
||||
const pane = document.querySelector(paneSelector);
|
||||
return await page.evaluate(({ scrollHostSelector, editorSelector }) => {
|
||||
const scrollHost = document.querySelector(scrollHostSelector);
|
||||
const editor = document.querySelector(editorSelector);
|
||||
const findScrollable = (start) => {
|
||||
const findScrollable = (start, boundary) => {
|
||||
let current = start;
|
||||
while (current instanceof HTMLElement) {
|
||||
if (current.scrollHeight > current.clientHeight + 8) {
|
||||
const overflowY = window.getComputedStyle(current).overflowY;
|
||||
if (current.scrollHeight > current.clientHeight + 8 && /auto|scroll|overlay/.test(overflowY)) {
|
||||
return current;
|
||||
}
|
||||
if (current === boundary) {
|
||||
break;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const target = findScrollable(editor) || findScrollable(pane);
|
||||
const target = scrollHost instanceof HTMLElement ? (findScrollable(editor, scrollHost) || findScrollable(scrollHost, scrollHost)) : null;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
@@ -332,26 +380,30 @@ async function readPaneScrollState(page, role) {
|
||||
clientHeight: target.clientHeight,
|
||||
};
|
||||
}, {
|
||||
paneSelector: paneSelector(role),
|
||||
scrollHostSelector: paneScrollHostSelector(role),
|
||||
editorSelector: paneEditorSelector(role),
|
||||
});
|
||||
}
|
||||
|
||||
async function setPaneScrollTop(page, role, top) {
|
||||
return await page.evaluate(({ paneSelector, editorSelector, topValue }) => {
|
||||
const pane = document.querySelector(paneSelector);
|
||||
return await page.evaluate(({ scrollHostSelector, editorSelector, topValue }) => {
|
||||
const scrollHost = document.querySelector(scrollHostSelector);
|
||||
const editor = document.querySelector(editorSelector);
|
||||
const findScrollable = (start) => {
|
||||
const findScrollable = (start, boundary) => {
|
||||
let current = start;
|
||||
while (current instanceof HTMLElement) {
|
||||
if (current.scrollHeight > current.clientHeight + 8) {
|
||||
const overflowY = window.getComputedStyle(current).overflowY;
|
||||
if (current.scrollHeight > current.clientHeight + 8 && /auto|scroll|overlay/.test(overflowY)) {
|
||||
return current;
|
||||
}
|
||||
if (current === boundary) {
|
||||
break;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const target = findScrollable(editor) || findScrollable(pane);
|
||||
const target = scrollHost instanceof HTMLElement ? (findScrollable(editor, scrollHost) || findScrollable(scrollHost, scrollHost)) : null;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
@@ -362,7 +414,7 @@ async function setPaneScrollTop(page, role, top) {
|
||||
clientHeight: target.clientHeight,
|
||||
};
|
||||
}, {
|
||||
paneSelector: paneSelector(role),
|
||||
scrollHostSelector: paneScrollHostSelector(role),
|
||||
editorSelector: paneEditorSelector(role),
|
||||
topValue: top,
|
||||
});
|
||||
@@ -379,6 +431,11 @@ async function setViewportScroll(page, top) {
|
||||
}, top);
|
||||
}
|
||||
|
||||
async function clickSidebarRowOpen(page, text) {
|
||||
const row = page.getByTestId("wolai-sidebar-row").filter({ hasText: text }).first();
|
||||
await row.locator(".tree-link").first().click({ timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function waitForRequestCount(requests, startIndex, predicate, expected, label) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -401,6 +458,100 @@ async function readDocumentSessionSnapshot(page) {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForTreeLiveConnected(page, label) {
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected",
|
||||
{},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const snapshot = await readTreeLiveSnapshot(page);
|
||||
assert(snapshot.transport, `${label} 应暴露 tree live transport`);
|
||||
assert(snapshot.sourceKind, `${label} 应持有 tree live source`);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function readTreeLiveSnapshot(page) {
|
||||
return await page.evaluate(() => {
|
||||
const source = window.__mnoteTreeLiveEventSource || null;
|
||||
if (!window.__mnoteSmokeTreeLiveSourceIds) {
|
||||
window.__mnoteSmokeTreeLiveSourceIds = new WeakMap();
|
||||
window.__mnoteSmokeTreeLiveNextSourceId = 1;
|
||||
}
|
||||
let sourceId = "";
|
||||
if (source && typeof source === "object") {
|
||||
if (!window.__mnoteSmokeTreeLiveSourceIds.has(source)) {
|
||||
window.__mnoteSmokeTreeLiveSourceIds.set(source, window.__mnoteSmokeTreeLiveNextSourceId++);
|
||||
}
|
||||
sourceId = String(window.__mnoteSmokeTreeLiveSourceIds.get(source) || "");
|
||||
}
|
||||
return {
|
||||
status: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
|
||||
transport: document.documentElement.getAttribute("data-mnote-tree-live-transport") || "",
|
||||
sourceKind: source
|
||||
? (typeof WebSocket !== "undefined" && source instanceof WebSocket
|
||||
? "websocket"
|
||||
: (typeof EventSource !== "undefined" && source instanceof EventSource ? "eventsource" : "unknown"))
|
||||
: "",
|
||||
sourceId,
|
||||
url: source && typeof source.url === "string" ? source.url : "",
|
||||
readyState: source && typeof source.readyState === "number" ? source.readyState : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function requestUrl(record) {
|
||||
try {
|
||||
return new URL(record.url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeLocalFolderEventRequests(requests, startIndex) {
|
||||
const summary = {
|
||||
total: 0,
|
||||
documentChannel: 0,
|
||||
treeLive: 0,
|
||||
byRootUri: {},
|
||||
};
|
||||
for (const record of requests.slice(startIndex)) {
|
||||
if (record.method !== "GET" || !record.url.includes("/api/local-folder/events")) continue;
|
||||
const url = requestUrl(record);
|
||||
const rootUri = url?.searchParams.get("rootUri") || "";
|
||||
const phase = url?.searchParams.get("treeLive") === "true" ? "treeLive" : "documentChannel";
|
||||
summary.total += 1;
|
||||
summary[phase] += 1;
|
||||
if (!summary.byRootUri[rootUri]) {
|
||||
summary.byRootUri[rootUri] = { total: 0, documentChannel: 0, treeLive: 0 };
|
||||
}
|
||||
summary.byRootUri[rootUri].total += 1;
|
||||
summary.byRootUri[rootUri][phase] += 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function recordLocalFolderEventDiagnostics(diagnostics, label, requests, startIndex, snapshot) {
|
||||
diagnostics.localFolderEventPhases.push({
|
||||
label,
|
||||
requests: summarizeLocalFolderEventRequests(requests, startIndex),
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForLocalFolderChannelCount(page, expected, label) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
let snapshot = null;
|
||||
while (Date.now() < deadline) {
|
||||
snapshot = await readDocumentSessionSnapshot(page);
|
||||
if (snapshot.localFolderChannelCount === expected) {
|
||||
return snapshot;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
assert.equal(snapshot?.localFolderChannelCount, expected, label);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function readNoReloadProbe(page) {
|
||||
return await page.evaluate(({ primaryRootSelector, secondaryRootSelector }) => ({
|
||||
pagehideCount: Number(window.sessionStorage?.getItem("__mnoteSmokePagehideCount") || "0"),
|
||||
@@ -425,25 +576,31 @@ function countRequests(requests, startIndex, predicate) {
|
||||
}
|
||||
|
||||
function isSaveRequest(record) {
|
||||
return record.method === "POST" && record.url.includes("/api/documents/save");
|
||||
return record.method === "POST"
|
||||
&& (record.url.includes("/api/documents/save") || record.url.includes("/api/page-body/write"));
|
||||
}
|
||||
|
||||
function isTreeEventRequest(record) {
|
||||
return record.method === "GET" && record.url.includes("/api/tree/events");
|
||||
return (record.method === "GET" && record.url.includes("/api/tree/events"))
|
||||
|| (record.method === "WS" && record.url.includes("/api/realtime/ws"));
|
||||
}
|
||||
|
||||
function isLocalFolderEventRequest(record) {
|
||||
return record.method === "GET" && record.url.includes("/api/local-folder/events");
|
||||
if (record.method !== "GET" || !record.url.includes("/api/local-folder/events")) return false;
|
||||
try {
|
||||
const url = new URL(record.url);
|
||||
return url.searchParams.get("treeLive") !== "true";
|
||||
} catch {
|
||||
return !record.url.includes("treeLive=true");
|
||||
}
|
||||
}
|
||||
|
||||
async function runFixturePhase(page, baseUrl, requests) {
|
||||
const url = `${baseUrl}/documents/doc_1?workspaceId=ws_demo&secondaryDocumentId=doc_1`;
|
||||
const openIndex = requests.length;
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForDualPaneReady(page);
|
||||
await waitForRequestCount(requests, openIndex, isTreeEventRequest, 1, "tree EventSource 建连");
|
||||
await waitForTreeLiveConnected(page, "fixture tree live 建连");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 1, "双 pane fixture 页面不应建立第二条 tree EventSource");
|
||||
|
||||
const closeButton = page.locator('[data-mnote-pane-close="secondary"]').first();
|
||||
await closeButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
@@ -453,8 +610,8 @@ async function runFixturePhase(page, baseUrl, requests) {
|
||||
await typePaneText(page, "primary", primaryToSecondary);
|
||||
await waitForPaneText(page, "primary", primaryToSecondary);
|
||||
await waitForPaneText(page, "secondary", primaryToSecondary);
|
||||
await waitForPaneStatus(page, "primary", "saved");
|
||||
await waitForPaneStatus(page, "secondary", "saved");
|
||||
await waitForPaneStatusWithSnapshot(page, "primary", "saved", "cross-doc primary save");
|
||||
await waitForPaneStatusWithSnapshot(page, "secondary", "saved", "cross-doc secondary save");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(countRequests(requests, primarySaveIndex, isSaveRequest), 1, "同 session 双 view 主 pane 输入后应只触发一次保存请求");
|
||||
assert.equal(await readActivePaneRole(page), "primary", "primary 输入后焦点不应被同步到 secondary");
|
||||
@@ -464,26 +621,24 @@ async function runFixturePhase(page, baseUrl, requests) {
|
||||
await typePaneText(page, "secondary", secondaryToPrimary);
|
||||
await waitForPaneText(page, "primary", secondaryToPrimary);
|
||||
await waitForPaneText(page, "secondary", secondaryToPrimary);
|
||||
await waitForPaneStatus(page, "primary", "saved");
|
||||
await waitForPaneStatus(page, "secondary", "saved");
|
||||
await waitForPaneStatusWithSnapshot(page, "primary", "saved", "different-doc primary save");
|
||||
await waitForPaneStatusWithSnapshot(page, "secondary", "saved", "different-doc secondary save");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(countRequests(requests, secondarySaveIndex, isSaveRequest), 1, "同 session 双 view 次 pane 输入后应只触发一次保存请求");
|
||||
assert.equal(await readActivePaneRole(page), "secondary", "secondary 输入后焦点不应被同步到 primary");
|
||||
|
||||
const reloadIndex = requests.length;
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForDualPaneReady(page);
|
||||
await waitForRequestCount(requests, reloadIndex, isTreeEventRequest, 1, "reload 后 tree EventSource 建连");
|
||||
await waitForTreeLiveConnected(page, "reload 后 tree live 建连");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(countRequests(requests, reloadIndex, isTreeEventRequest), 1, "reload 后双 pane 仍应只建立一条 tree EventSource");
|
||||
const fixtureSnapshot = await readDocumentSessionSnapshot(page);
|
||||
assert.equal(fixtureSnapshot.sessionCount, 1, "同文档双开时应只复用一个 session");
|
||||
assert.equal(fixtureSnapshot.sessions[0]?.viewCount, 2, "同文档双开时单 session 应挂两个 view");
|
||||
|
||||
const navigationIndex = requests.length;
|
||||
const beforeFixtureNavigation = await readNoReloadProbe(page);
|
||||
const beforeTreeLiveNavigation = await readTreeLiveSnapshot(page);
|
||||
assert(beforeFixtureNavigation.secondaryMountId, "fixture 导航前应已挂载 secondary editor");
|
||||
await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Fixture Other" }).first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await clickSidebarRowOpen(page, "Fixture Other");
|
||||
await waitForDocumentPath(page, "doc_other");
|
||||
await waitForDualPaneReady(page);
|
||||
await page.waitForTimeout(800);
|
||||
@@ -498,7 +653,12 @@ async function runFixturePhase(page, baseUrl, requests) {
|
||||
beforeFixtureNavigation.secondaryMountId,
|
||||
"fixture sidebar 导航不应重挂 secondary editor",
|
||||
);
|
||||
assert.equal(countRequests(requests, navigationIndex, isTreeEventRequest), 0, "fixture sidebar 导航不应重建 tree EventSource");
|
||||
const afterTreeLiveNavigation = await readTreeLiveSnapshot(page);
|
||||
assert.equal(
|
||||
afterTreeLiveNavigation.sourceId,
|
||||
beforeTreeLiveNavigation.sourceId,
|
||||
"fixture sidebar 导航不应重建 tree live source",
|
||||
);
|
||||
const navigatedFixtureUrl = new URL(page.url());
|
||||
assert.equal(
|
||||
navigatedFixtureUrl.searchParams.get("secondaryDocumentId"),
|
||||
@@ -507,15 +667,18 @@ async function runFixturePhase(page, baseUrl, requests) {
|
||||
);
|
||||
}
|
||||
|
||||
async function runLocalFolderPhase(page, baseUrl, requests, fixture) {
|
||||
async function runLocalFolderPhase(page, baseUrl, requests, fixture, diagnostics) {
|
||||
const differentDocUrl = `${baseUrl}/documents/${encodeURIComponent(fixture.documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(fixture.rootUri)}&secondaryDocumentId=${encodeURIComponent(fixture.sideDocumentId)}&secondarySourceKind=local_folder&secondaryRootUri=${encodeURIComponent(fixture.rootUri)}`;
|
||||
const differentDocIndex = requests.length;
|
||||
await page.goto(differentDocUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForDualPaneReady(page);
|
||||
await waitForRequestCount(requests, differentDocIndex, isLocalFolderEventRequest, 1, "不同文档 local folder EventSource 建连");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(countRequests(requests, differentDocIndex, isLocalFolderEventRequest), 1, "同 rootUri 不同文档双 pane 也应只复用一条 local-folder EventSource");
|
||||
const differentDocSnapshot = await readDocumentSessionSnapshot(page);
|
||||
const differentDocSnapshot = await waitForLocalFolderChannelCount(
|
||||
page,
|
||||
1,
|
||||
"同 rootUri 不同文档双开时应只复用一个 local-folder channel",
|
||||
);
|
||||
recordLocalFolderEventDiagnostics(diagnostics, "different-doc-open", requests, differentDocIndex, differentDocSnapshot);
|
||||
assert.equal(differentDocSnapshot.sessionCount, 2, "不同文档双开时应建立两个 session");
|
||||
assert.equal(differentDocSnapshot.localFolderChannelCount, 1, "同 rootUri 不同文档双开时应只复用一个 local-folder channel");
|
||||
assert.deepEqual(
|
||||
@@ -523,7 +686,49 @@ async function runLocalFolderPhase(page, baseUrl, requests, fixture) {
|
||||
[fixture.documentId, fixture.sideDocumentId].sort(),
|
||||
"不同文档双开时 session 应分别归属到两个 documentId",
|
||||
);
|
||||
await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Local Third" }).first().click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const crossDocPrimaryText = `cross-doc-primary-${Date.now().toString().slice(-6)}`;
|
||||
const crossDocSecondaryText = `cross-doc-secondary-${(Date.now() + 1).toString().slice(-6)}`;
|
||||
const crossDocPrimarySaveIndex = requests.length;
|
||||
await typePaneText(page, "primary", crossDocPrimaryText);
|
||||
await waitForPaneText(page, "primary", crossDocPrimaryText);
|
||||
await waitForPaneStatusWithSnapshot(page, "primary", "saved", "same-doc primary save");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(
|
||||
countRequests(requests, crossDocPrimarySaveIndex, isSaveRequest),
|
||||
1,
|
||||
"同 rootUri 不同文档时 primary pane 输入后应只触发一次保存请求",
|
||||
);
|
||||
const afterPrimaryCrossDocSnapshot = await readDocumentSessionSnapshot(page);
|
||||
const afterPrimarySecondarySession = afterPrimaryCrossDocSnapshot.sessions.find((item) => item.documentId === fixture.sideDocumentId);
|
||||
assert(afterPrimarySecondarySession, "primary save 后 secondary session 应存在");
|
||||
assert.equal(
|
||||
afterPrimarySecondarySession.status,
|
||||
"saved",
|
||||
`primary save 后 secondary session 不应被误判冲突: ${JSON.stringify(afterPrimarySecondarySession)}`,
|
||||
);
|
||||
|
||||
const crossDocSecondarySaveIndex = requests.length;
|
||||
await typePaneText(page, "secondary", crossDocSecondaryText);
|
||||
await waitForPaneText(page, "secondary", crossDocSecondaryText);
|
||||
await waitForPaneStatusWithSnapshot(page, "secondary", "saved", "same-doc secondary save");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(
|
||||
countRequests(requests, crossDocSecondarySaveIndex, isSaveRequest),
|
||||
1,
|
||||
"同 rootUri 不同文档时 secondary pane 输入后应只触发一次保存请求",
|
||||
);
|
||||
|
||||
const crossDocSnapshot = await readDocumentSessionSnapshot(page);
|
||||
const primarySession = crossDocSnapshot.sessions.find((item) => item.documentId === fixture.documentId);
|
||||
const secondarySession = crossDocSnapshot.sessions.find((item) => item.documentId === fixture.sideDocumentId);
|
||||
assert(primarySession, "cross-doc primary session 应存在");
|
||||
assert(secondarySession, "cross-doc secondary session 应存在");
|
||||
assert.equal(primarySession.status, "saved", `cross-doc primary session 不应进入冲突态: ${JSON.stringify(primarySession)}`);
|
||||
assert.notEqual(secondarySession.status, "external-change-conflict", `cross-doc secondary session 不应进入冲突态: ${JSON.stringify(secondarySession)}`);
|
||||
assert.notEqual(secondarySession.dirtyState, "ExternalModified", `cross-doc secondary session 不应被误判为外部修改: ${JSON.stringify(secondarySession)}`);
|
||||
|
||||
await clickSidebarRowOpen(page, "Local Third");
|
||||
await waitForDocumentPath(page, fixture.thirdDocumentId);
|
||||
await waitForDualPaneReady(page);
|
||||
const navigatedUrl = new URL(page.url());
|
||||
@@ -561,23 +766,33 @@ async function runLocalFolderPhase(page, baseUrl, requests, fixture) {
|
||||
const openIndex = requests.length;
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForDualPaneReady(page);
|
||||
await waitForRequestCount(requests, openIndex, isLocalFolderEventRequest, 1, "local folder EventSource 建连");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(countRequests(requests, openIndex, isLocalFolderEventRequest), 1, "同 rootUri 双 pane 不应建立第二条 local-folder EventSource");
|
||||
const sameDocOpenSnapshot = await waitForLocalFolderChannelCount(page, 1, "同 rootUri 双 pane 应只保留一个 local-folder channel");
|
||||
recordLocalFolderEventDiagnostics(diagnostics, "same-doc-open", requests, openIndex, sameDocOpenSnapshot);
|
||||
assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 0, "local folder 页面不应建立 tree EventSource");
|
||||
|
||||
const reloadIndex = requests.length;
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForDualPaneReady(page);
|
||||
await waitForRequestCount(requests, reloadIndex, isLocalFolderEventRequest, 1, "reload 后 local folder EventSource 建连");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(countRequests(requests, reloadIndex, isLocalFolderEventRequest), 1, "reload 后同 rootUri 双 pane 仍应只建立一条 local-folder EventSource");
|
||||
const sameDocSnapshot = await readDocumentSessionSnapshot(page);
|
||||
const sameDocSnapshot = await waitForLocalFolderChannelCount(page, 1, "reload 后同 rootUri 双 pane 仍应只保留一个 local-folder channel");
|
||||
recordLocalFolderEventDiagnostics(diagnostics, "same-doc-reload", requests, reloadIndex, sameDocSnapshot);
|
||||
assert.equal(sameDocSnapshot.sessionCount, 1, "同文档 local folder 双开时应只复用一个 session");
|
||||
assert.equal(sameDocSnapshot.sessions[0]?.viewCount, 2, "同文档 local folder 双开时单 session 应挂两个 view");
|
||||
|
||||
const viewportBefore = await setViewportScroll(page, 260);
|
||||
assert((viewportBefore?.y || 0) >= 200, "本地双栏页面应可滚动到可观察位置");
|
||||
const primaryScrollBefore = await readPaneScrollState(page, "primary");
|
||||
const secondaryScrollBefore = await readPaneScrollState(page, "secondary");
|
||||
assert(primaryScrollBefore && primaryScrollBefore.scrollHeight > primaryScrollBefore.clientHeight, `primary pane 应有独立滚动容器: ${JSON.stringify(primaryScrollBefore)}`);
|
||||
assert(secondaryScrollBefore && secondaryScrollBefore.scrollHeight > secondaryScrollBefore.clientHeight, `secondary pane 应有独立滚动容器: ${JSON.stringify(secondaryScrollBefore)}`);
|
||||
const primaryScrollAfter = await setPaneScrollTop(page, "primary", 260);
|
||||
const secondaryScrollAfter = await setPaneScrollTop(page, "secondary", 40);
|
||||
assert((primaryScrollAfter?.scrollTop || 0) > 120, `primary pane 内滚动应生效: ${JSON.stringify(primaryScrollAfter)}`);
|
||||
assert((secondaryScrollAfter?.scrollTop || 0) < 120, `secondary pane 内滚动应独立于 primary: ${JSON.stringify(secondaryScrollAfter)}`);
|
||||
const primaryScrollFinal = await readPaneScrollState(page, "primary");
|
||||
assert((primaryScrollFinal?.scrollTop || 0) > 120, `secondary pane 滚动不应重置 primary pane: ${JSON.stringify(primaryScrollFinal)}`);
|
||||
const primaryScrollBeforeSync = primaryScrollFinal;
|
||||
const secondaryScrollBeforeSync = await readPaneScrollState(page, "secondary");
|
||||
|
||||
const externalText = `external-sync-${Date.now().toString().slice(-6)}`;
|
||||
fs.writeFileSync(
|
||||
fixture.readmePath,
|
||||
@@ -597,10 +812,15 @@ async function runLocalFolderPhase(page, baseUrl, requests, fixture) {
|
||||
await waitForPaneStatus(page, "primary", "synced-external-change");
|
||||
await waitForPaneStatus(page, "secondary", "synced-external-change");
|
||||
await page.waitForTimeout(800);
|
||||
const viewportAfter = await readViewportScroll(page);
|
||||
const primaryScrollAfterSync = await readPaneScrollState(page, "primary");
|
||||
const secondaryScrollAfterSync = await readPaneScrollState(page, "secondary");
|
||||
assert(
|
||||
Math.abs((viewportAfter?.y || 0) - (viewportBefore?.y || 0)) < 40,
|
||||
"远端 replaceContent 后共享页面滚动不应被重置",
|
||||
Math.abs((primaryScrollAfterSync?.scrollTop || 0) - (primaryScrollBeforeSync?.scrollTop || 0)) < 40,
|
||||
`远端 replaceContent 后 primary pane 滚动不应被重置: before=${JSON.stringify(primaryScrollBeforeSync)} after=${JSON.stringify(primaryScrollAfterSync)}`,
|
||||
);
|
||||
assert(
|
||||
Math.abs((secondaryScrollAfterSync?.scrollTop || 0) - (secondaryScrollBeforeSync?.scrollTop || 0)) < 40,
|
||||
`远端 replaceContent 后 secondary pane 滚动不应被重置: before=${JSON.stringify(secondaryScrollBeforeSync)} after=${JSON.stringify(secondaryScrollAfterSync)}`,
|
||||
);
|
||||
|
||||
const savedMarkdown = fs.readFileSync(fixture.readmePath, "utf8");
|
||||
@@ -681,6 +901,7 @@ async function main() {
|
||||
const gateway = useExistingServer ? null : startGateway(port);
|
||||
const localFixture = createLocalFolderFixture();
|
||||
const requests = [];
|
||||
const diagnostics = { localFolderEventPhases: [] };
|
||||
let stderr = "";
|
||||
let stdout = "";
|
||||
|
||||
@@ -694,7 +915,14 @@ async function main() {
|
||||
await waitForGateway(baseUrl);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
locale: "zh-CN",
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "user_real",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
const key = "__mnoteSmokePagehideCount";
|
||||
window.addEventListener("pagehide", () => {
|
||||
@@ -718,12 +946,20 @@ async function main() {
|
||||
});
|
||||
});
|
||||
|
||||
page.on("websocket", (socket) => {
|
||||
requests.push({
|
||||
url: socket.url(),
|
||||
method: "WS",
|
||||
payload: null,
|
||||
});
|
||||
});
|
||||
|
||||
let caughtError = null;
|
||||
try {
|
||||
if (!useExistingServer) {
|
||||
await runFixturePhase(page, baseUrl, requests);
|
||||
}
|
||||
await runLocalFolderPhase(page, baseUrl, requests, localFixture);
|
||||
await runLocalFolderPhase(page, baseUrl, requests, localFixture, diagnostics);
|
||||
await runSinglePaneTitlePhase(page, baseUrl, localFixture);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
@@ -737,6 +973,7 @@ async function main() {
|
||||
treeEventRequests: requests.filter(isTreeEventRequest).length,
|
||||
localFolderEventRequests: requests.filter(isLocalFolderEventRequest).length,
|
||||
},
|
||||
diagnostics,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
Reference in New Issue
Block a user