Files
mnote/scripts/task524-workspace-object-identity-matrix-smoke.js
T
lix-2026 1882db7681 收口 MNote P0 P1 P2 审查尾项
- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目
- 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线
- 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径

验证:
- cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1
- cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1
- git diff --check
- git diff --cached --check
- codegraph index . --force && codegraph status .
- codegraph sync . && codegraph status .
2026-06-01 09:29:12 +08:00

332 lines
17 KiB
JavaScript

#!/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 { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_UI_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 PROBE_DOCX_PATH = process.env.MNOTE_ONLYOFFICE_PROBE_DOCX
|| "/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
const OUT_DIR = path.join(process.cwd(), "tmp", "task524-workspace-object-identity-matrix-smoke");
const RESULT_PATH = path.join(OUT_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));
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, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
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 !== "/auth", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
}
async function filetreeRowState(page, relativePath) {
return await page.evaluate((targetRelativePath) => {
const row = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
.find((candidate) => candidate instanceof HTMLElement && candidate.getAttribute("data-local-relative-path") === targetRelativePath);
if (!(row instanceof HTMLElement)) {
return null;
}
const params = new URL(window.location.href).searchParams;
const workspacePath = window.__mnoteFileTreeRuntime?.readWorkspacePathFromRow?.(row, {
currentSourceKind: () => params.get("sourceKind") || document.body.dataset.mnoteSourceKind || "local_folder",
currentRootUri: () => params.get("rootUri") || document.body.dataset.mnoteRootUri || "",
resolveWorkspaceId: () => document.body.dataset.workspaceId || "",
rowTitle: (candidate) => candidate.textContent || "",
}) || null;
const objectIdentityRaw = row.getAttribute("data-object-identity") || "";
let objectIdentity = null;
try {
objectIdentity = objectIdentityRaw ? JSON.parse(objectIdentityRaw) : null;
} catch {
objectIdentity = objectIdentityRaw;
}
return {
rowId: row.getAttribute("data-row-id") || "",
rowKind: row.getAttribute("data-row-kind") || "",
documentId: row.getAttribute("data-document-id") || "",
assetId: row.getAttribute("data-asset-id") || "",
relativePath: row.getAttribute("data-local-relative-path") || "",
objectIdentity,
workspacePath,
expanded: row.getAttribute("aria-expanded") || "",
};
}, relativePath);
}
async function clickFiletreeOpen(page, relativePath) {
const row = page.locator(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${relativePath.replace(/"/g, '\\"')}"]`).first();
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await row.locator('[data-rust-action="open"]').click({ timeout: UI_TIMEOUT_MS });
}
async function waitFiletreeRow(page, relativePath) {
const row = page.locator(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${relativePath.replace(/"/g, '\\"')}"]`).first();
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function expandFiletreeFolder(page, relativePath) {
const row = page.locator(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${relativePath.replace(/"/g, '\\"')}"]`).first();
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const expanded = await row.getAttribute("aria-expanded");
if (expanded === "true") return;
await row.locator('[data-rust-action="open"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(targetRelativePath) => {
const candidate = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
.find((row) => row instanceof HTMLElement && row.getAttribute("data-local-relative-path") === targetRelativePath);
return candidate && candidate.getAttribute("aria-expanded") === "true";
},
relativePath,
{ timeout: UI_TIMEOUT_MS },
);
}
async function activeSnapshotEntry(page, expectedRelativePath, expectedKind) {
return await page.waitForFunction(
({ expectedRelativePath, expectedKind }) => {
const snapshot = window.__mnoteOpenEditorsSnapshot || null;
const liveSnapshot = window.__mnoteDocumentPaneRuntime?.getOpenEditorsSnapshot?.() || snapshot;
const resources = Array.isArray(liveSnapshot?.resourceEditors) ? liveSnapshot.resourceEditors : [];
const entries = Array.isArray(liveSnapshot?.editors) ? liveSnapshot.editors.concat(resources) : resources;
const activeObjectIdentity = String(liveSnapshot?.activeObjectIdentity || "");
const active = entries.find((entry) =>
entry
&& (entry.active === true || entry.objectIdentity === activeObjectIdentity)
&& (!expectedRelativePath || entry.workspacePath?.relativePath === expectedRelativePath || entry.path === expectedRelativePath)
&& (!expectedKind || entry.kind === expectedKind || entry.editorKind === expectedKind || entry.workspacePath?.resourceKind === expectedKind)
);
return active || null;
},
{ expectedRelativePath, expectedKind },
{ timeout: UI_TIMEOUT_MS },
);
}
function assertWorkspacePathMatchesRow(label, rowState, snapshotEntry, expected) {
assert(rowState, `${label}: 缺少 FileTree row state`);
assert(rowState.workspacePath, `${label}: FileTree row 缺少 workspacePath: ${JSON.stringify(rowState)}`);
assert(snapshotEntry, `${label}: 缺少 OpenEditorsSnapshot entry`);
assert.equal(rowState.workspacePath.schema, "mnote.workspace_path.v1", `${label}: row workspacePath schema`);
assert.equal(snapshotEntry.workspacePath?.schema, "mnote.workspace_path.v1", `${label}: snapshot workspacePath schema`);
assert.equal(rowState.workspacePath.relativePath, expected.relativePath, `${label}: row relativePath`);
assert.equal(snapshotEntry.workspacePath?.relativePath, expected.relativePath, `${label}: snapshot relativePath`);
assert.equal(snapshotEntry.workspacePath?.sourceKind, rowState.workspacePath.sourceKind, `${label}: sourceKind 应一致`);
assert.equal(snapshotEntry.workspacePath?.rootUri, rowState.workspacePath.rootUri, `${label}: rootUri 应一致`);
assert.equal(snapshotEntry.workspacePath?.documentId, rowState.workspacePath.documentId, `${label}: documentId 应一致`);
if (expected.assetId) {
assert.equal(rowState.workspacePath.assetId, expected.assetId, `${label}: row assetId`);
assert.equal(snapshotEntry.workspacePath?.assetId, expected.assetId, `${label}: snapshot assetId`);
}
if (expected.resourceKind) {
assert.equal(rowState.workspacePath.resourceKind, expected.resourceKind, `${label}: row resourceKind`);
assert.equal(snapshotEntry.workspacePath?.resourceKind, expected.resourceKind, `${label}: resourceKind`);
}
if (expected.objectKind) {
assert.equal(rowState.workspacePath.objectIdentity?.objectKind, expected.objectKind, `${label}: row objectKind`);
assert.equal(snapshotEntry.workspacePath?.objectIdentity?.objectKind, expected.objectKind, `${label}: snapshot objectKind`);
}
if (rowState.workspacePath.objectIdentity && typeof rowState.workspacePath.objectIdentity === "object"
&& snapshotEntry.workspacePath?.objectIdentity && typeof snapshotEntry.workspacePath.objectIdentity === "object") {
assert.deepStrictEqual(
snapshotEntry.workspacePath.objectIdentity,
rowState.workspacePath.objectIdentity,
`${label}: snapshot objectIdentity 应与 row objectIdentity 对齐`,
);
}
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task524`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task524-identity-"));
const rootUri = fileUrl(root);
fs.mkdirSync(path.join(root, "Page"), { recursive: true });
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, "Page.md"), "# Page\n\nTask524 page\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "Plan.md"), "# Plan\n\nTask524 plan\n", "utf8");
fs.writeFileSync(path.join(root, "Page", "notes.txt"), "Task524 text resource\n", "utf8");
fs.writeFileSync(path.join(root, "Page", "map.mindmap.json"), JSON.stringify({
root: { data: { text: "Task524 Mindmap" }, children: [] },
}, null, 2), "utf8");
if (fs.existsSync(PROBE_DOCX_PATH)) {
fs.copyFileSync(PROBE_DOCX_PATH, path.join(root, "Page", "office.docx"));
} else {
fs.writeFileSync(path.join(root, "Page", "office.docx"), "task524 office probe\n", "utf8");
}
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const result = {
ok: false,
task: "task524-workspace-object-identity-matrix-smoke",
baseUrl: BASE_URL,
root,
rootUri,
checks: [],
};
try {
await quickLogin(page);
await page.goto(documentUrl(root, "Page.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(() => Boolean(window.__mnoteFileTreeRuntime?.readWorkspacePathFromRow), null, {
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(() => Boolean(window.__mnoteOpenEditorsSnapshot), null, {
timeout: UI_TIMEOUT_MS,
});
await page.evaluate(() => {
window.__task524IdentityEvents = [];
window.addEventListener("tree.filetree.open", (event) => {
window.__task524IdentityEvents.push({ type: "tree.filetree.open", detail: event.detail || null });
});
window.addEventListener("tree.asset.open", (event) => {
window.__task524IdentityEvents.push({ type: "tree.asset.open", detail: event.detail || null });
});
});
const pageRow = await filetreeRowState(page, "Page.md");
const pageEntry = await page.evaluate(() => {
const snapshot = window.__mnoteDocumentPaneRuntime?.getOpenEditorsSnapshot?.() || window.__mnoteOpenEditorsSnapshot || null;
return snapshot?.groups?.primary?.editors?.find((entry) => entry.kind === "page" && entry.documentId === "local-md:Page.md") || null;
});
assertWorkspacePathMatchesRow("page", pageRow, pageEntry, {
relativePath: "Page.md",
resourceKind: "page",
objectKind: "page",
});
result.checks.push({ kind: "page", row: pageRow, snapshot: pageEntry });
const folderRow = await filetreeRowState(page, "docs");
assert(folderRow, "folder: 缺少 docs row");
assert.equal(folderRow.workspacePath?.schema, "mnote.workspace_path.v1", "folder row workspacePath schema");
assert.equal(folderRow.workspacePath?.relativePath, "docs", "folder row relativePath");
assert.equal(folderRow.workspacePath?.documentId, "local-dir:docs", "folder row documentId");
assert.equal(folderRow.workspacePath?.objectIdentity?.objectKind, "index", "folder row objectKind");
const beforeFolderUrl = page.url();
await clickFiletreeOpen(page, "docs");
await page.waitForFunction(() => {
const row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="docs"]');
return row && row.getAttribute("aria-expanded") === "true";
}, null, { timeout: UI_TIMEOUT_MS });
assert.equal(page.url(), beforeFolderUrl, "FileTree folder open 只应展开,不应改 URL");
result.checks.push({ kind: "folder", row: folderRow });
await expandFiletreeFolder(page, "Page");
const resources = [
{ label: "text", relativePath: "Page/notes.txt", kind: "text", resourceKind: "attachment", objectKind: "attachment" },
{ label: "mindmap", relativePath: "Page/map.mindmap.json", kind: "mindmap", resourceKind: "mindmap", objectKind: "mindmap" },
{ label: "office", relativePath: "Page/office.docx", kind: "office", resourceKind: "only_office", objectKind: "only_office" },
];
for (const resource of resources) {
await waitFiletreeRow(page, resource.relativePath);
const rowState = await filetreeRowState(page, resource.relativePath);
assert(rowState, `${resource.label}: 缺少 FileTree row`);
await clickFiletreeOpen(page, resource.relativePath);
const entryHandle = await activeSnapshotEntry(page, resource.relativePath, resource.kind);
const entry = await entryHandle.jsonValue();
assertWorkspacePathMatchesRow(resource.label, rowState, entry, {
relativePath: resource.relativePath,
assetId: rowState.workspacePath.assetId,
resourceKind: resource.resourceKind,
objectKind: resource.objectKind,
});
assert.equal(entry.kind, resource.kind, `${resource.label}: editor kind`);
const eventDetail = await page.evaluate((relativePath) => {
const events = Array.isArray(window.__task524IdentityEvents) ? window.__task524IdentityEvents : [];
return [...events].reverse().find((event) => event?.detail?.workspacePath?.relativePath === relativePath) || null;
}, resource.relativePath);
assert(eventDetail, `${resource.label}: 应捕获真实 tree.asset.open 事件`);
assert.equal(eventDetail.detail.workspacePath?.sourceKind, rowState.workspacePath.sourceKind, `${resource.label}: event sourceKind`);
assert.equal(eventDetail.detail.workspacePath?.rootUri, rowState.workspacePath.rootUri, `${resource.label}: event rootUri`);
assert.equal(eventDetail.detail.workspacePath?.documentId, rowState.workspacePath.documentId, `${resource.label}: event documentId`);
assert.equal(eventDetail.detail.workspacePath?.assetId, rowState.workspacePath.assetId, `${resource.label}: event assetId`);
assert.deepStrictEqual(eventDetail.detail.workspacePath?.objectIdentity, rowState.workspacePath.objectIdentity, `${resource.label}: event objectIdentity`);
const url = new URL(page.url());
const resourceTab = url.searchParams.get("resourceTab") || "";
assert(
resourceTab === `primary::${entry.objectIdentity}`,
`${resource.label}: URL resourceTab 应等于 active snapshot objectIdentity: ${JSON.stringify({ resourceTab, entry })}`,
);
const activeTabIdentity = await page.evaluate(() => {
const activeTab = document.querySelector(".mnote-main-tab.is-active");
return activeTab?.getAttribute("data-mnote-object-identity") || "";
});
if (activeTabIdentity) {
assert.equal(activeTabIdentity, entry.objectIdentity, `${resource.label}: active tab identity`);
}
result.checks.push({ kind: resource.label, row: rowState, snapshot: entry, resourceTab, activeTabIdentity });
}
result.ok = true;
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
result.error = error && error.stack ? error.stack : String(error);
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
throw error;
} 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);
});