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:
@@ -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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user