Files
mnote/scripts/task-page-tree-expand-persist-regression.js
T

515 lines
21 KiB
JavaScript

#!/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);
});