Files
mnote/scripts/task429-trash-restore-location-reveal-smoke.js
T
lix-2026 384da4e44c 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。
2026-05-16 07:38:45 +08:00

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);
});