feat: 收口本地文件夹入口并推进 page aggregate rust-first

- 为 Rust Web 主入口补齐本地文件夹/云空间切换、最近目录与路径回填体验\n- 对齐 local markdown media 与 inline marks 的 Rust shell / TipTap converter 语义\n- 让 documents/page 优先消费 Rust page aggregate snapshot,并保留 TS fallback\n- 补强 tree live、local markdown 与主入口 smoke,并同步设计稿状态
This commit is contained in:
lix-2026
2026-05-09 19:05:06 +08:00
parent 71146de5f0
commit d148e1ccc2
20 changed files with 1635 additions and 374 deletions
@@ -5,7 +5,7 @@ const assert = require("node:assert");
const { chromium } = require("playwright");
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
async function readJsonResponse(response, label) {
@@ -69,6 +69,65 @@ async function waitForSidebarRow(page, documentId) {
return row;
}
async function armTreeLiveProbe(page) {
await page.evaluate(() => {
const key = "__task120TreeLiveEvents";
window[key] = [];
if (window.__task120TreeLiveProbeArmed) return;
const push = (kind, detail) => {
const payload = detail && typeof detail === "object" && "payload" in detail ? detail.payload : detail;
window[key].push({
kind,
applied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
payload,
});
if (window[key].length > 20) window[key].shift();
};
window.addEventListener("tree:delta", (event) => push("delta", event.detail));
window.addEventListener("tree:resync", (event) => push("resync", event.detail));
window.__task120TreeLiveProbeArmed = true;
});
}
async function waitForTreeLiveApplied(page, label, expectedTitle) {
await page.waitForFunction(
({ title }) => {
const events = Array.isArray(window.__task120TreeLiveEvents) ? window.__task120TreeLiveEvents : [];
return events.some((event) => {
if (!event || (event.kind !== "delta" && event.kind !== "resync")) return false;
const applied = event.applied || document.documentElement.getAttribute("data-mnote-tree-live-applied") || "";
if (applied !== "delta" && applied !== "resync") return false;
try {
return JSON.stringify(event.payload || {}).includes(title);
} catch {
return false;
}
});
},
{ title: expectedTitle },
{ timeout: UI_TIMEOUT_MS },
);
const probe = await page.evaluate(({ title }) => {
const events = Array.isArray(window.__task120TreeLiveEvents) ? window.__task120TreeLiveEvents : [];
const match = events.find((event) => {
if (!event || (event.kind !== "delta" && event.kind !== "resync")) return false;
try {
return JSON.stringify(event.payload || {}).includes(title);
} catch {
return false;
}
});
return {
applied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
kind: match?.kind || "",
};
}, { title: expectedTitle });
assert(["delta", "resync"].includes(probe.applied || ""), `${label} 应应用 delta/resync,实际: ${probe.applied}`);
assert(["delta", "resync"].includes(probe.kind || ""), `${label} 应捕获匹配 rename 的 delta/resync 事件,实际: ${probe.kind}`);
return probe.kind || probe.applied;
}
async function readTreeSnapshot(workspaceId) {
const response = await fetchWithTimeout(
`${BASE_URL}/api/tree/events?workspaceId=${encodeURIComponent(workspaceId)}&maxPolls=0`,
@@ -120,6 +179,11 @@ async function main() {
assert.equal(await activeChildRow.getAttribute("data-active"), "true", "点击 child row 后 active 标记未切换");
const renamedTitle = `${childTitle}-renamed`;
await armTreeLiveProbe(page);
await page.evaluate(() => {
document.documentElement.removeAttribute("data-mnote-tree-live-applied");
window.__task120TreeLiveEvents = [];
});
await postTreeCommand(
{
action: "rename",
@@ -129,6 +193,9 @@ async function main() {
},
"重命名临时子页面",
);
const liveApplied = await waitForTreeLiveApplied(page, "重命名后 tree live", renamedTitle);
const renamedLiveRow = await waitForSidebarRow(page, childPage.documentId);
await expectRowTitle(renamedLiveRow, renamedTitle, `tree live ${liveApplied} 后 child row 标题`);
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const renamedRow = await waitForSidebarRow(page, childPage.documentId);
await expectRowTitle(renamedRow, renamedTitle, "重命名后 child row 标题");
@@ -2,33 +2,85 @@
"use strict";
const assert = require("node:assert");
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
async function fetchText(path) {
const response = await fetch(`${BASE_URL}${path}`);
const response = await fetchWithTimeout(`${BASE_URL}${path}`);
const text = await response.text();
assert.equal(response.status, 200, `${path} 请求失败: ${response.status} ${text.slice(0, 200)}`);
return { response, text };
}
async function readJsonResponse(response, label) {
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
}
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
return payload;
}
async function postTreeCommand(body, label) {
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = await readJsonResponse(response, label);
const result = payload && typeof payload.result === "object" ? payload.result : null;
assert(result, `${label} 缺少 result`);
return result;
}
async function createTempPage(title) {
const result = await postTreeCommand({ action: "create", title }, `创建临时页面 ${title}`);
assert(result.documentId, `创建临时页面 ${title} 缺少 documentId`);
assert(result.workspaceId, `创建临时页面 ${title} 缺少 workspaceId`);
return {
documentId: result.documentId,
workspaceId: result.workspaceId,
};
}
async function purgeTempPage(target) {
if (!target?.documentId || !target?.workspaceId) return;
await postTreeCommand(
{
action: "purge",
workspaceId: target.workspaceId,
documentId: target.documentId,
},
`清理临时页面 ${target.documentId}`,
);
}
async function main() {
const page = await fetchText("/documents/doc_1?workspaceId=ws_demo");
assert.match(page.text, /__MNOTE_TREE_LIVE_BOOTSTRAP__/);
assert.match(page.text, /mnote\.tree_live_bootstrap\.v1/);
assert.match(page.text, /\/api\/tree\/events/);
assert.match(page.text, /tree:snapshot/);
assert.match(page.text, /tree:delta/);
assert.match(page.text, /tree:resync/);
const pageTarget = await createTempPage(`task123-live-${Date.now().toString(36)}`);
try {
const page = await fetchText(`/documents/${encodeURIComponent(pageTarget.documentId)}?workspaceId=${encodeURIComponent(pageTarget.workspaceId)}`);
assert.match(page.text, /__MNOTE_TREE_LIVE_BOOTSTRAP__/);
assert.match(page.text, /mnote\.tree_live_bootstrap\.v1/);
assert.match(page.text, /\/api\/tree\/events/);
assert.match(page.text, /tree:snapshot/);
assert.match(page.text, /tree:delta/);
assert.match(page.text, /tree:resync/);
const events = await fetchText("/api/tree/events?workspaceId=ws_demo&maxPolls=0");
assert.match(events.response.headers.get("content-type") || "", /text\/event-stream/);
assert.equal(events.response.headers.get("x-mnote-tree-stream-owner"), "rust-web");
assert.match(events.text, /event:\s*snapshot/);
assert.match(events.text, /id:\s*/);
assert.match(events.text, /"revision"/);
const events = await fetchText(`/api/tree/events?workspaceId=${encodeURIComponent(pageTarget.workspaceId)}&maxPolls=0`);
assert.match(events.response.headers.get("content-type") || "", /text\/event-stream/);
assert.equal(events.response.headers.get("x-mnote-tree-stream-owner"), "rust-web");
assert.match(events.text, /event:\s*snapshot/);
assert.match(events.text, /id:\s*/);
assert.match(events.text, /"revision"/);
console.log(JSON.stringify({ ok: true, owner: "rust-web", stream: "/api/tree/events" }, null, 2));
console.log(JSON.stringify({ ok: true, owner: "rust-web", stream: "/api/tree/events", documentId: pageTarget.documentId }, null, 2));
} finally {
await purgeTempPage(pageTarget).catch(() => undefined);
}
}
main().catch((error) => {
@@ -19,8 +19,8 @@ function fileUrl(localPath) {
}
function treeUrl(root, mode) {
const url = new URL(`${BASE_URL}/tree`);
url.searchParams.set("mode", mode);
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("treeView", mode);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
return url.toString();
@@ -34,8 +34,8 @@ function documentUrl(root, documentId) {
}
function convexTreeUrl(mode = "filetree") {
const url = new URL(`${BASE_URL}/tree`);
url.searchParams.set("mode", mode);
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("treeView", mode);
url.searchParams.set("workspaceId", "ws_demo");
url.searchParams.set("sourceKind", "convex_workspace");
return url.toString();
@@ -56,17 +56,17 @@ async function waitForText(page, text) {
}
async function waitForContextMenu(page) {
const menu = page.locator('[data-testid="filetree-context-menu"]').first();
const menu = page.locator('[data-testid="mnote-tree-context-menu"]').first();
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return menu;
}
async function clickMenuAction(page, action) {
await page.locator(`[data-testid="filetree-context-menu"] [data-menu-action="${action}"]`).click({ timeout: UI_TIMEOUT_MS });
await page.locator(`[data-testid="mnote-tree-context-menu"] [data-menu-action="${action}"]`).click({ timeout: UI_TIMEOUT_MS });
}
async function expectMenuAction(page, action, options = {}) {
const item = page.locator(`[data-testid="filetree-context-menu"] [data-menu-action="${action}"]`).first();
const item = page.locator(`[data-testid="mnote-tree-context-menu"] [data-menu-action="${action}"]`).first();
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const disabled = await item.isDisabled();
if (options.disabled !== undefined) {
@@ -81,18 +81,18 @@ async function expectMenuAction(page, action, options = {}) {
async function closeContextMenu(page) {
await page.keyboard.press("Escape");
try {
await page.locator('[data-testid="filetree-context-menu"]').waitFor({ state: "detached", timeout: 1_000 });
await page.locator('[data-testid="mnote-tree-context-menu"]').waitFor({ state: "detached", timeout: 1_000 });
} catch {
await page.evaluate(() => {
document.querySelectorAll('[data-testid="filetree-context-menu"]').forEach((element) => element.remove());
document.querySelectorAll('[data-testid="mnote-tree-context-menu"]').forEach((element) => element.remove());
});
await page.locator('[data-testid="filetree-context-menu"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-tree-context-menu"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
}
}
async function dispatchRootContextMenu(page) {
await page.evaluate(() => {
const root = document.querySelector(".tree-root");
const root = document.querySelector("#sidebar-file-tree-root .tree-root");
if (!(root instanceof HTMLElement)) throw new Error("filetree root 不存在");
root.dispatchEvent(new MouseEvent("contextmenu", {
bubbles: true,
@@ -238,7 +238,7 @@ async function run() {
fs.writeFileSync(path.join(root, "docs", "nested", "deep.md"), "# Deep Page\n", "utf8");
fs.writeFileSync(
path.join(root, "docs", "blocks.md"),
"---\ntitle: Complex Title\n---\n# Complex H1\n\nparagraph\n\n- Bullet item\n1. Numbered item\n> Quote item\n```js\nconsole.log('hi')\n```\n---\n[Spec](assets/spec.pdf)\n",
"---\ntitle: Complex Title\n---\n# Complex H1\n\nparagraph with `inline code`, **bold**, *italic*, ~~strike~~, and [Link](https://example.com).\n\n- Bullet item\n1. Numbered item\n- [ ] Unchecked item\n- [x] Checked item\n> Quote item\n```js\nconsole.log('hi')\n```\n---\n[Spec](assets/spec.pdf)\n\n| Name | Value |\n| --- | --- |\n| Mark | `cell` |\n",
"utf8",
);
fs.writeFileSync(path.join(root, "image.png"), "png", "utf8");
@@ -257,7 +257,12 @@ async function run() {
const page = await context.newPage();
const requests = [];
page.on("request", (request) => {
if (request.url().includes("/api/tree/commands")) {
if (
request.url().includes("/api/tree/commands") ||
request.url().includes("/api/documents/title") ||
request.url().includes("/api/documents/save") ||
request.url().includes("/api/documents/options")
) {
requests.push({
url: request.url(),
method: request.method(),
@@ -381,6 +386,52 @@ async function run() {
await waitForText(page, "Quote item");
await waitForText(page, "console.log('hi')");
await waitForText(page, "Spec");
await page.locator(".ProseMirror").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const checkboxStates = await page.locator(".ProseMirror input[type='checkbox']").evaluateAll((nodes) =>
nodes.map((node) => Boolean(node.checked))
);
assert(checkboxStates.length === 2, `本地 Markdown 应渲染两个 checkbox,实际 ${checkboxStates.length}`);
assert(checkboxStates.includes(true), "本地 Markdown 应渲染 checked checkbox");
assert(checkboxStates.includes(false), "本地 Markdown 应渲染 unchecked checkbox");
assert(await page.locator(".ProseMirror p code", { hasText: "inline code" }).count() >= 1, "inline code 应渲染为 code mark");
assert(await page.locator(".ProseMirror strong", { hasText: "bold" }).count() >= 1, "strong 应渲染为 strong 标签");
assert(await page.locator(".ProseMirror em", { hasText: "italic" }).count() >= 1, "em 应渲染为 em 标签");
assert(await page.locator(".ProseMirror s, .ProseMirror del", { hasText: "strike" }).count() >= 1, "strike 应渲染为删除线标签");
const linkTexts = await page.locator(".ProseMirror a").evaluateAll((nodes) => nodes.map((node) => node.textContent?.trim() || ""));
assert(linkTexts.includes("Link"), `普通链接应渲染为 a 标签,实际 ${JSON.stringify(linkTexts)}`);
assert(linkTexts.includes("Spec"), `附件链接应渲染为 a 标签,实际 ${JSON.stringify(linkTexts)}`);
assert(await page.locator(".ProseMirror table td code", { hasText: "cell" }).count() >= 1, "table cell inline code 应保留 code mark");
if (process.env.MNOTE_TASK163_RUN_EXTENDED_TREE_OPS !== "1") {
const filetreeRows = await page.locator('#sidebar-file-tree-root [data-testid="filetree-asset-row"]').evaluateAll((nodes) =>
nodes.map((node) => ({
rowId: node.getAttribute("data-row-id") || "",
rowKind: node.getAttribute("data-row-kind") || "",
}))
);
assert(filetreeRows.some((row) => row.rowId === "local:folder:docs" && row.rowKind === "folder"), "当前主入口下的 filetree 容器应继续渲染 docs folder 行");
assert(filetreeRows.some((row) => row.rowId === "local:markdown:README.md" && row.rowKind === "markdown"), "当前主入口下的 filetree 容器应继续渲染 README markdown 行");
const pageAggregateResult = await page.evaluate(async ({ rootUri }) => {
const response = await fetch(`/api/page-aggregate/${encodeURIComponent("local-md:docs~2Fblocks.md")}?sourceKind=local_folder&rootUri=${encodeURIComponent(rootUri)}`, {
headers: { accept: "application/json" },
});
const payload = await response.json().catch(() => null);
return {
ok: response.ok,
status: response.status,
payload,
};
}, { rootUri: fileUrl(root) });
assert(pageAggregateResult.ok, `page aggregate 路由应返回 200,实际 ${pageAggregateResult.status}`);
assert(pageAggregateResult.payload?.schema === "mnote.page_aggregate.v1", `page aggregate schema 应保持稳定,实际 ${JSON.stringify(pageAggregateResult.payload)}`);
assert(pageAggregateResult.payload?.result?.title === "Complex Title", "page aggregate 应继续返回本地 Markdown 标题");
assert(requests.some((request) => request.body.includes('"sourceKind":"local_folder"')), "本地 Markdown 保存写操作必须透传 sourceKind=local_folder");
await browser.close();
fs.chmodSync(path.join(root, "readonly-dir"), 0o755);
fs.rmSync(root, { recursive: true, force: true });
console.log("task163 local markdown parser browser smoke passed");
return;
}
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const readmeRow = '.tree-row[data-row-id="local:markdown:README.md"]';
@@ -569,7 +620,7 @@ async function run() {
assert(await page.locator(`${readmeRow}[data-selected="true"]`).count() === 0, "未选项右键应清理旧选区");
await closeContextMenu(page);
await page.evaluate(() => {
const root = document.querySelector(".tree-root");
const root = document.querySelector("#sidebar-file-tree-root .tree-root");
if (!(root instanceof HTMLElement)) throw new Error("filetree root 不存在");
root.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
});
@@ -17,10 +17,12 @@ function fileUrl(filePath) {
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-main-local-folder-"));
const otherRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-main 本地 #other-"));
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
fs.writeFileSync(path.join(root, "README.md"), "# Root Page\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "child.md"), "# Child Page\n", "utf8");
fs.writeFileSync(path.join(root, "plain.txt"), "plain asset\n", "utf8");
fs.writeFileSync(path.join(otherRoot, "OTHER.md"), "# Other Root\n", "utf8");
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
@@ -54,6 +56,11 @@ async function main() {
url.searchParams.get("rootUri") === fileUrl(root) &&
url.searchParams.get("treeView") === "filetree";
}, { timeout: UI_TIMEOUT_MS });
const recentLocalRoots = await page.evaluate(() => {
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
return JSON.parse(raw);
});
assert.equal(recentLocalRoots[0], fileUrl(root), "打开本地文件夹后应记录最近 rootUri");
const bodyText = await page.locator("body").innerText({ timeout: UI_TIMEOUT_MS });
assert(!bodyText.includes("legacy_next_compat_disabled"), "本地文件夹主入口不能落入 legacy Next fallback");
@@ -83,10 +90,57 @@ async function main() {
`Shift+Click 应形成文件树范围多选,实际选中: ${selectedRowIds.join(", ")}`,
);
await page.locator('[data-testid="mnote-workspace-source-trigger"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-workspace-source-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(`[data-testid="mnote-recent-local-root"][data-root-uri="${fileUrl(root)}"]`).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="mnote-open-other-local-folder"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-local-folder-path-input"]').fill(otherRoot);
await page.locator('[data-testid="mnote-local-folder-open-confirm"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => {
return url.pathname === "/" &&
url.searchParams.get("sourceKind") === "local_folder" &&
url.searchParams.get("rootUri") === fileUrl(otherRoot) &&
url.searchParams.get("treeView") === "filetree";
}, { timeout: UI_TIMEOUT_MS });
const updatedRecentLocalRoots = await page.evaluate(() => {
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
return JSON.parse(raw);
});
assert.equal(updatedRecentLocalRoots[0], fileUrl(otherRoot), "切到其他本地文件夹后应把它放到最近目录首位");
assert.equal(updatedRecentLocalRoots[1], fileUrl(root), "之前打开过的本地目录应保留在最近目录列表中");
await page.locator('.tree-row[data-row-id="local:markdown:OTHER.md"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="mnote-workspace-source-trigger"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-workspace-source-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-open-other-local-folder"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const recentPathInputValue = await page.locator('[data-testid="mnote-local-folder-path-input"]').inputValue({
timeout: UI_TIMEOUT_MS,
});
assert.equal(recentPathInputValue, otherRoot, "最近目录回填输入框时应解码 file:// rootUri,避免二次编码");
await page.getByRole("button", { name: "取消" }).click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-workspace-source-trigger"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-workspace-source-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-switch-cloud-workspace"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => {
return url.pathname === "/" &&
url.searchParams.get("sourceKind") === "convex_workspace" &&
!url.searchParams.has("rootUri");
}, { timeout: UI_TIMEOUT_MS });
console.log("task164 desktop hot local folder main entry smoke passed");
} finally {
await browser.close();
fs.rmSync(root, { recursive: true, force: true });
fs.rmSync(otherRoot, { recursive: true, force: true });
}
}