提交当前顶层 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。
324 lines
14 KiB
JavaScript
324 lines
14 KiB
JavaScript
#!/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);
|
|
});
|