Files
mnote/scripts/task442-trash-modal-workbench-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

180 lines
8.8 KiB
JavaScript

#!/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);
});