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