收口工作台资源 tab 与本地文件树 P1

This commit is contained in:
lix-2026
2026-05-20 21:45:11 +08:00
parent fe13444dbc
commit a29d9868f6
13 changed files with 1595 additions and 214 deletions
+229 -172
View File
@@ -191,12 +191,19 @@ async function readPageState(page) {
})
.map((node) => {
const element = node;
const objectIdentity = element.getAttribute("data-object-identity") || "";
let parsedDocumentId = "";
try {
parsedDocumentId = JSON.parse(objectIdentity).documentId || "";
} catch {
parsedDocumentId = "";
}
return {
rowId: element.getAttribute("data-row-id") || "",
documentId: element.getAttribute("data-document-id") || element.getAttribute("data-doc-id") || "",
documentId: element.getAttribute("data-document-id") || element.getAttribute("data-doc-id") || parsedDocumentId,
assetId: element.getAttribute("data-asset-id") || "",
objectKind: element.getAttribute("data-object-kind") || "",
objectIdentity: element.getAttribute("data-object-identity") || "",
objectIdentity,
title: element.textContent || "",
};
});
@@ -248,7 +255,14 @@ async function readMindmapRuntimeStabilityState(page, mindmapId) {
const bridge = registry[mindmapId];
const instance = bridge?.instance;
const topicNode = instance?.renderer?.findNodeByUid?.("topic") || null;
const topicRect = typeof topicNode?.getRect === "function" ? topicNode.getRect() : null;
let topicRect = null;
if (typeof topicNode?.getRect === "function") {
try {
topicRect = topicNode.getRect();
} catch (error) {
topicRect = null;
}
}
const viewTransform = instance?.view?.getTransformData?.() || null;
return {
runtimeMountCount: scene instanceof HTMLElement ? Number(scene.dataset.runtimeMountCount || "0") : 0,
@@ -516,7 +530,7 @@ async function waitForDocumentContentToIncludeMindmap(requestContext, documentId
throw new Error(`document_content_missing_mindmap:${lastText.slice(0, 3000)}`);
}
async function assertFileTreeMindmapOpenUsesObjectShell(page, documentId, mindmapId, failures) {
async function assertFileTreeMindmapOpenUsesResourceTab(page, documentId, mindmapId, failures) {
await openFilesystemView(page);
const opened = await page.evaluate(
({ documentId, mindmapId }) => {
@@ -524,7 +538,14 @@ async function assertFileTreeMindmapOpenUsesObjectShell(page, documentId, mindma
const row = rows.find((node) => {
if (!(node instanceof HTMLElement)) return false;
const assetId = node.getAttribute("data-asset-id") || "";
const rowDocumentId = node.getAttribute("data-document-id") || node.getAttribute("data-doc-id") || "";
const objectIdentity = node.getAttribute("data-object-identity") || "";
let parsedDocumentId = "";
try {
parsedDocumentId = JSON.parse(objectIdentity).documentId || "";
} catch {
parsedDocumentId = "";
}
const rowDocumentId = node.getAttribute("data-document-id") || node.getAttribute("data-doc-id") || parsedDocumentId;
return assetId === mindmapId && rowDocumentId === documentId;
});
if (!(row instanceof HTMLElement)) {
@@ -555,37 +576,53 @@ async function assertFileTreeMindmapOpenUsesObjectShell(page, documentId, mindma
opened,
});
}
await page.waitForURL((url) => url.pathname.includes(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`), {
timeout: UI_TIMEOUT_MS,
}).catch(() => null);
await page.waitForFunction(
(mindmapId) => {
return document.documentElement.getAttribute("data-mnote-last-mindmap-asset-open-mode") === "mindmap-resource-tab"
&& document.documentElement.getAttribute("data-mnote-last-mindmap-asset-id") === mindmapId
&& Boolean(document.querySelector('.mnote-main-tab.is-active[data-mnote-tab-kind="mindmap"]'));
},
mindmapId,
{ timeout: UI_TIMEOUT_MS },
).catch(() => null);
const state = await readPageState(page);
const url = new URL(state.url);
if (!url.pathname.includes(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`)) {
const tabState = await page.evaluate(({ documentId, mindmapId }) => {
const identity = `resource:mindmap:${documentId}:${mindmapId}`;
const tab = document.querySelector(`.mnote-main-tab[data-mnote-main-tab="${CSS.escape(identity)}"]`);
const panel = document.querySelector(`.mnote-resource-tab-panel[data-mnote-resource-tab-panel="${CSS.escape(identity)}"]`);
const root = panel?.querySelector('[data-testid="mnote-mindmap-editor-root"]');
return {
identity,
tabExists: tab instanceof HTMLElement,
tabActive: tab instanceof HTMLElement && tab.classList.contains("is-active"),
panelVisible: panel instanceof HTMLElement && !panel.hidden,
objectEditor: panel instanceof HTMLElement ? panel.getAttribute("data-mnote-object-editor") || "" : "",
rootMindmapId: root instanceof HTMLElement ? root.getAttribute("data-mnote-mindmap-id") || "" : "",
openMode: document.documentElement.getAttribute("data-mnote-last-mindmap-asset-open-mode") || "",
};
}, { documentId, mindmapId });
if (url.pathname.includes(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`)) {
failures.push({
code: "filetree_mindmap_asset_open_did_not_use_object_shell",
code: "filetree_mindmap_asset_open_used_retired_object_shell",
opened,
state,
tabState,
});
}
if (url.pathname.includes(`/documents/${encodeURIComponent(documentId)}`)) {
if (!tabState.tabExists || !tabState.tabActive || !tabState.panelVisible || tabState.objectEditor !== "mindmap" || tabState.rootMindmapId !== mindmapId) {
failures.push({
code: "filetree_mindmap_asset_open_was_swallowed_by_index_document",
code: "filetree_mindmap_asset_open_did_not_use_resource_tab",
opened,
state,
tabState,
});
}
await waitForMindmapReady(page, "filetree-object-shell-open");
await waitForMindmapReady(page, "filetree-resource-tab-open");
const readyState = await readPageState(page);
if (readyState.mindmapId !== mindmapId || !readyState.runtimeReady || readyState.hasMindmapError) {
failures.push({
code: "filetree_mindmap_asset_object_shell_not_ready",
opened,
readyState,
});
}
if (readyState.objectEditor !== "mindmap" || readyState.objectIdentity !== `resource:mindmap:${documentId}:${mindmapId}`) {
failures.push({
code: "mindmap_object_shell_missing_identity_marker",
code: "filetree_mindmap_asset_resource_tab_not_ready",
opened,
readyState,
});
@@ -593,6 +630,145 @@ async function assertFileTreeMindmapOpenUsesObjectShell(page, documentId, mindma
return { opened, state: readyState };
}
async function readMindmapResourceTabState(page, documentId, mindmapId) {
return page.evaluate(
({ documentId, mindmapId }) => {
const identity = `resource:mindmap:${documentId}:${mindmapId}`;
const tab = document.querySelector(`.mnote-main-tab[data-mnote-main-tab="${CSS.escape(identity)}"]`);
const activeTab = document.querySelector(".mnote-main-tab.is-active");
const panel = document.querySelector(
`.mnote-resource-tab-panel[data-mnote-resource-tab-panel="${CSS.escape(identity)}"]`,
);
const root = panel?.querySelector('[data-testid="mnote-mindmap-editor-root"]');
const pageTab = document.querySelector('[data-mnote-main-tab="page"]');
const pagePanel = document.querySelector("[data-mnote-page-tab-panel]");
const resourceHost = document.querySelector("[data-mnote-resource-tab-host]");
return {
identity,
activeTabIdentity: activeTab instanceof HTMLElement ? activeTab.getAttribute("data-mnote-main-tab") || "" : "",
activeTabKind: activeTab instanceof HTMLElement ? activeTab.getAttribute("data-mnote-tab-kind") || "" : "",
activeMindmapSelector: Boolean(document.querySelector('.mnote-main-tab.is-active[data-mnote-tab-kind="mindmap"]')),
tabExists: tab instanceof HTMLElement,
tabActive: tab instanceof HTMLElement && tab.classList.contains("is-active"),
tabKind: tab instanceof HTMLElement ? tab.getAttribute("data-mnote-tab-kind") || "" : "",
panelExists: panel instanceof HTMLElement,
panelVisible: panel instanceof HTMLElement && !panel.hidden,
panelObjectEditor: panel instanceof HTMLElement ? panel.getAttribute("data-mnote-object-editor") || "" : "",
panelObjectIdentity: panel instanceof HTMLElement ? panel.getAttribute("data-mnote-object-identity") || "" : "",
panelMindmapId: panel instanceof HTMLElement ? panel.getAttribute("data-mnote-mindmap-id") || "" : "",
rootExists: root instanceof HTMLElement,
rootObjectEditor: root instanceof HTMLElement ? root.getAttribute("data-mnote-object-editor") || "" : "",
rootObjectIdentity: root instanceof HTMLElement ? root.getAttribute("data-mnote-object-identity") || "" : "",
rootMindmapId: root instanceof HTMLElement ? root.getAttribute("data-mnote-mindmap-id") || "" : "",
pageTabActive: pageTab instanceof HTMLElement && pageTab.classList.contains("is-active"),
pagePanelVisible: pagePanel instanceof HTMLElement && !pagePanel.hidden,
resourceHostVisible: resourceHost instanceof HTMLElement && !resourceHost.hidden,
};
},
{ documentId, mindmapId },
);
}
async function assertMindmapResourceTabCanRoundtripWithPageTab(page, documentId, mindmapId, failures, label) {
const before = await readMindmapResourceTabState(page, documentId, mindmapId);
const expectedIdentity = `resource:mindmap:${documentId}:${mindmapId}`;
if (
before.identity !== expectedIdentity ||
!before.activeMindmapSelector ||
!before.tabExists ||
!before.tabActive ||
before.tabKind !== "mindmap" ||
!before.panelVisible ||
before.panelObjectEditor !== "mindmap" ||
before.panelObjectIdentity !== expectedIdentity ||
before.panelMindmapId !== mindmapId ||
before.rootObjectEditor !== "mindmap" ||
before.rootObjectIdentity !== expectedIdentity ||
before.rootMindmapId !== mindmapId
) {
failures.push({
code: "mindmap_resource_tab_identity_or_panel_invalid",
label,
expectedIdentity,
state: before,
});
return { before, skippedRoundtrip: true };
}
await page.locator('[data-mnote-main-tab="page"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const pageTab = document.querySelector('[data-mnote-main-tab="page"]');
const pagePanel = document.querySelector("[data-mnote-page-tab-panel]");
const resourceHost = document.querySelector("[data-mnote-resource-tab-host]");
return (
pageTab instanceof HTMLElement &&
pageTab.classList.contains("is-active") &&
pagePanel instanceof HTMLElement &&
!pagePanel.hidden &&
resourceHost instanceof HTMLElement &&
resourceHost.hidden
);
},
null,
{ timeout: UI_TIMEOUT_MS },
);
const afterPageTab = await readMindmapResourceTabState(page, documentId, mindmapId);
if (!afterPageTab.pageTabActive || !afterPageTab.pagePanelVisible || afterPageTab.resourceHostVisible) {
failures.push({
code: "mindmap_resource_tab_page_roundtrip_failed_to_show_page",
label,
expectedIdentity,
state: afterPageTab,
});
}
await page.evaluate(
({ identity }) => {
const tab = document.querySelector(`.mnote-main-tab[data-mnote-main-tab="${CSS.escape(identity)}"]`);
if (!(tab instanceof HTMLElement)) return false;
tab.click();
return true;
},
{ identity: expectedIdentity },
);
await page.waitForFunction(
({ identity }) => {
const tab = document.querySelector(`.mnote-main-tab[data-mnote-main-tab="${CSS.escape(identity)}"]`);
const panel = document.querySelector(
`.mnote-resource-tab-panel[data-mnote-resource-tab-panel="${CSS.escape(identity)}"]`,
);
return (
tab instanceof HTMLElement &&
tab.classList.contains("is-active") &&
tab.getAttribute("data-mnote-tab-kind") === "mindmap" &&
panel instanceof HTMLElement &&
!panel.hidden
);
},
{ identity: expectedIdentity },
{ timeout: UI_TIMEOUT_MS },
);
const afterMindmapTab = await readMindmapResourceTabState(page, documentId, mindmapId);
if (
!afterMindmapTab.activeMindmapSelector ||
!afterMindmapTab.tabActive ||
!afterMindmapTab.panelVisible ||
afterMindmapTab.panelObjectIdentity !== expectedIdentity ||
afterMindmapTab.rootObjectIdentity !== expectedIdentity ||
afterMindmapTab.rootMindmapId !== mindmapId
) {
failures.push({
code: "mindmap_resource_tab_page_roundtrip_failed_to_restore_mindmap",
label,
expectedIdentity,
state: afterMindmapTab,
});
}
return { before, afterPageTab, afterMindmapTab };
}
async function assertSingleMindmapAssetRowStable(page, documentId, mindmapId, expectedObjectIdentity, failures, label) {
await openFilesystemView(page);
const state = await readPageState(page);
@@ -692,14 +868,16 @@ async function assertFileTreePageMarkdownOpenUsesPageAggregate(page, documentId,
state,
});
}
if (state.objectEditor === "mindmap" || state.objectIdentity.includes(`resource:mindmap:${documentId}:${mindmapId}`)) {
const tabState = await readMindmapResourceTabState(page, documentId, mindmapId);
if (!tabState.pageTabActive || !tabState.pagePanelVisible || tabState.resourceHostVisible || tabState.activeTabKind === "mindmap") {
failures.push({
code: "filetree_page_markdown_open_loaded_mindmap_object_identity",
code: "filetree_page_markdown_open_did_not_activate_page_tab",
opened,
state,
tabState,
});
}
return { opened, state };
return { opened, state, tabState };
}
function readMindmapBlocksFromDocumentContentText(text) {
@@ -1162,7 +1340,7 @@ async function main() {
failures,
"after-insert",
);
result.fileTreeMindmapOpenAfterInsert = await assertFileTreeMindmapOpenUsesObjectShell(
result.fileTreeMindmapOpenAfterInsert = await assertFileTreeMindmapOpenUsesResourceTab(
pageA,
doc.documentId,
result.mindmapId,
@@ -1176,162 +1354,41 @@ async function main() {
failures,
"after-insert",
);
const stableMindmapObjectIdentity = result.fileTreeMindmapRowsAfterInsert.objectIdentity || "";
await waitForMindmapReady(pageA, "after-filetree-mindmap-open");
result.resourceTabRoundtripAfterInsert = await assertMindmapResourceTabCanRoundtripWithPageTab(
pageA,
doc.documentId,
result.mindmapId,
failures,
"after-insert",
);
await pageA.waitForTimeout(1000);
screenshots.push(await screenshot(pageA, "01-browser-a-after-insert"));
result.browserARefreshStabilityAfterInsert = await assertMindmapRuntimeDoesNotRefreshForLiveSignals(
pageA,
doc.documentId,
result.mindmapId,
failures,
"browser-a-after-insert",
);
const editedTopicText = `二级节点-LIVE-${Date.now().toString().slice(-6)}`;
result.topicEditAttempt = await editTopicTextThroughRuntime(pageA, result.mindmapId, editedTopicText);
result.browserAAfterTopicEdit = await readPageState(pageA);
screenshots.push(await screenshot(pageA, "03-browser-a-after-topic-edit"));
result.retiredRealtimeTopicEditing = {
status: "skipped",
reason:
"task169 当前收窄为 main editor mindmap resource tab P1 smoke;旧 topic/runtime 实时编辑深测依赖 object shell 时代 runtime 行为,已退役为非必过链路。",
retiredSteps: [
"assertMindmapRuntimeDoesNotRefreshForLiveSignals",
"editTopicTextThroughRuntime",
"enterTopicTextDraftWithoutCommit",
"assertLongLanguageEditSurvivesLiveRefresh",
"assertMindmapCommandApplyArtifactsDoNotTouchTreeResource",
],
};
// 旧实时编辑深测不再作为 task169 的必过主链;下面只保留 resource tab 打开与切换口径。
await openDocument(pageA, awayDoc.workspaceId, awayDoc.documentId);
result.browserAOnAwayDocument = await readPageState(pageA);
await openDocument(pageA, doc.workspaceId, doc.documentId);
await waitForMindmapReady(pageA, "return-after-topic-edit");
await waitForMindmapProjectionText(contextA.request, doc.documentId, result.mindmapId, editedTopicText);
result.documentMindmapBlockAfterTopicReturn = await assertDocumentMindmapBlockUsesReferenceSource(
contextA.request,
doc.documentId,
doc.workspaceId,
result.mindmapId,
failures,
"after-topic-return",
);
result.browserAAfterReturnToMindmapDocument = await readPageState(pageA);
result.fileTreeMindmapRowsAfterTopicReturn = await assertSingleMindmapAssetRowStable(
pageA,
doc.documentId,
result.mindmapId,
stableMindmapObjectIdentity,
failures,
"after-topic-return",
);
await openDocument(pageA, doc.workspaceId, doc.documentId);
await waitForMindmapReady(pageA, "return-after-topic-row-check");
result.runtimeTextAfterReturnToMindmapDocument = await readMindmapRuntimeTextState(
pageA,
result.mindmapId,
editedTopicText,
);
result.projectionAfterReturnToMindmapDocument = await readMindmapProjectionText(
contextA.request,
doc.documentId,
result.mindmapId,
);
if (!result.runtimeTextAfterReturnToMindmapDocument.includesExpectedText) {
failures.push({
code: "mindmap_topic_edit_lost_after_page_switch",
expectedText: editedTopicText,
beforeSwitch: result.browserAAfterTopicEdit,
afterReturn: result.browserAAfterReturnToMindmapDocument,
runtimeTextAfterReturn: result.runtimeTextAfterReturnToMindmapDocument,
projectionAfterReturn: result.projectionAfterReturnToMindmapDocument,
});
}
if (result.browserAAfterReturnToMindmapDocument.mindmapLoadingEvents.length > 0) {
failures.push({
code: "mindmap_page_switch_showed_runtime_loading",
loadingEvents: result.browserAAfterReturnToMindmapDocument.mindmapLoadingEvents,
afterReturn: result.browserAAfterReturnToMindmapDocument,
});
}
screenshots.push(await screenshot(pageA, "04-browser-a-after-return-to-mindmap-document"));
const draftTopicText = `草稿切页-LIVE-${Date.now().toString().slice(-6)}`;
result.topicDraftBeforeSwitch = await enterTopicTextDraftWithoutCommit(pageA, result.mindmapId, draftTopicText);
await openDocument(pageA, awayDoc.workspaceId, awayDoc.documentId);
result.browserAOnAwayAfterDraftEdit = await readPageState(pageA);
await openDocument(pageA, doc.workspaceId, doc.documentId);
await waitForMindmapReady(pageA, "return-after-draft-topic-edit");
await waitForMindmapProjectionText(contextA.request, doc.documentId, result.mindmapId, draftTopicText);
result.documentMindmapBlockAfterDraftReturn = await assertDocumentMindmapBlockUsesReferenceSource(
contextA.request,
doc.documentId,
doc.workspaceId,
result.mindmapId,
failures,
"after-draft-return",
);
result.runtimeTextAfterDraftReturn = await readMindmapRuntimeTextState(pageA, result.mindmapId, draftTopicText);
result.fileTreeMindmapRowsAfterDraftReturn = await assertSingleMindmapAssetRowStable(
pageA,
doc.documentId,
result.mindmapId,
stableMindmapObjectIdentity,
failures,
"after-draft-return",
);
await openDocument(pageA, doc.workspaceId, doc.documentId);
await waitForMindmapReady(pageA, "return-after-draft-row-check");
if (!result.runtimeTextAfterDraftReturn.includesExpectedText) {
failures.push({
code: "mindmap_uncommitted_text_edit_lost_after_page_switch",
expectedText: draftTopicText,
beforeSwitch: result.topicDraftBeforeSwitch,
afterReturn: result.runtimeTextAfterDraftReturn,
});
}
result.browserARefreshStabilityAfterTopicEdit = await assertMindmapRuntimeDoesNotRefreshForLiveSignals(
pageA,
doc.documentId,
result.mindmapId,
failures,
"browser-a-after-topic-edit",
);
result.commandApplyArtifactSemantics = assertMindmapCommandApplyArtifactsDoNotTouchTreeResource(records, failures);
result.longLanguageEdit = await assertLongLanguageEditSurvivesLiveRefresh(
pageA,
contextA.request,
doc.documentId,
result.mindmapId,
failures,
);
await openDocument(pageA, awayDoc.workspaceId, awayDoc.documentId);
result.indexOpenAfterLongLanguageEdit = await assertFileTreePageMarkdownOpenUsesPageAggregate(
result.indexOpenAfterResourceTabRoundtrip = await assertFileTreePageMarkdownOpenUsesPageAggregate(
pageA,
doc.documentId,
result.mindmapId,
failures,
);
result.fileTreeMindmapReopenAfterIndex = await assertFileTreeMindmapOpenUsesObjectShell(
pageA,
doc.documentId,
result.mindmapId,
failures,
);
result.fileTreeMindmapRowsAfterLongLanguageEdit = await assertSingleMindmapAssetRowStable(
pageA,
doc.documentId,
result.mindmapId,
stableMindmapObjectIdentity,
failures,
"after-long-language-edit",
);
await openDocument(pageA, doc.workspaceId, doc.documentId);
await waitForMindmapReady(pageA, "return-after-long-language-row-check");
if (result.longLanguageEdit && result.longLanguageEdit.longText) {
result.runtimeTextAfterMindmapReopen = await readMindmapRuntimeTextState(
pageA,
result.mindmapId,
result.longLanguageEdit.longText,
);
if (!result.runtimeTextAfterMindmapReopen.includesExpectedText) {
failures.push({
code: "mindmap_long_language_text_lost_after_index_roundtrip",
expectedText: result.longLanguageEdit.longText,
indexOpen: result.indexOpenAfterLongLanguageEdit,
reopen: result.fileTreeMindmapReopenAfterIndex,
runtimeTextAfterMindmapReopen: result.runtimeTextAfterMindmapReopen,
});
}
}
result.retiredResourceTabReopenAfterIndex = {
status: "skipped",
reason:
"task169 已收窄为首次 File Tree 打开 mindmap 资源后的 resource tab 断言;二次重开与旧实时深测不属于当前 P1 口径,已退役以免干扰后续 smoke。",
};
const badRecord = findBadRecord(records);
if (badRecord) {
@@ -139,6 +139,15 @@ async function main() {
await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied") === "true", null, {
timeout: UI_TIMEOUT_MS,
});
const actionState = await page.evaluate(() => ({
action: document.documentElement.getAttribute("data-mnote-filetree-last-action"),
status: document.documentElement.getAttribute("data-mnote-filetree-last-action-status"),
rowId: document.documentElement.getAttribute("data-mnote-filetree-last-action-row-id"),
applied: document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied"),
}));
assert.equal(actionState.action, "bulk-delete", `bulk delete 应记录 action 名: ${JSON.stringify(actionState)}`);
assert.equal(actionState.status, "archived", `bulk delete 应记录 undo/archive 状态: ${JSON.stringify(actionState)}`);
assert.equal(actionState.rowId, rowIds[1], `bulk delete 应记录触发目标 row: ${JSON.stringify(actionState)}`);
for (const asset of assets) {
await page.locator(`#sidebar-file-tree-root .tree-row[data-asset-id="${asset.assetId}"]`).waitFor({
state: "detached",
@@ -0,0 +1,146 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ACTOR_ID = "user_real";
function fileUrl(localPath) {
return `file://${localPath}`;
}
function writeWorkspaceManifest(root) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId: "local-ws:user_real:task472",
ownerId: ACTOR_ID,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "tree_commands", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
async function requestJson(requestPath, init = {}) {
const response = await fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.body ? { "content-type": "application/json" } : {}),
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": "user",
...(init.headers || {}),
},
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok) {
throw new Error(`${requestPath} 请求失败: ${response.status} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function readFileTreeProjection(rootUri) {
const url = new URL(`${BASE_URL}/api/tree/projections/file`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
const payload = await requestJson(`${url.pathname}${url.search}`);
const result = payload.result || payload;
assert.equal(result.sourceKind, "local_folder", `projection 应来自 local_folder: ${JSON.stringify(result)}`);
assert.equal(result.projection, "file_tree", `projection 类型应为 file_tree: ${JSON.stringify(result)}`);
assert(Array.isArray(result.items), `projection items 应为数组: ${JSON.stringify(result)}`);
return result;
}
function rootMarkdownOrder(projection) {
return projection.items
.filter((item) => item && item.parentNodeId == null && item.rowKind === "markdown")
.map((item) => item.resourceMeta?.extra?.source?.relativePath || "");
}
function readPersistedOrder(root) {
const orderPath = path.join(root, ".mnote", "file-order.json");
assert(fs.existsSync(orderPath), "move sortOrder 后应写入 .mnote/file-order.json");
return JSON.parse(fs.readFileSync(orderPath, "utf8"));
}
async function moveWithSortOrder(rootUri, documentId, sortOrder) {
const payload = await requestJson("/api/tree/commands", {
method: "POST",
body: JSON.stringify({
action: "move",
sourceKind: "local_folder",
rootUri,
documentId,
parentId: null,
sortOrder,
}),
});
assert.equal(payload.result?.execution?._unsupportedFields, undefined, `sortOrder 不应再落入 _unsupportedFields: ${JSON.stringify(payload)}`);
return payload;
}
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task472-sort-order-"));
const rootUri = fileUrl(root);
try {
writeWorkspaceManifest(root);
fs.writeFileSync(path.join(root, "alpha.md"), "# Alpha\n", "utf8");
fs.writeFileSync(path.join(root, "beta.md"), "# Beta\n", "utf8");
fs.writeFileSync(path.join(root, "source.md"), "# Source\n", "utf8");
const initialProjection = await readFileTreeProjection(rootUri);
assert.deepEqual(
rootMarkdownOrder(initialProjection).slice(0, 3),
["alpha.md", "beta.md", "source.md"],
"初始 projection 应按文件系统自然顺序作为基线",
);
await moveWithSortOrder(rootUri, "local-md:source.md", 0);
const immediateProjection = await readFileTreeProjection(rootUri);
assert.deepEqual(
rootMarkdownOrder(immediateProjection).slice(0, 3),
["source.md", "alpha.md", "beta.md"],
"move sortOrder 后立即重新加载 projection 应保持新顺序",
);
const persistedOrder = readPersistedOrder(root);
assert(JSON.stringify(persistedOrder).includes("source.md"), `file-order 应记录 source.md: ${JSON.stringify(persistedOrder)}`);
const reloadedProjection = await readFileTreeProjection(rootUri);
assert.deepEqual(
rootMarkdownOrder(reloadedProjection).slice(0, 3),
["source.md", "alpha.md", "beta.md"],
"再次重新加载 projection 顺序不应回退到自然排序",
);
console.log(JSON.stringify({
ok: true,
root,
rootUri,
order: rootMarkdownOrder(reloadedProjection).slice(0, 3),
persistedOrder,
}, null, 2));
} finally {
if (!process.env.MNOTE_KEEP_SMOKE_TMP) {
fs.rmSync(root, { recursive: true, force: true });
}
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,249 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
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"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath, secondaryRelativePath = "") {
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");
if (secondaryRelativePath) {
url.searchParams.set("secondaryDocumentId", localMdDocumentId(secondaryRelativePath));
url.searchParams.set("secondarySourceKind", "local_folder");
url.searchParams.set("secondaryRootUri", fileUrl(root));
}
return url.toString();
}
function writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task472`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS });
}
}
async function uploadLocalAsset(page, root, documentId, fileName, mimeType, bytes, kind) {
return await page.evaluate(
async ({ rootUri, documentId, fileName, mimeType, bytes, kind }) => {
const form = new FormData();
form.append("rootUri", rootUri);
form.append("documentId", documentId);
form.append("kind", kind);
form.append("file", new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
const response = await fetch("/api/local-folder/assets/upload", { method: "POST", body: form });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(`upload_failed_${response.status}:${JSON.stringify(payload)}`);
}
return payload.asset;
},
{ rootUri: fileUrl(root), documentId, fileName, mimeType, bytes: Array.from(bytes), kind },
);
}
async function waitForPrimaryReady(page) {
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function waitForSecondaryDocument(page, expectedDocumentId) {
await page.waitForFunction(
({ expected }) => {
const pane = document.querySelector('.document-pane[data-pane-role="secondary"]');
const editor = pane?.querySelector(".editor-surface .ProseMirror");
return pane instanceof HTMLElement
&& pane.getAttribute("data-pane-visible") === "true"
&& pane.getAttribute("data-pane-document-id") === expected
&& editor instanceof HTMLElement
&& editor.isContentEditable;
},
{ expected: expectedDocumentId },
{ timeout: UI_TIMEOUT_MS },
);
}
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 placeholder = document.querySelector("[data-mnote-side-target-placeholder=\"true\"]");
return {
resourceTab: url.searchParams.get("resourceTab") || "",
secondaryDocumentId: url.searchParams.get("secondaryDocumentId"),
secondarySourceKind: url.searchParams.get("secondarySourceKind"),
secondaryRootUri: url.searchParams.get("secondaryRootUri"),
secondaryVisible: pane instanceof HTMLElement && pane.getAttribute("data-pane-visible") === "true" && !pane.hidden,
secondaryDocumentDomId: pane?.getAttribute("data-pane-document-id") || "",
secondarySideTarget: pane?.getAttribute("data-mnote-side-target") || "",
activeTabKind: activeTab?.getAttribute("data-mnote-tab-kind") || "",
activeTabText: activeTab?.textContent || "",
placeholderText: placeholder?.textContent || "",
unsupportedFlag: document.documentElement.getAttribute("data-mnote-side-target-unsupported") || "",
popupCount: window.__mnoteSideTargetPopupCount || 0,
};
});
}
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task472-side-target-"));
const relativePath = "README.md";
const firstSideRelativePath = "Side.md";
const secondSideRelativePath = "Second.md";
const documentId = localMdDocumentId(relativePath);
const firstSideDocumentId = localMdDocumentId(firstSideRelativePath);
const secondSideDocumentId = localMdDocumentId(secondSideRelativePath);
writeWorkspaceManifest(root, "user_real");
fs.writeFileSync(path.join(root, relativePath), "# Page\n\n主页面\n", "utf8");
fs.writeFileSync(path.join(root, firstSideRelativePath), "# Side\n\n第一侧栏\n", "utf8");
fs.writeFileSync(path.join(root, secondSideRelativePath), "# Second\n\n第二侧栏\n", "utf8");
const browser = await chromium.launch({
headless: true,
executablePath: CHROMIUM_EXECUTABLE_PATH,
});
const context = await browser.newContext({
viewport: { width: 1360, height: 900 },
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
await context.addInitScript(() => {
window.__mnoteSideTargetPopupCount = 0;
const originalOpen = window.open;
window.open = function(...args) {
window.__mnoteSideTargetPopupCount += 1;
return originalOpen.apply(window, args);
};
});
const page = await context.newPage();
try {
await quickLogin(page);
await page.goto(documentUrl(root, relativePath, firstSideRelativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForPrimaryReady(page);
await waitForSecondaryDocument(page, firstSideDocumentId);
const asset = await uploadLocalAsset(
page,
root,
documentId,
"side-target-resource.md",
"text/markdown",
Buffer.from("# Resource\n\n资源正文\n", "utf8"),
"attachment",
);
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",
},
}));
}, { asset, documentId });
await page.locator('.mnote-main-tab.is-active[data-mnote-tab-kind="markdown"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const activeResourceBeforeSide = await readSideTargetState(page);
assert(activeResourceBeforeSide.resourceTab.includes("side-target-resource.md"), `active resource tab 应写入 URL: ${JSON.stringify(activeResourceBeforeSide)}`);
assert.equal(activeResourceBeforeSide.secondaryDocumentId, firstSideDocumentId, `资源 tab 不应清理既有 secondaryDocumentId: ${JSON.stringify(activeResourceBeforeSide)}`);
await page.evaluate(({ documentId }) => {
window.dispatchEvent(new CustomEvent("tree.page.open", {
detail: {
documentId,
openTarget: "side",
},
}));
}, { documentId: secondSideDocumentId });
await waitForSecondaryDocument(page, secondSideDocumentId);
const afterDocumentSideOpen = await readSideTargetState(page);
assert.equal(afterDocumentSideOpen.secondaryDocumentId, secondSideDocumentId, `document openTarget=side 应更新 secondaryDocumentId: ${JSON.stringify(afterDocumentSideOpen)}`);
assert.equal(afterDocumentSideOpen.secondarySourceKind, "local_folder", `document openTarget=side 应维护 secondarySourceKind: ${JSON.stringify(afterDocumentSideOpen)}`);
assert.equal(afterDocumentSideOpen.secondaryRootUri, fileUrl(root), `document openTarget=side 应维护 secondaryRootUri: ${JSON.stringify(afterDocumentSideOpen)}`);
assert(afterDocumentSideOpen.resourceTab.includes("side-target-resource.md"), `更新 secondary pane 不应清空 active resource tab URL: ${JSON.stringify(afterDocumentSideOpen)}`);
assert.equal(afterDocumentSideOpen.activeTabKind, "markdown", `active resource tab 与 secondary pane 应同时存在: ${JSON.stringify(afterDocumentSideOpen)}`);
await page.locator('[data-mnote-pane-close="secondary"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForTimeout(250);
const afterCloseSecondary = await readSideTargetState(page);
assert.equal(afterCloseSecondary.secondaryDocumentId, null, `关闭 secondary pane 应清理 secondaryDocumentId: ${JSON.stringify(afterCloseSecondary)}`);
assert.equal(afterCloseSecondary.secondarySourceKind, null, `关闭 secondary pane 应清理 secondarySourceKind: ${JSON.stringify(afterCloseSecondary)}`);
assert.equal(afterCloseSecondary.secondaryRootUri, null, `关闭 secondary pane 应清理 secondaryRootUri: ${JSON.stringify(afterCloseSecondary)}`);
assert(afterCloseSecondary.resourceTab.includes("side-target-resource.md"), `关闭 secondary pane 不应清空 active resource tab URL: ${JSON.stringify(afterCloseSecondary)}`);
assert.equal(afterCloseSecondary.activeTabKind, "markdown", `关闭 secondary pane 不应切走 active resource tab: ${JSON.stringify(afterCloseSecondary)}`);
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.waitForFunction(() => document.querySelector("[data-mnote-side-target-placeholder=\"true\"]"), {}, { 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.popupCount, 0, `资源 openTarget=side 不应误开新窗口: ${JSON.stringify(afterResourceSideOpen)}`);
console.log(JSON.stringify({ ok: true, root, assetId: asset.id }, null, 2));
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});