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:
lix-2026
2026-05-23 23:38:42 +08:00
parent 42fb58310c
commit 5f97800489
110 changed files with 5344 additions and 889 deletions
+283 -46
View File
@@ -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,
@@ -1,41 +1,105 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs/promises");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
openDocument,
openFilesystemView,
renameDocument,
requestJson,
} = require("./tree-shell-smoke-helpers");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
const TASK = "task443-filetree-mindmap-click-active-row-smoke";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUT_DIR, "result.json");
const ACTOR_ID = "user_real";
async function writeResult(result) {
await fs.mkdir(OUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
function fileUrl(localPath) {
return `file://${localPath}`;
}
async function createMindmap(request, workspaceId, documentId, mindmapId, title) {
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
method: "POST",
data: {
commandName: "mindmaps.put",
workspaceId,
createOnly: true,
data: { root: { data: { text: title }, children: [] } },
},
});
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ACTOR_ID}:task443`,
ownerId: ACTOR_ID,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
function writeMindmapFixture(root, stamp) {
const pageDir = path.join(root, "Task443");
fs.mkdirSync(pageDir, { recursive: true });
fs.writeFileSync(
path.join(pageDir, "Task443.md"),
[
"---",
`title: TEST-443-mindmap-active-${stamp}`,
"---",
"",
"# TEST 443",
"",
`[TEST-443-mind-${stamp}](map-${stamp}.mindmap.json)`,
"",
].join("\n"),
"utf8",
);
fs.writeFileSync(
path.join(pageDir, `map-${stamp}.mindmap.json`),
`${JSON.stringify({ data: { uid: "root", text: `TEST-443-mind-${stamp}` }, children: [] }, null, 2)}\n`,
"utf8",
);
return {
relativePath: "Task443/Task443.md",
documentId: localMdDocumentId("Task443/Task443.md"),
mindmapFileName: `map-${stamp}.mindmap.json`,
};
}
async function writeResult(result) {
await fsp.mkdir(OUT_DIR, { recursive: true });
await fsp.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function waitForMindmapRow(page, mindmapFileName) {
await page.waitForFunction((fileName) => {
return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
.some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName));
}, mindmapFileName, { timeout: UI_TIMEOUT_MS });
}
async function clickMindmapRow(page, mindmapFileName) {
await page.evaluate((fileName) => {
const row = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
.find((candidate) => (candidate.getAttribute("data-asset-id") || "").includes(fileName));
const link = row?.querySelector(".tree-link");
if (!(link instanceof HTMLElement)) {
throw new Error(`找不到 mindmap 文件行: ${fileName}`);
}
link.click();
}, mindmapFileName);
}
async function readSelectedFileTreeRows(page) {
@@ -44,6 +108,7 @@ async function readSelectedFileTreeRows(page) {
rowId: row.getAttribute("data-row-id") || "",
rowKind: row.getAttribute("data-row-kind") || "",
docId: row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "",
ownerDocumentId: row.getAttribute("data-owner-document-id") || "",
assetId: row.getAttribute("data-asset-id") || "",
objectIdentity: row.getAttribute("data-object-identity") || "",
title: (row.textContent || "").trim().slice(0, 160),
@@ -52,9 +117,23 @@ async function readSelectedFileTreeRows(page) {
}
(async () => {
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await fsp.mkdir(OUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task443-local-mindmap-"));
const stamp = Date.now().toString().slice(-8);
writeWorkspaceManifest(root);
const fixture = writeMindmapFixture(root, stamp);
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
extraHTTPHeaders: {
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const navEvents = [];
const network = [];
@@ -68,10 +147,9 @@ async function readSelectedFileTreeRows(page) {
}
});
let doc = null;
const result = {
baseUrl: BASE_URL,
fixture: {},
fixture: { root, ...fixture },
beforeSelectedRows: [],
afterSelectedRows: [],
navEvents,
@@ -80,35 +158,29 @@ async function readSelectedFileTreeRows(page) {
};
try {
await ensureAuthenticated(page, context.request);
doc = await createTempDocument(context.request, null);
const stamp = Date.now().toString().slice(-8);
const title = `TEST-443-mindmap-active-${stamp}`;
await renameDocument(context.request, doc.workspaceId, doc.documentId, title);
const mindmapId = `mindmap_443_active_${stamp}`;
await createMindmap(context.request, doc.workspaceId, doc.documentId, mindmapId, `TEST-443-mind-${stamp}`);
result.fixture = { ...doc, title, mindmapId };
await openDocument(page, doc.workspaceId, doc.documentId);
await openFilesystemView(page);
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"]`, { timeout: UI_TIMEOUT_MS });
result.beforeSelectedRows = await readSelectedFileTreeRows(page);
await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
({ documentId, mindmapId }) => {
const url = new URL(window.location.href);
if (!url.pathname.includes(`/documents/${encodeURIComponent(documentId)}`)) return false;
const rt = url.searchParams.get("resourceTab") || "";
if (!rt) return false;
return decodeURIComponent(rt).includes(`resource:mindmap:${documentId}:${mindmapId}`);
},
{ documentId: doc.documentId, mindmapId },
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"][data-selected="true"]`, {
await page.goto(documentUrl(root, fixture.relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await waitForMindmapRow(page, fixture.mindmapFileName);
result.beforeSelectedRows = await readSelectedFileTreeRows(page);
await clickMindmapRow(page, fixture.mindmapFileName);
await page.waitForFunction(
({ documentId, fileName }) => {
const url = new URL(window.location.href);
if (!url.pathname.includes(`/documents/${encodeURIComponent(documentId)}`)) return false;
const rt = decodeURIComponent(url.searchParams.get("resourceTab") || "");
return rt.includes("resource:mindmap:") && rt.includes(documentId) && rt.includes(fileName);
},
{ documentId: fixture.documentId, fileName: fixture.mindmapFileName },
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForFunction((fileName) => {
return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]'))
.some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName));
}, fixture.mindmapFileName, { timeout: UI_TIMEOUT_MS });
result.afterUrl = page.url();
result.afterSelectedRows = await readSelectedFileTreeRows(page);
const screenshotPath = path.join(OUT_DIR, "after-mindmap-click.png");
@@ -116,11 +188,11 @@ async function readSelectedFileTreeRows(page) {
result.screenshots.push(screenshotPath);
assert(
result.afterSelectedRows.some((row) => row.rowId === `asset:${mindmapId}` && row.assetId === mindmapId),
result.afterSelectedRows.some((row) => row.assetId.includes(fixture.mindmapFileName)),
`点击 mindmap 文件行后应保持 asset row 选中: ${JSON.stringify(result.afterSelectedRows)}`,
);
assert(
!result.afterSelectedRows.some((row) => row.rowId === `doc:${doc.documentId}`),
!result.afterSelectedRows.some((row) => row.rowId === `doc:${fixture.documentId}`),
`点击 mindmap 文件行后不应闪回父页面行选中: ${JSON.stringify(result.afterSelectedRows)}`,
);
@@ -135,8 +207,8 @@ async function readSelectedFileTreeRows(page) {
});
throw error;
} finally {
if (doc) await cleanupDocuments(context.request, [doc.documentId]).catch(() => null);
await browser.close().catch(() => null);
fs.rmSync(root, { recursive: true, force: true });
}
})().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
@@ -1,49 +1,154 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs/promises");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
openDocument,
openFilesystemView,
renameDocument,
requestJson,
} = require("./tree-shell-smoke-helpers");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
const TASK = "task445-filetree-mindmap-switch-no-flicker-smoke";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUT_DIR, "result.json");
const ACTOR_ID = "user_real";
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ACTOR_ID}:task445`,
ownerId: ACTOR_ID,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
function writePage(root, relativePath, title, bodyLines) {
const fullPath = path.join(root, relativePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(
fullPath,
["---", `title: ${title}`, "---", "", ...bodyLines, ""].join("\n"),
"utf8",
);
}
function writeMindmapFixture(root, stamp) {
const rootRelativePath = "Task445Root/Task445Root.md";
const otherRelativePath = "Task445Other.md";
const mindmapFileName = `map-${stamp}.mindmap.json`;
writePage(root, rootRelativePath, `TEST-445-root-${stamp}`, [
"# TEST 445 Root",
"",
`[TEST-445-mind-${stamp}](${mindmapFileName})`,
]);
writePage(root, otherRelativePath, `TEST-445-other-${stamp}`, [
"# TEST 445 Other",
"",
"用于验证从另一个页面点击资源行时仍回到资源 owner document。",
]);
fs.writeFileSync(
path.join(root, "Task445Root", mindmapFileName),
`${JSON.stringify({ data: { uid: "root", text: `TEST-445-mind-${stamp}` }, children: [] }, null, 2)}\n`,
"utf8",
);
return {
rootRelativePath,
otherRelativePath,
documentId: localMdDocumentId(rootRelativePath),
otherDocumentId: localMdDocumentId(otherRelativePath),
mindmapFileName,
};
}
async function writeResult(result) {
await fs.mkdir(OUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
await fsp.mkdir(OUT_DIR, { recursive: true });
await fsp.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function createMindmap(request, workspaceId, documentId, mindmapId, title) {
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
method: "POST",
data: {
commandName: "mindmaps.put",
workspaceId,
createOnly: true,
data: { root: { data: { text: title }, children: [] } },
},
});
async function waitForMindmapRow(page, mindmapFileName) {
await page.waitForFunction((fileName) => {
return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
.some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName));
}, mindmapFileName, { timeout: UI_TIMEOUT_MS });
}
async function readFileTreeState(page, documentId, mindmapId) {
async function clickMindmapRow(page, mindmapFileName) {
await page.evaluate((fileName) => {
const row = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
.find((candidate) => (candidate.getAttribute("data-asset-id") || "").includes(fileName));
const link = row?.querySelector(".tree-link");
if (!(link instanceof HTMLElement)) {
throw new Error(`找不到 mindmap 文件行: ${fileName}`);
}
link.click();
}, mindmapFileName);
}
async function clickDocumentRow(page, documentId) {
await page.evaluate((docId) => {
const row = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
.find((candidate) => (
(candidate.getAttribute("data-document-id") || candidate.getAttribute("data-doc-id") || "") === docId
));
const link = row?.querySelector(".tree-link");
if (!(link instanceof HTMLElement)) {
throw new Error(`找不到文档行: ${docId}`);
}
link.click();
}, documentId);
}
async function readAllFileTreeRows(page) {
return await page.evaluate(() =>
Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')).map((row) => ({
rowId: row.getAttribute("data-row-id") || "",
rowKind: row.getAttribute("data-row-kind") || "",
nodeId: row.getAttribute("data-node-id") || "",
documentId: row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "",
ownerDocumentId: row.getAttribute("data-owner-document-id") || "",
assetId: row.getAttribute("data-asset-id") || "",
title: (row.textContent || "").trim().slice(0, 160),
selected: row.getAttribute("data-selected") || "",
})),
);
}
async function readFileTreeState(page, documentId, mindmapFileName) {
return await page.evaluate(
({ documentId: docId, mindmapId: mapId }) => {
({ documentId: docId, mindmapFileName: fileName }) => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const pageRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(docId)}"]`);
const mindmapRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${CSS.escape(mapId)}"]`);
const rows = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'));
const pageRow = rows.find((row) => (
(row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "") === docId
));
const mindmapRow = rows.find((row) => (row.getAttribute("data-asset-id") || "").includes(fileName));
const titleNode = mindmapRow?.querySelector(":scope > .tree-link > .tree-link-title");
return {
fileRootStable: Boolean(fileRoot && fileRoot === window.__task445FileRoot),
@@ -52,20 +157,81 @@ async function readFileTreeState(page, documentId, mindmapId) {
pageSelected: pageRow instanceof HTMLElement ? pageRow.getAttribute("data-selected") || "" : "",
mindmapSelected: mindmapRow instanceof HTMLElement ? mindmapRow.getAttribute("data-selected") || "" : "",
mindmapTitle: titleNode instanceof HTMLElement ? (titleNode.textContent || "").trim() : "",
mindmapAssetId: mindmapRow instanceof HTMLElement ? mindmapRow.getAttribute("data-asset-id") || "" : "",
mindmapOwnerDocumentId: mindmapRow instanceof HTMLElement ? mindmapRow.getAttribute("data-owner-document-id") || "" : "",
objectEditor:
document.querySelector("[data-mnote-object-editor]")?.getAttribute("data-mnote-object-editor") ||
document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-mnote-object-editor") ||
"",
};
},
{ documentId, mindmapId },
{ documentId, mindmapFileName },
);
}
async function waitForMindmapResourceTab(page, documentId, mindmapFileName) {
await page.waitForFunction(
({ docId, fileName }) => {
const url = new URL(window.location.href);
if (!url.pathname.includes(`/documents/${encodeURIComponent(docId)}`)) return false;
const rt = decodeURIComponent(url.searchParams.get("resourceTab") || "");
return rt.includes("resource:mindmap:") && rt.includes(docId) && rt.includes(fileName);
},
{ docId: documentId, fileName: mindmapFileName },
{ timeout: UI_TIMEOUT_MS },
);
}
async function readMindmapTabLayout(page) {
return await page.evaluate(() => {
const host = document.querySelector('[data-mnote-resource-tab-host][data-pane-role="primary"]');
const panel = document.querySelector('.mnote-resource-tab-panel[data-pane-role="primary"][data-resource-kind="mindmap"]:not([hidden])');
const shell = panel?.querySelector('.mnote-resource-tab-mindmap-shell');
const root = panel?.querySelector('[data-testid="mnote-mindmap-editor-root"]');
const rectOf = (node) => {
if (!(node instanceof HTMLElement)) return null;
const rect = node.getBoundingClientRect();
return {
width: Math.round(rect.width),
height: Math.round(rect.height),
left: Math.round(rect.left),
right: Math.round(rect.right),
scrollWidth: node.scrollWidth,
clientWidth: node.clientWidth,
scrollHeight: node.scrollHeight,
clientHeight: node.clientHeight,
};
};
return {
hostHidden: host instanceof HTMLElement ? host.hidden : true,
host: rectOf(host),
panel: rectOf(panel),
shell: rectOf(shell),
root: rectOf(root),
shellOverflow: shell instanceof HTMLElement ? getComputedStyle(shell).overflow : "",
rootOverflow: root instanceof HTMLElement ? getComputedStyle(root).overflow : "",
};
});
}
(async () => {
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await fsp.mkdir(OUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task445-local-mindmap-"));
const stamp = Date.now().toString().slice(-8);
writeWorkspaceManifest(root);
const fixture = writeMindmapFixture(root, stamp);
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
extraHTTPHeaders: {
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const navEvents = [];
const consoleMessages = [];
@@ -81,10 +247,9 @@ async function readFileTreeState(page, documentId, mindmapId) {
consoleMessages.push({ type: "pageerror", text: error instanceof Error ? error.stack || error.message : String(error) });
});
const createdDocuments = [];
const result = {
baseUrl: BASE_URL,
fixture: {},
fixture: { root, ...fixture },
before: null,
afterMindmap: null,
afterPage: null,
@@ -99,80 +264,76 @@ async function readFileTreeState(page, documentId, mindmapId) {
};
try {
await ensureAuthenticated(page, context.request);
const doc = await createTempDocument(context.request, null);
createdDocuments.push(doc.documentId);
const otherDoc = await createTempDocument(context.request, null);
createdDocuments.push(otherDoc.documentId);
const stamp = Date.now().toString().slice(-8);
await renameDocument(context.request, doc.workspaceId, doc.documentId, `TEST-445-root-${stamp}`);
await renameDocument(context.request, otherDoc.workspaceId, otherDoc.documentId, `TEST-445-other-${stamp}`);
const mindmapId = `mindmap_445_${stamp}`;
await createMindmap(context.request, doc.workspaceId, doc.documentId, mindmapId, `TEST-445-mind-${stamp}`);
result.fixture = { ...doc, otherDocumentId: otherDoc.documentId, mindmapId };
await openDocument(page, doc.workspaceId, doc.documentId);
await openFilesystemView(page);
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"]`, { timeout: UI_TIMEOUT_MS });
await page.goto(documentUrl(root, fixture.rootRelativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await waitForMindmapRow(page, fixture.mindmapFileName);
await page.evaluate(() => {
window.__task445FileRoot = document.getElementById("sidebar-file-tree-root");
});
result.before = await readFileTreeState(page, doc.documentId, mindmapId);
result.before = await readFileTreeState(page, fixture.documentId, fixture.mindmapFileName);
if (/^mindmap-mindmap[_-]/i.test(result.before.mindmapTitle)) {
recordFailure("mindmap_title_double_technical_prefix", result.before.mindmapTitle);
}
if (result.before.mindmapTitle.length > 24) {
if (result.before.mindmapTitle.length > 32) {
recordFailure("mindmap_title_too_long", result.before.mindmapTitle);
}
await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
({ documentId, mindmapId }) => {
const url = new URL(window.location.href);
if (!url.pathname.includes(`/documents/${encodeURIComponent(documentId)}`)) return false;
const rt = url.searchParams.get("resourceTab") || "";
if (!rt) return false;
return decodeURIComponent(rt).includes(`resource:mindmap:${documentId}:${mindmapId}`);
},
{ documentId: doc.documentId, mindmapId },
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"][data-selected="true"]`, {
timeout: UI_TIMEOUT_MS,
});
result.afterMindmap = await readFileTreeState(page, doc.documentId, mindmapId);
await clickMindmapRow(page, fixture.mindmapFileName);
await waitForMindmapResourceTab(page, fixture.documentId, fixture.mindmapFileName);
await page.waitForFunction((fileName) => {
return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]'))
.some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName));
}, fixture.mindmapFileName, { timeout: UI_TIMEOUT_MS });
result.afterMindmap = await readFileTreeState(page, fixture.documentId, fixture.mindmapFileName);
if (!result.afterMindmap.fileRootStable) recordFailure("mindmap_click_replaced_sidebar_root", result.afterMindmap);
result.rowsBeforeOtherClick = await readAllFileTreeRows(page);
await page.click(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${otherDoc.documentId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname.includes(`/documents/${encodeURIComponent(otherDoc.documentId)}`), { timeout: UI_TIMEOUT_MS });
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${otherDoc.documentId}"][data-selected="true"]`, {
timeout: UI_TIMEOUT_MS,
});
result.afterPage = await readFileTreeState(page, otherDoc.documentId, mindmapId);
await clickDocumentRow(page, fixture.otherDocumentId);
await page.waitForURL((url) => url.pathname.includes(`/documents/${encodeURIComponent(fixture.otherDocumentId)}`), { timeout: UI_TIMEOUT_MS });
await page.waitForFunction((docId) => {
return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]'))
.some((row) => (row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "") === docId);
}, fixture.otherDocumentId, { timeout: UI_TIMEOUT_MS });
result.afterPage = await readFileTreeState(page, fixture.otherDocumentId, fixture.mindmapFileName);
if (!result.afterPage.fileRootStable) recordFailure("page_click_after_mindmap_replaced_sidebar_root", result.afterPage);
await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
({ mindmapId, docId }) => {
const url = new URL(window.location.href);
if (!url.pathname.startsWith("/documents/")) return false;
const rt = url.searchParams.get("resourceTab") || "";
if (!rt) return false;
return decodeURIComponent(rt).includes(`resource:mindmap:${docId}:${mindmapId}`);
},
{ mindmapId, docId: doc.documentId },
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"][data-selected="true"]`, {
timeout: UI_TIMEOUT_MS,
});
result.afterMindmapAgain = await readFileTreeState(page, doc.documentId, mindmapId);
await clickMindmapRow(page, fixture.mindmapFileName);
await waitForMindmapResourceTab(page, fixture.documentId, fixture.mindmapFileName);
await page.waitForFunction((fileName) => {
return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]'))
.some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName));
}, fixture.mindmapFileName, { timeout: UI_TIMEOUT_MS });
result.afterMindmapAgain = await readFileTreeState(page, fixture.documentId, fixture.mindmapFileName);
result.mindmapLayout = await readMindmapTabLayout(page);
if (!result.afterMindmapAgain.fileRootStable) recordFailure("mindmap_click_again_replaced_sidebar_root", result.afterMindmapAgain);
if (result.mindmapLayout.hostHidden) recordFailure("mindmap_resource_host_hidden", result.mindmapLayout);
if (!result.mindmapLayout.shell || !result.mindmapLayout.root) recordFailure("mindmap_resource_shell_or_root_missing", result.mindmapLayout);
if ((result.mindmapLayout.shell?.width || 0) < 320 || (result.mindmapLayout.root?.width || 0) < 320) {
recordFailure("mindmap_resource_width_too_small", result.mindmapLayout);
}
if ((result.mindmapLayout.shell?.clientWidth || 0) + 4 < (result.mindmapLayout.shell?.scrollWidth || 0)) {
recordFailure("mindmap_resource_horizontal_overflow", result.mindmapLayout);
}
if ((result.mindmapLayout.root?.height || 0) < 600) {
recordFailure("mindmap_resource_height_too_small", result.mindmapLayout);
}
if (!/hidden/.test(result.mindmapLayout.shellOverflow) || !/hidden/.test(result.mindmapLayout.rootOverflow)) {
recordFailure("mindmap_resource_overflow_not_clipped", result.mindmapLayout);
}
if (!page.url().includes(`/documents/${encodeURIComponent(fixture.documentId)}`)) {
recordFailure("mindmap_click_again_used_wrong_owner_document", {
expectedDocumentId: fixture.documentId,
actualUrl: page.url(),
});
}
const screenshotPath = path.join(OUT_DIR, "after-mindmap-again.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshots.push(screenshotPath);
assert(result.failures.length === 0, `task445 failures: ${JSON.stringify(result.failures, null, 2)}`);
assert.equal(result.failures.length, 0, `task445 failures: ${JSON.stringify(result.failures, null, 2)}`);
await writeResult({ ...result, ok: true, finalUrl: page.url() });
console.log(`ok ${TASK} ${RESULT_PATH}`);
} catch (error) {
@@ -184,8 +345,8 @@ async function readFileTreeState(page, documentId, mindmapId) {
});
throw error;
} finally {
if (createdDocuments.length) await cleanupDocuments(context.request, createdDocuments).catch(() => null);
await browser.close().catch(() => null);
fs.rmSync(root, { recursive: true, force: true });
}
})().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
@@ -11,6 +11,9 @@ const {
UI_TIMEOUT_MS,
} = require("./tree-shell-smoke-helpers");
const TASK = "task453-local-folder-page-ai-changed-files-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
@@ -19,6 +22,10 @@ function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
@@ -33,16 +40,76 @@ function writeWorkspaceManifest(root, ownerId, workspaceId) {
);
}
async function fetchPageAggregate(page, documentId, rootUri) {
return await page.evaluate(async ({ id, uri }) => {
const url = new URL(`/api/page-aggregate/${encodeURIComponent(id)}`, window.location.origin);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", uri);
const response = await fetch(url.toString(), { headers: { accept: "application/json" } });
return {
ok: response.ok,
status: response.status,
payload: await response.json().catch(() => null),
};
}, { id: documentId, uri: rootUri });
}
async function waitForEditorText(page, expected) {
await page.waitForFunction(
(text) => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
return (editor?.textContent || "").includes(text);
},
expected,
{ timeout: UI_TIMEOUT_MS },
);
}
async function typeDirtyText(page, text) {
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.type(text, { delay: 8 });
await waitForEditorText(page, text.trim());
}
async function waitForEditorStatus(page, status) {
await page.waitForFunction(
(expected) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
return root?.getAttribute("data-runtime-editor-status") === expected;
},
status,
{ timeout: UI_TIMEOUT_MS },
);
}
async function conflictEnvelope(page, documentId) {
return await page.evaluate((docId) => {
const snapshot = window.__mnoteDebugDocumentSessions?.snapshot?.();
if (!snapshot) return null;
const session = snapshot.sessions.find((item) => item.documentId === docId);
return session?.lastExternalConflictEnvelope || null;
}, documentId);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-ai-changed-files-"));
const documentId = "local-md:README.md";
const documentId = localMdDocumentId("README.md");
const dirtyDocumentId = localMdDocumentId("Dirty.md");
const actorId = "user_real";
const sessionId = `mnote_local_ai_changed_${suffix}`;
const runId = `run_local_ai_changed_${suffix}`;
const dirtySessionId = `mnote_local_ai_dirty_${suffix}`;
const dirtyRunId = `run_local_ai_dirty_${suffix}`;
const marker = `LOCAL-AI-CHANGED-FILES-${suffix}`;
const dirtyMarker = `LOCAL-AI-DIRTY-FILES-${suffix}`;
const dirtyLocalToken = `LOCAL-UNSAVED-DIRTY-${suffix}`;
const readmePath = path.join(root, "README.md");
const dirtyPath = path.join(root, "Dirty.md");
const captured = [];
let currentScenario = "clean";
const browser = await chromium.launch({
headless: true,
@@ -61,11 +128,34 @@ async function main() {
const workspaceId = `local-ws:${actorId}:task453`;
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(readmePath, `# Local AI Changed Files\n初始内容 ${suffix}\n`, "utf8");
fs.writeFileSync(dirtyPath, `# Dirty AI Changed Files\n初始 dirty 内容 ${suffix}\n`, "utf8");
const rootUri = fileUrl(root);
const scenarioConfig = () => currentScenario === "dirty"
? {
sessionId: dirtySessionId,
runId: dirtyRunId,
documentId: dirtyDocumentId,
filePath: dirtyPath,
relativePath: "Dirty.md",
marker: dirtyMarker,
message: "已修改本地 Dirty。",
}
: {
sessionId,
runId,
documentId,
filePath: readmePath,
relativePath: "README.md",
marker,
message: "已修改本地 README。",
};
await page.route("**/api/ai-agent/run", async (route) => {
throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
});
await page.route("**/api/documents/save", async (route) => {
throw new Error(`local-first AI smoke 不应请求 compat /api/documents/save: ${route.request().url()}`);
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
@@ -97,13 +187,14 @@ async function main() {
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
const scenario = scenarioConfig();
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId,
sessionId: scenario.sessionId,
title: "本地 changed files",
traceId: `trace_local_changed_${suffix}`,
persistence: "local_ai_session_jsonl",
@@ -111,35 +202,37 @@ async function main() {
}),
});
});
await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => {
await page.route("**/api/hermes/client/sessions/*/resume", async (route) => {
const scenario = scenarioConfig();
captured.push({ kind: "session-resume", method: route.request().method(), body: "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId,
session: { sessionId, messages: [] },
sessionId: scenario.sessionId,
session: { sessionId: scenario.sessionId, messages: [] },
runtime: {
sessionId,
runId,
sessionId: scenario.sessionId,
runId: scenario.runId,
status: "completed",
profile: "reasonix",
documentId,
documentId: scenario.documentId,
traceId: `trace_local_changed_resume_${suffix}`,
},
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
const scenario = scenarioConfig();
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId,
runId,
sessionId: scenario.sessionId,
runId: scenario.runId,
events: [],
traceId: `trace_local_changed_run_${suffix}`,
persistence: "local_ai_session_jsonl",
@@ -147,28 +240,29 @@ async function main() {
}),
});
});
await page.route(`**/api/hermes/client/events/${runId}`, async (route) => {
await page.route("**/api/hermes/client/events/*", async (route) => {
const scenario = scenarioConfig();
captured.push({ kind: "events", method: route.request().method(), body: "" });
fs.appendFileSync(readmePath, `\nAI 写入标记:${marker}\n`, "utf8");
fs.appendFileSync(scenario.filePath, `\nAI 写入标记:${scenario.marker}\n`, "utf8");
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body:
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "已修改本地 README。" })}\n\n` +
`data: ${JSON.stringify({ event: "message.delta", run_id: scenario.runId, session_id: scenario.sessionId, delta: scenario.message })}\n\n` +
`data: ${JSON.stringify({
event: "run.completed",
run_id: runId,
session_id: sessionId,
output: "已修改本地 README。",
run_id: scenario.runId,
session_id: scenario.sessionId,
output: scenario.message,
agentAudit: {
eventId: `audit_local_changed_${suffix}`,
eventId: `audit_local_changed_${currentScenario}_${suffix}`,
rootUri,
diffSummary: "1 changed file(s)",
changedFiles: [
{
path: "README.md",
path: scenario.relativePath,
changeType: "modified",
summary: `追加 ${marker}`,
summary: `追加 ${scenario.marker}`,
},
],
},
@@ -217,19 +311,112 @@ async function main() {
),
`本地 AI changed files 工具卡未显示 README.md 与 diff 摘要: ${JSON.stringify(cards)}`,
);
assert(fs.readFileSync(readmePath, "utf8").includes(marker), "本地 README.md 未写入 smoke 标记");
assert(captured.some((entry) => entry.kind === "run"), "未捕获 page AI run 请求");
console.log(
JSON.stringify({
ok: true,
root,
documentId,
sessionId,
runId,
marker,
capturedKinds: captured.map((entry) => entry.kind),
}, null, 2),
const diskText = fs.readFileSync(readmePath, "utf8");
assert(diskText.includes(marker), "本地 README.md 未写入 smoke 标记");
const aggregate = await fetchPageAggregate(page, documentId, rootUri);
assert.equal(aggregate.ok, true, `Page Aggregate 应能读取 local_folder 文档: ${JSON.stringify(aggregate)}`);
assert(
JSON.stringify(aggregate.payload || {}).includes(marker),
`Page Aggregate 应读回 AI 写入标记: ${JSON.stringify(aggregate)}`,
);
await waitForEditorText(page, marker);
const editorState = await page.evaluate(() => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const conflictPanel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
return {
status: root?.getAttribute("data-runtime-editor-status") || "",
text: editor?.textContent || "",
conflictVisible: Boolean(conflictPanel && conflictPanel.getClientRects().length > 0),
};
});
assert.notEqual(editorState.status, "external-change-conflict", `clean AI 写入不应触发冲突态: ${JSON.stringify(editorState)}`);
assert.equal(editorState.conflictVisible, false, `clean AI 写入不应显示冲突面板: ${JSON.stringify(editorState)}`);
assert(captured.some((entry) => entry.kind === "run"), "未捕获 page AI run 请求");
const runBody = JSON.parse(captured.find((entry) => entry.kind === "run")?.body || "{}");
assert.equal(runBody.documentId, documentId, `Hermes run 应携带 local documentId: ${JSON.stringify(runBody)}`);
assert.equal(runBody.sourceKind, "local_folder", `Hermes run 应携带 local_folder sourceKind: ${JSON.stringify(runBody)}`);
assert.equal(runBody.rootUri, rootUri, `Hermes run 应携带 rootUri: ${JSON.stringify(runBody)}`);
const cleanCapturedKinds = captured.map((entry) => entry.kind);
currentScenario = "dirty";
captured.length = 0;
const dirtyUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(dirtyDocumentId)}`);
dirtyUrl.searchParams.set("sourceKind", "local_folder");
dirtyUrl.searchParams.set("rootUri", rootUri);
await page.goto(dirtyUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForEditorText(page, "Dirty AI Changed Files");
await typeDirtyText(page, ` ${dirtyLocalToken}`);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill(`请修改 Dirty 并记录 changed files ${dirtyMarker}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
return drawerText.includes("agent.changed_files") && drawerText.includes("Dirty.md");
},
null,
{ timeout: UI_TIMEOUT_MS },
);
await waitForEditorStatus(page, "external-change-conflict");
await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(expected) => {
const panel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
return (panel?.textContent || "").includes(expected);
},
`agent run ${dirtyRunId}`,
{ timeout: UI_TIMEOUT_MS },
);
const envelope = await conflictEnvelope(page, dirtyDocumentId);
assert(envelope, "dirty AI 写入应生成冲突信封");
assert("externalActor" in envelope, `冲突信封应包含 externalActor: ${JSON.stringify(envelope)}`);
assert("dirtyState" in envelope, `冲突信封应包含 dirtyState: ${JSON.stringify(envelope)}`);
assert("bufferFileVersion" in envelope, `冲突信封应包含 bufferFileVersion: ${JSON.stringify(envelope)}`);
await page.locator('[data-testid="mnote-conflict-open-diff"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-conflict-diff-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
({ localToken, aiToken }) => {
const panel = document.querySelector('[data-testid="mnote-conflict-diff-panel"]');
const text = panel?.textContent || "";
return text.includes(localToken) && text.includes(aiToken);
},
{ localToken: dirtyLocalToken, aiToken: dirtyMarker },
{ timeout: UI_TIMEOUT_MS },
);
const diffText = await page.locator('[data-testid="mnote-conflict-diff-panel"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(diffText.includes(dirtyLocalToken), `dirty diff 应包含本地未保存内容: ${diffText}`);
assert(diffText.includes(dirtyMarker), `dirty diff 应包含 AI 写盘内容: ${diffText}`);
const dirtyAggregate = await fetchPageAggregate(page, dirtyDocumentId, rootUri);
assert(
JSON.stringify(dirtyAggregate.payload || {}).includes(dirtyMarker),
`dirty Page Aggregate 应读回 AI 写入标记: ${JSON.stringify(dirtyAggregate)}`,
);
const dirtyRunBody = JSON.parse(captured.find((entry) => entry.kind === "run")?.body || "{}");
assert.equal(dirtyRunBody.documentId, dirtyDocumentId, `dirty Hermes run 应携带 local documentId: ${JSON.stringify(dirtyRunBody)}`);
assert.equal(dirtyRunBody.sourceKind, "local_folder", `dirty Hermes run 应携带 local_folder sourceKind: ${JSON.stringify(dirtyRunBody)}`);
assert.equal(dirtyRunBody.rootUri, rootUri, `dirty Hermes run 应携带 rootUri: ${JSON.stringify(dirtyRunBody)}`);
const result = {
ok: true,
root,
documentId,
sessionId,
runId,
marker,
aggregateRevision: aggregate.payload?.result?.body?.revision ?? aggregate.payload?.body?.revision ?? null,
editorStatus: editorState.status,
dirtyDocumentId,
dirtyRunId,
dirtyMarker,
dirtyConflictStatus: "external-change-conflict",
dirtyEnvelope: envelope,
capturedKinds: cleanCapturedKinds,
dirtyCapturedKinds: captured.map((entry) => entry.kind),
resultPath: RESULT_PATH,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
@@ -53,6 +53,62 @@ async function quickLogin(page) {
}
}
async function activatePrimaryPageTab(page) {
const pageTab = page.locator('[data-mnote-main-tab="page"][data-pane-role="primary"]').first();
if (await pageTab.count()) {
await pageTab.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function uploadAttachmentViaPrimarySlash(page, fileName, markdown) {
await activatePrimaryPageTab(page);
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("End").catch(() => undefined);
await page.keyboard.type("/");
const item = page
.locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]')
.first();
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
item.click({ timeout: UI_TIMEOUT_MS }),
]);
await fileChooser.setFiles({
name: fileName,
mimeType: "text/markdown",
buffer: Buffer.from(markdown, "utf8"),
});
await page.waitForFunction(
(name) => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
return editor instanceof HTMLElement && (editor.textContent || "").includes(name);
},
fileName,
{ timeout: UI_TIMEOUT_MS },
);
}
async function clickPrimaryAttachmentAndExpectTab(page, fileName) {
await activatePrimaryPageTab(page);
const link = page
.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]', {
hasText: fileName,
})
.first();
await link.click({ timeout: UI_TIMEOUT_MS });
await page.locator('.mnote-main-tab.is-active[data-pane-role="primary"][data-mnote-tab-kind="markdown"]', {
hasText: fileName,
}).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task459-md-attachment-"));
const relativePath = "README.md";
@@ -100,7 +156,8 @@ async function main() {
await link.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const node = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"][href*="README%2Fresource-note.md"]');
return node && node.classList.contains("mnote-uploaded-attachment-row") && node.getAttribute("data-mnote-attachment-link") === "true";
return node instanceof HTMLAnchorElement
&& (node.classList.contains("mnote-uploaded-attachment-row") || getComputedStyle(node).display === "inline-flex");
}, null, { timeout: UI_TIMEOUT_MS });
await link.click({ timeout: UI_TIMEOUT_MS });
@@ -142,6 +199,13 @@ async function main() {
});
await resourceEditor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await uploadAttachmentViaPrimarySlash(page, "uploaded-one.md", "# Uploaded One\n\n第一个上传附件\n");
await clickPrimaryAttachmentAndExpectTab(page, "uploaded-one.md");
await uploadAttachmentViaPrimarySlash(page, "uploaded-two.md", "# Uploaded Two\n\n第二个上传附件\n");
await clickPrimaryAttachmentAndExpectTab(page, "uploaded-one.md");
await clickPrimaryAttachmentAndExpectTab(page, "uploaded-two.md");
assert.equal(popups.length, 0, `连续上传两个 MD 附件后点击不应打开浏览器新窗口,实际 popup=${popups.length}`);
console.log(JSON.stringify({ ok: true, root, documentId, popups: popups.length }, null, 2));
} finally {
await context.close().catch(() => undefined);
@@ -77,6 +77,47 @@ async function uploadLocalAsset(page, root, documentId, fileName, mimeType, byte
);
}
async function uploadAttachmentViaSecondarySlash(page, fileName, markdown, action) {
const editor = page
.locator('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror')
.first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("End").catch(() => undefined);
await page.keyboard.type("/");
const item = page
.locator('.mnote-resource-tab-panel[data-pane-role="secondary"] [data-testid="slash-item-upload-attachment"]')
.first();
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
item.click({ timeout: UI_TIMEOUT_MS }),
]);
await fileChooser.setFiles({
name: fileName,
mimeType: "text/markdown",
buffer: Buffer.from(markdown, "utf8"),
});
await page.waitForFunction(
({ name }) => {
const editor = document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror');
return (editor instanceof HTMLElement && (editor.textContent || "").includes(name))
|| document.documentElement.getAttribute("data-mnote-last-upload-inserted") === "false";
},
{ name: fileName },
{ timeout: UI_TIMEOUT_MS },
);
const inserted = await page.evaluate((name) => {
const editor = document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror');
return {
hasText: editor instanceof HTMLElement && (editor.textContent || "").includes(name),
inserted: document.documentElement.getAttribute("data-mnote-last-upload-inserted") || "",
error: document.documentElement.getAttribute("data-mnote-last-upload-insert-error") || "",
text: editor?.textContent || "",
};
}, fileName);
assert(inserted.hasText, `${action} 上传后未插入 secondary 编辑器:${JSON.stringify(inserted)}`);
}
async function waitForPrimaryReady(page) {
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
state: "visible",
@@ -104,7 +145,7 @@ async function readSideTargetState(page) {
return await page.evaluate(() => {
const url = new URL(window.location.href);
const pane = document.querySelector('.document-pane[data-pane-role="secondary"]');
const activeTab = document.querySelector(".mnote-main-tab.is-active");
const activeTab = document.querySelector('.mnote-main-tab.is-active[data-pane-role="primary"]');
const placeholder = document.querySelector("[data-mnote-side-target-placeholder=\"true\"]");
return {
resourceTab: url.searchParams.get("resourceTab") || "",
@@ -116,6 +157,13 @@ async function readSideTargetState(page) {
secondarySideTarget: pane?.getAttribute("data-mnote-side-target") || "",
activeTabKind: activeTab?.getAttribute("data-mnote-tab-kind") || "",
activeTabText: activeTab?.textContent || "",
secondaryActiveTabKind: document.querySelector('.mnote-main-tab.is-active[data-pane-role="secondary"]')?.getAttribute("data-mnote-tab-kind") || "",
secondaryActiveTabText: document.querySelector('.mnote-main-tab.is-active[data-pane-role="secondary"]')?.textContent || "",
secondaryResourceText: document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror')?.textContent || "",
primaryVisibleEditorText: document.querySelector('.document-pane[data-pane-role="primary"] .mnote-resource-tab-panel:not([hidden]) .ProseMirror, .document-pane[data-pane-role="primary"] [data-mnote-page-tab-panel]:not([hidden]) .ProseMirror')?.textContent || "",
secondaryOfficeFrameSrc: document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) iframe.mnote-resource-tab-frame')?.getAttribute("src") || "",
primarySlashVisible: Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] [data-testid="mnote-leptos-tiptap-slash-menu"]')).some((node) => node instanceof HTMLElement && getComputedStyle(node).display !== "none"),
secondarySlashVisible: Array.from(document.querySelectorAll('.document-pane[data-pane-role="secondary"] [data-testid="mnote-leptos-tiptap-slash-menu"]')).some((node) => node instanceof HTMLElement && getComputedStyle(node).display !== "none"),
placeholderText: placeholder?.textContent || "",
unsupportedFlag: document.documentElement.getAttribute("data-mnote-side-target-unsupported") || "",
popupCount: window.__mnoteSideTargetPopupCount || 0,
@@ -123,6 +171,53 @@ async function readSideTargetState(page) {
});
}
async function waitForDiskTextContains(filePath, expectedNames) {
const deadline = Date.now() + UI_TIMEOUT_MS;
while (Date.now() < deadline) {
const text = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
if (expectedNames.every((name) => text.includes(name))) return text;
await new Promise((resolve) => setTimeout(resolve, 150));
}
const text = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
throw new Error(`等待资源文件保存超时: ${filePath} text=${JSON.stringify(text)}`);
}
async function waitForSecondaryAttachmentLinks(page, expectedNames) {
await page.waitForFunction(
({ names }) => {
const editor = document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror');
if (!(editor instanceof HTMLElement)) return false;
return names.every((name) => {
const link = Array.from(editor.querySelectorAll("a[href]"))
.find((node) => (node.textContent || "").includes(name));
if (!(link instanceof HTMLAnchorElement)) return false;
const href = link.getAttribute("href") || "";
const className = link.getAttribute("class") || "";
const styledAsAttachment = getComputedStyle(link).display === "inline-flex";
const enhancedAsAttachment = link.getAttribute("data-mnote-attachment-link") === "true"
|| className.includes("mnote-uploaded-attachment-row");
return href.includes("/api/local-folder/files/open")
&& (styledAsAttachment || enhancedAsAttachment);
});
},
{ names: expectedNames },
{ timeout: UI_TIMEOUT_MS },
);
}
async function clickSecondaryAttachment(page, fileName) {
await page.evaluate((name) => {
const editor = document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror');
const link = Array.from(editor?.querySelectorAll('a[href*="/api/local-folder/files/open"], a[data-mnote-attachment-link="true"]') || [])
.find((node) => (node.textContent || "").includes(name));
if (!(link instanceof HTMLAnchorElement)) {
throw new Error(`secondary_attachment_link_missing:${name}`);
}
link.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
}, fileName);
}
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task472-side-target-"));
const relativePath = "README.md";
@@ -156,7 +251,6 @@ async function main() {
};
});
const page = await context.newPage();
try {
await quickLogin(page);
await page.goto(documentUrl(root, relativePath, firstSideRelativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
@@ -172,6 +266,15 @@ async function main() {
Buffer.from("# Resource\n\n资源正文\n", "utf8"),
"attachment",
);
const officeAsset = await uploadLocalAsset(
page,
root,
documentId,
"side-target-office.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
Buffer.from("task472 secondary office probe", "utf8"),
"attachment",
);
await page.evaluate(({ asset, documentId }) => {
window.dispatchEvent(new CustomEvent("tree.asset.open", {
@@ -227,16 +330,196 @@ async function main() {
},
}));
}, { asset, documentId });
await page.waitForFunction(() => document.querySelector("[data-mnote-side-target-placeholder=\"true\"]"), {}, { timeout: UI_TIMEOUT_MS });
await page.locator('.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const afterResourceSideOpen = await readSideTargetState(page);
assert.equal(afterResourceSideOpen.secondaryDocumentId, null, `资源 openTarget=side placeholder 不应保留旧 secondaryDocumentId: ${JSON.stringify(afterResourceSideOpen)}`);
assert.equal(afterResourceSideOpen.secondarySideTarget, "unsupported-resource", `资源 openTarget=side 应标记 unsupported side target: ${JSON.stringify(afterResourceSideOpen)}`);
assert.match(afterResourceSideOpen.placeholderText, /暂不支持在侧栏打开此资源/, `资源 side placeholder 应可观测: ${JSON.stringify(afterResourceSideOpen)}`);
assert(afterResourceSideOpen.resourceTab.includes("side-target-resource.md"), `资源 side placeholder 不应清空 active resource tab URL: ${JSON.stringify(afterResourceSideOpen)}`);
assert.equal(afterResourceSideOpen.activeTabKind, "markdown", `资源 side placeholder 不应切走 active resource tab: ${JSON.stringify(afterResourceSideOpen)}`);
assert.equal(afterResourceSideOpen.secondaryDocumentId, null, `资源 openTarget=side 不应保留旧 secondaryDocumentId: ${JSON.stringify(afterResourceSideOpen)}`);
assert.equal(afterResourceSideOpen.secondaryActiveTabKind, "markdown", `资源 openTarget=side 应在 secondary 资源标签打开 markdown: ${JSON.stringify(afterResourceSideOpen)}`);
assert(afterResourceSideOpen.secondaryActiveTabText.includes("side-target-resource.md"), `secondary 资源标签标题应可观测: ${JSON.stringify(afterResourceSideOpen)}`);
assert(afterResourceSideOpen.secondaryResourceText.includes("资源正文"), `secondary 资源标签应渲染附件内容: ${JSON.stringify(afterResourceSideOpen)}`);
assert(afterResourceSideOpen.resourceTab.includes("side-target-resource.md"), `资源 side open 不应清空 primary active resource tab URL: ${JSON.stringify(afterResourceSideOpen)}`);
assert.equal(afterResourceSideOpen.activeTabKind, "markdown", `资源 side open 不应切走 primary active resource tab: ${JSON.stringify(afterResourceSideOpen)}`);
assert.equal(afterResourceSideOpen.popupCount, 0, `资源 openTarget=side 不应误开新窗口: ${JSON.stringify(afterResourceSideOpen)}`);
const secondaryResourceEditor = page.locator('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror').first();
await secondaryResourceEditor.click({ timeout: UI_TIMEOUT_MS });
await page.waitForTimeout(150);
const afterSecondaryFirstClick = await readSideTargetState(page);
assert.equal(afterSecondaryFirstClick.primarySlashVisible, false, `secondary 首次点击资源正文不应让 primary 菜单闪现: ${JSON.stringify(afterSecondaryFirstClick)}`);
console.log(JSON.stringify({ ok: true, root, assetId: asset.id }, null, 2));
await uploadAttachmentViaSecondarySlash(
page,
"secondary-real-upload-1.md",
"# Upload One\n\n第一个真实上传\n",
"secondary 第一个真实 md",
);
await page.waitForTimeout(250);
const afterFirstRealSecondaryUpload = await readSideTargetState(page);
if (!afterFirstRealSecondaryUpload.secondaryResourceText.includes("secondary-real-upload-1.md")) {
const debug = await page.evaluate(() => {
const root = document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) [data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = root?.querySelector(".editor-surface .ProseMirror");
return {
rootKind: root instanceof HTMLElement ? root.getAttribute("data-editor-host-kind") : "",
rootDocumentId: root instanceof HTMLElement ? root.getAttribute("data-document-id") : "",
rootWorkspaceId: root instanceof HTMLElement ? root.getAttribute("data-workspace-id") : "",
rootStatus: root instanceof HTMLElement ? root.getAttribute("data-runtime-editor-status") : "",
hasEditorHandle: Boolean(editor?.editor?.chain),
lastRootKind: window.__mnoteLastEditorUploadRoot instanceof HTMLElement ? window.__mnoteLastEditorUploadRoot.getAttribute("data-editor-host-kind") : "",
lastRootPane: window.__mnoteLastEditorUploadRoot instanceof HTMLElement ? window.__mnoteLastEditorUploadRoot.getAttribute("data-pane-role") : "",
visibleResourceEditors: Array.from(document.querySelectorAll('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror')).map((node) => ({
text: node.textContent,
hasEditorHandle: Boolean(node.editor?.chain),
})),
};
});
throw new Error(`secondary 真实上传第一个 md 未插入 secondary 资源编辑器,debug=${JSON.stringify(debug)} state=${JSON.stringify(afterFirstRealSecondaryUpload)}`);
}
assert(afterFirstRealSecondaryUpload.secondaryResourceText.includes("secondary-real-upload-1.md"), `secondary 真实上传第一个 md 应插入 secondary 资源编辑器: ${JSON.stringify(afterFirstRealSecondaryUpload)}`);
assert(!afterFirstRealSecondaryUpload.primaryVisibleEditorText.includes("secondary-real-upload-1.md"), `secondary 真实上传第一个 md 不应插入 primary 编辑器: ${JSON.stringify(afterFirstRealSecondaryUpload)}`);
assert.equal(afterFirstRealSecondaryUpload.primarySlashVisible, false, `secondary 真实上传第一个 md 后 primary 菜单不应可见: ${JSON.stringify(afterFirstRealSecondaryUpload)}`);
await uploadAttachmentViaSecondarySlash(
page,
"secondary-real-upload-2.md",
"# Upload Two\n\n第二个真实上传\n",
"secondary 第二个真实 md",
);
await page.waitForTimeout(250);
const afterSecondRealSecondaryUpload = await readSideTargetState(page);
assert(afterSecondRealSecondaryUpload.secondaryResourceText.includes("secondary-real-upload-2.md"), `secondary 真实上传第二个 md 应插入 secondary 资源编辑器: ${JSON.stringify(afterSecondRealSecondaryUpload)}`);
assert(!afterSecondRealSecondaryUpload.primaryVisibleEditorText.includes("secondary-real-upload-2.md"), `secondary 真实上传第二个 md 不应插入 primary 编辑器: ${JSON.stringify(afterSecondRealSecondaryUpload)}`);
assert.equal(afterSecondRealSecondaryUpload.primarySlashVisible, false, `secondary 真实上传第二个 md 后 primary 菜单不应可见: ${JSON.stringify(afterSecondRealSecondaryUpload)}`);
const sideResourceDiskPath = path.join(root, "README", "side-target-resource.md");
await waitForDiskTextContains(sideResourceDiskPath, [
"secondary-real-upload-1.md",
"secondary-real-upload-2.md",
]);
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForPrimaryReady(page);
await page.evaluate(({ asset, documentId }) => {
window.dispatchEvent(new CustomEvent("tree.asset.open", {
detail: {
assetId: asset.id,
documentId,
title: asset.file_name || "side-target-resource.md",
assetType: asset.asset_type || "attachment",
openTarget: "side",
},
}));
}, { asset, documentId });
await page.locator('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await waitForSecondaryAttachmentLinks(page, [
"secondary-real-upload-1.md",
"secondary-real-upload-2.md",
]);
await clickSecondaryAttachment(page, "secondary-real-upload-1.md");
await page.locator('.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const afterReloadFirstAttachmentClick = await readSideTargetState(page);
assert(afterReloadFirstAttachmentClick.secondaryActiveTabText.includes("secondary-real-upload-1.md"), `刷新后第一个 md 附件应在 secondary tab 打开: ${JSON.stringify(afterReloadFirstAttachmentClick)}`);
assert.equal(afterReloadFirstAttachmentClick.popupCount, 0, `刷新后第一个 md 附件不应新开窗口: ${JSON.stringify(afterReloadFirstAttachmentClick)}`);
await page.evaluate(({ asset, documentId }) => {
window.dispatchEvent(new CustomEvent("tree.asset.open", {
detail: {
assetId: asset.id,
documentId,
title: asset.file_name || "side-target-resource.md",
assetType: asset.asset_type || "attachment",
openTarget: "side",
},
}));
}, { asset, documentId });
await waitForSecondaryAttachmentLinks(page, [
"secondary-real-upload-1.md",
"secondary-real-upload-2.md",
]);
await clickSecondaryAttachment(page, "secondary-real-upload-2.md");
await page.locator('.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const afterReloadSecondAttachmentClick = await readSideTargetState(page);
assert(afterReloadSecondAttachmentClick.secondaryActiveTabText.includes("secondary-real-upload-2.md"), `刷新后第二个 md 附件应在 secondary tab 打开: ${JSON.stringify(afterReloadSecondAttachmentClick)}`);
assert.equal(afterReloadSecondAttachmentClick.popupCount, 0, `刷新后第二个 md 附件不应新开窗口: ${JSON.stringify(afterReloadSecondAttachmentClick)}`);
fs.writeFileSync(path.join(root, "Second-resource.md"), "# Second Resource\n\n第二个资源正文\n", "utf8");
const secondAsset = await uploadLocalAsset(
page,
root,
documentId,
"Second-resource.md",
"text/markdown",
Buffer.from("# Second Resource\n\n第二个资源正文\n", "utf8"),
"attachment",
);
await page.evaluate(({ secondAsset, documentId }) => {
window.dispatchEvent(new CustomEvent("tree.asset.open", {
detail: {
assetId: secondAsset.id,
documentId,
title: secondAsset.file_name || "Second-resource.md",
assetType: secondAsset.asset_type || "attachment",
openTarget: "side",
},
}));
}, { secondAsset, documentId });
await page.locator('.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const afterSecondResourceSideOpen = await readSideTargetState(page);
assert(afterSecondResourceSideOpen.secondaryActiveTabText.includes("Second-resource.md"), `secondary 第二个 md 资源应切到新标签: ${JSON.stringify(afterSecondResourceSideOpen)}`);
assert(afterSecondResourceSideOpen.secondaryResourceText.includes("第二个资源正文"), `secondary 第二个 md 资源应渲染新正文: ${JSON.stringify(afterSecondResourceSideOpen)}`);
assert.equal(afterSecondResourceSideOpen.secondaryActiveTabKind, "markdown", `secondary 第二个 md 资源不应回到 primary: ${JSON.stringify(afterSecondResourceSideOpen)}`);
assert.equal(afterSecondResourceSideOpen.popupCount, 0, `secondary 第二个 md 资源不应误开新窗口: ${JSON.stringify(afterSecondResourceSideOpen)}`);
await page.evaluate(({ officeAsset, documentId }) => {
window.dispatchEvent(new CustomEvent("tree.asset.open", {
detail: {
assetId: officeAsset.id,
documentId,
title: officeAsset.file_name || "side-target-office.docx",
assetType: officeAsset.asset_type || "attachment",
openTarget: "side",
},
}));
}, { officeAsset, documentId });
await page.locator('.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="office"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) iframe.mnote-resource-tab-frame').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const afterOfficeSideOpen = await readSideTargetState(page);
assert.equal(afterOfficeSideOpen.secondaryDocumentId, null, `Office openTarget=side 不应恢复 secondaryDocumentId: ${JSON.stringify(afterOfficeSideOpen)}`);
assert.equal(afterOfficeSideOpen.secondaryActiveTabKind, "office", `Office openTarget=side 应在 secondary 资源标签打开 office: ${JSON.stringify(afterOfficeSideOpen)}`);
assert(afterOfficeSideOpen.secondaryActiveTabText.includes("side-target-office.docx"), `secondary Office 标签标题应可观测: ${JSON.stringify(afterOfficeSideOpen)}`);
assert(afterOfficeSideOpen.secondaryOfficeFrameSrc, `secondary Office 应创建 iframe: ${JSON.stringify(afterOfficeSideOpen)}`);
const secondaryOfficeFrameUrl = new URL(afterOfficeSideOpen.secondaryOfficeFrameSrc, BASE_URL);
assert.equal(secondaryOfficeFrameUrl.pathname, "/onlyoffice", `secondary Office iframe 应指向 /onlyoffice: ${JSON.stringify(afterOfficeSideOpen)}`);
assert.equal(secondaryOfficeFrameUrl.searchParams.get("assetId"), officeAsset.id, `secondary Office iframe 应携带 assetId: ${JSON.stringify(afterOfficeSideOpen)}`);
assert.equal(secondaryOfficeFrameUrl.searchParams.get("mode"), "view", `secondary Office iframe 应使用 view 模式: ${JSON.stringify(afterOfficeSideOpen)}`);
assert.equal(afterOfficeSideOpen.popupCount, 0, `Office openTarget=side 不应误开新窗口: ${JSON.stringify(afterOfficeSideOpen)}`);
console.log(JSON.stringify({ ok: true, root, assetId: asset.id, officeAssetId: officeAsset.id }, null, 2));
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);