#!/usr/bin/env node "use strict"; const assert = require("node:assert/strict"); const fs = require("node:fs"); const http = require("node:http"); const net = require("node:net"); const os = require("node:os"); const path = require("node:path"); const { spawn } = require("node:child_process"); const { chromium } = require("playwright"); const TASK = "task494-filetree-lazy-loading-dedup-smoke"; const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); function resolveChromiumExecutablePath() { const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ""; if (explicit && fs.existsSync(explicit)) return explicit; return [ "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium", "/usr/bin/chromium-browser", ].find((candidate) => fs.existsSync(candidate)) || ""; } function pickPort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = address && typeof address === "object" ? address.port : 0; server.close(() => resolve(port)); }); server.on("error", reject); }); } function waitForHttpOk(url, timeoutMs) { const deadline = Date.now() + timeoutMs; return new Promise((resolve, reject) => { const tick = () => { const request = http.get(url, (response) => { response.resume(); if (response.statusCode >= 200 && response.statusCode < 500) { resolve(); return; } retry(); }); request.on("error", retry); request.setTimeout(1000, () => { request.destroy(); retry(); }); }; const retry = () => { if (Date.now() > deadline) { reject(new Error(`server_not_ready: ${url}`)); return; } setTimeout(tick, 250); }; tick(); }); } function fileUrlToPath(value) { const url = new URL(value); return decodeURIComponent(url.pathname); } function localMarkdownDocumentPath(documentId) { const encoded = String(documentId || "").replace(/^local-md:/, ""); return decodeURIComponent(encoded.replace(/~/g, "%")); } async function main() { const port = await pickPort(); const baseUrl = `http://127.0.0.1:${port}`; const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`)); const dbPath = path.join(dataRoot, "control-plane.sqlite3"); const actorId = `${TASK}-${process.pid}-${Date.now()}`; const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], { cwd: path.join(__dirname, "..", "rust"), env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", MNOTE_CONTROL_PLANE_DB_PATH: dbPath, MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9", NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9", }, stdio: ["ignore", "pipe", "pipe"], }); let stderr = ""; server.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); const executablePath = resolveChromiumExecutablePath(); const browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}), }); const context = await browser.newContext(); const page = await context.newPage(); const browserDiagnostics = []; let targetChildrenRequests = 0; let staleScopeChildrenRequests = 0; let scopedRootProjectionRequests = 0; let workspaceRootProjectionRequests = 0; let forceChangingWatchRevision = false; let localWatchRevision = 0; page.on("console", (message) => { browserDiagnostics.push(`console:${message.type()}:${message.text()}`); }); page.on("pageerror", (error) => { browserDiagnostics.push(`pageerror:${error.message}`); }); await page.route("**/api/tree/projections/file**", async (route) => { const url = new URL(route.request().url()); const parentRelativePath = url.searchParams.get("parentRelativePath") || ""; if (url.pathname.endsWith("/api/tree/projections/file") && parentRelativePath === "design") { scopedRootProjectionRequests += 1; } if (url.pathname.endsWith("/api/tree/projections/file") && !parentRelativePath) { workspaceRootProjectionRequests += 1; } if (url.pathname.endsWith("/api/tree/projections/file/children") && parentRelativePath === "design/03-rust-web") { targetChildrenRequests += 1; await new Promise((resolve) => setTimeout(resolve, 250)); } if (url.pathname.endsWith("/api/tree/projections/file/children") && parentRelativePath === "design/slow-scope") { staleScopeChildrenRequests += 1; await new Promise((resolve) => setTimeout(resolve, 600)); } await route.continue(); }); await page.route("**/api/tree/local-folder-watch**", async (route) => { if (!forceChangingWatchRevision) { await route.continue(); return; } localWatchRevision += 1; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true, result: { revision: `forced-watch-${localWatchRevision}`, }, }), }); }); try { await waitForHttpOk(`${baseUrl}/health`, 60_000); const authResponse = await context.request.fetch(`${baseUrl}/api/auth`, { method: "POST", data: { action: "auth:signIn", args: { provider: "password", params: { email: `${actorId}@example.com`, username: actorId, name: actorId, password: TEST_PASSWORD, flow: "signUp", }, }, }, }); assert(authResponse.ok(), `测试账号注册失败: ${authResponse.status()} ${await authResponse.text()}`); await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first(); if (await createButton.isVisible({ timeout: 3000 }).catch(() => false)) { await createButton.click({ timeout: UI_TIMEOUT_MS }); await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", { timeout: UI_TIMEOUT_MS, }); } const initialUrl = new URL(page.url()); const workspaceId = initialUrl.searchParams.get("workspaceId") || ""; const rootUri = initialUrl.searchParams.get("rootUri") || await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || ""); assert(rootUri, "应进入 local_folder workspace"); const rootPath = fileUrlToPath(rootUri); fs.mkdirSync(path.join(rootPath, "design", "03-rust-web", "done"), { recursive: true }); fs.mkdirSync(path.join(rootPath, "design", "03-rust-web", "process"), { recursive: true }); fs.mkdirSync(path.join(rootPath, "design", "03-rust-web", "reference"), { recursive: true }); fs.mkdirSync(path.join(rootPath, "design", "reveal-parent", "child"), { recursive: true }); fs.mkdirSync(path.join(rootPath, "design", "slow-scope", "child"), { recursive: true }); fs.mkdirSync(path.join(rootPath, "docs", "target"), { recursive: true }); fs.writeFileSync(path.join(rootPath, "Home.md"), "# Home\n", "utf8"); fs.writeFileSync(path.join(rootPath, "design", "Overview.md"), "Plain paragraph without office attachment.\n", "utf8"); fs.writeFileSync(path.join(rootPath, "design", "03-rust-web", "plan.md"), "# Plan\n", "utf8"); fs.writeFileSync(path.join(rootPath, "design", "reveal-parent", "child", "note.md"), "# Reveal\n", "utf8"); fs.writeFileSync(path.join(rootPath, "design", "slow-scope", "child", "note.md"), "# Slow child\n", "utf8"); fs.writeFileSync(path.join(rootPath, "docs", "target", "note.md"), "# Docs child\n", "utf8"); fs.writeFileSync(path.join(rootPath, "docs", "opened-rename.md"), "# Opened rename\n", "utf8"); const fileTreeUrl = new URL(baseUrl); if (workspaceId) fileTreeUrl.searchParams.set("workspaceId", workspaceId); fileTreeUrl.searchParams.set("sourceKind", "local_folder"); fileTreeUrl.searchParams.set("rootUri", rootUri); fileTreeUrl.searchParams.set("treeView", "filetree"); await page.goto(fileTreeUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS }); await page.getByRole("button", { name: "新建页面" }).first().click({ timeout: UI_TIMEOUT_MS }); await page.waitForURL((url) => url.pathname.startsWith("/documents/"), { timeout: UI_TIMEOUT_MS }); await page.waitForTimeout(300); const firstCreatedDocumentId = decodeURIComponent(new URL(page.url()).pathname.split("/").filter(Boolean).pop() || ""); const firstCreatedRelativePath = localMarkdownDocumentPath(firstCreatedDocumentId); const firstCreatedParentPath = firstCreatedRelativePath.split("/").slice(0, -1).join("/"); await page.getByRole("button", { name: "新建页面" }).first().click({ timeout: UI_TIMEOUT_MS }); await page.waitForURL((url) => { const documentId = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() || ""); return url.pathname.startsWith("/documents/") && documentId !== firstCreatedDocumentId; }, { timeout: UI_TIMEOUT_MS }); await page.waitForTimeout(500); const secondCreatedDocumentId = decodeURIComponent(new URL(page.url()).pathname.split("/").filter(Boolean).pop() || ""); const secondCreatedRelativePath = localMarkdownDocumentPath(secondCreatedDocumentId); const secondCreatedParentPath = secondCreatedRelativePath.split("/").slice(0, -1).join("/"); const createExpansionState = await page.evaluate(({ firstParent, secondParent }) => { const readRow = (relativePath) => { const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(relativePath)}"]`); return row ? { relativePath, expanded: row.getAttribute("aria-expanded"), selected: row.getAttribute("data-selected"), active: row.getAttribute("data-active"), } : null; }; return { first: readRow(firstParent), second: readRow(secondParent), firstMarkdownVisible: Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(firstParent + "/" + firstParent.split("/").pop() + ".md")}"]`)), secondMarkdownVisible: Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(secondParent + "/" + secondParent.split("/").pop() + ".md")}"]`)), }; }, { firstParent: firstCreatedParentPath, secondParent: secondCreatedParentPath }); assert.equal( createExpansionState.first?.expanded, "false", `连续新建页面不应把上一个页面包目录自动展开: ${JSON.stringify(createExpansionState)}`, ); assert.equal( createExpansionState.second?.expanded, "false", `连续新建页面不应为了选中内部 md 而展开当前页面包目录: ${JSON.stringify(createExpansionState)}`, ); assert.equal(createExpansionState.firstMarkdownVisible, false, `上一个页面包内部 md 不应闪现/常驻: ${JSON.stringify(createExpansionState)}`); assert.equal(createExpansionState.secondMarkdownVisible, false, `当前页面包内部 md 不应闪现/常驻: ${JSON.stringify(createExpansionState)}`); assert.equal(createExpansionState.second?.active, "true", `当前页面包目录应承接 active 状态: ${JSON.stringify(createExpansionState)}`); const scopedUrl = new URL(baseUrl); if (workspaceId) scopedUrl.searchParams.set("workspaceId", workspaceId); scopedUrl.searchParams.set("sourceKind", "local_folder"); scopedUrl.searchParams.set("rootUri", rootUri); scopedUrl.searchParams.set("treeView", "filetree"); scopedUrl.searchParams.set("fileTreeScope", "design"); await page.goto(scopedUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); const scopedRootRequestsAfterInitialLoad = scopedRootProjectionRequests; const workspaceRootRequestsAfterInitialLoad = workspaceRootProjectionRequests; await page.evaluate(() => { window.__mnoteScopedFileOpenNoReloadMarker = "kept"; document.documentElement.setAttribute("data-mnote-scoped-file-open-no-reload-marker", "kept"); }); await page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/Overview.md"] .tree-link').first().click({ timeout: UI_TIMEOUT_MS, }); await page.waitForURL((nextUrl) => nextUrl.pathname.startsWith("/documents/"), { timeout: UI_TIMEOUT_MS }); await page.locator('.document-pane[data-pane-role="primary"] .editor-surface').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); await page.evaluate(() => { if (typeof window.__mnoteEnhanceEditorAttachmentLinks === "function") { window.__mnoteEnhanceEditorAttachmentLinks(); } }); await page.waitForTimeout(800); const scopedOpenMarker = await page.evaluate(() => window.__mnoteScopedFileOpenNoReloadMarker || ""); assert.equal(scopedOpenMarker, "kept", "scoped 文件树打开 md 应走 pane 内导航,不应整页 reload 重建侧栏"); assert.equal( scopedRootProjectionRequests, scopedRootRequestsAfterInitialLoad, "scoped 文件树打开已可见 md 不应重新请求 scope 根 projection", ); assert.equal( workspaceRootProjectionRequests, workspaceRootRequestsAfterInitialLoad, "普通 Markdown 打开不应为了 legacy office 附件兼容重拉 workspace root projection", ); const scopedRootRequestsBeforeRootSnapshot = scopedRootProjectionRequests; await page.evaluate(() => { window.dispatchEvent(new CustomEvent("tree:snapshot", { detail: { payload: { dataset: { kernel_file_tree_projection: { parentRelativePath: "", items: [ { rowId: "local:folder:root-probe", nodeId: "local:folder:root-probe", title: "root-probe", rowKind: "folder", resourceMeta: { workspacePath: { relativePath: "root-probe" }, }, }, ], }, }, }, }, })); }); await page.waitForTimeout(350); assert.equal( scopedRootProjectionRequests, scopedRootRequestsBeforeRootSnapshot, "scoped 文件树收到非 scope root snapshot 不应兜底重拉 scope 根 projection", ); const scopedRootRequestsBeforeCoarseWatch = scopedRootProjectionRequests; forceChangingWatchRevision = true; await page.waitForTimeout(1600); forceChangingWatchRevision = false; assert.equal( scopedRootProjectionRequests, scopedRootRequestsBeforeCoarseWatch, "scoped 文件树收到 coarse local-folder-watch revision 不应重拉 scope 根 projection", ); const rowSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/03-rust-web"]'; const childSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path^="design/03-rust-web/"]'; await page.locator(rowSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); assert.equal(targetChildrenRequests, 0, "scoped 首屏不应预加载 design/03-rust-web children"); await page.locator(rowSelector).evaluate((row) => { const toggle = row.querySelector('[data-rust-action="toggle"]'); for (let index = 0; index < 5; index += 1) toggle.click(); }); await page.waitForFunction((selector) => { const row = document.querySelector(selector); return row && row.getAttribute("aria-expanded") === "true" && row.getAttribute("data-filetree-children-loaded") === "true"; }, rowSelector, { timeout: UI_TIMEOUT_MS }); await page.locator(`${childSelector}[data-local-relative-path="design/03-rust-web/done"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); assert.equal(targetChildrenRequests, 1, "同一路径快速重复展开最多只能产生一个 children 请求"); const firstExpandRequests = targetChildrenRequests; await page.locator(`${rowSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS }); await page.waitForFunction((selector) => { const row = document.querySelector(selector); return row && row.getAttribute("aria-expanded") === "false"; }, rowSelector, { timeout: UI_TIMEOUT_MS }); const expandStartedAt = Date.now(); await page.locator(`${rowSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS }); await page.waitForFunction((selector) => { const row = document.querySelector(selector); const child = document.querySelector('[data-local-relative-path="design/03-rust-web/process"]'); return row && child && row.getAttribute("aria-expanded") === "true"; }, rowSelector, { timeout: UI_TIMEOUT_MS }); const cachedExpandMs = Date.now() - expandStartedAt; assert.equal(targetChildrenRequests, firstExpandRequests, "收起后再次展开应命中 cache,不应再次请求 children"); await page.locator(`${childSelector}[data-local-relative-path="design/03-rust-web/done"]`).evaluate((row) => { row.setAttribute("data-selected", "true"); row.setAttribute("data-focused", "true"); row.setAttribute("data-active", "true"); }); await page.evaluate(() => { window.dispatchEvent(new CustomEvent("tree:local-command", { detail: { body: { action: "rename", documentId: "local-md:design~2F03-rust-web~2Fplan.md", title: "plan", parentRelativePath: "design/03-rust-web", }, result: { parentRelativePath: "design/03-rust-web", }, }, })); }); await page.waitForFunction((selector) => { const row = document.querySelector(selector); const done = document.querySelector('[data-local-relative-path="design/03-rust-web/done"]'); const process = document.querySelector('[data-local-relative-path="design/03-rust-web/process"]'); return row && done && process && row.getAttribute("aria-expanded") === "true"; }, rowSelector, { timeout: UI_TIMEOUT_MS }); assert.equal(targetChildrenRequests, firstExpandRequests, "局部 parent refresh 不应绕过 cache 触发 children 重复请求"); const selectedAfterRefresh = await page.locator(`${childSelector}[data-local-relative-path="design/03-rust-web/done"]`).evaluate((row) => ({ selected: row.getAttribute("data-selected"), focused: row.getAttribute("data-focused"), active: row.getAttribute("data-active"), })); assert.deepEqual(selectedAfterRefresh, { selected: "true", focused: "true", active: "true", }, "局部 parent refresh 后应按 logical state 复投影 selection/focus/active"); fs.writeFileSync(path.join(rootPath, "design", "03-rust-web", "watch-created.md"), "# Watch\n", "utf8"); await page.evaluate(() => { window.dispatchEvent(new CustomEvent("tree:local-folder-watch-batch", { detail: { payload: { schema: "mnote.local_folder_watch_batch.v1", revision: "smoke-watch-1", affectedParents: [{ relativePath: "design/03-rust-web", reason: "child-watch" }], changedPaths: [{ relativePath: "design/03-rust-web/watch-created.md", kind: "Create(File)" }], eventKinds: ["Create(File)"], fallbackResync: false, }, }, })); }); await page.waitForFunction((selector) => { const row = document.querySelector(selector); const created = document.querySelector('[data-local-relative-path="design/03-rust-web/watch-created.md"]'); return row && created && row.getAttribute("aria-expanded") === "true"; }, rowSelector, { timeout: UI_TIMEOUT_MS }); assert.equal( await page.locator(rowSelector).getAttribute("aria-expanded"), "true", "watch batch 局部刷新后已展开 parent 不应折叠", ); await page.evaluate(() => { window.dispatchEvent(new CustomEvent("mnote:primary-document-activated", { detail: { documentId: "local-md:design~2Freveal-parent~2Fchild~2Fnote.md" }, })); }); const revealTargetSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/reveal-parent/child/note.md"]'; await page.waitForFunction((selector) => { const row = document.querySelector(selector); return row && row.getAttribute("data-selected") === "true" && row.getAttribute("data-focused") === "true" && row.getAttribute("data-active") === "true"; }, revealTargetSelector, { timeout: UI_TIMEOUT_MS }); const slowScopeSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/slow-scope"]'; await page.locator(slowScopeSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator(`${slowScopeSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS }); const docsUrl = new URL(scopedUrl.toString()); docsUrl.searchParams.set("fileTreeScope", "docs"); await page.goto(docsUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="docs"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); await page.waitForTimeout(800); assert.equal(staleScopeChildrenRequests, 1, "慢目录切 scope 前应触发一次 children 请求"); const staleRowsInDocsScope = await page.locator('#sidebar-file-tree-root .tree-row[data-local-relative-path^="design/slow-scope/"]').count(); assert.equal(staleRowsInDocsScope, 0, "慢请求返回后不得把旧 design scope children patch 到 docs scope"); const rootText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: UI_TIMEOUT_MS }); assert.match(rootText, /target/); const openedDocumentId = "local-md:docs~2Fopened-rename.md"; const renamedDocumentId = "local-md:docs~2Fopened-renamed.md"; const documentUrl = new URL(`${baseUrl}/documents/${encodeURIComponent(openedDocumentId)}`); if (workspaceId) documentUrl.searchParams.set("workspaceId", workspaceId); documentUrl.searchParams.set("sourceKind", "local_folder"); documentUrl.searchParams.set("rootUri", rootUri); await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.locator('.document-pane[data-pane-role="primary"] .editor-surface').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); const renameResponse = await context.request.fetch(`${baseUrl}/api/tree/commands`, { method: "POST", data: { action: "rename", workspaceId, sourceKind: "local_folder", rootUri, documentId: openedDocumentId, title: "opened-renamed", }, }); assert(renameResponse.ok(), `opened markdown rename command failed: ${renameResponse.status()} ${await renameResponse.text()}`); const bufferStateUrl = new URL(`${baseUrl}/api/documents/buffer-state`); bufferStateUrl.searchParams.set("documentId", renamedDocumentId); if (workspaceId) bufferStateUrl.searchParams.set("workspaceId", workspaceId); bufferStateUrl.searchParams.set("sourceKind", "local_folder"); bufferStateUrl.searchParams.set("rootUri", rootUri); bufferStateUrl.searchParams.set("relativePath", "docs/opened-renamed.md"); const bufferStateResponse = await context.request.fetch(bufferStateUrl.toString()); assert(bufferStateResponse.ok(), `opened markdown rename should rekey buffer: ${bufferStateResponse.status()} ${await bufferStateResponse.text()}`); const bufferStatePayload = await bufferStateResponse.json(); assert.equal(bufferStatePayload.result.documentId, renamedDocumentId, "opened markdown rename 后 buffer key 应更新到新 documentId"); console.log(JSON.stringify({ ok: true, task: TASK, baseUrl, targetChildrenRequests, staleScopeChildrenRequests, cachedExpandMs, }, null, 2)); } finally { await browser.close(); server.kill("SIGINT"); fs.rmSync(dataRoot, { recursive: true, force: true }); if (server.exitCode == null) { await new Promise((resolve) => server.once("exit", resolve)); } if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) { process.stderr.write(stderr); } } } main().catch((error) => { console.error(error); process.exit(1); });