feat(tree): checkpoint resource lifecycle work

提交当前顶层 mnote Git 工作区,范围集中在 04-tree-domain 的 resource/trash/filetree 生命周期、mnote-web resource_trash 路由、Convex/Next 兼容接口、sidebar/file-tree 客户端适配、smoke 脚本与对应设计/bug 记录。

不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。
This commit is contained in:
lix-2026
2026-05-16 07:38:45 +08:00
parent 274779c0d8
commit 384da4e44c
85 changed files with 12570 additions and 394 deletions
@@ -90,6 +90,22 @@ async function closeContextMenu(page) {
}
}
async function dispatchFolderContextMenu(page, rowSelector) {
await page.evaluate(
({ rowSelector }) => {
const row = document.querySelector(rowSelector);
if (!(row instanceof HTMLElement)) throw new Error("filetree folder row 不存在");
row.dispatchEvent(new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: 24,
clientY: 64,
}));
},
{ rowSelector },
);
}
async function dispatchRootContextMenu(page) {
await page.evaluate(() => {
const root = document.querySelector("#sidebar-file-tree-root .tree-root");
@@ -256,6 +272,15 @@ async function run() {
});
const page = await context.newPage();
const requests = [];
const navigationEvents = [];
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
navigationEvents.push({
url: frame.url(),
timestamp: Date.now(),
});
}
});
page.on("request", (request) => {
if (
request.url().includes("/api/tree/commands") ||
@@ -449,7 +474,7 @@ async function run() {
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await dispatchRootContextMenu(page);
await dispatchFolderContextMenu(page, docsRow);
await waitForContextMenu(page);
await expectMenuAction(page, "newPage", { disabled: false });
await expectMenuAction(page, "newFolder", { disabled: false });
@@ -496,7 +521,7 @@ async function run() {
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await dispatchRootContextMenu(page);
await dispatchFolderContextMenu(page, docsRow);
await waitForContextMenu(page);
await clickMenuAction(page, "newFolder");
let createRenameInput = page.locator(".tree-rename-input").first();
@@ -712,13 +737,19 @@ async function run() {
assert(!fs.existsSync(path.join(root, "readonly-dir", "readonly-drop.txt")), "readonly 目标预检失败后不应写入文件");
await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
let watcherNavigationStart = navigationEvents.length;
fs.writeFileSync(path.join(root, "docs", "watcher-added.md"), "# Watcher Added\n", "utf8");
await waitForText(page, "Watcher Added");
assert(navigationEvents.length === watcherNavigationStart, "外部新增 Markdown 后 page tree 不应发生浏览器导航或 reload");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
watcherNavigationStart = navigationEvents.length;
fs.writeFileSync(path.join(root, "docs", "watcher-asset.txt"), "watcher asset", "utf8");
await waitForText(page, "watcher-asset.txt");
assert(navigationEvents.length === watcherNavigationStart, "外部新增非 md 资源后 filetree 不应发生浏览器导航或 reload");
watcherNavigationStart = navigationEvents.length;
fs.rmSync(path.join(root, "docs", "watcher-asset.txt"));
await page.getByText("watcher-asset.txt", { exact: true }).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
assert(navigationEvents.length === watcherNavigationStart, "外部删除非 md 资源后 filetree 不应发生浏览器导航或 reload");
await page.goto(convexTreeUrl("filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "工作区首页");
+11 -11
View File
@@ -643,23 +643,23 @@ async function assertSingleMindmapAssetRowStable(page, documentId, mindmapId, ex
};
}
async function assertFileTreeIndexOpenUsesPageAggregate(page, documentId, mindmapId, failures) {
async function assertFileTreePageMarkdownOpenUsesPageAggregate(page, documentId, mindmapId, failures) {
await openFilesystemView(page);
const opened = await page.evaluate(
({ documentId }) => {
const row =
document.querySelector(`#sidebar-file-tree-root [data-row-id="index:${CSS.escape(documentId)}"]`) ||
Array.from(document.querySelectorAll("#sidebar-file-tree-root [data-row-kind='index']")).find((node) => {
document.querySelector(`#sidebar-file-tree-root [data-row-id="doc:${CSS.escape(documentId)}"]`) ||
Array.from(document.querySelectorAll("#sidebar-file-tree-root [data-row-kind='document']")).find((node) => {
if (!(node instanceof HTMLElement)) return false;
const rowDocumentId = node.getAttribute("data-document-id") || node.getAttribute("data-doc-id") || "";
return rowDocumentId === documentId;
});
if (!(row instanceof HTMLElement)) {
return { clicked: false, reason: "index_row_missing" };
return { clicked: false, reason: "page_markdown_row_missing" };
}
const button = row.querySelector('[data-rust-action="open"], .tree-link');
if (!(button instanceof HTMLElement)) {
return { clicked: false, reason: "index_open_button_missing" };
return { clicked: false, reason: "page_markdown_open_button_missing" };
}
button.click();
return {
@@ -671,12 +671,12 @@ async function assertFileTreeIndexOpenUsesPageAggregate(page, documentId, mindma
{ documentId },
);
if (!opened.clicked) {
failures.push({ code: "filetree_index_open_row_missing", opened });
failures.push({ code: "filetree_page_markdown_open_row_missing", opened });
return opened;
}
if (!opened.objectIdentity.includes(`"objectKind":"index"`) || !opened.objectIdentity.includes(`"documentId":"${documentId}"`)) {
if (!opened.objectIdentity.includes(`"objectKind":"page"`) || !opened.objectIdentity.includes(`"documentId":"${documentId}"`)) {
failures.push({
code: "filetree_index_row_missing_object_identity",
code: "filetree_page_markdown_row_missing_object_identity",
opened,
});
}
@@ -687,14 +687,14 @@ async function assertFileTreeIndexOpenUsesPageAggregate(page, documentId, mindma
const url = new URL(state.url);
if (!url.pathname.includes(`/documents/${encodeURIComponent(documentId)}`)) {
failures.push({
code: "filetree_index_open_did_not_return_document_page",
code: "filetree_page_markdown_open_did_not_return_document_page",
opened,
state,
});
}
if (state.objectEditor === "mindmap" || state.objectIdentity.includes(`resource:mindmap:${documentId}:${mindmapId}`)) {
failures.push({
code: "filetree_index_open_loaded_mindmap_object_identity",
code: "filetree_page_markdown_open_loaded_mindmap_object_identity",
opened,
state,
});
@@ -1294,7 +1294,7 @@ async function main() {
failures,
);
await openDocument(pageA, awayDoc.workspaceId, awayDoc.documentId);
result.indexOpenAfterLongLanguageEdit = await assertFileTreeIndexOpenUsesPageAggregate(
result.indexOpenAfterLongLanguageEdit = await assertFileTreePageMarkdownOpenUsesPageAggregate(
pageA,
doc.documentId,
result.mindmapId,
@@ -106,7 +106,7 @@ async function main() {
const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`);
const fileRows = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
.filter((row) => row.getAttribute("data-doc-id") === id || row.getAttribute("data-document-id") === id);
return pageRow && fileRows.some((row) => row.getAttribute("data-row-id") === `doc:${id}`) && fileRows.some((row) => row.getAttribute("data-row-id") === `index:${id}`);
return pageRow && fileRows.some((row) => row.getAttribute("data-row-id") === `doc:${id}`);
},
createdDocumentId,
{ timeout: UI_TIMEOUT_MS },
@@ -114,7 +114,7 @@ async function main() {
const afterCreate = await readCreatedState(page, createdDocumentId);
assert.equal(afterCreate.localApplied, "create", "新建页面应由主文档壳本地 apply");
assert.equal(afterCreate.pageRows, 1, "Page Tree 应立即出现新页面");
assert.deepEqual(afterCreate.fileRows.sort(), [`doc:${createdDocumentId}`, `index:${createdDocumentId}`].sort(), "File Tree 应立即出现新页面 doc/index 行");
assert.deepEqual(afterCreate.fileRows.sort(), [`doc:${createdDocumentId}`], "File Tree 应立即出现新页面 .md 行");
const mindmapId = await insertMindmapThroughSlash(page);
await page.waitForFunction(
@@ -0,0 +1,205 @@
"use strict";
const assert = require("node:assert/strict");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
async function requestJson(request, path, init = {}) {
const response = await request.fetch(`${BASE_URL}${path}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${path} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function convexCall(context, kind, path, args) {
const cookies = await context.cookies(BASE_URL);
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
assert(jwt, "缺少 __convexAuthJWT cookie");
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt}`,
"content-type": "application/json",
"Convex-Client": "mnote-trash-empty-smoke",
},
body: JSON.stringify({ path, format: "convex_encoded_json", args: [args] }),
});
const body = await response.json();
if (!response.ok || body.status !== "success") {
throw new Error(`Convex ${kind} ${path} 失败: ${response.status} ${JSON.stringify(body)}`);
}
return body.value;
}
async function main() {
const stamp = Date.now();
const email = `mnote.trash.${stamp}@example.com`;
const username = `trash-${stamp}`;
const assetId = `asset_trash_${stamp}`;
const mindmapId = `mind_trash_${stamp}`;
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const request = context.request;
try {
await requestJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email, password: e2ePassword(), flow: "signUp", name: username },
},
},
});
await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded", timeout: 20_000 });
const currentUser = await convexCall(context, "query", "users:currentUser", {});
const userId = currentUser?._id;
assert(userId, "缺少 Convex currentUser._id");
const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
const workspaceId = workspaces.activeWorkspaceId;
assert(workspaceId, "缺少隔离 workspaceId");
const root = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "create", workspaceId, title: `TEST-10REVIEW-07-P3-root-${stamp}` },
});
const rootId = root.result.documentId;
const child = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "create", workspaceId, parentId: rootId, title: `TEST-10REVIEW-07-P3-child-${stamp}` },
});
const childId = child.result.documentId;
const resourceDoc = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "create", workspaceId, title: `TEST-10REVIEW-07-P3-resources-${stamp}` },
});
const resourceDocId = resourceDoc.result.documentId;
await convexCall(context, "mutation", "mediaAssets:create", {
userId,
asset: {
id: assetId,
workspace_id: workspaceId,
document_id: resourceDocId,
asset_type: "file",
file_url: null,
thumbnail_url: null,
storage_id: null,
bucket: null,
storage_path: null,
file_name: `TEST-10REVIEW-07-P3-file-${stamp}.txt`,
file_size: 12,
mime_type: "text/plain",
},
});
await convexCall(context, "mutation", "mindmaps:put", {
docId: resourceDocId,
mindmapId,
data: { root: { data: { text: `TEST-10REVIEW-07-P3-mind-${stamp}` }, children: [] } },
createOnly: true,
});
const table = await convexCall(context, "mutation", "tables:create", {
userId,
workspaceId,
documentId: resourceDocId,
title: `TEST-10REVIEW-07-P3-table-${stamp}`,
schema: {},
snapshot: null,
});
const tableId = table.id;
await convexCall(context, "mutation", "tables:update", {
userId,
tableId,
rows: [{ cells: ["p3-row"] }],
});
await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "archive", workspaceId, documentId: rootId },
});
await requestJson(request, "/api/media/batch", {
method: "POST",
data: { action: "delete", assetIds: [assetId] },
});
await requestJson(request, `/api/mindmap/${encodeURIComponent(resourceDocId)}/${encodeURIComponent(mindmapId)}`, {
method: "DELETE",
});
await requestJson(request, `/api/tables/${encodeURIComponent(tableId)}`, { method: "DELETE" });
const trashedDocs = await convexCall(context, "query", "documents:listTrashedByWorkspace", { workspaceId });
assert(trashedDocs.some((doc) => doc.id === rootId), "根页面未进入垃圾箱");
assert(trashedDocs.some((doc) => doc.id === childId), "子页面未级联进入垃圾箱");
const mediaBefore = await convexCall(context, "query", "mediaAssets:getById", { userId, id: assetId });
assert(mediaBefore?.deleted_at, "附件未进入垃圾箱");
const mindmapsBefore = await convexCall(context, "query", "mindmaps:listByWorkspace", { workspaceId, includeDeleted: true });
assert(mindmapsBefore.some((row) => row.mindmap_id === mindmapId && row.deleted_at), "mindmap 未进入垃圾箱");
const tablesBefore = await convexCall(context, "query", "tables:listByWorkspaceForSearch", {
userId,
workspaceId,
includeArchived: true,
limit: 100,
});
assert(tablesBefore.some((row) => row.id === tableId && row.deleted_at), "table 未进入垃圾箱");
const pageEmpty = await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } });
const mediaEmpty = await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId } });
const mindmapEmpty = await requestJson(request, "/api/mindmap-trash/empty", { method: "POST", data: { workspaceId } });
const tableEmpty = await requestJson(request, "/api/tables/empty-trash", { method: "POST", data: { workspaceId } });
assert.equal(await convexCall(context, "query", "documents:getMeta", { id: rootId }), null, "根页面 purge 后仍可查询");
assert.equal(await convexCall(context, "query", "documents:getMeta", { id: childId }), null, "子页面 purge 后仍可查询");
assert.equal(await convexCall(context, "query", "mediaAssets:getById", { userId, id: assetId }), null, "附件 purge 后仍可查询");
const mindmapsAfter = await convexCall(context, "query", "mindmaps:listByWorkspace", { workspaceId, includeDeleted: true });
assert(!mindmapsAfter.some((row) => row.mindmap_id === mindmapId), "mindmap purge 后仍存在");
assert.equal(await convexCall(context, "query", "tables:get", { userId, tableId }), null, "table purge 后仍可查询");
assert.equal((await convexCall(context, "query", "tables:getRows", { tableId })).length, 0, "table rows purge 后仍存在");
await requestJson(request, "/api/documents/purge", { method: "POST", data: { documentId: resourceDocId } }).catch(() => null);
console.log(JSON.stringify({
ok: true,
email,
userId,
workspaceId,
rootId,
childId,
resourceDocId,
assetId,
mindmapId,
tableId,
pageEmpty: pageEmpty.result,
mediaEmpty: mediaEmpty.result,
mindmapEmpty: mindmapEmpty.result,
tableEmpty: tableEmpty.result,
}, null, 2));
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,534 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task428-filetree-bulk-delete-selection-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, requestPath, init = {}) {
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function convexCall(context, kind, convexPath, args) {
const cookies = await context.cookies(BASE_URL);
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
assert(jwt, "缺少 __convexAuthJWT cookie");
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt}`,
"content-type": "application/json",
"Convex-Client": TASK,
},
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
});
const body = await response.json();
if (!response.ok || body.status !== "success") {
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
}
return body.value;
}
function docRowSelector(documentId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
}
function assetRowSelector(assetId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`;
}
function cssEscape(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
async function openDocumentFileTree(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
return Boolean(fileRoot || fileTab);
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForRow(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
const text = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => "");
throw new Error(`${label} 未出现: ${error.message}; filetree=${text.slice(0, 2000)}`);
});
}
async function expandDocIfNeeded(page, documentId) {
const selector = docRowSelector(documentId);
await waitForRow(page, selector, `页面 ${documentId}`);
const expanded = await page.locator(selector).first().getAttribute("aria-expanded").catch(() => null);
if (expanded === "true") return;
const clicked = await page.locator(`${selector} [data-testid="filetree-toggle"]`).first().click({ timeout: 2_000 }).then(() => true).catch(() => false);
if (!clicked) {
await page.locator(selector).first().dblclick({ timeout: 2_000 }).catch(() => undefined);
}
}
async function selectedRows(page) {
return page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]').evaluateAll((rows) =>
rows.map((row) => ({
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "",
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
text: row.textContent || "",
})),
);
}
async function clickRow(page, selector, options = {}) {
await page.locator(selector).first().click({ timeout: UI_TIMEOUT_MS, ...options });
}
async function rightClickRow(page, selector) {
await page.locator(selector).first().click({ button: "right", timeout: UI_TIMEOUT_MS });
}
async function closeContextMenu(page) {
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(150);
}
async function deleteSelectionAndCaptureConfirm(page, key, focusSelector) {
const target = focusSelector ? page.locator(focusSelector).first() : null;
if (focusSelector) {
await target.focus({ timeout: UI_TIMEOUT_MS });
}
const dialogPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
const message = dialog.message();
await dialog.accept();
return message;
});
if (target) {
await target.press(key, { timeout: UI_TIMEOUT_MS });
} else {
await page.keyboard.press(key);
}
return await dialogPromise.catch((error) => {
throw new Error(`${key} 未触发删除确认弹窗: ${error instanceof Error ? error.message : String(error)}`);
});
}
async function deleteSelectionAndCaptureConfirmAndAlert(page, key, focusSelector) {
const confirmPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
const message = dialog.message();
await dialog.accept();
return message;
});
await page.locator(focusSelector).first().press(key, { timeout: UI_TIMEOUT_MS });
const confirm = await confirmPromise.catch((error) => {
throw new Error(`${key} 未触发删除确认弹窗: ${error instanceof Error ? error.message : String(error)}`);
});
const alert = await page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
const message = dialog.message();
await dialog.accept();
return message;
}).catch((error) => {
throw new Error(`${key} 未触发失败摘要弹窗: ${error instanceof Error ? error.message : String(error)}`);
});
return { confirm, alert };
}
async function expectDetached(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
const state = await selectedRows(page).catch(() => []);
throw new Error(`${label} 未从 filetree 消失: ${error.message}; selection=${JSON.stringify(state)}`);
});
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: {
action: "create",
workspaceId,
parentId,
title,
},
});
const result = payload.result || payload;
assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`);
return result.documentId;
}
async function seedFixture(context, request, stamp) {
const currentUser = await convexCall(context, "query", "users:currentUser", {});
const userId = currentUser?._id;
assert(userId, "缺少 Convex currentUser._id");
const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
const workspaceId = workspaces.activeWorkspaceId;
assert(workspaceId, "缺少隔离 workspaceId");
const rootId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-root-${stamp}`);
const childId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-child-${stamp}`, rootId);
const siblingId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-sibling-${stamp}`);
const failureDocId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-failure-doc-${stamp}`);
const resourceDocId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-resources-${stamp}`);
const fileAssetA = `asset_p4_a_${stamp}`;
const fileAssetB = `asset_p4_b_${stamp}`;
const failureAssetId = `asset_p4_failure_${stamp}`;
const mindmapId = `mind_p4_${stamp}`;
for (const [assetId, fileName] of [
[fileAssetA, `TEST-10REVIEW-07-P4-file-a-${stamp}.txt`],
[fileAssetB, `TEST-10REVIEW-07-P4-file-b-${stamp}.txt`],
[failureAssetId, `TEST-10REVIEW-07-P4-failure-file-${stamp}.txt`],
]) {
await convexCall(context, "mutation", "mediaAssets:create", {
userId,
asset: {
id: assetId,
workspace_id: workspaceId,
document_id: resourceDocId,
asset_type: "file",
file_url: null,
thumbnail_url: null,
storage_id: null,
bucket: null,
storage_path: null,
file_name: fileName,
file_size: 12,
mime_type: "text/plain",
},
});
}
await convexCall(context, "mutation", "mindmaps:put", {
docId: resourceDocId,
mindmapId,
data: { root: { data: { text: `TEST-10REVIEW-07-P4-mind-${stamp}` }, children: [] } },
createOnly: true,
});
const table = await convexCall(context, "mutation", "tables:create", {
userId,
workspaceId,
documentId: resourceDocId,
title: `TEST-10REVIEW-07-P4-table-${stamp}`,
schema: {},
snapshot: null,
});
await convexCall(context, "mutation", "tables:update", {
userId,
tableId: table.id,
rows: [{ cells: ["p4-row"] }],
});
return {
userId,
workspaceId,
rootId,
childId,
siblingId,
failureDocId,
resourceDocId,
fileAssetA,
fileAssetB,
failureAssetId,
mindmapId,
tableId: table.id,
};
}
async function assertTrashState(context, fixture, stage) {
const trashedDocs = await convexCall(context, "query", "documents:listTrashedByWorkspace", {
workspaceId: fixture.workspaceId,
});
for (const documentId of stage.documentIds || []) {
assert(trashedDocs.some((doc) => doc.id === documentId), `${stage.name}: 页面 ${documentId} 未进入垃圾箱`);
}
for (const assetId of stage.fileAssetIds || []) {
const row = await convexCall(context, "query", "mediaAssets:getById", { userId: fixture.userId, id: assetId });
assert(row?.deleted_at, `${stage.name}: 附件 ${assetId} 未进入垃圾箱`);
}
if (stage.mindmapId) {
const mindmaps = await convexCall(context, "query", "mindmaps:listByWorkspace", {
workspaceId: fixture.workspaceId,
includeDeleted: true,
});
assert(
mindmaps.some((row) => row.mindmap_id === stage.mindmapId && row.deleted_at),
`${stage.name}: mindmap ${stage.mindmapId} 未进入垃圾箱`,
);
}
if (stage.tableId) {
const tables = await convexCall(context, "query", "tables:listByWorkspaceForSearch", {
userId: fixture.userId,
workspaceId: fixture.workspaceId,
includeArchived: true,
limit: 100,
});
assert(tables.some((row) => row.id === stage.tableId && row.deleted_at), `${stage.name}: table ${stage.tableId} 未进入垃圾箱`);
}
}
async function cleanup(context, request, fixture) {
if (!fixture?.workspaceId) return;
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
await requestJson(request, "/api/mindmap-trash/empty", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
await requestJson(request, "/api/tables/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
if (fixture.resourceDocId) {
if (fixture.failureAssetId) {
await requestJson(request, "/api/media/batch", {
method: "POST",
data: { action: "delete", assetIds: [fixture.failureAssetId] },
}).catch(() => null);
await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
}
await requestJson(request, "/api/documents/purge", { method: "POST", data: { documentId: fixture.resourceDocId } }).catch(() => null);
}
await convexCall(context, "query", "users:currentUser", {}).catch(() => null);
}
async function main() {
const stamp = Date.now();
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
convexUrl: CONVEX_URL,
email: `mnote.filetree.${stamp}@example.com`,
requests: [],
confirms: [],
fixture: null,
};
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const request = context.request;
let fixture = null;
page.on("request", (req) => {
const url = req.url();
if (
url.includes("/api/tree/filetree/delete-preflight") ||
url.includes("/api/tree/commands") ||
url.includes("/api/media/batch") ||
url.includes("/api/mindmap/") ||
url.includes("/api/tables/")
) {
result.requests.push({
method: req.method(),
url,
body: req.postData() || null,
});
}
});
try {
await requestJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `filetree-${stamp}` },
},
},
});
await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
fixture = await seedFixture(context, request, stamp);
result.fixture = fixture;
await openDocumentFileTree(page, fixture.workspaceId, fixture.resourceDocId);
for (const documentId of [fixture.rootId, fixture.siblingId, fixture.failureDocId, fixture.resourceDocId]) {
await waitForRow(page, docRowSelector(documentId), `页面 ${documentId}`);
}
await expandDocIfNeeded(page, fixture.rootId);
await waitForRow(page, docRowSelector(fixture.childId), `子页面 ${fixture.childId}`);
await expandDocIfNeeded(page, fixture.resourceDocId);
for (const assetId of [fixture.fileAssetA, fixture.fileAssetB, fixture.failureAssetId, fixture.mindmapId, fixture.tableId]) {
await waitForRow(page, assetRowSelector(assetId), `资源 ${assetId}`);
}
const accel = process.platform === "darwin" ? "Meta" : "Control";
await clickRow(page, docRowSelector(fixture.rootId));
await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] });
let selected = await selectedRows(page);
assert(selected.length >= 2, `Ctrl/Cmd 多选后选区数量异常: ${JSON.stringify(selected)}`);
await clickRow(page, docRowSelector(fixture.rootId));
await clickRow(page, docRowSelector(fixture.childId), { modifiers: ["Shift"] });
selected = await selectedRows(page);
assert(selected.length >= 2, `Shift 范围选择后选区数量异常: ${JSON.stringify(selected)}`);
await clickRow(page, docRowSelector(fixture.rootId));
await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] });
const beforeContextSelected = await selectedRows(page);
await rightClickRow(page, assetRowSelector(fixture.fileAssetA));
const afterContextSelected = await selectedRows(page);
assert(
afterContextSelected.length === beforeContextSelected.length,
`右键已选项不应清空多选: before=${JSON.stringify(beforeContextSelected)} after=${JSON.stringify(afterContextSelected)}`,
);
await closeContextMenu(page);
await rightClickRow(page, assetRowSelector(fixture.fileAssetB));
selected = await selectedRows(page);
assert(
selected.length === 1 && selected[0].assetId === fixture.fileAssetB,
`右键未选项应切换 action target: ${JSON.stringify(selected)}`,
);
await closeContextMenu(page);
await clickRow(page, docRowSelector(fixture.rootId));
await clickRow(page, docRowSelector(fixture.childId), { modifiers: [accel] });
await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] });
await clickRow(page, assetRowSelector(fixture.mindmapId), { modifiers: [accel] });
await clickRow(page, assetRowSelector(fixture.tableId), { modifiers: [accel] });
selected = await selectedRows(page);
assert(selected.length >= 5, `Delete 前混合选区数量异常: ${JSON.stringify(selected)}`);
const deleteConfirm = await deleteSelectionAndCaptureConfirm(page, "Delete", assetRowSelector(fixture.tableId));
result.confirms.push({ key: "Delete", message: deleteConfirm });
assert(deleteConfirm.includes("1 个页面"), `Delete 确认文案缺少页面计数: ${deleteConfirm}`);
assert(deleteConfirm.includes("1 个附件"), `Delete 确认文案缺少附件计数: ${deleteConfirm}`);
assert(deleteConfirm.includes("1 个思维导图"), `Delete 确认文案缺少思维导图计数: ${deleteConfirm}`);
assert(deleteConfirm.includes("1 个在线表格"), `Delete 确认文案缺少在线表格计数: ${deleteConfirm}`);
await expectDetached(page, docRowSelector(fixture.rootId), "root 页面");
await expectDetached(page, docRowSelector(fixture.childId), "child 页面");
await expectDetached(page, assetRowSelector(fixture.fileAssetA), "普通附件 A");
await expectDetached(page, assetRowSelector(fixture.mindmapId), "mindmap");
await expectDetached(page, assetRowSelector(fixture.tableId), "table");
await assertTrashState(context, fixture, {
name: "Delete",
documentIds: [fixture.rootId, fixture.childId],
fileAssetIds: [fixture.fileAssetA],
mindmapId: fixture.mindmapId,
tableId: fixture.tableId,
});
await clickRow(page, docRowSelector(fixture.siblingId));
await clickRow(page, assetRowSelector(fixture.fileAssetB), { modifiers: [accel] });
selected = await selectedRows(page);
assert(selected.length >= 2, `Backspace 前混合选区数量异常: ${JSON.stringify(selected)}`);
const backspaceConfirm = await deleteSelectionAndCaptureConfirm(page, "Backspace", assetRowSelector(fixture.fileAssetB));
result.confirms.push({ key: "Backspace", message: backspaceConfirm });
assert(backspaceConfirm.includes("1 个页面"), `Backspace 确认文案缺少页面计数: ${backspaceConfirm}`);
assert(backspaceConfirm.includes("1 个附件"), `Backspace 确认文案缺少附件计数: ${backspaceConfirm}`);
await expectDetached(page, docRowSelector(fixture.siblingId), "sibling 页面");
await expectDetached(page, assetRowSelector(fixture.fileAssetB), "普通附件 B");
await assertTrashState(context, fixture, {
name: "Backspace",
documentIds: [fixture.siblingId],
fileAssetIds: [fixture.fileAssetB],
});
await clickRow(page, docRowSelector(fixture.failureDocId));
await clickRow(page, assetRowSelector(fixture.failureAssetId), { modifiers: [accel] });
let mediaBatchFailed = false;
await page.route("**/api/media/batch", async (route) => {
const postData = route.request().postData() || "";
if (postData.includes(fixture.failureAssetId)) {
mediaBatchFailed = true;
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "task428 forced media failure" }),
});
return;
}
await route.continue();
});
const failureDialogs = await deleteSelectionAndCaptureConfirmAndAlert(page, "Delete", assetRowSelector(fixture.failureAssetId));
await page.unroute("**/api/media/batch").catch(() => undefined);
result.confirms.push({ key: "Delete-partial", message: failureDialogs.confirm });
result.partialFailureAlert = failureDialogs.alert;
assert(mediaBatchFailed, "部分失败场景未拦截到 media batch 请求");
assert(failureDialogs.alert.includes("部分对象删除失败"), `失败摘要文案不正确: ${failureDialogs.alert}`);
await expectDetached(page, docRowSelector(fixture.failureDocId), "partial failure 页面");
await waitForRow(page, assetRowSelector(fixture.failureAssetId), "partial failure 附件应保留");
await assertTrashState(context, fixture, {
name: "PartialFailure",
documentIds: [fixture.failureDocId],
});
const failureAsset = await convexCall(context, "query", "mediaAssets:getById", {
userId: fixture.userId,
id: fixture.failureAssetId,
});
assert(failureAsset && !failureAsset.deleted_at, "部分失败后附件不应被错误移入垃圾箱");
result.ok = true;
await writeResult(result);
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.selectedRows = await selectedRows(page).catch(() => []);
result.filetreeText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => "");
await writeResult(result);
throw error;
} finally {
await cleanup(context, request, fixture).catch((error) => {
console.warn(`清理 task428 临时数据失败: ${error instanceof Error ? error.message : String(error)}`);
});
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,323 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task429-trash-restore-location-reveal-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, requestPath, init = {}) {
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function convexCall(context, kind, convexPath, args) {
const cookies = await context.cookies(BASE_URL);
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
assert(jwt, "缺少 __convexAuthJWT cookie");
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt}`,
"content-type": "application/json",
"Convex-Client": TASK,
},
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
});
const body = await response.json();
if (!response.ok || body.status !== "success") {
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
}
return body.value;
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: {
action: "create",
workspaceId,
parentId,
title,
},
});
const result = payload.result || payload;
assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`);
return result.documentId;
}
async function postTreeCommand(request, data) {
return await requestJson(request, "/api/tree/commands", {
method: "POST",
data,
});
}
function pageTreeRowSelector(documentId) {
const escaped = String(documentId).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `[data-testid="page-tree-row"][data-node-id="${escaped}"], [data-testid="wolai-sidebar-row"][data-node-id="${escaped}"], #sidebar-file-tree-root .tree-row[data-document-id="${escaped}"], #sidebar-file-tree-root .tree-row[data-doc-id="${escaped}"]`;
}
async function waitForPageTreeRow(page, documentId, label) {
await page.locator(pageTreeRowSelector(documentId)).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
const text = await page.locator('[data-testid="sidebar-page-tree-shell"], #sidebar-file-tree-root, [data-testid="wolai-sidebar"]').innerText({ timeout: 3_000 }).catch(() => "");
throw new Error(`${label} 未出现在侧边栏树: ${error.message}; tree=${text.slice(0, 1600)}`);
});
}
async function openDocumentFileTree(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
return Boolean(fileRoot || fileTab);
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
}
async function clickTrashButton(page) {
if (await page.getByRole("heading", { name: "垃圾桶" }).isVisible().catch(() => false)) {
return;
}
await page.getByRole("button", { name: /^垃圾桶\b/ }).first().click({ timeout: UI_TIMEOUT_MS });
await page.getByRole("heading", { name: "垃圾桶" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function restoreTrashItemWithDialogs(page, title, expectFallbackAlert = false) {
const confirmPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
const message = dialog.message();
await dialog.accept();
return message;
});
const clicked = await page.evaluate((targetTitle) => {
const rows = Array.from(document.querySelectorAll("div"));
for (const row of rows) {
if (!row.textContent?.includes(targetTitle)) continue;
const button = Array.from(row.querySelectorAll("button")).find((candidate) => candidate.textContent?.trim() === "恢复");
if (button instanceof HTMLButtonElement) {
button.click();
return true;
}
}
return false;
}, title);
assert(clicked, `未找到垃圾桶恢复按钮: ${title}`);
const dialogs = [await confirmPromise];
if (expectFallbackAlert) {
const alertMessage = await page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
const message = dialog.message();
await dialog.accept();
return message;
});
dialogs.push(alertMessage);
assert(dialogs.some((message) => message.includes("原父页面已不存在")), `缺少 fallback 提示: ${JSON.stringify(dialogs)}`);
}
return dialogs;
}
async function listDocuments(context, workspaceId) {
return await convexCall(context, "query", "documents:listByWorkspace", { workspaceId });
}
async function cleanup(request, workspaceId, rootId, orphanId) {
if (!workspaceId) return;
if (orphanId) {
await postTreeCommand(request, { action: "archive", workspaceId, documentId: orphanId }).catch(() => null);
}
if (rootId) {
await postTreeCommand(request, { action: "archive", workspaceId, documentId: rootId }).catch(() => null);
}
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
}
async function main() {
const stamp = Date.now();
const email = `mnote.restore.${stamp}@example.com`;
const username = `restore-${stamp}`;
const parentTitle = `TEST-10REVIEW-07-P6-parent-${stamp}`;
const childTitle = `TEST-10REVIEW-07-P6-child-${stamp}`;
const siblingTitle = `TEST-10REVIEW-07-P6-sibling-${stamp}`;
const orphanTitle = `TEST-10REVIEW-07-P6-orphan-${stamp}`;
const orphanId = `tree_restore_orphan_${stamp}`;
const missingParentId = `tree_missing_parent_${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
convexUrl: CONVEX_URL,
email,
dialogs: [],
};
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const request = context.request;
let workspaceId = null;
let parentId = null;
try {
await requestJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email, password: e2ePassword(), flow: "signUp", name: username },
},
},
});
await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
const currentUser = await convexCall(context, "query", "users:currentUser", {});
const userId = currentUser?._id;
assert(userId, "缺少 Convex currentUser._id");
const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
workspaceId = workspaces.activeWorkspaceId;
assert(workspaceId, "缺少隔离 workspaceId");
parentId = await createPage(request, workspaceId, parentTitle);
const childId = await createPage(request, workspaceId, childTitle, parentId);
const siblingId = await createPage(request, workspaceId, siblingTitle, parentId);
await convexCall(context, "mutation", "documents:create", {
id: orphanId,
workspaceId,
parentId: missingParentId,
title: orphanTitle,
accessScope: "private",
content: [],
});
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(parentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await waitForPageTreeRow(page, parentId, "父页面");
await waitForPageTreeRow(page, childId, "子页面");
await waitForPageTreeRow(page, siblingId, "兄弟页面");
await postTreeCommand(request, { action: "archive", workspaceId, documentId: childId });
await page.locator(pageTreeRowSelector(childId)).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
await postTreeCommand(request, { action: "archive", workspaceId, documentId: orphanId });
const restorePayload = await postTreeCommand(request, { action: "restore", workspaceId, documentId: childId });
result.restorePayload = restorePayload.result || restorePayload;
await waitForPageTreeRow(page, childId, "恢复后的子页面");
await page.waitForFunction(
(documentId) => {
const row = document.querySelector(`[data-testid="page-tree-row"][data-node-id="${documentId}"], [data-testid="wolai-sidebar-row"][data-node-id="${documentId}"], #sidebar-file-tree-root .tree-row[data-document-id="${documentId}"], #sidebar-file-tree-root .tree-row[data-doc-id="${documentId}"]`);
return row instanceof HTMLElement && row.getClientRects().length > 0;
},
childId,
{ timeout: UI_TIMEOUT_MS },
);
let docs = await listDocuments(context, workspaceId);
const childDoc = docs.find((doc) => doc.id === childId);
const siblingDoc = docs.find((doc) => doc.id === siblingId);
assert.equal(childDoc?.parent_id, parentId, "恢复后的子页面应回到原父页面");
assert.equal(childDoc?.sort_order, 0, "恢复后的子页面应回到原排序位");
assert.equal(siblingDoc?.sort_order, 1, "原排序位被恢复页面占用时,兄弟页面应后移");
const fallbackPayload = await postTreeCommand(request, { action: "restore", workspaceId, documentId: orphanId });
result.fallbackPayload = fallbackPayload.result || fallbackPayload;
assert.equal(
result.fallbackPayload?.execution?.restore_location?.fallback_reason,
"parent_missing_or_deleted",
`fallback restore 应返回 parent_missing_or_deleted: ${JSON.stringify(result.fallbackPayload)}`,
);
await waitForPageTreeRow(page, orphanId, "fallback 恢复后的孤儿页面");
docs = await listDocuments(context, workspaceId);
const orphanDoc = docs.find((doc) => doc.id === orphanId);
assert.equal(orphanDoc?.parent_id ?? null, null, "原父页面不存在时应恢复到根目录");
const order = await page.evaluate(
({ child, sibling }) =>
Array.from(document.querySelectorAll('[data-testid="page-tree-row"], [data-testid="wolai-sidebar-row"], #sidebar-file-tree-root .tree-row'))
.map((row) => row instanceof HTMLElement ? row.dataset.nodeId : "")
.filter((id) => id === child || id === sibling)
.filter((id, index, list) => list.indexOf(id) === index),
{ child: childId, sibling: siblingId },
);
assert.deepEqual(order, [childId, siblingId], `页面树顺序应为 child -> sibling: ${JSON.stringify(order)}`);
result.ok = true;
result.workspaceId = workspaceId;
result.parentId = parentId;
result.childId = childId;
result.siblingId = siblingId;
result.orphanId = orphanId;
await writeResult(result);
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.pageTreeText = await page.locator('[data-testid="sidebar-page-tree-shell"], #sidebar-file-tree-root, [data-testid="wolai-sidebar"]').innerText({ timeout: 3_000 }).catch(() => "");
await writeResult(result);
throw error;
} finally {
await cleanup(request, workspaceId, parentId, orphanId).catch((error) => {
console.warn(`清理 task429 临时数据失败: ${error instanceof Error ? error.message : String(error)}`);
});
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,533 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task430-vscode-explorer-stage7-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, requestPath, init = {}) {
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function requestJsonAllowError(request, requestPath, init = {}) {
let response;
try {
response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
} catch (error) {
return {
ok: false,
status: 0,
payload: {
error: error instanceof Error ? error.message : String(error),
},
};
}
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
return { ok: response.ok(), status: response.status(), payload };
}
async function convexCall(context, kind, convexPath, args) {
const cookies = await context.cookies(BASE_URL);
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
assert(jwt, "缺少 __convexAuthJWT cookie");
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt}`,
"content-type": "application/json",
"Convex-Client": TASK,
},
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
});
const body = await response.json();
if (!response.ok || body.status !== "success") {
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
}
return body.value;
}
function cssEscape(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function docRowSelector(documentId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
}
function assetRowSelector(assetId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`;
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: {
action: "create",
workspaceId,
parentId,
title,
},
});
const result = payload.result || payload;
assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`);
return result.documentId;
}
async function openDocumentFileTree(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
return Boolean(fileRoot || fileTab);
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForRow(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
const text = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => "");
throw new Error(`${label} 未出现: ${error.message}; filetree=${text.slice(0, 2000)}`);
});
}
async function expandDocIfNeeded(page, documentId) {
const selector = docRowSelector(documentId);
await waitForRow(page, selector, `页面 ${documentId}`);
const expanded = await page.locator(selector).first().getAttribute("aria-expanded").catch(() => null);
if (expanded === "true") return;
const clicked = await page.locator(`${selector} [data-testid="filetree-toggle"]`).first().click({ timeout: 2_000 }).then(() => true).catch(() => false);
if (!clicked) {
await page.locator(selector).first().dblclick({ timeout: 2_000 }).catch(() => undefined);
}
}
async function readDocument(context, workspaceId, documentId) {
const docs = await convexCall(context, "query", "documents:listByWorkspace", { workspaceId });
return docs.find((doc) => doc.id === documentId) || null;
}
async function waitForAssetFileName(context, userId, assetId, fileName) {
const deadline = Date.now() + UI_TIMEOUT_MS;
let lastAsset = null;
while (Date.now() < deadline) {
lastAsset = await convexCall(context, "query", "mediaAssets:getById", { userId, id: assetId });
if (lastAsset?.file_name === fileName) {
return lastAsset;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
assert.equal(lastAsset?.file_name, fileName, "F2 inline rename 附件后 Convex 文件名未更新");
return lastAsset;
}
async function renameRowWithF2(page, selector, nextTitle) {
await page.locator(selector).first().click({ timeout: UI_TIMEOUT_MS });
await page.locator(selector).first().press("F2", { timeout: UI_TIMEOUT_MS });
const input = page.locator(`${selector} .tree-rename-input`).first();
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await input.fill(nextTitle, { timeout: UI_TIMEOUT_MS });
await input.press("Enter", { timeout: UI_TIMEOUT_MS });
await page.locator(selector).first().getByText(nextTitle, { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function visibleContextMenuLabels(page, selector) {
await page.locator(selector).first().click({ button: "right", timeout: UI_TIMEOUT_MS });
await page.waitForTimeout(250);
const labels = ["New File", "New Folder", "Paste Into", "Refresh", "Collapse All", "Copy Path", "Reveal"];
const visible = [];
for (const label of labels) {
if (await page.getByText(label, { exact: true }).first().isVisible().catch(() => false)) {
visible.push(label);
}
}
const disabledTitles = await page.evaluate(() =>
Array.from(document.querySelectorAll("button[disabled][title]"))
.map((button) => button instanceof HTMLButtonElement ? button.title : "")
.filter(Boolean),
);
await page.keyboard.press("Escape").catch(() => undefined);
return { visible, disabledTitles };
}
async function runDropPreflight(request, payload) {
return await requestJsonAllowError(request, "/api/tree/filetree/drop-preflight", {
method: "POST",
data: payload,
});
}
function preflightRow({ rowId, rowKind, documentId, assetId = null, assetDocumentId = null, assetType = null, storagePath = null }) {
return { rowId, rowKind, documentId, assetId, assetDocumentId, assetType, storagePath };
}
async function cleanup(request, workspaceId, ids) {
if (!workspaceId) return;
for (const documentId of ids.filter(Boolean)) {
await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "archive", workspaceId, documentId },
}).catch(() => null);
}
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
}
async function runOptional(result, area, fn) {
try {
const details = await fn();
result.checks.push({ area, ok: true, details });
return details;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
result.skipped.push({ area, reason });
result.checks.push({ area, ok: false, skipped: true, reason });
return null;
}
}
async function main() {
const stamp = Date.now();
const prefix = `TEST-10REVIEW-07-P7-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
convexUrl: CONVEX_URL,
email: `mnote.stage7.${stamp}@example.com`,
prefix,
requests: [],
checks: [],
skipped: [],
};
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const request = context.request;
let workspaceId = null;
const cleanupDocIds = [];
page.on("request", (req) => {
const url = req.url();
if (
url.includes("/api/tree/commands") ||
url.includes("/api/tree/filetree/drop-preflight") ||
url.includes("/api/media/batch")
) {
result.requests.push({
method: req.method(),
url,
body: req.postData() || null,
});
}
});
try {
await requestJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `stage7-${stamp}` },
},
},
});
await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
const currentUser = await convexCall(context, "query", "users:currentUser", {});
const userId = currentUser?._id;
assert(userId, "缺少 Convex currentUser._id");
const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
workspaceId = workspaces.activeWorkspaceId;
assert(workspaceId, "缺少隔离 workspaceId");
const parentA = await createPage(request, workspaceId, `${prefix}-parent-a`);
const parentB = await createPage(request, workspaceId, `${prefix}-parent-b`);
const child = await createPage(request, workspaceId, `${prefix}-child`, parentA);
const renameDoc = await createPage(request, workspaceId, `${prefix}-rename-doc`, parentA);
const resourceDoc = await createPage(request, workspaceId, `${prefix}-resource-doc`);
cleanupDocIds.push(parentA, parentB, child, renameDoc, resourceDoc);
const assetId = `asset_p7_${stamp}`;
await convexCall(context, "mutation", "mediaAssets:create", {
userId,
asset: {
id: assetId,
workspace_id: workspaceId,
document_id: resourceDoc,
asset_type: "file",
file_url: null,
thumbnail_url: null,
storage_id: null,
bucket: null,
storage_path: null,
file_name: `${prefix}-file.txt`,
file_size: 12,
mime_type: "text/plain",
},
});
await openDocumentFileTree(page, workspaceId, resourceDoc);
for (const documentId of [parentA, parentB, resourceDoc]) {
await waitForRow(page, docRowSelector(documentId), `页面 ${documentId}`);
}
await expandDocIfNeeded(page, parentA);
await waitForRow(page, docRowSelector(child), `子页面 ${child}`);
await waitForRow(page, docRowSelector(renameDoc), `重命名页面 ${renameDoc}`);
await expandDocIfNeeded(page, resourceDoc);
await waitForRow(page, assetRowSelector(assetId), `附件 ${assetId}`);
await runOptional(result, "f2-inline-rename-doc", async () => {
const renamedDocTitle = `${prefix}-renamed-doc`;
await renameRowWithF2(page, docRowSelector(renameDoc), renamedDocTitle);
const renamedDoc = await readDocument(context, workspaceId, renameDoc);
assert.equal(renamedDoc?.title, renamedDocTitle, "F2 inline rename 页面后 Convex 标题未更新");
return { documentId: renameDoc, title: renamedDocTitle };
});
await runOptional(result, "f2-inline-rename-asset", async () => {
const renamedAssetTitle = `${prefix}-renamed-file.txt`;
await renameRowWithF2(page, assetRowSelector(assetId), renamedAssetTitle);
await waitForAssetFileName(context, userId, assetId, renamedAssetTitle);
return { assetId, fileName: renamedAssetTitle };
});
const menu = await visibleContextMenuLabels(page, docRowSelector(parentA));
result.contextMenu = menu;
const expectedMenuLabels = ["New File", "New Folder", "Paste Into", "Refresh", "Collapse All", "Copy Path", "Reveal"];
const missingMenuLabels = expectedMenuLabels.filter((label) => !menu.visible.includes(label));
if (missingMenuLabels.length > 0) {
result.skipped.push({
area: "context-menu-minimum",
reason: `当前真实 filetree 右键菜单未暴露这些 React Sidebar 菜单项: ${missingMenuLabels.join(", ")}`,
});
result.checks.push({
area: "context-menu-minimum",
ok: false,
skipped: true,
reason: `当前真实 filetree 右键菜单未暴露这些 React Sidebar 菜单项: ${missingMenuLabels.join(", ")}`,
});
}
assert(
menu.visible.length === 0 || menu.disabledTitles.some((title) => title.includes("右键 Paste Into") || title.includes("文件夹")),
`右键菜单禁用态缺少可解释原因: ${JSON.stringify(menu)}`,
);
if (missingMenuLabels.length === 0) {
result.checks.push({
area: "context-menu-minimum",
ok: true,
details: menu,
});
}
const accel = process.platform === "darwin" ? "Meta" : "Control";
await runOptional(result, "cut-paste-move", async () => {
await page.locator(docRowSelector(child)).first().click({ timeout: UI_TIMEOUT_MS });
await page.locator(docRowSelector(child)).first().press(`${accel}+X`, { timeout: UI_TIMEOUT_MS });
await page.locator(docRowSelector(parentB)).first().click({ timeout: UI_TIMEOUT_MS });
const moveRequestCount = result.requests.length;
await page.locator(docRowSelector(parentB)).first().press(`${accel}+V`, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
({ selector, expectedParent }) => {
const row = document.querySelector(selector);
return row instanceof HTMLElement && row.textContent?.includes(expectedParent);
},
{ selector: docRowSelector(parentB), expectedParent: `${prefix}-parent-b` },
{ timeout: UI_TIMEOUT_MS },
).catch(() => undefined);
await page.waitForTimeout(800);
const movedChild = await readDocument(context, workspaceId, child);
assert.equal(movedChild?.parent_id, parentB, "Ctrl/Cmd+X 后 Ctrl/Cmd+V 应移动页面到目标父页面");
const moveRequests = result.requests.slice(moveRequestCount).filter((entry) => entry.body?.includes('"action":"move"'));
assert(moveRequests.length > 0, "Cut/Paste move 未捕获到 tree move 请求");
return { documentId: child, parentId: parentB, moveRequestCount: moveRequests.length };
});
const childAfterCutPaste = await readDocument(context, workspaceId, child);
const childParentId = childAfterCutPaste?.parent_id ?? parentA;
const rows = [
preflightRow({ rowId: `doc:${parentA}`, rowKind: "doc", documentId: parentA }),
preflightRow({ rowId: `doc:${parentB}`, rowKind: "doc", documentId: parentB }),
preflightRow({ rowId: `doc:${child}`, rowKind: "doc", documentId: child }),
preflightRow({ rowId: `doc:${renameDoc}`, rowKind: "doc", documentId: renameDoc }),
preflightRow({
rowId: `asset:${assetId}`,
rowKind: "asset",
documentId: resourceDoc,
assetId,
assetDocumentId: resourceDoc,
assetType: "file",
}),
];
const documentParents = [
{ documentId: parentA, parentId: null },
{ documentId: parentB, parentId: null },
{ documentId: child, parentId: childParentId },
{ documentId: renameDoc, parentId: parentA },
{ documentId: resourceDoc, parentId: null },
];
const selfDrop = await runDropPreflight(request, {
workspaceId,
copy: false,
targetDocumentId: child,
targetRowId: `doc:${child}`,
focusedRowId: `doc:${child}`,
activeDocumentId: resourceDoc,
rowIds: [`doc:${child}`],
rows,
documentParents,
});
const parentToChildDrop = await runDropPreflight(request, {
workspaceId,
copy: false,
targetDocumentId: renameDoc,
targetRowId: `doc:${renameDoc}`,
focusedRowId: `doc:${renameDoc}`,
activeDocumentId: resourceDoc,
rowIds: [`doc:${parentA}`],
rows,
documentParents,
});
const copyDrop = await runDropPreflight(request, {
workspaceId,
copy: true,
targetDocumentId: parentB,
targetRowId: `doc:${parentB}`,
focusedRowId: `doc:${parentB}`,
activeDocumentId: resourceDoc,
rowIds: [`doc:${renameDoc}`],
rows,
documentParents,
});
if ([selfDrop, parentToChildDrop, copyDrop].some((entry) => entry.status === 0 || entry.status === 404)) {
result.skipped.push({
area: "dnd-preflight-guard",
reason: "当前 3000 入口未暴露或已断开 /api/tree/filetree/drop-preflight,无法稳定自动化 DnD preflight guard;未把入口失败伪造成通过。",
});
result.checks.push({
area: "dnd-preflight-guard",
ok: false,
skipped: true,
reason: "当前 3000 入口未暴露或已断开 /api/tree/filetree/drop-preflight",
});
} else {
assert(!selfDrop.ok, `拖到自身应被 preflight 拒绝: ${JSON.stringify(selfDrop)}`);
assert(!parentToChildDrop.ok, `父拖子应被 preflight 拒绝: ${JSON.stringify(parentToChildDrop)}`);
assert(copyDrop.ok && copyDrop.payload?.plan?.copy === true, `copy modifier preflight 应保留 copy=true: ${JSON.stringify(copyDrop)}`);
result.checks.push({
area: "dnd-preflight-guard",
ok: true,
details: {
selfDropStatus: selfDrop.status,
parentToChildDropStatus: parentToChildDrop.status,
copyDropPlan: copyDrop.payload?.plan ?? null,
},
});
}
result.dndPreflight = {
selfDropStatus: selfDrop.status,
parentToChildDropStatus: parentToChildDrop.status,
copyDropPlan: copyDrop.payload?.plan ?? null,
};
result.skipped.push({
area: "dnd-readonly-conflict",
reason: "主 Sidebar Convex filetree 当前 smoke 未构造 readonly source 与真实重名冲突确认弹窗;已有 local-folder smoke 和 bridge preflight 单测覆盖,仍需后续端到端矩阵补齐。",
});
result.ok = true;
result.workspaceId = workspaceId;
result.fixture = { parentA, parentB, child, renameDoc, resourceDoc, assetId };
await writeResult(result);
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.filetreeText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => "");
await writeResult(result);
throw error;
} finally {
await cleanup(request, workspaceId, cleanupDocIds).catch((error) => {
console.warn(`清理 task430 临时数据失败: ${error instanceof Error ? error.message : String(error)}`);
});
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,295 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task431-vscode-explorer-dnd-readonly-conflict-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, requestPath, init = {}) {
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function requestAuthJson(request, requestPath, init = {}) {
const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function requestJsonAllowError(request, requestPath, init = {}) {
let response;
try {
response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
} catch (error) {
return {
ok: false,
status: 0,
payload: { error: error instanceof Error ? error.message : String(error) },
};
}
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
return { ok: response.ok(), status: response.status(), payload };
}
async function convexCall(context, kind, convexPath, args) {
const cookies = await context.cookies(BASE_URL);
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
assert(jwt, "缺少 __convexAuthJWT cookie");
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt}`,
"content-type": "application/json",
"Convex-Client": TASK,
},
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
});
const body = await response.json();
if (!response.ok || body.status !== "success") {
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
}
return body.value;
}
async function createPage(request, workspaceId, title, parentId = null) {
const data = {
action: "create",
parentId,
title,
};
if (workspaceId) {
data.workspaceId = workspaceId;
}
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data,
});
const result = payload.result || payload;
assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`);
return result;
}
function docRow(documentId, title) {
return {
rowId: `doc:${documentId}`,
rowKind: "doc",
documentId,
assetId: null,
assetDocumentId: null,
assetType: null,
storagePath: null,
title,
};
}
async function runDropPreflight(request, payload) {
return await requestJsonAllowError(request, "/api/tree/filetree/drop-preflight", {
method: "POST",
data: payload,
});
}
async function cleanup(request, workspaceId, ids) {
if (!workspaceId) return;
for (const documentId of ids.filter(Boolean)) {
await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "archive", workspaceId, documentId },
}).catch(() => null);
}
await requestJson(request, "/api/documents/empty-trash", {
method: "POST",
data: { workspaceId },
}).catch(() => null);
}
(async () => {
const stamp = Date.now();
const prefix = `TEST-10REVIEW-07-P7-DND-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
authBaseUrl: AUTH_BASE_URL,
convexUrl: CONVEX_URL,
email: `mnote.stage7.dnd.${stamp}@example.com`,
prefix,
checks: [],
};
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const request = context.request;
let workspaceId = null;
const cleanupDocIds = [];
try {
await requestAuthJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `stage7-dnd-${stamp}` },
},
},
});
const currentUser = await convexCall(context, "query", "users:currentUser", {});
assert(currentUser?._id, "缺少 Convex currentUser._id");
const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
workspaceId = workspaces.activeWorkspaceId;
const targetResult = await createPage(request, workspaceId, `${prefix}-target`);
workspaceId = workspaceId || targetResult.workspaceId;
assert(workspaceId, "缺少隔离 workspaceId");
const target = targetResult.documentId;
const source = (await createPage(request, workspaceId, `${prefix}-same-name`)).documentId;
const existing = (await createPage(request, workspaceId, `${prefix}-same-name`, target)).documentId;
cleanupDocIds.push(target, source, existing);
const rows = [
docRow(target, `${prefix}-target`),
docRow(source, `${prefix}-same-name`),
docRow(existing, `${prefix}-same-name`),
];
const documentParents = [
{ documentId: target, parentId: null },
{ documentId: source, parentId: null },
{ documentId: existing, parentId: target },
];
const basePayload = {
workspaceId,
copy: false,
sourceCapabilities: ["read", "write", "move"],
targetCapabilities: ["read", "write", "drop"],
targetDocumentId: target,
targetRowId: `doc:${target}`,
focusedRowId: `doc:${target}`,
activeDocumentId: target,
rowIds: [`doc:${source}`],
rows,
targetChildren: [
{
rowKind: "doc",
documentId: existing,
assetId: null,
title: `${prefix}-same-name`,
},
],
documentParents,
conflictPolicy: "prompt",
};
const readonlyTarget = await runDropPreflight(request, {
...basePayload,
targetCapabilities: ["read"],
targetChildren: [],
});
assert.equal(readonlyTarget.ok, false, `readonly target 应被拒绝: ${JSON.stringify(readonlyTarget)}`);
assert.match(JSON.stringify(readonlyTarget.payload), /只读|readonly|目标位置/);
const readonlySource = await runDropPreflight(request, {
...basePayload,
sourceCapabilities: ["read"],
targetChildren: [],
});
assert.equal(readonlySource.ok, false, `readonly source 应被拒绝: ${JSON.stringify(readonlySource)}`);
assert.match(JSON.stringify(readonlySource.payload), /只读|readonly|来源/);
const conflict = await runDropPreflight(request, basePayload);
assert.equal(conflict.ok, true, `同名冲突应返回确认计划: ${JSON.stringify(conflict)}`);
assert.equal(conflict.payload?.plan?.requiresConfirmation, true);
assert.equal(conflict.payload?.plan?.conflicts?.[0]?.title, `${prefix}-same-name`);
assert.equal(conflict.payload?.plan?.conflicts?.[0]?.existingDocumentId, existing);
const normalCopy = await runDropPreflight(request, {
...basePayload,
copy: true,
sourceCapabilities: ["read"],
targetChildren: [],
});
assert.equal(normalCopy.ok, true, `copy modifier 不应被 readonly source move 规则误拒绝: ${JSON.stringify(normalCopy)}`);
assert.equal(normalCopy.payload?.plan?.copy, true);
assert.equal(normalCopy.payload?.plan?.requiresConfirmation ?? false, false);
result.checks.push(
{ area: "readonly-target", ok: true, status: readonlyTarget.status },
{ area: "readonly-source", ok: true, status: readonlySource.status },
{ area: "conflict-confirmation-plan", ok: true, conflict: conflict.payload.plan.conflicts[0] },
{ area: "copy-modifier-no-false-positive", ok: true, plan: normalCopy.payload.plan },
);
result.workspaceId = workspaceId;
result.fixture = { target, source, existing };
result.ok = true;
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
process.exitCode = 1;
} finally {
await cleanup(request, workspaceId, cleanupDocIds).catch((error) => {
result.cleanupError = error instanceof Error ? error.message : String(error);
});
await browser.close().catch(() => undefined);
await writeResult(result);
}
})();
@@ -0,0 +1,389 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task432-filetree-trash-page-dual-browser-no-refresh-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, requestPath, init = {}) {
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function requestAuthJson(request, requestPath, init = {}) {
const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
function cssEscape(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function docRowSelector(documentId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
}
function trashDocRowSelector(documentId) {
return `[data-testid="mnote-trash-workbench"] [data-trash-row="document"][data-document-id="${cssEscape(documentId)}"]`;
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: {
action: "create",
workspaceId,
parentId,
title,
},
});
const result = payload.result || payload;
const documentId = result.documentId || payload.documentId || result.id || "";
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
return { documentId, workspaceId: resolvedWorkspaceId, payload };
}
async function treeCommand(request, workspaceId, action, documentId) {
return await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action, workspaceId, documentId },
});
}
async function emptyDocumentTrash(request, workspaceId) {
return await requestJson(request, "/api/documents/empty-trash", {
method: "POST",
data: { workspaceId },
});
}
async function authenticate(request, email, name) {
await requestAuthJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email, password: e2ePassword(), flow: "signUp", name },
},
},
});
}
async function openDocumentFileTree(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
return Boolean(fileRoot || fileTab);
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(() => {
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
const fileRoot = document.getElementById("sidebar-file-tree-root");
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
}
async function openTrash(page, workspaceId) {
await page.goto(`${BASE_URL}/trash?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="mnote-trash-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function waitForVisible(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
throw new Error(`${label} 未出现: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
});
}
async function waitForDetached(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
throw new Error(`${label} 未消失: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
});
}
async function installTreeEventRecorder(page, label, records) {
await page.addInitScript(() => {
window.__MNOTE_TASK432_TREE_EVENTS__ = [];
const record = (name, event) => {
const detail = event && event.detail ? event.detail : {};
const payload = detail.payload || detail || {};
window.__MNOTE_TASK432_TREE_EVENTS__.push({
name,
at: Date.now(),
revision: detail.revision || payload.revision || payload.cursor || "",
op: payload && payload.data && payload.data.op ? payload.data.op : "",
});
};
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
});
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
records.push({ label, type: "request", method: request.method(), url, at: Date.now() });
}
});
page.on("requestfailed", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
records.push({
label,
type: "requestfailed",
method: request.method(),
url,
failure: request.failure()?.errorText || "",
at: Date.now(),
});
}
});
page.on("console", (message) => {
const text = message.text();
if (/tree live|EventSource|trash|error|failed/i.test(text)) {
records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() });
}
});
}
function recordNavigation(page, label, records) {
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
records.push({ label, url: frame.url(), at: Date.now() });
}
});
}
async function readPageState(page) {
return await page.evaluate(() => ({
url: window.location.href,
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
trashLiveReason:
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-reason") || "",
trashLiveAt:
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-at") || "",
filetreeRows: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
text: row.textContent || "",
})),
trashDocumentRows: Array.from(document.querySelectorAll('[data-testid="mnote-trash-workbench"] [data-trash-row="document"]')).map((row) => ({
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
text: row.textContent || "",
})),
treeEvents: window.__MNOTE_TASK432_TREE_EVENTS__ || [],
}));
}
async function snapshotStep(result, name, fileTreePage, trashPage, navigationStart) {
result.steps.push({
name,
fileTree: await readPageState(fileTreePage),
trash: await readPageState(trashPage),
navigationEvents: result.navigationEvents.slice(navigationStart),
});
}
async function cleanup(request, workspaceId, documentIds) {
if (!workspaceId) return;
for (const documentId of documentIds.filter(Boolean)) {
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
}
await emptyDocumentTrash(request, workspaceId).catch(() => null);
}
(async () => {
const stamp = Date.now();
const email = `mnote.stage8.page.${stamp}@example.com`;
const prefix = `TEST-10REVIEW-07-P8-PAGE-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
authBaseUrl: AUTH_BASE_URL,
email,
prefix,
fixture: {},
requests: [],
navigationEvents: [],
treeEventRequests: [],
steps: [],
};
const browser = await chromium.launch({ headless: true });
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const pageA = await contextA.newPage();
const fileTreeB = await contextB.newPage();
const trashB = await contextB.newPage();
const requestA = contextA.request;
let workspaceId = "";
const cleanupIds = [];
pageA.on("request", (request) => {
const url = request.url();
if (url.includes("/api/tree/commands") || url.includes("/api/documents/empty-trash")) {
result.requests.push({ side: "A-page", method: request.method(), url, body: request.postData() || null, at: Date.now() });
}
});
for (const requestContext of [requestA, contextB.request]) {
await authenticate(requestContext, email, `stage8-page-${stamp}`);
}
await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests);
await installTreeEventRecorder(trashB, "B-trash", result.treeEventRequests);
recordNavigation(fileTreeB, "B-filetree", result.navigationEvents);
recordNavigation(trashB, "B-trash", result.navigationEvents);
try {
const root = await createPage(requestA, null, `${prefix}-root`);
workspaceId = root.workspaceId;
cleanupIds.push(root.documentId);
result.fixture.rootId = root.documentId;
result.fixture.workspaceId = workspaceId;
await pageA.goto(`${BASE_URL}/documents/${encodeURIComponent(root.documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await openDocumentFileTree(fileTreeB, workspaceId, root.documentId);
await openTrash(trashB, workspaceId);
await waitForVisible(fileTreeB, docRowSelector(root.documentId), "B 文件树 root 页面");
const navigationStart = result.navigationEvents.length;
await snapshotStep(result, "initial", fileTreeB, trashB, navigationStart);
const lifecycle = await createPage(requestA, workspaceId, `${prefix}-lifecycle`, root.documentId);
cleanupIds.push(lifecycle.documentId);
result.fixture.lifecycleId = lifecycle.documentId;
await waitForVisible(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 新建页面");
await snapshotStep(result, "create-visible-on-b", fileTreeB, trashB, navigationStart);
await treeCommand(requestA, workspaceId, "archive", lifecycle.documentId);
await waitForDetached(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 删除后");
await waitForVisible(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 删除后");
await snapshotStep(result, "archive-visible-on-b", fileTreeB, trashB, navigationStart);
await treeCommand(requestA, workspaceId, "restore", lifecycle.documentId);
await waitForVisible(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 恢复后");
await waitForDetached(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 恢复后");
await snapshotStep(result, "restore-visible-on-b", fileTreeB, trashB, navigationStart);
await treeCommand(requestA, workspaceId, "archive", lifecycle.documentId);
await waitForVisible(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 再次删除后");
await treeCommand(requestA, workspaceId, "purge", lifecycle.documentId);
await waitForDetached(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 彻底删除后");
await waitForDetached(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 彻底删除后");
await snapshotStep(result, "purge-visible-on-b", fileTreeB, trashB, navigationStart);
const emptyA = await createPage(requestA, workspaceId, `${prefix}-empty-a`, root.documentId);
const emptyB = await createPage(requestA, workspaceId, `${prefix}-empty-b`, root.documentId);
cleanupIds.push(emptyA.documentId, emptyB.documentId);
result.fixture.emptyIds = [emptyA.documentId, emptyB.documentId];
await waitForVisible(fileTreeB, docRowSelector(emptyA.documentId), "B 文件树 empty-a 新建后");
await waitForVisible(fileTreeB, docRowSelector(emptyB.documentId), "B 文件树 empty-b 新建后");
await treeCommand(requestA, workspaceId, "archive", emptyA.documentId);
await treeCommand(requestA, workspaceId, "archive", emptyB.documentId);
await waitForVisible(trashB, trashDocRowSelector(emptyA.documentId), "B 垃圾箱 empty-a 删除后");
await waitForVisible(trashB, trashDocRowSelector(emptyB.documentId), "B 垃圾箱 empty-b 删除后");
await emptyDocumentTrash(requestA, workspaceId);
await waitForDetached(trashB, trashDocRowSelector(emptyA.documentId), "B 垃圾箱 empty-a 清空后");
await waitForDetached(trashB, trashDocRowSelector(emptyB.documentId), "B 垃圾箱 empty-b 清空后");
await waitForDetached(fileTreeB, docRowSelector(emptyA.documentId), "B 文件树 empty-a 清空后");
await waitForDetached(fileTreeB, docRowSelector(emptyB.documentId), "B 文件树 empty-b 清空后");
await snapshotStep(result, "empty-trash-visible-on-b", fileTreeB, trashB, navigationStart);
const unexpectedNavigations = result.navigationEvents.slice(navigationStart);
assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`);
result.ok = true;
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.failure = {
fileTree: await readPageState(fileTreeB).catch(() => null),
trash: await readPageState(trashB).catch(() => null),
};
process.exitCode = 1;
} finally {
await cleanup(requestA, workspaceId, cleanupIds).catch((error) => {
result.cleanupError = error instanceof Error ? error.message : String(error);
});
await browser.close().catch(() => undefined);
await writeResult(result);
}
})();
@@ -0,0 +1,425 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, requestPath, init = {}) {
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 30_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function requestAuthJson(request, requestPath, init = {}) {
const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
function cssEscape(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function docRowSelector(documentId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
}
function assetRowSelector(assetId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`;
}
function trashAssetRowSelector(assetId) {
return `[data-testid="mnote-trash-workbench"] [data-trash-row="resource"][data-resource-kind="media"][data-resource-id="${cssEscape(assetId)}"]`;
}
async function authenticate(request, email, name) {
await requestAuthJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email, password: e2ePassword(), flow: "signUp", name },
},
},
});
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "create", workspaceId, parentId, title },
});
const result = payload.result || payload;
const documentId = result.documentId || payload.documentId || result.id || "";
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
return { documentId, workspaceId: resolvedWorkspaceId, payload };
}
async function treeCommand(request, workspaceId, action, documentId) {
return await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action, workspaceId, documentId },
});
}
async function uploadFileAsset(request, workspaceId, documentId, fileName, text) {
const response = await request.fetch(`${BASE_URL}/api/media/upload`, {
method: "POST",
multipart: {
workspaceId,
documentId,
file: {
name: fileName,
mimeType: "text/plain",
buffer: Buffer.from(text, "utf8"),
},
},
timeout: 30_000,
});
const payload = await response.json().catch(async () => await response.text());
if (!response.ok()) {
throw new Error(`/api/media/upload 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
const assetId = payload?.asset?.id || payload?.assetId || "";
assert(assetId, `上传附件缺少 asset id: ${JSON.stringify(payload)}`);
return { assetId, payload };
}
async function archiveAsset(request, assetId) {
return await requestJson(request, "/api/media/batch", {
method: "POST",
data: { action: "delete", assetIds: [assetId] },
});
}
async function restoreAsset(request, assetId) {
return await requestJson(request, "/api/media/batch", {
method: "POST",
data: { action: "restore", assetIds: [assetId] },
});
}
async function purgeAsset(request, assetId) {
return await requestJson(request, "/api/media/purge", {
method: "POST",
data: { assetId },
});
}
async function emptyResourceTrash(request, workspaceId) {
return await requestJson(request, "/api/media/empty-trash", {
method: "POST",
data: { workspaceId },
});
}
async function openDocumentFileTree(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
return Boolean(fileRoot || fileTab);
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(() => {
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
const fileRoot = document.getElementById("sidebar-file-tree-root");
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
}
async function openTrash(page, workspaceId) {
await page.goto(`${BASE_URL}/trash?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="mnote-trash-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function waitForVisible(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
throw new Error(`${label} 未出现: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
});
}
async function waitForDetached(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
throw new Error(`${label} 未消失: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
});
}
async function installTreeEventRecorder(page, label, records) {
await page.addInitScript(() => {
window.__MNOTE_TASK433_TREE_EVENTS__ = [];
const record = (name, event) => {
const detail = event && event.detail ? event.detail : {};
const payload = detail.payload || detail || {};
window.__MNOTE_TASK433_TREE_EVENTS__.push({
name,
at: Date.now(),
revision: detail.revision || payload.revision || payload.cursor || "",
op: payload && payload.data && payload.data.op ? payload.data.op : "",
});
};
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
});
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
records.push({ label, type: "request", method: request.method(), url, at: Date.now() });
}
});
page.on("console", (message) => {
const text = message.text();
if (/tree live|EventSource|trash|error|failed/i.test(text)) {
records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() });
}
});
}
function recordNavigation(page, label, records) {
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
records.push({ label, url: frame.url(), at: Date.now() });
}
});
}
async function readPageState(page) {
return await page.evaluate(() => ({
url: window.location.href,
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
trashLiveReason:
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-reason") || "",
trashLiveAt:
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-at") || "",
filetreeRows: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "",
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
objectKind: row instanceof HTMLElement ? row.dataset.objectKind || "" : "",
text: row.textContent || "",
})),
trashResourceRows: Array.from(document.querySelectorAll('[data-testid="mnote-trash-workbench"] [data-trash-row="resource"]')).map((row) => ({
resourceKind: row instanceof HTMLElement ? row.dataset.resourceKind || "" : "",
resourceId: row instanceof HTMLElement ? row.dataset.resourceId || "" : "",
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
text: row.textContent || "",
})),
treeEvents: window.__MNOTE_TASK433_TREE_EVENTS__ || [],
}));
}
async function snapshotStep(result, name, fileTreePage, trashPage, navigationStart) {
result.steps.push({
name,
fileTree: await readPageState(fileTreePage),
trash: await readPageState(trashPage),
navigationEvents: result.navigationEvents.slice(navigationStart),
});
}
async function cleanup(request, workspaceId, documentIds, assetIds) {
for (const assetId of assetIds.filter(Boolean)) {
await archiveAsset(request, assetId).catch(() => null);
}
if (workspaceId) {
await emptyResourceTrash(request, workspaceId).catch(() => null);
}
for (const documentId of documentIds.filter(Boolean)) {
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
}
if (workspaceId) {
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
}
}
(async () => {
const stamp = Date.now();
const email = `mnote.stage8.file.${stamp}@example.com`;
const prefix = `TEST-10REVIEW-07-P8-FILE-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
authBaseUrl: AUTH_BASE_URL,
email,
prefix,
fixture: {},
navigationEvents: [],
treeEventRequests: [],
steps: [],
};
const browser = await chromium.launch({ headless: true });
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const requestA = contextA.request;
const fileTreeB = await contextB.newPage();
const trashB = await contextB.newPage();
let workspaceId = "";
const cleanupDocumentIds = [];
const cleanupAssetIds = [];
await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests);
await installTreeEventRecorder(trashB, "B-trash", result.treeEventRequests);
recordNavigation(fileTreeB, "B-filetree", result.navigationEvents);
recordNavigation(trashB, "B-trash", result.navigationEvents);
try {
for (const requestContext of [requestA, contextB.request]) {
await authenticate(requestContext, email, `stage8-file-${stamp}`);
}
const root = await createPage(requestA, null, `${prefix}-root`);
workspaceId = root.workspaceId;
cleanupDocumentIds.push(root.documentId);
result.fixture.rootId = root.documentId;
result.fixture.workspaceId = workspaceId;
await openDocumentFileTree(fileTreeB, workspaceId, root.documentId);
await openTrash(trashB, workspaceId);
await waitForVisible(fileTreeB, docRowSelector(root.documentId), "B 文件树 root 页面");
const navigationStart = result.navigationEvents.length;
await snapshotStep(result, "initial", fileTreeB, trashB, navigationStart);
const uploaded = await uploadFileAsset(
requestA,
workspaceId,
root.documentId,
`${prefix}-lifecycle.txt`,
`${prefix} lifecycle asset`,
);
cleanupAssetIds.push(uploaded.assetId);
result.fixture.lifecycleAssetId = uploaded.assetId;
await waitForVisible(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树上传附件");
await snapshotStep(result, "upload-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveAsset(requestA, uploaded.assetId);
await waitForDetached(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树附件删除后");
await waitForVisible(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件删除后");
await snapshotStep(result, "archive-visible-on-b", fileTreeB, trashB, navigationStart);
await restoreAsset(requestA, uploaded.assetId);
await waitForVisible(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树附件恢复后");
await waitForDetached(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件恢复后");
await snapshotStep(result, "restore-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveAsset(requestA, uploaded.assetId);
await waitForVisible(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件再次删除后");
await purgeAsset(requestA, uploaded.assetId);
await waitForDetached(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件彻底删除后");
await waitForDetached(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树附件彻底删除后");
await snapshotStep(result, "purge-visible-on-b", fileTreeB, trashB, navigationStart);
const emptyAssetA = await uploadFileAsset(requestA, workspaceId, root.documentId, `${prefix}-empty-a.txt`, "empty a");
const emptyAssetB = await uploadFileAsset(requestA, workspaceId, root.documentId, `${prefix}-empty-b.txt`, "empty b");
cleanupAssetIds.push(emptyAssetA.assetId, emptyAssetB.assetId);
result.fixture.emptyAssetIds = [emptyAssetA.assetId, emptyAssetB.assetId];
await waitForVisible(fileTreeB, assetRowSelector(emptyAssetA.assetId), "B 文件树 empty-a 上传后");
await waitForVisible(fileTreeB, assetRowSelector(emptyAssetB.assetId), "B 文件树 empty-b 上传后");
await archiveAsset(requestA, emptyAssetA.assetId);
await archiveAsset(requestA, emptyAssetB.assetId);
await waitForVisible(trashB, trashAssetRowSelector(emptyAssetA.assetId), "B 垃圾箱 empty-a 删除后");
await waitForVisible(trashB, trashAssetRowSelector(emptyAssetB.assetId), "B 垃圾箱 empty-b 删除后");
await emptyResourceTrash(requestA, workspaceId);
await waitForDetached(trashB, trashAssetRowSelector(emptyAssetA.assetId), "B 垃圾箱 empty-a 清空后");
await waitForDetached(trashB, trashAssetRowSelector(emptyAssetB.assetId), "B 垃圾箱 empty-b 清空后");
await waitForDetached(fileTreeB, assetRowSelector(emptyAssetA.assetId), "B 文件树 empty-a 清空后");
await waitForDetached(fileTreeB, assetRowSelector(emptyAssetB.assetId), "B 文件树 empty-b 清空后");
await snapshotStep(result, "empty-trash-visible-on-b", fileTreeB, trashB, navigationStart);
const unexpectedNavigations = result.navigationEvents.slice(navigationStart);
assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`);
result.ok = true;
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.failure = {
fileTree: await readPageState(fileTreeB).catch(() => null),
trash: await readPageState(trashB).catch(() => null),
};
process.exitCode = 1;
} finally {
await cleanup(requestA, workspaceId, cleanupDocumentIds, cleanupAssetIds).catch((error) => {
result.cleanupError = error instanceof Error ? error.message : String(error);
});
await browser.close().catch(() => undefined);
await writeResult(result);
}
})();
@@ -0,0 +1,558 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, requestPath, init = {}) {
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 30_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function requestAuthJson(request, requestPath, init = {}) {
const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function convexCall(context, kind, convexPath, args) {
const cookies = await context.cookies(BASE_URL);
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
assert(jwt, "缺少 __convexAuthJWT cookie");
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt}`,
"content-type": "application/json",
"Convex-Client": TASK,
},
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
});
const body = await response.json();
if (!response.ok || body.status !== "success") {
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
}
return body.value;
}
function cssEscape(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function docRowSelector(documentId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
}
function assetRowSelector(assetId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`;
}
function trashAssetRowSelector(assetId) {
return `[data-testid="mnote-trash-workbench"] [data-trash-row="resource"][data-resource-kind="media"][data-resource-id="${cssEscape(assetId)}"]`;
}
function trashResourceRowSelector(kind, resourceId) {
return `[data-testid="mnote-trash-workbench"] [data-trash-row="resource"][data-resource-kind="${cssEscape(kind)}"][data-resource-id="${cssEscape(resourceId)}"]`;
}
async function authenticate(request, email, name) {
await requestAuthJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email, password: e2ePassword(), flow: "signUp", name },
},
},
});
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "create", workspaceId, parentId, title },
});
const result = payload.result || payload;
const documentId = result.documentId || payload.documentId || result.id || "";
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
return { documentId, workspaceId: resolvedWorkspaceId, payload };
}
async function treeCommand(request, workspaceId, action, documentId) {
return await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action, workspaceId, documentId },
});
}
async function uploadFileAsset(request, workspaceId, documentId, fileName, text) {
const response = await request.fetch(`${BASE_URL}/api/media/upload`, {
method: "POST",
multipart: {
workspaceId,
documentId,
file: {
name: fileName,
mimeType: "text/plain",
buffer: Buffer.from(text, "utf8"),
},
},
timeout: 30_000,
});
const payload = await response.json().catch(async () => await response.text());
if (!response.ok()) {
throw new Error(`/api/media/upload 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
const assetId = payload?.asset?.id || payload?.assetId || "";
assert(assetId, `上传附件缺少 asset id: ${JSON.stringify(payload)}`);
return { assetId, payload };
}
async function createMindmap(request, workspaceId, documentId, mindmapId, title) {
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
method: "POST",
data: {
commandName: "mindmaps.put",
workspaceId,
createOnly: true,
data: { root: { data: { text: title }, children: [] } },
},
});
}
async function archiveMindmap(request, documentId, mindmapId) {
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
method: "DELETE",
});
}
async function restoreMindmap(request, documentId, mindmapId) {
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
method: "PATCH",
data: { action: "restore" },
});
}
async function purgeMindmap(request, documentId, mindmapId) {
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
method: "PATCH",
data: { action: "purge" },
});
}
async function createTable(request, documentId, title) {
const payload = await requestJson(request, "/api/tables/create", {
method: "POST",
data: {
documentId,
title,
schema: {},
snapshot: null,
},
});
const tableId = payload?.result?.id || payload?.id || "";
assert(tableId, `创建 table 缺少 id: ${JSON.stringify(payload)}`);
return tableId;
}
async function archiveTable(request, tableId) {
return await requestJson(request, `/api/tables/${encodeURIComponent(tableId)}`, { method: "DELETE" });
}
async function restoreTable(request, tableId) {
return await requestJson(request, "/api/tables/restore", {
method: "POST",
data: { tableId },
});
}
async function purgeTable(request, tableId) {
return await requestJson(request, "/api/tables/purge", {
method: "POST",
data: { tableId },
});
}
async function archiveAsset(request, assetId) {
return await requestJson(request, "/api/media/batch", {
method: "POST",
data: { action: "delete", assetIds: [assetId] },
});
}
async function restoreAsset(request, assetId) {
return await requestJson(request, "/api/media/batch", {
method: "POST",
data: { action: "restore", assetIds: [assetId] },
});
}
async function purgeAsset(request, assetId) {
return await requestJson(request, "/api/media/purge", {
method: "POST",
data: { assetId },
});
}
async function emptyResourceTrash(request, workspaceId) {
return await requestJson(request, "/api/media/empty-trash", {
method: "POST",
data: { workspaceId },
});
}
async function emptyMindmapTrash(request, workspaceId) {
return await requestJson(request, "/api/mindmap-trash/empty", {
method: "POST",
data: { workspaceId },
});
}
async function emptyTableTrash(request, workspaceId) {
return await requestJson(request, "/api/tables/empty-trash", {
method: "POST",
data: { workspaceId },
});
}
async function openDocumentFileTree(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
return Boolean(fileRoot || fileTab);
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(() => {
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
const fileRoot = document.getElementById("sidebar-file-tree-root");
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
}
async function openTrash(page, workspaceId) {
await page.goto(`${BASE_URL}/trash?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="mnote-trash-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function waitForVisible(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
throw new Error(`${label} 未出现: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
});
}
async function waitForDetached(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
throw new Error(`${label} 未消失: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
});
}
async function installTreeEventRecorder(page, label, records) {
await page.addInitScript(() => {
window.__MNOTE_TASK433_TREE_EVENTS__ = [];
const record = (name, event) => {
const detail = event && event.detail ? event.detail : {};
const payload = detail.payload || detail || {};
window.__MNOTE_TASK433_TREE_EVENTS__.push({
name,
at: Date.now(),
revision: detail.revision || payload.revision || payload.cursor || "",
op: payload && payload.data && payload.data.op ? payload.data.op : "",
});
};
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
});
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
records.push({ label, type: "request", method: request.method(), url, at: Date.now() });
}
});
page.on("console", (message) => {
const text = message.text();
if (/tree live|EventSource|trash|error|failed/i.test(text)) {
records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() });
}
});
}
function recordNavigation(page, label, records) {
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
records.push({ label, url: frame.url(), at: Date.now() });
}
});
}
async function readPageState(page) {
return await page.evaluate(() => ({
url: window.location.href,
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
trashLiveReason:
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-reason") || "",
trashLiveAt:
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-at") || "",
filetreeRows: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "",
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
objectKind: row instanceof HTMLElement ? row.dataset.objectKind || "" : "",
text: row.textContent || "",
})),
trashResourceRows: Array.from(document.querySelectorAll('[data-testid="mnote-trash-workbench"] [data-trash-row="resource"]')).map((row) => ({
resourceKind: row instanceof HTMLElement ? row.dataset.resourceKind || "" : "",
resourceId: row instanceof HTMLElement ? row.dataset.resourceId || "" : "",
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
text: row.textContent || "",
})),
treeEvents: window.__MNOTE_TASK433_TREE_EVENTS__ || [],
}));
}
async function snapshotStep(result, name, fileTreePage, trashPage, navigationStart) {
result.steps.push({
name,
fileTree: await readPageState(fileTreePage),
trash: await readPageState(trashPage),
navigationEvents: result.navigationEvents.slice(navigationStart),
});
}
async function cleanup(request, workspaceId, documentIds, mindmapIds, tableIds) {
for (const mindmapId of mindmapIds.filter(Boolean)) {
for (const documentId of documentIds.filter(Boolean)) {
await archiveMindmap(request, documentId, mindmapId).catch(() => null);
}
}
for (const tableId of tableIds.filter(Boolean)) {
await archiveTable(request, tableId).catch(() => null);
}
if (workspaceId) {
await emptyMindmapTrash(request, workspaceId).catch(() => null);
await emptyTableTrash(request, workspaceId).catch(() => null);
}
for (const documentId of documentIds.filter(Boolean)) {
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
}
if (workspaceId) {
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
}
}
(async () => {
const stamp = Date.now();
const email = `mnote.stage8.mindtable.${stamp}@example.com`;
const prefix = `TEST-10REVIEW-07-P8-MT-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
authBaseUrl: AUTH_BASE_URL,
email,
prefix,
fixture: {},
navigationEvents: [],
treeEventRequests: [],
steps: [],
};
const browser = await chromium.launch({ headless: true });
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const requestA = contextA.request;
const fileTreeB = await contextB.newPage();
const trashB = await contextB.newPage();
let workspaceId = "";
const cleanupDocumentIds = [];
const cleanupMindmapIds = [];
const cleanupTableIds = [];
await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests);
await installTreeEventRecorder(trashB, "B-trash", result.treeEventRequests);
recordNavigation(fileTreeB, "B-filetree", result.navigationEvents);
recordNavigation(trashB, "B-trash", result.navigationEvents);
try {
for (const requestContext of [requestA, contextB.request]) {
await authenticate(requestContext, email, `stage8-mindtable-${stamp}`);
}
const root = await createPage(requestA, null, `${prefix}-root`);
workspaceId = root.workspaceId;
cleanupDocumentIds.push(root.documentId);
result.fixture.rootId = root.documentId;
result.fixture.workspaceId = workspaceId;
await openDocumentFileTree(fileTreeB, workspaceId, root.documentId);
await openTrash(trashB, workspaceId);
await waitForVisible(fileTreeB, docRowSelector(root.documentId), "B 文件树 root 页面");
const navigationStart = result.navigationEvents.length;
await snapshotStep(result, "initial", fileTreeB, trashB, navigationStart);
const mindmapId = `mind_stage8_${stamp}_lifecycle`;
await createMindmap(requestA, workspaceId, root.documentId, mindmapId, `${prefix}-mind-lifecycle`);
cleanupMindmapIds.push(mindmapId);
result.fixture.mindmapId = mindmapId;
await waitForVisible(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 创建后");
await snapshotStep(result, "mindmap-create-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveMindmap(requestA, root.documentId, mindmapId);
await waitForDetached(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 删除后");
await waitForVisible(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 删除后");
await snapshotStep(result, "mindmap-archive-visible-on-b", fileTreeB, trashB, navigationStart);
await restoreMindmap(requestA, root.documentId, mindmapId);
await waitForVisible(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 恢复后");
await waitForDetached(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 恢复后");
await snapshotStep(result, "mindmap-restore-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveMindmap(requestA, root.documentId, mindmapId);
await waitForVisible(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 再次删除后");
await purgeMindmap(requestA, root.documentId, mindmapId);
await waitForDetached(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 彻底删除后");
await waitForDetached(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 彻底删除后");
await snapshotStep(result, "mindmap-purge-visible-on-b", fileTreeB, trashB, navigationStart);
const tableId = await createTable(requestA, root.documentId, `${prefix}-table-lifecycle`);
cleanupTableIds.push(tableId);
result.fixture.tableId = tableId;
await waitForVisible(fileTreeB, assetRowSelector(tableId), "B 文件树 table 创建后");
await snapshotStep(result, "table-create-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveTable(requestA, tableId);
await waitForDetached(fileTreeB, assetRowSelector(tableId), "B 文件树 table 删除后");
await waitForVisible(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 删除后");
await snapshotStep(result, "table-archive-visible-on-b", fileTreeB, trashB, navigationStart);
await restoreTable(requestA, tableId);
await waitForVisible(fileTreeB, assetRowSelector(tableId), "B 文件树 table 恢复后");
await waitForDetached(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 恢复后");
await snapshotStep(result, "table-restore-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveTable(requestA, tableId);
await waitForVisible(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 再次删除后");
await purgeTable(requestA, tableId);
await waitForDetached(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 彻底删除后");
await waitForDetached(fileTreeB, assetRowSelector(tableId), "B 文件树 table 彻底删除后");
await snapshotStep(result, "table-purge-visible-on-b", fileTreeB, trashB, navigationStart);
const emptyMindmapId = `mind_stage8_${stamp}_empty`;
await createMindmap(requestA, workspaceId, root.documentId, emptyMindmapId, `${prefix}-mind-empty`);
const emptyTableId = await createTable(requestA, root.documentId, `${prefix}-table-empty`);
cleanupMindmapIds.push(emptyMindmapId);
cleanupTableIds.push(emptyTableId);
result.fixture.emptyMindmapId = emptyMindmapId;
result.fixture.emptyTableId = emptyTableId;
await waitForVisible(fileTreeB, assetRowSelector(emptyMindmapId), "B 文件树 empty mindmap 创建后");
await waitForVisible(fileTreeB, assetRowSelector(emptyTableId), "B 文件树 empty table 创建后");
await archiveMindmap(requestA, root.documentId, emptyMindmapId);
await archiveTable(requestA, emptyTableId);
await waitForVisible(trashB, trashResourceRowSelector("mindmap", emptyMindmapId), "B 垃圾箱 empty mindmap 删除后");
await waitForVisible(trashB, trashResourceRowSelector("table", emptyTableId), "B 垃圾箱 empty table 删除后");
await emptyMindmapTrash(requestA, workspaceId);
await emptyTableTrash(requestA, workspaceId);
await waitForDetached(trashB, trashResourceRowSelector("mindmap", emptyMindmapId), "B 垃圾箱 empty mindmap 清空后");
await waitForDetached(trashB, trashResourceRowSelector("table", emptyTableId), "B 垃圾箱 empty table 清空后");
await waitForDetached(fileTreeB, assetRowSelector(emptyMindmapId), "B 文件树 empty mindmap 清空后");
await waitForDetached(fileTreeB, assetRowSelector(emptyTableId), "B 文件树 empty table 清空后");
await snapshotStep(result, "mindmap-table-empty-trash-visible-on-b", fileTreeB, trashB, navigationStart);
const unexpectedNavigations = result.navigationEvents.slice(navigationStart);
assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`);
result.ok = true;
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.failure = {
fileTree: await readPageState(fileTreeB).catch(() => null),
trash: await readPageState(trashB).catch(() => null),
};
process.exitCode = 1;
} finally {
await cleanup(requestA, workspaceId, cleanupDocumentIds, cleanupMindmapIds, cleanupTableIds).catch((error) => {
result.cleanupError = error instanceof Error ? error.message : String(error);
});
await browser.close().catch(() => undefined);
await writeResult(result);
}
})();
@@ -0,0 +1,246 @@
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task435-local-folder-watch-no-reload-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function fileUrl(localPath) {
return `file://${localPath}`;
}
function treeUrl(root, 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();
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS });
}
}
async function waitForVisibleText(page, text) {
await page.waitForFunction(
(expectedText) => Array.from(document.querySelectorAll("body *")).some((element) => {
const textContent = element.textContent ? element.textContent.trim() : "";
if (textContent !== expectedText) return false;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
}),
text,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForGone(page, selector) {
await page.locator(selector).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
}
async function waitForFileTreeRow(page, rowId) {
await page.locator(`.tree-row[data-row-id="${rowId}"]`).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function waitForFileTreeRowGone(page, rowId) {
await waitForGone(page, `.tree-row[data-row-id="${rowId}"]`);
}
async function waitForPageTreeNode(page, documentId) {
await page.waitForFunction(
(expectedDocumentId) => Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]"))
.some((row) => row.getAttribute("data-node-id") === expectedDocumentId),
documentId,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForPageTreeNodeGone(page, documentId) {
await page.waitForFunction(
(expectedDocumentId) => !Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]"))
.some((row) => row.getAttribute("data-node-id") === expectedDocumentId),
documentId,
{ timeout: UI_TIMEOUT_MS },
);
}
async function runStep(label, navigationEvents, action) {
const before = navigationEvents.length;
await action();
const after = navigationEvents.length;
assert(after === before, `${label} 不应触发浏览器导航或 reloadbefore=${before} after=${after}`);
return { label, navigationEventsBefore: before, navigationEventsAfter: after };
}
async function run() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-watch-no-reload-"));
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, "README.md"), "# Local Root\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "stable.md"), "# Stable Page\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "stable-asset.txt"), "stable asset", "utf8");
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1280, height: 860 },
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const navigationEvents = [];
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
navigationEvents.push({ url: frame.url(), timestamp: Date.now() });
}
});
const steps = [];
try {
await quickLogin(page);
await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForVisibleText(page, "Local Root");
await waitForVisibleText(page, "Stable Page");
await page.waitForResponse((response) => response.url().includes("/api/tree/local-folder-watch") && response.ok(), {
timeout: UI_TIMEOUT_MS,
}).catch(() => {});
await page.waitForTimeout(100);
navigationEvents.length = 0;
steps.push(await runStep("Markdown 外部创建后 page tree 原地更新", navigationEvents, async () => {
fs.writeFileSync(path.join(root, "docs", "watcher-create.md"), "# Watcher Create\n", "utf8");
await waitForPageTreeNode(page, localMdDocumentId("docs/watcher-create.md"));
}));
steps.push(await runStep("Markdown 外部重命名后 page tree 原地更新", navigationEvents, async () => {
fs.renameSync(
path.join(root, "docs", "watcher-create.md"),
path.join(root, "docs", "watcher-renamed.md"),
);
await waitForPageTreeNode(page, localMdDocumentId("docs/watcher-renamed.md"));
await waitForPageTreeNodeGone(page, localMdDocumentId("docs/watcher-create.md"));
}));
steps.push(await runStep("Markdown 外部删除后 page tree 原地更新", navigationEvents, async () => {
fs.rmSync(path.join(root, "docs", "watcher-renamed.md"));
await waitForPageTreeNodeGone(page, localMdDocumentId("docs/watcher-renamed.md"));
}));
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForFileTreeRow(page, "local:folder:docs");
await waitForFileTreeRow(page, "local:asset:docs/stable-asset.txt");
await page.waitForResponse((response) => response.url().includes("/api/tree/local-folder-watch") && response.ok(), {
timeout: UI_TIMEOUT_MS,
}).catch(() => {});
await page.waitForTimeout(100);
navigationEvents.length = 0;
steps.push(await runStep("Markdown 外部创建后 filetree 原地更新", navigationEvents, async () => {
fs.writeFileSync(path.join(root, "docs", "watcher-filetree-md.md"), "# Watcher Filetree Markdown\n", "utf8");
await waitForFileTreeRow(page, "local:markdown:docs/watcher-filetree-md.md");
}));
steps.push(await runStep("Markdown 外部重命名后 filetree 原地更新", navigationEvents, async () => {
fs.renameSync(
path.join(root, "docs", "watcher-filetree-md.md"),
path.join(root, "docs", "watcher-filetree-md-renamed.md"),
);
await waitForFileTreeRow(page, "local:markdown:docs/watcher-filetree-md-renamed.md");
await waitForFileTreeRowGone(page, "local:markdown:docs/watcher-filetree-md.md");
}));
steps.push(await runStep("Markdown 外部删除后 filetree 原地更新", navigationEvents, async () => {
fs.rmSync(path.join(root, "docs", "watcher-filetree-md-renamed.md"));
await waitForFileTreeRowGone(page, "local:markdown:docs/watcher-filetree-md-renamed.md");
}));
steps.push(await runStep("非 md 资源外部创建后 filetree 原地更新", navigationEvents, async () => {
fs.writeFileSync(path.join(root, "docs", "watcher-asset.txt"), "watcher asset", "utf8");
await waitForFileTreeRow(page, "local:asset:docs/watcher-asset.txt");
}));
steps.push(await runStep("非 md 资源外部重命名后 filetree 原地更新", navigationEvents, async () => {
fs.renameSync(
path.join(root, "docs", "watcher-asset.txt"),
path.join(root, "docs", "watcher-asset-renamed.txt"),
);
await waitForFileTreeRow(page, "local:asset:docs/watcher-asset-renamed.txt");
await waitForFileTreeRowGone(page, "local:asset:docs/watcher-asset.txt");
}));
steps.push(await runStep("非 md 资源外部删除后 filetree 原地更新", navigationEvents, async () => {
fs.rmSync(path.join(root, "docs", "watcher-asset-renamed.txt"));
await waitForFileTreeRowGone(page, "local:asset:docs/watcher-asset-renamed.txt");
}));
steps.push(await runStep("第二类非 md 资源外部创建后 filetree 原地更新", navigationEvents, async () => {
fs.writeFileSync(path.join(root, "docs", "watcher-image.png"), "png", "utf8");
await waitForFileTreeRow(page, "local:asset:docs/watcher-image.png");
}));
steps.push(await runStep("第二类非 md 资源外部重命名后 filetree 原地更新", navigationEvents, async () => {
fs.renameSync(
path.join(root, "docs", "watcher-image.png"),
path.join(root, "docs", "watcher-image-renamed.png"),
);
await waitForFileTreeRow(page, "local:asset:docs/watcher-image-renamed.png");
await waitForFileTreeRowGone(page, "local:asset:docs/watcher-image.png");
}));
steps.push(await runStep("第二类非 md 资源外部删除后 filetree 原地更新", navigationEvents, async () => {
fs.rmSync(path.join(root, "docs", "watcher-image-renamed.png"));
await waitForFileTreeRowGone(page, "local:asset:docs/watcher-image-renamed.png");
}));
const result = {
ok: true,
baseUrl: BASE_URL,
root,
steps,
navigationEvents,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(`task435 local folder watcher no-reload smoke passed: ${RESULT_PATH}`);
} finally {
await browser.close().catch(() => {});
fs.rmSync(root, { recursive: true, force: true });
}
}
run().catch((error) => {
const result = {
ok: false,
baseUrl: BASE_URL,
error: error && error.stack ? error.stack : String(error),
};
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.error(error);
process.exit(1);
});
@@ -0,0 +1,221 @@
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task436-local-markdown-open-document-external-change-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
return url.toString();
}
function markdown(title, lines) {
return [
"---",
`title: ${title}`,
"---",
"",
...lines,
"",
].join("\n");
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS });
}
}
async function openDocument(page, root, relativePath) {
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function waitForEditorText(page, text) {
await page.waitForFunction(
(expected) => {
const editor = document.querySelector(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror");
return (editor?.textContent || "").includes(expected);
},
text,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForEditorStatus(page, status) {
await page.waitForFunction(
(expected) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
return root?.getAttribute("data-runtime-editor-status") === expected;
},
status,
{ timeout: UI_TIMEOUT_MS },
);
}
async function readEditorRuntime(page) {
return await page.evaluate(() => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = document.querySelector(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror");
return {
status: root?.getAttribute("data-runtime-editor-status") || "",
error: root?.getAttribute("data-runtime-editor-error") || "",
text: editor?.textContent || "",
};
});
}
async function typeDirtyText(page, text) {
const editor = page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.type(text, { delay: 10 });
await waitForEditorText(page, text.trim());
}
async function runStep(label, navigationEvents, action) {
const before = navigationEvents.length;
await action();
const after = navigationEvents.length;
assert.equal(after, before, `${label} 不应触发浏览器导航或 reload`);
return {
label,
navigationEventsBefore: before,
navigationEventsAfter: after,
};
}
async function run() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-open-doc-external-"));
const files = {
clean: "clean-sync.md",
dirty: "dirty-conflict.md",
rename: "rename-open.md",
delete: "delete-open.md",
};
fs.writeFileSync(path.join(root, files.clean), markdown("Clean Sync", ["initial clean"]), "utf8");
fs.writeFileSync(path.join(root, files.dirty), markdown("Dirty Conflict", ["initial dirty"]), "utf8");
fs.writeFileSync(path.join(root, files.rename), markdown("Rename Open", ["initial rename"]), "utf8");
fs.writeFileSync(path.join(root, files.delete), markdown("Delete Open", ["initial delete"]), "utf8");
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1280, height: 860 },
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const navigationEvents = [];
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
navigationEvents.push({ url: frame.url(), timestamp: Date.now() });
}
});
const steps = [];
try {
await quickLogin(page);
await openDocument(page, root, files.clean);
await waitForEditorText(page, "initial clean");
await page.waitForTimeout(300);
navigationEvents.length = 0;
steps.push(await runStep("打开文档外部修改后自动同步内容", navigationEvents, async () => {
const token = `external-clean-${Date.now()}`;
fs.writeFileSync(path.join(root, files.clean), markdown("Clean Sync", ["initial clean", token]), "utf8");
await waitForEditorText(page, token);
await waitForEditorStatus(page, "synced-external-change");
}));
await openDocument(page, root, files.dirty);
await waitForEditorText(page, "initial dirty");
await page.waitForTimeout(300);
navigationEvents.length = 0;
steps.push(await runStep("dirty 文档外部修改后进入冲突提示", navigationEvents, async () => {
const localToken = `local-dirty-${Date.now()}`;
const externalToken = `external-dirty-${Date.now()}`;
await typeDirtyText(page, ` ${localToken}`);
fs.writeFileSync(path.join(root, files.dirty), markdown("Dirty Conflict", ["initial dirty", externalToken]), "utf8");
await waitForEditorStatus(page, "external-change-conflict");
const runtime = await readEditorRuntime(page);
assert(runtime.error.includes("本地 Markdown 文件已在外部更新"), `冲突提示不正确: ${JSON.stringify(runtime)}`);
assert(runtime.text.includes(localToken), "dirty 冲突时不应静默覆盖用户正在编辑的内容");
}));
await openDocument(page, root, files.rename);
await waitForEditorText(page, "initial rename");
await page.waitForTimeout(300);
navigationEvents.length = 0;
steps.push(await runStep("打开文档外部重命名后给出冲突提示", navigationEvents, async () => {
fs.renameSync(path.join(root, files.rename), path.join(root, "rename-open-renamed.md"));
await waitForEditorStatus(page, "external-change-conflict");
const runtime = await readEditorRuntime(page);
assert(runtime.error.includes("本地 Markdown 文件已在外部更新"), `重命名提示不正确: ${JSON.stringify(runtime)}`);
}));
await openDocument(page, root, files.delete);
await waitForEditorText(page, "initial delete");
await page.waitForTimeout(300);
navigationEvents.length = 0;
steps.push(await runStep("打开文档外部删除后给出冲突提示", navigationEvents, async () => {
fs.rmSync(path.join(root, files.delete));
await waitForEditorStatus(page, "external-change-conflict");
const runtime = await readEditorRuntime(page);
assert(runtime.error.includes("本地 Markdown 文件已在外部更新"), `删除提示不正确: ${JSON.stringify(runtime)}`);
}));
const result = {
ok: true,
baseUrl: BASE_URL,
root,
steps,
navigationEvents,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(`task436 local markdown open document external change smoke passed: ${RESULT_PATH}`);
} finally {
await browser.close().catch(() => {});
fs.rmSync(root, { recursive: true, force: true });
}
}
run().catch((error) => {
const result = {
ok: false,
baseUrl: BASE_URL,
error: error && error.stack ? error.stack : String(error),
};
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,188 @@
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task437-local-folder-asset-trash-lifecycle-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
function fileUrl(localPath) {
return `file://${localPath}`;
}
function treeUrl(root) {
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("treeView", "filetree");
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
return url.toString();
}
function commandUrl() {
return `${BASE_URL}/api/tree/commands`;
}
async function waitForFileTreeRow(page, rowId) {
await page.locator(`.tree-row[data-row-id="${rowId}"]`).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function waitForFileTreeRowGone(page, rowId) {
await page.locator(`.tree-row[data-row-id="${rowId}"]`).waitFor({
state: "detached",
timeout: UI_TIMEOUT_MS,
});
}
async function postTreeCommand(page, payload) {
return await page.evaluate(async ({ url, body }) => {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const json = await response.json().catch(() => null);
return { status: response.status, json };
}, { url: commandUrl(), body: payload });
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS });
}
}
async function runStep(label, navigationEvents, action) {
const before = navigationEvents.length;
const detail = await action();
const after = navigationEvents.length;
assert.equal(after, before, `${label} 不应触发浏览器导航或 reload`);
return {
label,
navigationEventsBefore: before,
navigationEventsAfter: after,
detail: detail || null,
};
}
async function run() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-asset-trash-smoke-"));
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
fs.writeFileSync(path.join(root, "README.md"), "# Local Root\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "asset.txt"), "asset body", "utf8");
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1280, height: 860 },
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const navigationEvents = [];
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
navigationEvents.push({ url: frame.url(), timestamp: Date.now() });
}
});
const rowId = "local:asset:docs/asset.txt";
const rootUri = fileUrl(root);
const steps = [];
try {
page.on("dialog", async (dialog) => {
if (dialog.type() === "confirm") await dialog.accept();
else await dialog.dismiss().catch(() => {});
});
await quickLogin(page);
await page.goto(treeUrl(root), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForFileTreeRow(page, rowId);
navigationEvents.length = 0;
steps.push(await runStep("local asset Delete 进入本地回收站", navigationEvents, async () => {
await page.locator(`.tree-row[data-row-id="${rowId}"]`).click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Delete");
await waitForFileTreeRowGone(page, rowId);
assert(!fs.existsSync(path.join(root, "docs", "asset.txt")), "Delete 后源文件应消失");
assert(fs.existsSync(path.join(root, ".mnote", "trash", "asset.txt")), "Delete 后文件应进入 .mnote/trash");
const index = fs.readFileSync(path.join(root, ".mnote", "trash-index.json"), "utf8");
assert(index.includes("local-file:docs/asset.txt"), "trash index 应记录 local_file entry");
return { trashIndexHasLocalFile: true };
}));
steps.push(await runStep("local asset restore 回原路径", navigationEvents, async () => {
const result = await postTreeCommand(page, {
action: "restore",
sourceKind: "local_folder",
rootUri,
documentId: rowId,
});
assert.equal(result.status, 200, `restore failed: ${JSON.stringify(result)}`);
assert.equal(result.json?.result?.execution?.canonicalCommand, "tree.resource.restore");
await waitForFileTreeRow(page, rowId);
assert(fs.existsSync(path.join(root, "docs", "asset.txt")), "restore 后源文件应恢复");
return result.json?.result?.execution || null;
}));
steps.push(await runStep("local asset purge 清理 trash 文件与索引", navigationEvents, async () => {
let result = await postTreeCommand(page, {
action: "delete",
sourceKind: "local_folder",
rootUri,
documentId: rowId,
});
assert.equal(result.status, 200, `delete before purge failed: ${JSON.stringify(result)}`);
await waitForFileTreeRowGone(page, rowId);
result = await postTreeCommand(page, {
action: "purge",
sourceKind: "local_folder",
rootUri,
documentId: rowId,
});
assert.equal(result.status, 200, `purge failed: ${JSON.stringify(result)}`);
assert.equal(result.json?.result?.execution?.canonicalCommand, "tree.resource.purge");
assert(!fs.existsSync(path.join(root, "docs", "asset.txt")), "purge 后源文件不应存在");
assert(!fs.existsSync(path.join(root, ".mnote", "trash", "asset.txt")), "purge 后 trash 文件不应存在");
const index = fs.readFileSync(path.join(root, ".mnote", "trash-index.json"), "utf8");
assert(!index.includes("local-file:docs/asset.txt"), "purge 后 trash index 应清理 entry");
return result.json?.result?.execution || null;
}));
const result = {
ok: true,
baseUrl: BASE_URL,
root,
steps,
navigationEvents,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(`task437 local folder asset trash lifecycle smoke passed: ${RESULT_PATH}`);
} finally {
await browser.close().catch(() => {});
fs.rmSync(root, { recursive: true, force: true });
}
}
run().catch((error) => {
const result = {
ok: false,
baseUrl: BASE_URL,
error: error && error.stack ? error.stack : String(error),
};
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,162 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUT_DIR = path.join(ROOT, "tmp", "task441-local-folder-cloud-switch-smoke");
function fileUrl(filePath) {
return `file://${filePath.split(path.sep).map((part, index) => (index === 0 ? "" : encodeURIComponent(part))).join("/")}`;
}
function cssString(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS });
}
}
async function openFileTree(page) {
await page.evaluate(() => {
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function readCloudState(page) {
return page.evaluate(() => {
const workspaceNode = document.querySelector("#sidebar-file-tree-root[data-workspace-id], #sidebar-tree-root[data-workspace-id], [data-workspace-id]");
const workspaceId = workspaceNode instanceof HTMLElement ? workspaceNode.getAttribute("data-workspace-id") || "" : "";
const docRows = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-row-id^='doc:']")).map((row) => ({
rowId: row.getAttribute("data-row-id") || "",
title: row.querySelector(".tree-link-title")?.textContent?.trim() || "",
}));
return {
url: window.location.href,
workspaceId,
storageValue: window.localStorage.getItem("mnote.workspace.lastCloudWorkspaceId") || "",
docRows,
localRows: document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-row-id^='local:']").length,
rootText: document.querySelector("#sidebar-file-tree-root")?.textContent?.slice(0, 500) || "",
};
});
}
async function ensureCloudDocument(page) {
await openFileTree(page);
let state = await readCloudState(page);
if (state.workspaceId && state.workspaceId !== "default" && state.docRows.length > 0) {
return { workspaceId: state.workspaceId, rowId: state.docRows[0].rowId };
}
const previousPathname = new URL(page.url()).pathname;
await page.getByRole("button", { name: "新建页面" }).click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname.startsWith("/documents/") && url.pathname !== previousPathname, { timeout: UI_TIMEOUT_MS });
const documentId = new URL(page.url()).pathname.split("/").filter(Boolean).pop();
assert(documentId, "新建云空间页面后 URL 缺少 documentId");
await openFileTree(page);
await page.waitForFunction(
(id) => Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`)),
documentId,
{ timeout: UI_TIMEOUT_MS },
);
state = await readCloudState(page);
assert(state.workspaceId && state.workspaceId !== "default", `云空间 workspaceId 不应为空或 default: ${JSON.stringify(state)}`);
return { workspaceId: state.workspaceId, rowId: `doc:${documentId}` };
}
async function openLocalFolder(page, root) {
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 });
await page.locator('[data-testid="mnote-local-folder-path-input"]').fill(root);
await page.locator('[data-testid="mnote-local-folder-open-confirm"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL(
(url) => url.pathname === "/" && url.searchParams.get("sourceKind") === "local_folder" && url.searchParams.get("rootUri") === fileUrl(root),
{ timeout: UI_TIMEOUT_MS },
);
await page.locator("#sidebar-file-tree-root .tree-row[data-row-id='local:markdown:README.md']").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function switchCloud(page) {
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) => url.pathname === "/" && url.searchParams.get("sourceKind") === "convex_workspace" && !url.searchParams.has("rootUri"),
{ timeout: UI_TIMEOUT_MS },
);
}
async function main() {
await fsp.mkdir(OUT_DIR, { recursive: true });
const localRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-cloud-switch-local-"));
fs.writeFileSync(path.join(localRoot, "README.md"), "# Local README\n", "utf8");
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const result = { ok: false, baseUrl: BASE_URL, localRoot, cloudTarget: null, localState: null, returnedState: null };
try {
await quickLogin(page);
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
result.cloudTarget = await ensureCloudDocument(page);
await openLocalFolder(page, localRoot);
result.localState = await readCloudState(page);
assert(result.localState.storageValue === result.cloudTarget.workspaceId, `进入本地文件夹前应记住真实云空间 workspaceId: ${JSON.stringify(result)}`);
await switchCloud(page);
await page.waitForURL((url) => url.searchParams.get("workspaceId") === result.cloudTarget.workspaceId, {
timeout: UI_TIMEOUT_MS,
});
await openFileTree(page);
await page.waitForFunction(
(rowId) => Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="${CSS.escape(rowId)}"]`)),
result.cloudTarget.rowId,
{ timeout: UI_TIMEOUT_MS },
);
result.returnedState = await readCloudState(page);
assert.equal(result.returnedState.workspaceId, result.cloudTarget.workspaceId, "切回云空间后应恢复原 workspaceId");
assert.equal(result.returnedState.storageValue, result.cloudTarget.workspaceId, "lastCloudWorkspaceId 不应被 default 污染");
assert.notEqual(result.returnedState.storageValue, "default", "lastCloudWorkspaceId 不能是 synthetic default");
assert.equal(result.returnedState.localRows, 0, "切回云空间后不应保留本地文件夹 row");
assert(
result.returnedState.docRows.some((row) => row.rowId === result.cloudTarget.rowId),
`切回云空间后原云空间页面 row 应恢复: ${JSON.stringify(result.returnedState)}`,
);
result.ok = true;
} finally {
await fsp.writeFile(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
await browser.close().catch(() => {});
fs.rmSync(localRoot, { recursive: true, force: true });
}
console.log(JSON.stringify(result, null, 2));
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,179 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUT_DIR = path.join(ROOT, "tmp", "task442-trash-modal-workbench-smoke");
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS });
}
}
async function readState(page) {
return page.evaluate(() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const activeRow = document.querySelector("#sidebar-file-tree-root .tree-row[data-selected='true'], #sidebar-tree-root .tree-row[data-active='true']");
const modal = document.querySelector('[data-testid="mnote-trash-modal"]');
const panel = document.querySelector('[data-testid="mnote-trash-modal"] .mnote-trash-modal__panel');
const workbench = document.querySelector('[data-testid="mnote-trash-modal"] [data-testid="mnote-trash-workbench"]');
const modalRect = modal instanceof HTMLElement ? modal.getBoundingClientRect() : null;
const panelRect = panel instanceof HTMLElement ? panel.getBoundingClientRect() : null;
return {
url: window.location.href,
pathname: window.location.pathname,
modalOpen: modal instanceof HTMLElement,
workbenchVisible: workbench instanceof HTMLElement,
panelRect: panelRect ? {
left: panelRect.left,
right: panelRect.right,
top: panelRect.top,
bottom: panelRect.bottom,
width: panelRect.width,
height: panelRect.height,
} : null,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
modalRect: modalRect ? {
left: modalRect.left,
right: modalRect.right,
top: modalRect.top,
bottom: modalRect.bottom,
} : null,
fileScrollTop: fileRoot instanceof HTMLElement ? fileRoot.scrollTop : null,
activeRowId: activeRow instanceof HTMLElement ? activeRow.getAttribute("data-row-id") || activeRow.getAttribute("data-node-id") || "" : "",
bodyShell: document.body.getAttribute("data-mnote-shell") || "",
modalRole: modal instanceof HTMLElement ? modal.getAttribute("role") || "" : "",
};
});
}
async function requestJson(request, pathName, data) {
const response = await request.fetch(`${BASE_URL}${pathName}`, {
method: "POST",
headers: { "content-type": "application/json" },
data,
timeout: UI_TIMEOUT_MS,
});
const payload = await response.json().catch(() => null);
if (!response.ok()) {
throw new Error(`${pathName} failed ${response.status()}: ${JSON.stringify(payload)}`);
}
return payload;
}
async function treeCommand(request, body) {
const payload = await requestJson(request, "/api/tree/commands", body);
assert(payload?.result, `tree command 缺少 result: ${JSON.stringify(payload)}`);
return payload.result;
}
async function readWorkspaceId(page) {
const workspaceId = await page.evaluate(() => {
const node = document.querySelector("#sidebar-file-tree-root[data-workspace-id], #sidebar-tree-root[data-workspace-id], [data-workspace-id]");
return node instanceof HTMLElement ? node.getAttribute("data-workspace-id") || "" : "";
});
assert(workspaceId && workspaceId !== "default" && !workspaceId.startsWith("local:"), `缺少真实云空间 workspaceId: ${workspaceId}`);
return workspaceId;
}
async function main() {
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const requests = [];
page.on("request", (request) => {
if (request.url().includes("/trash")) requests.push({ url: request.url(), method: request.method() });
});
const result = { ok: false, baseUrl: BASE_URL, workspaceId: null, archivedDocumentId: null, before: null, afterOpen: null, afterRestore: null, afterClose: null, requests };
try {
await quickLogin(page);
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-sidebar"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
result.workspaceId = await readWorkspaceId(page);
const created = await treeCommand(context.request, {
action: "create",
workspaceId: result.workspaceId,
parentId: null,
title: `TRASH-MODAL-${Date.now().toString().slice(-6)}`,
});
result.archivedDocumentId = created.documentId;
assert(result.archivedDocumentId, `创建临时页面缺少 documentId: ${JSON.stringify(created)}`);
await treeCommand(context.request, {
action: "archive",
workspaceId: result.workspaceId,
documentId: result.archivedDocumentId,
});
result.before = await readState(page);
const trashEntry = page.locator('[data-testid="mnote-sidebar-trash-entry"], .wolai-sidebar-footer .wolai-footer-entry[href="/trash"]').first();
await trashEntry.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await trashEntry.click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-trash-modal"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-trash-modal"] [data-testid="mnote-trash-workbench"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
result.afterOpen = await readState(page);
assert.equal(result.afterOpen.url, result.before.url, "点击垃圾箱应打开弹窗,不应离开当前 URL");
assert.equal(result.afterOpen.modalOpen, true, "应出现垃圾箱弹窗");
assert.equal(result.afterOpen.workbenchVisible, true, "弹窗内应复用 mnote-trash-workbench");
assert.equal(result.afterOpen.modalRole, "dialog", "垃圾箱弹窗应使用 dialog role");
assert(result.afterOpen.panelRect, "垃圾箱弹窗应包含面板");
assert(Math.abs((result.afterOpen.panelRect.left + result.afterOpen.panelRect.right) / 2 - result.afterOpen.viewportWidth / 2) <= 8,
`垃圾箱面板应居中显示: ${JSON.stringify(result.afterOpen.panelRect)}`);
assert(result.afterOpen.panelRect.top > 20 && result.afterOpen.panelRect.bottom < result.afterOpen.viewportHeight - 20,
`垃圾箱面板应保留上下留白: ${JSON.stringify(result.afterOpen.panelRect)}`);
const trashRow = page.locator(`[data-testid="mnote-trash-modal"] [data-trash-row="document"][data-document-id="${result.archivedDocumentId}"]`);
await trashRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await trashRow.locator('[data-trash-action="restore"]').click({ timeout: UI_TIMEOUT_MS });
await trashRow.waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
result.afterRestore = await readState(page);
assert.equal(result.afterRestore.url, result.before.url, "弹窗内恢复页面后仍应停留在原 URL");
await page.locator('[data-testid="mnote-trash-modal-close"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-trash-modal"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
result.afterClose = await readState(page);
assert.equal(result.afterClose.url, result.before.url, "关闭垃圾箱弹窗后仍应停留在原 URL");
assert.equal(result.afterClose.fileScrollTop, result.before.fileScrollTop, "关闭弹窗后 File Tree scrollTop 不应被无关重置");
assert.equal(result.afterClose.activeRowId, result.before.activeRowId, "关闭弹窗后 active/selected row 不应被无关重置");
result.ok = true;
} finally {
if (result.workspaceId && result.archivedDocumentId) {
await treeCommand(context.request, {
action: "archive",
workspaceId: result.workspaceId,
documentId: result.archivedDocumentId,
}).catch(() => {});
await treeCommand(context.request, {
action: "purge",
workspaceId: result.workspaceId,
documentId: result.archivedDocumentId,
}).catch(() => {});
}
await fs.writeFile(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
await browser.close().catch(() => {});
}
console.log(JSON.stringify(result, null, 2));
}
main().catch((error) => {
console.error(error);
process.exit(1);
});