feat: vault core/CLI/workbench, vaultd token path, filetree view-state cleanup

Land password-vault dedicated workbench and mnote-vault-core/CLI, agent token
read path design, vault transport split, and retire obsolete filetree smokes.
Ignore local vault reimport scripts that trip secret scanners.
This commit is contained in:
Agent Board
2026-07-24 11:36:06 +08:00
parent b798f628ee
commit bc6f8488ee
41 changed files with 13072 additions and 2316 deletions
+30
View File
@@ -58,6 +58,35 @@ paseo_browser_screenshot
| Paseo 终端无输出 | 前台进程阻塞 | 用 `nohup ... &` 后台启动 |
| `desktop:hot` vs `dev:hot` | 启动命令不同 | 浏览器测试用 `dev:hot` |
### 6. AI 密码本读密 / 登录态(12-2 · **不依赖 3000**
读密与 login/session 走本地 core + capability token**不要**为 resolve/login/session 启 mnote-web。
```bash
cd rust
cargo build -q -p mnote-vault
# 一次签发(写入 ~/.config/mnote/vault-tokens/default.token;默认 scope 含 login,session
./target/debug/mnote-vault issue-token --agent smoke --ttl 1d
./target/debug/mnote-vault doctor
# 可选常驻 UDS$XDG_RUNTIME_DIR/mnote-vaultd.sock 或 MNOTE_VAULT_SOCK
./target/debug/mnote-vault serve &
./target/debug/mnote-vault list # sock 优先,不可达则内嵌 core
./target/debug/mnote-vault resolve --id <cred_id> --field password
./target/debug/mnote-vault resolve --id <id> --field password --local # 强制内嵌
./target/debug/mnote-vault resolve --id <id> --field password --remote # 强制 UDS
# session 回写 + login 复用(无外站 HTTP
./target/debug/mnote-vault session --id <id> --cookie-header 'mnote_session=smoke; other=1' --local
./target/debug/mnote-vault login --id <id> --local # 期望 reused=true / mode=session
cargo test -p mnote-vault-core --lib
cargo test -p mnote-web --lib routes::vault::resolve_strategy_tests
cargo test -p mnote-web --lib routes::vault -- --nocapture 2>&1 | rg -n "login_reuses|ok"
```
- Crate`mnote-vault-core``mnote-vault`bin,含 `serve` + login/session)、`mnote-web` AI list/get/resolve/login/session thin-wrap core
- 设计:`design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md`
- Skill / 策略:`$mnote-vault``/home/lix/.agent-infra/vault-policy.md`
- Web UI / CRUD workbench 仍可走 3000**list/get/resolve/login/session 稳态禁止以 auth-e2e 为前置**
这份文档面向 `/mnt/Data1T/mnote/scripts` 目录下的现有测试脚本,目标不是重新设计测试体系,而是把当前已经在用的 smoke、回归脚本、截图取证和人工复核方式整理成一套可执行参考。
当前结论先说在前面:
@@ -113,6 +142,7 @@ node scripts/task490-runtime-surfaces-smoke.js
- WeKnora 默认 provider 脚本(`task544``task769``task772``task777``task781``task783``task784``task785``task787``task788``task789``task790``task79x`)已退役;它们与 LightRAG 默认 provider 主线相反。
- Hermes/ACP/Reasonix 页面 AI 主流程 smoke 已退役;只保留 `task-hermes-page-ai-retirement-guard.js` 防止旧 runtime 复活。`task762-page-ai-board-first-smoke.js` 也已退役。
-`task019``task021``task022` 这类早期 UI regression 脚本已软删除到 `recycle/scripts/retired-ui-regressions/`,只作为历史对照;后续默认不要用于当前 Rust SSR / local-first 主路径验收。
- `task494-filetree-lazy-loading-dedup-smoke.js``task498-starred-page-tree-scope-and-local-edit-smoke.js``task499-sidebar-tree-view-state-smoke.js`2026-07-21 诊断为 fixture/契约过时(非 Playwright 安装问题),已迁到 `recycle/scripts/obsolete-tree-smokes-20260721/`。全局 Playwright 栈:`~/.agent-infra/playwright-stack`(见该目录 `use-in-project.md`)。树移动排序优先 `task447-tree-move-order-dual-browser-live-smoke.js` 或 Hermes tree QA。
### 0.3 其他 current 候选脚本
+11 -3
View File
@@ -13,6 +13,7 @@
* node scripts/mnote-vault-cli.js list
* node scripts/mnote-vault-cli.js get --id cred_xxx
* node scripts/mnote-vault-cli.js resolve --id cred_xxx --field password
* node scripts/mnote-vault-cli.js resolve --id cred_xxx --field password --account-id acc_xxx
*
* Never print secrets to logs other than resolve stdout value line when --raw.
* Default resolve prints JSON including value for tool piping; use --raw for plain value only.
@@ -29,7 +30,8 @@ function usage() {
mnote-vault-cli.js auth-e2e # print export MNOTE_COOKIE=... for local mnote-web
mnote-vault-cli.js list [--status active|deleted]
mnote-vault-cli.js get --id <credentialId>
mnote-vault-cli.js resolve --id <credentialId> --field password|apikey|token [--raw]
mnote-vault-cli.js resolve --id <credentialId> --field password|apikey|token|username|email \\
[--account-id <accountId>] [--secret-id <secretId>] [--raw]
mnote-vault-cli.js login --id <credentialId> [--force]
mnote-vault-cli.js session --id <credentialId> --cookie <CookieHeader> [--source human_bridge]
@@ -37,6 +39,7 @@ Env: MNOTE_BASE_URL, MNOTE_COOKIE
auth-e2e uses MNOTE_E2E_EMAIL / MNOTE_E2E_PASSWORD (defaults: mnote.e2e@example.com / MnoteE2E123!)
Steady-state: auth-e2e once → list (optional) → login (reuse session).
Multi-account: get item → pick accounts[].id → resolve --account-id …
Human Cloudflare: browser then "session" write-back.`);
process.exit(2);
}
@@ -51,7 +54,9 @@ function parseArgs(argv) {
a === '--status' ||
a === '--cookie' ||
a === '--source' ||
a === '--expires'
a === '--expires' ||
a === '--account-id' ||
a === '--secret-id'
) {
out[a.slice(2)] = argv[++i];
} else if (a === '--raw' || a === '--force') {
@@ -173,10 +178,13 @@ async function main() {
}
if (cmd === 'resolve') {
if (!args.id || !args.field) usage();
const body = { field: args.field };
if (args['account-id']) body.accountId = args['account-id'];
if (args['secret-id']) body.secretId = args['secret-id'];
const result = await api(
'POST',
`/api/vault/ai/items/${encodeURIComponent(args.id)}/resolve`,
{ field: args.field }
body
);
if (args.raw) {
if (result && result.resolved && result.value != null) {
@@ -1,658 +0,0 @@
#!/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 { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
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, "%"));
}
function cssEscape(value) {
return String(value).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 controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
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",
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
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 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();
});
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"),
focused: row.getAttribute("data-focused"),
active: row.getAttribute("data-active"),
}
: null;
};
const firstMarkdownPath = firstParent + "/" + firstParent.split("/").pop() + ".md";
const secondMarkdownPath = secondParent + "/" + secondParent.split("/").pop() + ".md";
return {
first: readRow(firstParent),
second: readRow(secondParent),
firstMarkdown: readRow(firstMarkdownPath),
secondMarkdown: readRow(secondMarkdownPath),
};
}, { firstParent: firstCreatedParentPath, secondParent: secondCreatedParentPath });
assert.equal(
createExpansionState.second?.expanded,
"true",
`新建页面后当前页面包目录应展开,避免焦点落到父文件夹: ${JSON.stringify(createExpansionState)}`,
);
assert.equal(
createExpansionState.secondMarkdown?.selected,
"true",
`新建页面后应选中内部 Markdown 行: ${JSON.stringify(createExpansionState)}`,
);
assert.equal(
createExpansionState.secondMarkdown?.focused,
"true",
`新建页面后文件树焦点应落在内部 Markdown 行: ${JSON.stringify(createExpansionState)}`,
);
assert.equal(
createExpansionState.secondMarkdown?.active,
"true",
`新建页面后 active 应落在内部 Markdown 行: ${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;
localWatchRevision += 1;
await page.evaluate((revision) => {
window.dispatchEvent(new CustomEvent("tree:local-folder-watch-batch", {
detail: {
payload: {
schema: "mnote.local_folder_watch_batch.v1",
sourceKind: "local_folder",
revision: `forced-watch-${revision}`,
affectedParents: [{ relativePath: "", reason: "coarse-watch" }],
changedPaths: [{ relativePath: "design", kind: "Modify(Name(Both))" }],
eventKinds: ["Modify(Name(Both))"],
fallbackResync: false,
},
},
}));
}, localWatchRevision);
await page.waitForTimeout(300);
assert.equal(
scopedRootProjectionRequests,
scopedRootRequestsBeforeCoarseWatch,
"scoped 文件树收到 coarse local-folder 事件不应重拉 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(() => {
const root = document.getElementById("sidebar-file-tree-root");
const original = Element.prototype.replaceChildren;
window.__mnoteRevealReplaceChildrenProbe = {
root,
original,
rootCalls: 0,
descendantCalls: [],
};
Element.prototype.replaceChildren = function(...nodes) {
const probe = window.__mnoteRevealReplaceChildrenProbe;
if (this === probe.root) {
probe.rootCalls += 1;
} else if (probe.root?.contains(this)) {
probe.descendantCalls.push({
className: this instanceof HTMLElement ? this.className : "",
relativePath: this.closest(".tree-node")?.querySelector(":scope > .tree-row")?.getAttribute("data-local-relative-path") || "",
incomingChildren: nodes.length,
});
}
return probe.original.apply(this, nodes);
};
window.dispatchEvent(new CustomEvent("mnote:primary-document-activated", {
detail: { documentId: "local-md:design~2Freveal-parent~2Fchild~2Fnote.md" },
}));
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 revealReplaceChildrenProbe = await page.evaluate(() => {
const probe = window.__mnoteRevealReplaceChildrenProbe;
if (!probe) return null;
Element.prototype.replaceChildren = probe.original;
return {
rootCalls: probe.rootCalls,
descendantCalls: probe.descendantCalls,
};
});
assert.equal(
revealReplaceChildrenProbe?.rootCalls,
0,
`打开深层页面只能定位 FileTree,不能替换 FileTree 根节点: ${JSON.stringify(revealReplaceChildrenProbe)}`,
);
assert.ok(
(revealReplaceChildrenProbe?.descendantCalls || []).every((call) => String(call.className).includes("tree-children")),
`打开深层页面不能替换 FileTree 的非局部节点: ${JSON.stringify(revealReplaceChildrenProbe)}`,
);
assert.ok(
(revealReplaceChildrenProbe?.descendantCalls || []).length <= 2,
`已存在的祖先节点不应在页面打开时被重复重绘: ${JSON.stringify(revealReplaceChildrenProbe)}`,
);
await page.locator('[data-mnote-sidebar-tree-tab="page"]').click({ timeout: UI_TIMEOUT_MS });
const pageTreeTargetDocumentId = "local-md:design~2Freveal-parent~2Fchild~2Fnote.md";
const pageTreeTargetSelector = `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssEscape(pageTreeTargetDocumentId)}"]`;
await page.locator(pageTreeTargetSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const pageTreeFileSelectionBeforeOpen = await page.evaluate(() => {
const root = document.getElementById("sidebar-file-tree-root");
const selected = root?.querySelector('.tree-row[data-selected="true"]');
return selected?.getAttribute("data-row-id") || "";
});
await page.evaluate(() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const pageRoot = document.getElementById("sidebar-tree-root");
const original = Element.prototype.replaceChildren;
window.__mnotePageTreeOpenProbe = {
fileRoot,
pageRoot,
original,
fileRootCalls: 0,
pageRootCalls: 0,
};
Element.prototype.replaceChildren = function(...nodes) {
const probe = window.__mnotePageTreeOpenProbe;
if (this === probe.fileRoot) probe.fileRootCalls += 1;
if (this === probe.pageRoot) probe.pageRootCalls += 1;
return probe.original.apply(this, nodes);
};
});
await page.locator(`${pageTreeTargetSelector} .tree-link`).click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction((documentId) => {
const activeTab = document.querySelector('[data-mnote-sidebar-tree-tab="page"][aria-selected="true"]');
const row = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(documentId)}"]`);
return activeTab && row && row.getAttribute("data-active") === "true";
}, pageTreeTargetDocumentId, { timeout: UI_TIMEOUT_MS });
const pageTreeOpenProbe = await page.evaluate(() => {
const probe = window.__mnotePageTreeOpenProbe;
if (!probe) return null;
Element.prototype.replaceChildren = probe.original;
const selected = probe.fileRoot?.querySelector('.tree-row[data-selected="true"]');
return {
fileRootCalls: probe.fileRootCalls,
pageRootCalls: probe.pageRootCalls,
fileTreeSelection: selected?.getAttribute("data-row-id") || "",
};
});
assert.equal(
pageTreeOpenProbe?.fileRootCalls,
0,
`页面树打开页面时不得全量重建 FileTree: ${JSON.stringify(pageTreeOpenProbe)}`,
);
assert.equal(
pageTreeOpenProbe?.pageRootCalls,
0,
`页面树打开已加载页面时不得全量重建 PageTree: ${JSON.stringify(pageTreeOpenProbe)}`,
);
assert.equal(
pageTreeOpenProbe?.fileTreeSelection,
pageTreeFileSelectionBeforeOpen,
`页面树激活时打开页面不得改写 FileTree selection: ${JSON.stringify(pageTreeOpenProbe)}`,
);
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ 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");
await page.locator("#sidebar-file-tree-root").innerText({ timeout: UI_TIMEOUT_MS });
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);
});
@@ -1,337 +0,0 @@
#!/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 { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task498-starred-page-tree-scope-and-local-edit-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_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 retry = () => {
if (Date.now() > deadline) {
reject(new Error(`server_not_ready: ${url}`));
return;
}
setTimeout(tick, 250);
};
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();
});
};
tick();
});
}
function fileUrlToPath(value) {
const url = new URL(value);
return decodeURIComponent(url.pathname);
}
function cssEscape(value) {
return String(value).replace(/["\\]/g, "\\$&");
}
async function setPageRowExpanded(page, nodeId, expanded) {
const selector = `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssEscape(nodeId)}"]`;
await page.locator(selector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const current = await page.locator(selector).first().getAttribute("aria-expanded");
if ((current === "true") !== expanded) {
await page.locator(`${selector} [data-rust-action="toggle"]`).first().click({ timeout: UI_TIMEOUT_MS });
}
await page.waitForFunction(
({ targetNodeId, expected }) => {
const row = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(targetNodeId)}"]`);
return row && row.getAttribute("aria-expanded") === String(expected);
},
{ targetNodeId: nodeId, expected: expanded },
{ timeout: UI_TIMEOUT_MS },
);
}
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(response.ok(), `测试账号注册失败: ${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: 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 url = new URL(page.url());
const rootUri = url.searchParams.get("rootUri")
|| await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || "");
assert(rootUri, "应进入 local_folder workspace");
return {
rootUri,
workspaceId: url.searchParams.get("workspaceId") || "",
};
}
async function openLocalDocument(page, baseUrl, rootUri, workspaceId, documentId) {
const url = new URL(`/documents/${encodeURIComponent(documentId)}`, baseUrl);
if (workspaceId) url.searchParams.set("workspaceId", workspaceId);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
url.searchParams.set("treeView", "filetree");
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror[contenteditable='true']").first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function readEditorText(page) {
return page.locator(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror").first().innerText({
timeout: UI_TIMEOUT_MS,
});
}
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 controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
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",
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
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({ viewport: { width: 1360, height: 920 } });
const page = await context.newPage();
const diagnostics = [];
const viewStateRequests = [];
page.on("console", (message) => diagnostics.push(`console:${message.type()}:${message.text()}`));
page.on("pageerror", (error) => diagnostics.push(`pageerror:${error.message}`));
page.on("request", (request) => {
if (!request.url().includes("/api/tree/view-state")) return;
viewStateRequests.push({
method: request.method(),
url: request.url(),
postData: request.postData() || "",
at: Date.now(),
});
});
page.on("response", async (response) => {
if (!response.url().includes("/api/tree/view-state")) return;
const request = response.request();
const entry = viewStateRequests.findLast((candidate) => candidate.url === request.url() && candidate.method === request.method() && !candidate.status);
if (!entry) return;
entry.status = response.status();
entry.doneAt = Date.now();
entry.durationMs = entry.doneAt - entry.at;
entry.body = await response.text().catch(() => "");
});
try {
await waitForHttpOk(`${baseUrl}/health`, 60_000);
await signUp(context, baseUrl, actorId);
const { rootUri, workspaceId } = await openDefaultLocalWorkspace(page, baseUrl);
const rootPath = fileUrlToPath(rootUri);
fs.mkdirSync(path.join(rootPath, "design"), { recursive: true });
fs.writeFileSync(path.join(rootPath, "Home.md"), "# Home\n\noriginal\n", "utf8");
fs.writeFileSync(path.join(rootPath, "Other.md"), "# Other\n\nother\n", "utf8");
fs.writeFileSync(path.join(rootPath, "design", "Brief.md"), "# Brief\n\nbrief\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="page"]').click({ timeout: UI_TIMEOUT_MS });
const rootPageTreePut = page.waitForResponse((response) => {
const postData = response.request().postData() || "";
return response.url().includes("/api/tree/view-state")
&& response.request().method() === "PUT"
&& /"treeKind":"pagetree"/.test(postData)
&& /"scope":"root"/.test(postData);
}, { timeout: UI_TIMEOUT_MS });
await setPageRowExpanded(page, "local-dir:design", false);
const rootPageTreeResponse = await rootPageTreePut;
assert(rootPageTreeResponse.ok(), `root PageTree view-state 保存失败: ${rootPageTreeResponse.status()}`);
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
const designRow = page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design"]').first();
await designRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await designRow.click({ button: "right", timeout: UI_TIMEOUT_MS });
await page.getByText("加入/取消星标置顶").click({ timeout: UI_TIMEOUT_MS });
await page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const cloudUrl = new URL(baseUrl);
cloudUrl.searchParams.set("workspaceId", "default");
await page.goto(cloudUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const designShortcut = page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first();
await designShortcut.click({ timeout: UI_TIMEOUT_MS });
await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"] .tree-row[data-local-relative-path="design/Brief.md"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(() => {
const root = document.querySelector("#sidebar-tree-root");
return root instanceof HTMLElement && /Brief/.test(root.innerText || "");
}, { timeout: UI_TIMEOUT_MS }).catch(async (error) => {
const state = await page.evaluate(() => {
const root = document.querySelector("#sidebar-tree-root");
return {
href: location.href,
pageTreeText: root instanceof HTMLElement ? root.innerText : "",
pageTreeHtml: root instanceof HTMLElement ? root.innerHTML.slice(0, 1000) : "",
fileTreeText: document.querySelector("#sidebar-file-tree-root")?.textContent || "",
fileTreeScope: document.querySelector("#sidebar-file-tree-root")?.getAttribute("data-mnote-filetree-scope") || "",
};
});
throw new Error(`${error.message}; state=${JSON.stringify(state)}; diagnostics=${diagnostics.slice(-20).join(" | ")}`);
});
const pageTreeText = await page.locator("#sidebar-tree-root").innerText({ timeout: UI_TIMEOUT_MS });
assert.match(pageTreeText, /Brief/, `星标 design 后页面树应显示 design 内 Markdown: ${pageTreeText}`);
assert.doesNotMatch(pageTreeText, /Home|Other/, `星标 design 后页面树不应显示根目录 Markdown: ${pageTreeText}`);
assert(
viewStateRequests.some((entry) => entry.method === "PUT" && entry.status === 200 && /"treeKind":"pagetree"/.test(entry.postData) && /"scope":"root"/.test(entry.postData)),
`应记录 root PageTree view-state PUT: ${JSON.stringify(viewStateRequests)}`,
);
assert(
viewStateRequests.some((entry) => entry.method === "GET" && entry.status === 200 && entry.url.includes("treeKind=pagetree") && entry.url.includes("scope=design")),
`星标 design 应读取 scoped PageTree view-state: ${JSON.stringify(viewStateRequests)}`,
);
await openLocalDocument(page, baseUrl, rootUri, workspaceId, "local-md:Home.md");
const editor = page.locator(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror[contenteditable='true']").first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A");
await page.keyboard.press("Backspace");
const saveResponsePromise = page.waitForResponse(async (response) => {
if (!response.url().includes("/api/page-body/write") || response.request().method() !== "POST") return false;
const payload = response.request().postDataJSON();
return payload?.documentId === "local-md:Home.md" && response.ok();
}, { timeout: UI_TIMEOUT_MS });
await page.keyboard.type("111", { delay: 10 });
await page.waitForFunction(
(expected) => (document.querySelector(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror")?.textContent || "").includes(expected),
"111",
{ timeout: UI_TIMEOUT_MS },
);
const saveResponse = await saveResponsePromise.catch(async (error) => {
const state = await page.evaluate(() => ({
editorText: document.querySelector(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror")?.textContent || "",
status: document.querySelector("[data-runtime-editor-status]")?.getAttribute("data-runtime-editor-status") || "",
debugSessions: window.__mnoteDebugDocumentSessions?.snapshot?.() || null,
}));
throw new Error(`${error.message}; edit_state=${JSON.stringify(state)}; diagnostics=${diagnostics.slice(-20).join(" | ")}`);
});
assert(saveResponse.ok(), `本地 Markdown 保存失败: ${saveResponse.status()}`);
await page.waitForFunction(
(expected) => (document.querySelector(".editor-surface .ProseMirror")?.textContent || "").includes(expected),
"111",
{ timeout: UI_TIMEOUT_MS },
);
assert.match(fs.readFileSync(path.join(rootPath, "Home.md"), "utf8"), /111/, "保存后磁盘 Home.md 应包含 111");
await openLocalDocument(page, baseUrl, rootUri, workspaceId, "local-md:Other.md");
await openLocalDocument(page, baseUrl, rootUri, workspaceId, "local-md:Home.md");
const reloadedText = await readEditorText(page);
assert.match(reloadedText, /111/, `切换页面再回 Home.md 后编辑内容应保留: ${reloadedText}`);
console.log(JSON.stringify({ ok: true, task: TASK, baseUrl, viewStateRequests }, null, 2));
} finally {
await browser.close();
server.kill("SIGINT");
fs.rmSync(dataRoot, { recursive: true, force: true });
if (server.exitCode == null) {
setTimeout(() => server.kill("SIGKILL"), 2000).unref();
}
if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) {
process.stderr.write(stderr);
}
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -1,438 +0,0 @@
#!/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 { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task499-sidebar-tree-view-state-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
const RESULT_DIR = path.join(__dirname, "..", "tmp", TASK);
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 retry = () => {
if (Date.now() > deadline) {
reject(new Error(`server_not_ready: ${url}`));
return;
}
setTimeout(tick, 250);
};
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();
});
};
tick();
});
}
function fileUrlToPath(value) {
const url = new URL(value);
return decodeURIComponent(url.pathname);
}
function cssEscape(value) {
return String(value).replace(/["\\]/g, "\\$&");
}
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(response.ok(), `测试账号注册失败: ${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: 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 url = new URL(page.url());
const rootUri = url.searchParams.get("rootUri")
|| await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || "");
assert(rootUri, "应进入 local_folder workspace");
return {
rootUri,
workspaceId: url.searchParams.get("workspaceId") || "",
};
}
async function setFileTreeExpanded(page, relativePath, expanded) {
const rowSelector = `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${relativePath}"]`;
await page.locator(rowSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const row = page.locator(rowSelector).first();
const current = await row.getAttribute("aria-expanded");
if ((current === "true") !== expanded) {
await row.locator('[data-rust-action="toggle"]').click({ timeout: UI_TIMEOUT_MS });
}
await page.waitForFunction(
({ selector, expected }) => {
const row = document.querySelector(selector);
return row && row.getAttribute("aria-expanded") === String(expected);
},
{ selector: rowSelector, expected: expanded },
{ timeout: UI_TIMEOUT_MS },
);
}
async function setPageRowExpanded(page, nodeId, expanded) {
const selector = `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssEscape(nodeId)}"]`;
const row = page.locator(selector).first();
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const current = await row.getAttribute("aria-expanded");
if ((current === "true") !== expanded) {
await row.locator('[data-rust-action="toggle"]').click({ timeout: UI_TIMEOUT_MS });
}
await page.waitForFunction(
({ targetNodeId, expected }) => {
const row = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(targetNodeId)}"]`);
return row && row.getAttribute("aria-expanded") === String(expected);
},
{ targetNodeId: nodeId, expected: expanded },
{ timeout: UI_TIMEOUT_MS },
);
}
async function visibleTreeState(page) {
return page.evaluate(() => ({
href: location.href,
fileTree: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({
path: row.getAttribute("data-local-relative-path") || "",
expanded: row.getAttribute("aria-expanded") || "",
text: row.textContent || "",
})).filter((row) => row.path),
pageTree: Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-shell-mode='page']")).map((row) => ({
nodeId: row.getAttribute("data-node-id") || "",
expanded: row.getAttribute("aria-expanded") || "",
text: row.textContent || "",
})),
markers: {
saved: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-saved") || "",
pageApplied: document.documentElement.getAttribute("data-mnote-page-tree-view-state-applied") || "",
loadError: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-load-error") || "",
saveError: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-save-error") || "",
},
}));
}
async function main() {
fs.mkdirSync(RESULT_DIR, { recursive: true });
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
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",
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
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({ viewport: { width: 1440, height: 1000 } });
const page = await context.newPage();
const diagnostics = [];
const viewStateRequests = [];
const screenshots = [];
const timeline = {};
page.on("console", (message) => diagnostics.push(`console:${message.type()}:${message.text()}`));
page.on("pageerror", (error) => diagnostics.push(`pageerror:${error.message}`));
page.on("request", (request) => {
if (request.url().includes("/api/tree/view-state")) {
viewStateRequests.push({
method: request.method(),
url: request.url(),
at: Date.now(),
postData: request.postData() || "",
});
}
});
page.on("response", async (response) => {
if (!response.url().includes("/api/tree/view-state")) return;
const matching = viewStateRequests.findLast((entry) => entry.url === response.url() && !entry.status);
if (!matching) return;
matching.status = response.status();
matching.doneAt = Date.now();
matching.durationMs = matching.doneAt - matching.at;
matching.body = await response.text().catch(() => "");
});
try {
await waitForHttpOk(`${baseUrl}/health`, 60_000);
await signUp(context, baseUrl, actorId);
const { rootUri, workspaceId } = await openDefaultLocalWorkspace(page, baseUrl);
const rootPath = fileUrlToPath(rootUri);
fs.mkdirSync(path.join(rootPath, "design", "05-editor-mainline", "process"), { recursive: true });
fs.mkdirSync(path.join(rootPath, "design", "05-editor-mainline", "reference"), { recursive: true });
fs.writeFileSync(path.join(rootPath, "Home.md"), "# Home\n", "utf8");
fs.writeFileSync(path.join(rootPath, "design", "05-editor-mainline", "process", "Target.md"), "# Target\n", "utf8");
fs.writeFileSync(path.join(rootPath, "design", "05-editor-mainline", "reference", "Ref.md"), "# Ref\n", "utf8");
const workspaceUrl = new URL(baseUrl);
if (workspaceId) workspaceUrl.searchParams.set("workspaceId", workspaceId);
workspaceUrl.searchParams.set("sourceKind", "local_folder");
workspaceUrl.searchParams.set("rootUri", rootUri);
workspaceUrl.searchParams.set("treeView", "filetree");
await page.goto(workspaceUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
const designRow = page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design"]').first();
await designRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await designRow.click({ button: "right", timeout: UI_TIMEOUT_MS });
await page.getByText("加入/取消星标置顶").click({ timeout: UI_TIMEOUT_MS });
await page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const designShortcut = page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first();
timeline.starClickAt = Date.now();
await designShortcut.click({ timeout: UI_TIMEOUT_MS });
await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"] .tree-row[data-local-relative-path="design/05-editor-mainline"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(() => {
const root = document.querySelector("#sidebar-tree-root");
return root instanceof HTMLElement && /05-editor-mainline|Target|Ref/.test(root.innerText || "");
}, { timeout: UI_TIMEOUT_MS });
screenshots.push(path.join(RESULT_DIR, "01-after-starred-design.png"));
await page.screenshot({ path: screenshots.at(-1), fullPage: true });
const fileSavePromise = page.waitForResponse((response) => {
return response.url().includes("/api/tree/view-state")
&& response.request().method() === "PUT"
&& /"treeKind":"filetree"/.test(response.request().postData() || "");
}, { timeout: UI_TIMEOUT_MS }).catch((error) => error);
await setFileTreeExpanded(page, "design/05-editor-mainline", true);
await setFileTreeExpanded(page, "design/05-editor-mainline/process", true);
const fileSaveResult = await fileSavePromise;
if (fileSaveResult instanceof Error) {
throw new Error(`未观察到 FileTree view-state PUT: ${fileSaveResult.message}; state=${JSON.stringify(await visibleTreeState(page))}; requests=${JSON.stringify(viewStateRequests)}`);
}
assert(fileSaveResult.ok(), `FileTree view-state PUT 失败: ${fileSaveResult.status()}`);
timeline.fileStateSavedAt = Date.now();
await page.locator('[data-mnote-sidebar-tree-tab="page"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-mnote-sidebar-tree-panel="page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const pageRows = await page.evaluate(() => Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-shell-mode='page'][aria-expanded]")).filter((row) => {
const rect = row.getBoundingClientRect();
const style = window.getComputedStyle(row);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
}).map((row) => ({
nodeId: row.getAttribute("data-node-id") || "",
parentId: row.getAttribute("data-parent-id") || "",
expanded: row.getAttribute("aria-expanded") || "",
text: row.textContent || "",
})).filter((row) => row.nodeId));
const siblingPair = pageRows.flatMap((row, index) => {
return pageRows.slice(index + 1).filter((candidate) => candidate.parentId === row.parentId).map((candidate) => [row, candidate]);
})[0];
assert(siblingPair, `PageTree 应至少有两个同级可展开节点: ${JSON.stringify(pageRows)}`);
const pageCollapsed = siblingPair[0].nodeId;
const pageExpanded = siblingPair[1].nodeId;
const pageSavePromise = page.waitForResponse((response) => {
return response.url().includes("/api/tree/view-state")
&& response.request().method() === "PUT"
&& /"treeKind":"pagetree"/.test(response.request().postData() || "");
}, { timeout: UI_TIMEOUT_MS }).catch((error) => error);
await setPageRowExpanded(page, pageExpanded, false);
await setPageRowExpanded(page, pageCollapsed, false);
await setPageRowExpanded(page, pageExpanded, true);
const pageSaveResult = await pageSavePromise;
if (pageSaveResult instanceof Error) {
throw new Error(`未观察到 PageTree view-state PUT: ${pageSaveResult.message}; state=${JSON.stringify(await visibleTreeState(page))}; requests=${JSON.stringify(viewStateRequests)}`);
}
assert(pageSaveResult.ok(), `PageTree view-state PUT 失败: ${pageSaveResult.status()}`);
timeline.pageStateSavedAt = Date.now();
screenshots.push(path.join(RESULT_DIR, "02-after-state-edits.png"));
await page.screenshot({ path: screenshots.at(-1), fullPage: true });
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-saved") || document.querySelector("#sidebar-file-tree-root"), { 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 });
await page.waitForFunction(() => {
const row = document.querySelector('#sidebar-file-tree-root .tree-row[data-local-relative-path="design/05-editor-mainline/process"]');
return row && row.getAttribute("aria-expanded") === "true";
}, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-mnote-sidebar-tree-tab="page"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
({ pageCollapsed, pageExpanded }) => {
const collapsed = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(pageCollapsed)}"]`);
const expanded = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(pageExpanded)}"]`);
return collapsed && expanded
&& collapsed.getAttribute("aria-expanded") === "false"
&& expanded.getAttribute("aria-expanded") === "true";
},
{ pageCollapsed, pageExpanded },
{ timeout: UI_TIMEOUT_MS },
);
timeline.reloadedStateRestoredAt = Date.now();
const targetRow = page.locator('#sidebar-file-tree-root .tree-row[data-local-relative-path="design/05-editor-mainline/process/Target.md"]').first();
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
await targetRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await targetRow.locator(".tree-link").click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => location.pathname.startsWith("/documents/")
&& decodeURIComponent(location.pathname).includes("Target.md")
&& new URLSearchParams(location.search).get("fileTreeScope") === "design",
{ 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.goBack({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }).catch(() => page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }));
await page.waitForFunction(() => {
const row = document.querySelector('#sidebar-file-tree-root .tree-row[data-local-relative-path="design/05-editor-mainline/process"]');
return row && row.getAttribute("aria-expanded") === "true";
}, { timeout: UI_TIMEOUT_MS });
timeline.afterNavigationRestoredAt = Date.now();
screenshots.push(path.join(RESULT_DIR, "03-after-reload-and-navigation.png"));
await page.screenshot({ path: screenshots.at(-1), fullPage: true });
const finalState = await visibleTreeState(page);
assert.equal(
finalState.fileTree.find((row) => row.path === "design/05-editor-mainline")?.expanded,
"true",
`FileTree 父节点应恢复展开: ${JSON.stringify(finalState.fileTree)}`,
);
assert.equal(
finalState.fileTree.find((row) => row.path === "design/05-editor-mainline/process")?.expanded,
"true",
`FileTree process 节点应恢复展开: ${JSON.stringify(finalState.fileTree)}`,
);
assert.equal(
finalState.pageTree.find((row) => row.nodeId === pageCollapsed)?.expanded,
"false",
`PageTree 折叠态应恢复: ${JSON.stringify(finalState.pageTree)}`,
);
assert.equal(
finalState.pageTree.find((row) => row.nodeId === pageExpanded)?.expanded,
"true",
`PageTree 展开态应恢复: ${JSON.stringify(finalState.pageTree)}`,
);
assert(viewStateRequests.some((entry) => entry.method === "GET" && entry.status === 200), "应记录 view-state GET");
assert(viewStateRequests.some((entry) => entry.method === "PUT" && entry.status === 200 && /filetree/.test(entry.postData)), "应记录 FileTree view-state PUT");
assert(viewStateRequests.some((entry) => entry.method === "PUT" && entry.status === 200 && /pagetree/.test(entry.postData)), "应记录 PageTree view-state PUT");
const result = {
ok: true,
task: TASK,
baseUrl,
rootUri,
workspaceId,
pageCollapsed,
pageExpanded,
timeline,
viewStateRequests,
finalState,
screenshots,
};
fs.writeFileSync(path.join(RESULT_DIR, "result.json"), JSON.stringify(result, null, 2));
console.log(JSON.stringify(result, null, 2));
} catch (error) {
const failureState = await visibleTreeState(page).catch(() => null);
const failureScreenshot = path.join(RESULT_DIR, "99-failure.png");
await page.screenshot({ path: failureScreenshot, fullPage: true }).catch(() => {});
const result = {
ok: false,
task: TASK,
error: error && error.stack ? error.stack : String(error),
diagnostics: diagnostics.slice(-30),
viewStateRequests,
failureState,
screenshot: failureScreenshot,
stderr: stderr.slice(-8000),
};
fs.writeFileSync(path.join(RESULT_DIR, "failure.json"), JSON.stringify(result, null, 2));
console.error(JSON.stringify(result, null, 2));
process.exitCode = 1;
} finally {
await browser.close().catch(() => {});
server.kill("SIGTERM");
fs.rmSync(dataRoot, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});