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:
@@ -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, "工作区首页");
|
||||
|
||||
@@ -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} 不应触发浏览器导航或 reload,before=${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);
|
||||
});
|
||||
Reference in New Issue
Block a user