#!/usr/bin/env node const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const vm = require("node:vm"); const REPO_ROOT = path.resolve(__dirname, ".."); const RUNTIME_PATH = path.join( REPO_ROOT, "rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js", ); const FILETREE_RUNTIME_PATH = path.join( REPO_ROOT, "rust/crates/mnote-web/browser/filetree-runtime.js", ); const FILETREE_CONTEXT_MENU_RUNTIME_PATH = path.join( REPO_ROOT, "rust/crates/mnote-web/browser/filetree-context-menu-runtime.js", ); function loadRuntimeFactory() { const source = fs .readFileSync(RUNTIME_PATH, "utf8") .replace( "export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {", "const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {", ); const context = { console, CustomEvent: class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } }, HTMLElement: class HTMLElement {}, navigator: { clipboard: { writeText: async (text) => { context.__clipboardText = text; }, }, }, document: { body: {}, documentElement: { attrs: {}, setAttribute(name, value) { this.attrs[name] = String(value); }, }, createElement() { return {}; }, querySelector() { return null; }, querySelectorAll() { return []; }, }, window: { alert() {}, confirm() { return true; }, dispatchEvent() {}, }, URL, setTimeout, clearTimeout, }; context.globalThis = context; vm.createContext(context); vm.runInContext(fs.readFileSync(FILETREE_CONTEXT_MENU_RUNTIME_PATH, "utf8"), context, { filename: FILETREE_CONTEXT_MENU_RUNTIME_PATH, }); vm.runInContext( `${source}\nglobalThis.__factory = createSidebarFileTreeCommandRuntime;`, context, { filename: RUNTIME_PATH }, ); return context; } function loadFileTreeRuntime() { const context = { console, HTMLElement: class HTMLElement {}, document: {}, window: {}, URL, }; context.globalThis = context; context.window = context; vm.createContext(context); vm.runInContext(fs.readFileSync(FILETREE_RUNTIME_PATH, "utf8"), context, { filename: FILETREE_RUNTIME_PATH, }); return context; } async function runCopyId(runtime, context, detail) { context.__clipboardText = ""; runtime.handleTreeContextMenuAction("copy-id", detail, null); await new Promise((resolve) => setImmediate(resolve)); return context.__clipboardText; } async function main() { const fileTreeContext = loadFileTreeRuntime(); class FakeRow extends fileTreeContext.HTMLElement { constructor(attrs, textContent = "") { super(); this.attrs = attrs; this.textContent = textContent; } getAttribute(name) { return this.attrs[name] || ""; } querySelector() { return null; } } const readWorkspacePath = (attrs, textContent = "") => fileTreeContext.window.__mnoteFileTreeRuntime.readWorkspacePathFromRow( new FakeRow(attrs, textContent), { currentSourceKind: () => attrs.__sourceKind || "local_folder", currentRootUri: () => attrs.__rootUri || "file:///mnt/Data1T/mnote", resolveWorkspaceId: () => "local-folder", rowTitle: (row) => row.textContent, }, ); const workspacePath = readWorkspacePath({ "data-row-id": "local:asset:assets/report.pdf", "data-row-kind": "asset", "data-document-id": "local-md:README.md", "data-asset-id": "local-file:assets/report.pdf", "data-local-relative-path": "assets/report.pdf", "data-object-identity": JSON.stringify({ objectKind: "attachment", documentId: "local-md:README.md", assetId: "local-file:assets/report.pdf", }), }, "report.pdf"); assert.equal(workspacePath.schema, "mnote.workspace_path.v1"); assert.equal(workspacePath.workspaceId, "local-folder"); assert.equal(workspacePath.sourceKind, "local_folder"); assert.equal(workspacePath.rootUri, "file:///mnt/Data1T/mnote"); assert.equal(workspacePath.relativePath, "assets/report.pdf"); assert.equal(workspacePath.documentId, "local-md:README.md"); assert.equal(workspacePath.assetId, "local-file:assets/report.pdf"); assert.equal(workspacePath.objectKind, "attachment"); assert.equal(workspacePath.resourceKind, "attachment"); assert.equal(workspacePath.objectIdentity.assetId, "local-file:assets/report.pdf"); const markdownWorkspacePath = readWorkspacePath({ "data-row-id": "local:markdown:docs/Page.md", "data-row-kind": "markdown", "data-document-id": "local-md:docs~2FPage.md", "data-local-relative-path": "docs/Page.md", "data-object-identity": JSON.stringify({ objectKind: "page", documentId: "local-md:docs~2FPage.md", assetId: null, }), }, "Page.md"); assert.equal(markdownWorkspacePath.relativePath, "docs/Page.md"); assert.equal(markdownWorkspacePath.objectKind, "page"); assert.equal(markdownWorkspacePath.isLocalFolder, true); const folderWorkspacePath = readWorkspacePath({ "data-row-id": "local:folder:docs", "data-row-kind": "folder", "data-document-id": "local-dir:docs", "data-local-relative-path": "docs", "data-object-identity": JSON.stringify({ objectKind: "index", documentId: "local-dir:docs", assetId: null, }), }, "docs"); assert.equal(folderWorkspacePath.relativePath, "docs"); assert.equal(folderWorkspacePath.objectKind, "index"); const mindmapWorkspacePath = readWorkspacePath({ "data-row-id": "local:asset:Page/思维导图.json", "data-row-kind": "asset", "data-asset-id": "local-file:Page/思维导图.json", "data-local-relative-path": "Page/思维导图.json", "data-object-identity": JSON.stringify({ objectKind: "mindmap", documentId: "local-md:Page~2FPage.md", assetId: "local-file:Page/思维导图.json", }), }, "思维导图.json"); assert.equal(mindmapWorkspacePath.objectKind, "mindmap"); assert.equal(mindmapWorkspacePath.objectIdentity.assetId, "local-file:Page/思维导图.json"); const officeWorkspacePath = readWorkspacePath({ "data-row-id": "local:asset:Page/report.docx", "data-row-kind": "asset", "data-asset-id": "local-file:Page/report.docx", "data-local-relative-path": "Page/report.docx", "data-object-identity": JSON.stringify({ objectKind: "only_office", documentId: "local-md:Page~2FPage.md", assetId: "local-file:Page/report.docx", }), }, "report.docx"); assert.equal(officeWorkspacePath.objectKind, "only_office"); assert.equal(officeWorkspacePath.objectIdentity.assetId, "local-file:Page/report.docx"); const nonLocalWorkspacePath = readWorkspacePath({ __sourceKind: "cloud", __rootUri: "", "data-row-id": "doc:cloud-page", "data-row-kind": "document", "data-document-id": "local-cloud-compatible-id", "data-object-identity": JSON.stringify({ objectKind: "page", documentId: "local-cloud-compatible-id", assetId: null, }), }, "cloud"); assert.equal(nonLocalWorkspacePath.isLocalFolder, false, "sourceKind 非 local_folder 时不应仅因 local-* documentId 误判成本地文件夹"); const context = loadRuntimeFactory(); class FakeCommandBadge extends context.HTMLElement { constructor(kind) { super(); this.kind = kind; } getAttribute(name) { return name === "data-kind" ? this.kind : ""; } } class FakeCommandRow extends context.HTMLElement { constructor(attrs, textContent = "", badgeKind = "") { super(); this.attrs = attrs; this.textContent = textContent; this.badge = badgeKind ? new FakeCommandBadge(badgeKind) : null; } getAttribute(name) { return this.attrs[name] || ""; } querySelector(selector) { return selector === ".tree-kind-badge" ? this.badge : null; } } const runtime = context.__factory({ currentSourceKind: () => "local_folder", currentRootUri: () => "file:///mnt/Data1T/mnote", cssEscape: (value) => String(value).replace(/"/g, '\\"'), fileTreeRuntimeFunction: () => null, localFilePathFromAssetId: (assetId) => String(assetId || "").replace(/^local-file:/, ""), resolveWorkspaceId: () => "local-folder", runtimeState: { activeTreeContextMenu: null }, selectedSidebarFileTreeSelection: { selectedRowIds: new Set(), focusedRowId: null }, }); assert.equal( runtime.classifySidebarFileTreeAsset(new FakeCommandRow({}, "legacy.luckysheet", "luckysheet")), "table", "legacy luckysheet 行仍应归为 table 资源", ); const dirtyCommandRow = new FakeCommandRow({ "data-row-id": "local:markdown:docs/Page.md", "data-row-kind": "markdown", "data-document-id": "local-md:docs~2FPage.md", "data-local-relative-path": "docs/Page.md", }, "Page.md"); const commandContextRuntime = context.window.__mnoteFileTreeContextMenuRuntime; const dirtyContext = commandContextRuntime.buildFileTreeCommandContext( dirtyCommandRow, { schema: "mnote.open_editors_snapshot.v1", editors: [ { documentId: "local-md:docs~2FPage.md", dirtyState: "dirty", workspacePath: { relativePath: "docs/Page.md" }, }, ], }, null, { currentSourceKind: () => "local_folder", workspaceReadonly: () => false, selection: { selectedRowIds: new Set(["local:markdown:docs/Page.md"]) }, queryRowById: () => dirtyCommandRow, }, ); assert.equal(dirtyContext["workspace.sourceKind"], "local_folder"); assert.equal(dirtyContext["tree.selectionCount"], 1); assert.equal(dirtyContext["tree.selectionResourceKind"], "page"); assert.equal(dirtyContext["tree.targetResourceKind"], "page"); assert.equal(dirtyContext["editor.dirty"], true); assert.equal(dirtyContext["editor.dirtyState"], "dirty"); assert.equal(dirtyContext["ai.canWrite"], false); assert.equal( commandContextRuntime.evaluateSidebarFileTreeWhen(dirtyContext, "!workspace.readonly && !editor.dirty"), false, "dirty 目标下菜单与快捷键写操作应被统一 command context 禁用", ); const readonlyContext = commandContextRuntime.buildFileTreeCommandContext( dirtyCommandRow, { editors: [] }, null, { currentSourceKind: () => "local_folder", workspaceReadonly: () => true, selection: { selectedRowIds: new Set(["local:markdown:docs/Page.md"]) }, queryRowById: () => dirtyCommandRow, }, ); assert.equal(readonlyContext["workspace.readonly"], true); assert.equal( commandContextRuntime.evaluateSidebarFileTreeWhen(readonlyContext, "!workspace.readonly"), false, "readonly 目标下菜单与快捷键写操作应被统一 command context 禁用", ); const cleanTargetRow = new FakeCommandRow({ "data-row-id": "local:markdown:docs/Clean.md", "data-row-kind": "markdown", "data-document-id": "local-md:docs~2FClean.md", "data-local-relative-path": "docs/Clean.md", }, "Clean.md"); const selectedRows = new Map([ ["local:markdown:docs/Clean.md", cleanTargetRow], ["local:markdown:docs/Page.md", dirtyCommandRow], ]); const multiSelectDirtyContext = commandContextRuntime.buildFileTreeCommandContext( cleanTargetRow, { editors: [ { documentId: "local-md:docs~2FPage.md", dirtyState: "Dirty", workspacePath: { relativePath: "docs/Page.md" }, }, ], }, null, { currentSourceKind: () => "local_folder", workspaceReadonly: () => false, selection: { selectedRowIds: new Set(["local:markdown:docs/Clean.md", "local:markdown:docs/Page.md"]) }, queryRowById: (rowId) => selectedRows.get(rowId), }, ); assert.equal(multiSelectDirtyContext["tree.selectionCount"], 2); assert.equal(multiSelectDirtyContext["tree.selectionResourceKind"], "page"); assert.equal(multiSelectDirtyContext["editor.dirty"], true); assert.equal(multiSelectDirtyContext["editor.dirtyState"], "dirty"); assert.equal( commandContextRuntime.evaluateSidebarFileTreeWhen(multiSelectDirtyContext, "!workspace.readonly && !editor.dirty"), false, "多选中任一非 target 行 dirty 时,菜单与快捷键写操作也必须禁用", ); const dirtyAssetRow = new FakeCommandRow({ "data-row-id": "local:asset:assets/report.pdf", "data-row-kind": "asset", "data-asset-id": "local-file:assets/report.pdf", "data-local-relative-path": "assets/report.pdf", }, "report.pdf"); const dirtyAssetContext = commandContextRuntime.buildFileTreeCommandContext( dirtyAssetRow, { resourceEditors: [ { assetId: "local-file:assets/report.pdf", dirtyState: "ExternalModified", workspacePath: { relativePath: "assets/report.pdf", assetId: "local-file:assets/report.pdf" }, }, ], }, null, { currentSourceKind: () => "local_folder", workspaceReadonly: () => false, selection: { selectedRowIds: new Set(["local:asset:assets/report.pdf"]) }, queryRowById: () => dirtyAssetRow, }, ); assert.equal(dirtyAssetContext["tree.selectionResourceKind"], "asset"); assert.equal(dirtyAssetContext["tree.targetIsAsset"], true); assert.equal(dirtyAssetContext["editor.dirty"], true); assert.equal(dirtyAssetContext["editor.dirtyState"], "external_modified"); assert.equal( commandContextRuntime.evaluateSidebarFileTreeWhen(dirtyAssetContext, "!workspace.readonly && !editor.dirty"), false, "dirty resource tab 对应 asset 的 rename/delete 也必须禁用", ); assert.equal( await runCopyId(runtime, context, { contextKind: "filetree", rowKind: "folder", documentId: "local-dir:docs", rowId: "local:folder:docs", workspacePath: folderWorkspacePath, title: "docs", }), "/mnt/Data1T/mnote/docs", "复制本地文件夹页面 ID 应得到绝对路径", ); assert.equal( await runCopyId(runtime, context, { contextKind: "filetree", rowKind: "markdown", documentId: "local-md:docs~2FPage.md", rowId: "local:markdown:docs~2FPage.md", workspacePath: markdownWorkspacePath, title: "Page", }), "/mnt/Data1T/mnote/docs/Page.md", "复制本地 Markdown 页面 ID 应得到绝对路径", ); assert.equal( await runCopyId(runtime, context, { contextKind: "filetree", rowKind: "asset", assetId: "local-file:assets/report.pdf", rowId: "local:asset:assets/report.pdf", workspacePath, title: "report.pdf", }), "/mnt/Data1T/mnote/assets/report.pdf", "复制本地资源 ID 应得到绝对路径", ); console.log( JSON.stringify( { ok: true, copied: [ "/mnt/Data1T/mnote/docs", "/mnt/Data1T/mnote/docs/Page.md", "/mnt/Data1T/mnote/assets/report.pdf", ], }, null, 2, ), ); } main().catch((error) => { console.error(error); process.exit(1); });