#!/usr/bin/env node "use strict"; /** * Regression: page-tree expand/collapse icons + persistence + no duplicate rows. * Builds a liaibo-like nested local_folder tree, exercises expand/collapse/reload. */ 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 { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env"); const TASK = "task-page-tree-expand-persist-regression"; const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 60_000); const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); const RESULT_DIR = path.join(__dirname, "..", "tmp", TASK); const BINARY = process.env.MNOTE_WEB_BIN || path.join(__dirname, "..", "rust", "target", "debug", "mnote-web"); 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 = () => { if (Date.now() > deadline) { reject(new Error(`server_not_ready: ${url}`)); return; } const request = http.get(url, (response) => { response.resume(); if (response.statusCode >= 200 && response.statusCode < 500) { resolve(); return; } setTimeout(tick, 250); }); request.on("error", () => setTimeout(tick, 250)); }; tick(); }); } function writeTree(root) { // Mirror user screenshot shape: root page-group + several sibling folders. const space = path.join(root, "liaibo的个人空间"); const mk = (p, content = null) => { if (content == null) { fs.mkdirSync(p, { recursive: true }); return; } fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, content, "utf8"); }; mk(space); mk(path.join(space, "liaibo的个人空间.md"), "# liaibo的个人空间\n"); for (const name of ["知识", "项目", "工作资料", "日常", "剪藏", "个人"]) { mk(path.join(space, name)); mk(path.join(space, name, `${name}.md`), `# ${name}\n`); } // Nested under 个人 (matches screenshot after expand) for (const name of ["正版软件备份", "个人发展", "杂记", "密码"]) { mk(path.join(space, "个人", name)); mk(path.join(space, "个人", name, `${name}.md`), `# ${name}\n`); } // Nested under 知识 for (const name of ["有机化学", "文章"]) { mk(path.join(space, "知识", name)); mk(path.join(space, "知识", name, `${name}.md`), `# ${name}\n`); } return space; } async function signUp(context, baseUrl, actorId) { const response = 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.ok(response.ok(), `signUp failed: ${response.status()} ${await response.text()}`); } async function openDefaultLocalWorkspace(page, baseUrl) { 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: 5000 }).catch(() => false)) { await createButton.click({ timeout: UI_TIMEOUT_MS }); await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", { timeout: UI_TIMEOUT_MS, }); } const rootUri = new URL(page.url()).searchParams.get("rootUri") || (await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || "")); assert.ok(rootUri, "should open local_folder workspace"); return { rootUri, workspaceId: new URL(page.url()).searchParams.get("workspaceId") || "", }; } function fileUrlToPath(rootUri) { assert.ok(rootUri.startsWith("file://"), `unexpected rootUri ${rootUri}`); return decodeURIComponent(rootUri.slice("file://".length)); } async function reloadWorkspace(page, baseUrl, rootUri, workspaceId) { const url = new URL(baseUrl); if (workspaceId) url.searchParams.set("workspaceId", workspaceId); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", rootUri); await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.waitForSelector("#sidebar-tree-root .tree-row[data-shell-mode='page']", { timeout: UI_TIMEOUT_MS, }); await page.waitForTimeout(800); } function reportTree(page) { return page.evaluate(() => { function computeChevron(row) { const toggle = row.querySelector("[data-rust-action='toggle']"); if (!toggle) return null; const before = window.getComputedStyle(toggle, "::before"); const transform = before.transform || ""; // matrix(0,1,-1,0,0,0) ~= rotate(90deg) const rotated = transform.includes("matrix(0, 1, -1, 0") || transform.includes("matrix(0,1,-1,0") || (transform.includes("rotate") && !transform.includes("rotate(0")); return { transform, rotated }; } const rows = Array.from( document.querySelectorAll("#sidebar-tree-root .tree-row[data-shell-mode='page']") ); const byId = new Map(); const details = rows.map((row) => { const nodeId = row.getAttribute("data-node-id") || ""; byId.set(nodeId, (byId.get(nodeId) || 0) + 1); const node = row.closest(".tree-node"); const children = node && node.querySelector(":scope > .tree-children"); const toggle = row.querySelector("[data-rust-action='toggle']"); const title = String((row.querySelector(".tree-link-title") || {}).textContent || "").trim(); const chevron = computeChevron(row); const collapsed = children ? children.classList.contains("tree-children--collapsed") : null; const childCount = children ? children.children.length : 0; const visuallyOpen = collapsed === false && childCount > 0; const aria = row.getAttribute("aria-expanded"); const dataExpanded = row.getAttribute("data-expanded"); const iconSaysOpen = chevron ? chevron.rotated : null; return { nodeId, title, parentId: row.getAttribute("data-parent-id") || "", depth: Number(row.getAttribute("data-depth") || 0), aria, dataExpanded, toggleAria: toggle ? toggle.getAttribute("aria-expanded") : null, toggleData: toggle ? toggle.getAttribute("data-expanded") : null, collapsed, childCount, visuallyOpen, iconSaysOpen, desync: toggle != null && ((iconSaysOpen === true && !visuallyOpen) || (iconSaysOpen === false && visuallyOpen) || aria !== dataExpanded || (aria === "true") !== visuallyOpen), }; }); const dups = [...byId.entries()].filter(([, n]) => n > 1).map(([id, n]) => ({ id, n })); const titles = details.map((d) => d.title); const titleCounts = titles.reduce((m, t) => { m[t] = (m[t] || 0) + 1; return m; }, {}); // top-level (depth 0 or parent empty) duplicates by title are the screenshot bug const topTitles = details.filter((d) => !d.parentId || d.depth === 0).map((d) => d.title); const topTitleCounts = topTitles.reduce((m, t) => { m[t] = (m[t] || 0) + 1; return m; }, {}); const ls = {}; for (const k of Object.keys(localStorage)) { if (!k.includes("sidebarTreeViewState")) continue; try { ls[k.slice(-60)] = JSON.parse(localStorage.getItem(k) || "null"); } catch { ls[k.slice(-60)] = localStorage.getItem(k); } } return { href: location.href, rowCount: rows.length, dups, topTitleCounts, desync: details.filter((d) => d.desync), openRows: details.filter((d) => d.visuallyOpen).map((d) => d.title), sample: details, markers: { applied: document.documentElement.getAttribute("data-mnote-page-tree-view-state-applied"), synced: document.documentElement.getAttribute("data-mnote-page-tree-expand-synced"), restored: document.documentElement.getAttribute("data-mnote-page-tree-expansion-restored"), saved: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-saved"), loadErr: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-load-error"), saveErr: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-save-error"), }, ls, }; }); } async function clickToggleByTitle(page, title) { return page.evaluate((want) => { const rows = Array.from( document.querySelectorAll("#sidebar-tree-root .tree-row[data-shell-mode='page']") ); for (const row of rows) { const t = String((row.querySelector(".tree-link-title") || {}).textContent || "").trim(); if (t !== want) continue; const btn = row.querySelector("[data-rust-action='toggle']"); if (!btn) return { ok: false, reason: "no_toggle" }; btn.click(); return { ok: true, nodeId: row.getAttribute("data-node-id") }; } return { ok: false, reason: "not_found" }; }, title); } async function waitDesyncClear(page, timeoutMs = 3000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { const r = await reportTree(page); if (r.desync.length === 0) return r; await page.waitForTimeout(100); } return reportTree(page); } async function main() { fs.mkdirSync(RESULT_DIR, { recursive: true }); if (!fs.existsSync(BINARY)) { throw new Error(`binary missing: ${BINARY} (build mnote-web first)`); } const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-expand-reg-")); const port = await pickPort(); const baseUrl = `http://127.0.0.1:${port}`; const cpEnv = buildControlPlaneTestEnv(dataRoot); const env = { ...process.env, ...cpEnv, MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, RUST_LOG: process.env.RUST_LOG || "warn", }; const child = spawn(BINARY, [], { cwd: path.join(__dirname, "..", "rust"), env, stdio: ["ignore", "pipe", "pipe"], }); let serverLog = ""; child.stdout.on("data", (d) => { serverLog += d.toString(); }); child.stderr.on("data", (d) => { serverLog += d.toString(); }); const browser = await chromium.launch({ headless: true, executablePath: resolveChromiumExecutablePath() || undefined, }); const results = { steps: [] }; try { await waitForHttpOk(`${baseUrl}/health`, 45_000); const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); const actorId = `expand-reg-${Date.now()}`; await signUp(context, baseUrl, actorId); const page = await context.newPage(); const puts = []; const gets = []; page.on("response", async (res) => { if (!res.url().includes("/api/tree/view-state")) return; const method = res.request().method(); let body = null; try { body = await res.json(); } catch {} const entry = { method, status: res.status(), expandedIds: body && body.result && body.result.state && body.result.state.expandedIds, }; if (method === "PUT") puts.push(entry); else gets.push(entry); }); const { rootUri, workspaceId } = await openDefaultLocalWorkspace(page, baseUrl); const rootPath = fileUrlToPath(rootUri); // Seed liaibo-like tree into the default local workspace. writeTree(rootPath); await reloadWorkspace(page, baseUrl, rootUri, workspaceId); await page.screenshot({ path: path.join(RESULT_DIR, "01-initial.png") }); let r = await reportTree(page); results.steps.push({ name: "initial", r }); assert.equal(r.dups.length, 0, `initial dups: ${JSON.stringify(r.dups)}`); assert.equal(r.desync.length, 0, `initial desync: ${JSON.stringify(r.desync)}`); // Default: expandable rows closed + ▶ (ignore non-toggle leaves) for (const d of r.sample.filter((x) => x.toggleAria != null)) { assert.equal(d.visuallyOpen, false, `initial open ${d.title}`); assert.equal(d.iconSaysOpen, false, `initial icon open ${d.title}`); assert.equal(d.aria, "false", `initial aria ${d.title}`); } // Expand root then 个人 let click = await clickToggleByTitle(page, "liaibo的个人空间"); assert.equal(click.ok, true, `expand root: ${JSON.stringify(click)}`); await page.waitForTimeout(700); click = await clickToggleByTitle(page, "个人"); assert.equal(click.ok, true, `expand 个人: ${JSON.stringify(click)}`); await page.waitForTimeout(900); await page.waitForTimeout(700); await page.screenshot({ path: path.join(RESULT_DIR, "02-expanded.png") }); r = await waitDesyncClear(page); results.steps.push({ name: "expanded", r, puts: puts.slice(-2) }); assert.equal(r.dups.length, 0, `expanded dups: ${JSON.stringify(r.dups)}`); assert.equal(r.desync.length, 0, `expanded desync: ${JSON.stringify(r.desync)}`); assert.ok(r.openRows.includes("liaibo的个人空间"), "root should be open"); assert.ok(r.openRows.includes("个人"), "个人 should be open"); const personalChildren = r.sample.filter((d) => d.title === "正版软件备份"); assert.equal(personalChildren.length, 1, `个人 children dup: ${JSON.stringify(personalChildren)}`); // Collapse 个人 only click = await clickToggleByTitle(page, "个人"); assert.equal(click.ok, true); await page.waitForTimeout(500); r = await waitDesyncClear(page); results.steps.push({ name: "collapsed-personal", r }); assert.equal(r.dups.length, 0, `collapse dups ${JSON.stringify(r.dups)}`); assert.equal(r.desync.length, 0, `collapse desync ${JSON.stringify(r.desync)}`); const personal = r.sample.find((d) => d.title === "个人"); assert.ok(personal, "个人 row missing"); assert.equal(personal.visuallyOpen, false, "个人 should be closed"); assert.equal(personal.iconSaysOpen, false, "个人 icon should be ▶"); assert.equal(personal.aria, "false"); const titleCountsUnderRoot = {}; for (const d of r.sample) { if (d.depth === 1) titleCountsUnderRoot[d.title] = (titleCountsUnderRoot[d.title] || 0) + 1; } for (const [title, count] of Object.entries(titleCountsUnderRoot)) { assert.equal(count, 1, `duplicate depth1 title ${title} count=${count}`); } // Re-expand 个人, then reload — should restore open state click = await clickToggleByTitle(page, "个人"); assert.equal(click.ok, true); await page.waitForTimeout(1000); r = await reportTree(page); const lsValues = Object.values(r.ls); const hasLocal = lsValues.some((v) => v && Array.isArray(v.expandedIds) && v.expandedIds.length > 0) || puts.some((p) => p.status === 200); assert.ok(hasLocal || puts.length > 0, `persist missing ls=${JSON.stringify(r.ls)} puts=${puts.length}`); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.waitForSelector("#sidebar-tree-root .tree-row[data-shell-mode='page']", { timeout: UI_TIMEOUT_MS, }); await page.waitForTimeout(2500); await page.screenshot({ path: path.join(RESULT_DIR, "03-after-reload.png") }); r = await waitDesyncClear(page, 5000); results.steps.push({ name: "after-reload-open", r, gets: gets.slice(-4), puts: puts.slice(-2) }); assert.equal(r.dups.length, 0, `reload dups: ${JSON.stringify(r.dups)}`); assert.equal(r.desync.length, 0, `reload desync: ${JSON.stringify(r.desync)}`); assert.ok( r.openRows.includes("liaibo的个人空间") && r.openRows.includes("个人"), `reload should restore expands, openRows=${JSON.stringify(r.openRows)} markers=${JSON.stringify(r.markers)} ls=${JSON.stringify(r.ls)}` ); // Nested children of 个人 must actually reappear after cascade hydrate. const afterReloadPersonalChild = r.sample.filter((d) => d.title === "正版软件备份"); assert.equal( afterReloadPersonalChild.length, 1, `reload missing nested child: ${JSON.stringify(afterReloadPersonalChild)} sample=${JSON.stringify( r.sample.map((d) => ({ t: d.title, d: d.depth, open: d.visuallyOpen, children: d.childCount })) )}` ); const personalAfterReload = r.sample.find((d) => d.title === "个人"); assert.ok(personalAfterReload && personalAfterReload.childCount >= 4, `个人 childCount after reload ${personalAfterReload?.childCount}`); assert.equal(personalAfterReload.iconSaysOpen, true, "个人 icon should be ▼ after restore"); const d1 = {}; for (const d of r.sample) { if (d.depth === 1) d1[d.title] = (d1[d.title] || 0) + 1; } for (const [title, count] of Object.entries(d1)) { assert.equal(count, 1, `reload duplicate depth1 ${title}=${count}`); } // Collapse all expanded, reload — icons must stay ▶ for (const title of ["个人", "liaibo的个人空间"]) { const row = r.sample.find((d) => d.title === title); if (row && row.visuallyOpen) { await clickToggleByTitle(page, title); await page.waitForTimeout(300); } } await page.waitForTimeout(900); r = await waitDesyncClear(page); results.steps.push({ name: "all-collapsed", r }); assert.equal(r.desync.length, 0, `all-collapsed desync ${JSON.stringify(r.desync)}`); for (const d of r.sample.filter((x) => x.toggleAria != null)) { assert.equal(d.visuallyOpen, false, `should be closed ${d.title}`); assert.equal(d.iconSaysOpen, false, `icon should be ▶ for ${d.title}`); } await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.waitForSelector("#sidebar-tree-root .tree-row[data-shell-mode='page']", { timeout: UI_TIMEOUT_MS, }); await page.waitForTimeout(2500); await page.screenshot({ path: path.join(RESULT_DIR, "04-reload-collapsed.png") }); r = await waitDesyncClear(page, 5000); results.steps.push({ name: "after-reload-collapsed", r }); assert.equal(r.dups.length, 0, `collapsed-reload dups ${JSON.stringify(r.dups)}`); assert.equal(r.desync.length, 0, `collapsed-reload desync ${JSON.stringify(r.desync)}`); assert.deepEqual(r.openRows, [], `collapsed reload openRows=${JSON.stringify(r.openRows)}`); for (const d of r.sample.filter((x) => x.toggleAria != null)) { assert.equal(d.iconSaysOpen, false, `collapsed reload icon open for ${d.title}`); assert.equal(d.aria, "false", `collapsed reload aria for ${d.title}`); assert.equal(d.dataExpanded, "false", `collapsed reload data-expanded for ${d.title}`); } results.ok = true; results.puts = puts; results.gets = gets.slice(-6); fs.writeFileSync(path.join(RESULT_DIR, "result.json"), JSON.stringify(results, null, 2)); console.log( JSON.stringify( { ok: true, resultDir: RESULT_DIR, openAfterExpand: results.steps.find((s) => s.name === "expanded")?.r?.openRows, afterReload: results.steps.find((s) => s.name === "after-reload-open")?.r?.openRows, afterCollapsedReload: results.steps.find((s) => s.name === "after-reload-collapsed")?.r ?.openRows, putCount: puts.length, }, null, 2 ) ); } catch (error) { results.ok = false; results.error = String((error && error.stack) || error); fs.writeFileSync(path.join(RESULT_DIR, "result.json"), JSON.stringify(results, null, 2)); fs.writeFileSync(path.join(RESULT_DIR, "server.log"), serverLog); console.error(results.error); process.exitCode = 1; } finally { await browser.close().catch(() => {}); child.kill("SIGTERM"); setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 1000).unref(); } } main().catch((error) => { console.error(error); process.exit(1); });