#!/usr/bin/env node "use strict"; const { loginViaAuthForm } = require('./lib/browser-auth-login'); 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) { // 7-76 P0: 标准表单登录(无测试快速登录按钮) const base = (typeof BASE_URL !== "undefined" && BASE_URL) || (typeof baseUrl !== "undefined" && baseUrl) || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; const timeout = (typeof UI_TIMEOUT_MS !== "undefined" && UI_TIMEOUT_MS) || (typeof TIMEOUT !== "undefined" && TIMEOUT) || 30_000; if (!String(page.url() || "").includes("/auth")) { await page.goto(String(base).replace(/\/+$/, "") + "/auth", { waitUntil: "commit", timeout, }); } await loginViaAuthForm(page, { baseUrl: base, timeoutMs: timeout, gotoAuth: false, }); await page .waitForURL((url) => !String(url).includes("/auth"), { timeout }) .catch(() => {}); } 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); });