- 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
1016 lines
42 KiB
JavaScript
1016 lines
42 KiB
JavaScript
#!/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: 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);
|
|
});
|
|
}
|