Files
mnote/scripts/task427-trash-empty-isolated-workspace-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

206 lines
8.2 KiB
JavaScript

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