Files
mnote/scripts/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js
T
lix-2026 cdff672aa5 feat: align local-first workspace direction
Document the VSCode-like local-first product shape, demote Convex to a control-plane role, and retire stale architecture drafts.

Add local workspace migration/export references plus smoke coverage for no-Convex managed workspace startup, local markdown title/body/options persistence, asset upload behavior, and Convex fixture export.

Verification: git diff --cached --check; node scripts/check-local-first-convex-guard.js --staged; node scripts/task444-convex-workspace-export-local-fixture-smoke.js; node scripts/task166-local-first-managed-workspace-no-convex-smoke.js; node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js
2026-05-19 08:11:58 +08:00

560 lines
23 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 = "task434-filetree-trash-mindmap-table-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 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("");
// 说明:该 smoke 覆盖 convex-source mindmap/table trash 兼容链路,不代表 local-first 默认资源真相。
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: 30_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;
}
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;
}
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 assetRowSelector(assetId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`;
}
function trashAssetRowSelector(assetId) {
return `[data-testid="mnote-trash-workbench"] [data-trash-row="resource"][data-resource-kind="media"][data-resource-id="${cssEscape(assetId)}"]`;
}
function trashResourceRowSelector(kind, resourceId) {
return `[data-testid="mnote-trash-workbench"] [data-trash-row="resource"][data-resource-kind="${cssEscape(kind)}"][data-resource-id="${cssEscape(resourceId)}"]`;
}
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 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 uploadFileAsset(request, workspaceId, documentId, fileName, text) {
const response = await request.fetch(`${BASE_URL}/api/media/upload`, {
method: "POST",
multipart: {
workspaceId,
documentId,
file: {
name: fileName,
mimeType: "text/plain",
buffer: Buffer.from(text, "utf8"),
},
},
timeout: 30_000,
});
const payload = await response.json().catch(async () => await response.text());
if (!response.ok()) {
throw new Error(`/api/media/upload 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
const assetId = payload?.asset?.id || payload?.assetId || "";
assert(assetId, `上传附件缺少 asset id: ${JSON.stringify(payload)}`);
return { assetId, payload };
}
async function createMindmap(request, workspaceId, documentId, mindmapId, title) {
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
method: "POST",
data: {
commandName: "mindmaps.put",
workspaceId,
createOnly: true,
data: { root: { data: { text: title }, children: [] } },
},
});
}
async function archiveMindmap(request, documentId, mindmapId) {
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
method: "DELETE",
});
}
async function restoreMindmap(request, documentId, mindmapId) {
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
method: "PATCH",
data: { action: "restore" },
});
}
async function purgeMindmap(request, documentId, mindmapId) {
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
method: "PATCH",
data: { action: "purge" },
});
}
async function createTable(request, documentId, title) {
const payload = await requestJson(request, "/api/tables/create", {
method: "POST",
data: {
documentId,
title,
schema: {},
snapshot: null,
},
});
const tableId = payload?.result?.id || payload?.id || "";
assert(tableId, `创建 table 缺少 id: ${JSON.stringify(payload)}`);
return tableId;
}
async function archiveTable(request, tableId) {
return await requestJson(request, `/api/tables/${encodeURIComponent(tableId)}`, { method: "DELETE" });
}
async function restoreTable(request, tableId) {
return await requestJson(request, "/api/tables/restore", {
method: "POST",
data: { tableId },
});
}
async function purgeTable(request, tableId) {
return await requestJson(request, "/api/tables/purge", {
method: "POST",
data: { tableId },
});
}
async function archiveAsset(request, assetId) {
return await requestJson(request, "/api/media/batch", {
method: "POST",
data: { action: "delete", assetIds: [assetId] },
});
}
async function restoreAsset(request, assetId) {
return await requestJson(request, "/api/media/batch", {
method: "POST",
data: { action: "restore", assetIds: [assetId] },
});
}
async function purgeAsset(request, assetId) {
return await requestJson(request, "/api/media/purge", {
method: "POST",
data: { assetId },
});
}
async function emptyResourceTrash(request, workspaceId) {
return await requestJson(request, "/api/media/empty-trash", {
method: "POST",
data: { workspaceId },
});
}
async function emptyMindmapTrash(request, workspaceId) {
return await requestJson(request, "/api/mindmap-trash/empty", {
method: "POST",
data: { workspaceId },
});
}
async function emptyTableTrash(request, workspaceId) {
return await requestJson(request, "/api/tables/empty-trash", {
method: "POST",
data: { workspaceId },
});
}
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_TASK433_TREE_EVENTS__ = [];
const record = (name, event) => {
const detail = event && event.detail ? event.detail : {};
const payload = detail.payload || detail || {};
window.__MNOTE_TASK433_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("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 || "" : "",
assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "",
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
objectKind: row instanceof HTMLElement ? row.dataset.objectKind || "" : "",
text: row.textContent || "",
})),
trashResourceRows: Array.from(document.querySelectorAll('[data-testid="mnote-trash-workbench"] [data-trash-row="resource"]')).map((row) => ({
resourceKind: row instanceof HTMLElement ? row.dataset.resourceKind || "" : "",
resourceId: row instanceof HTMLElement ? row.dataset.resourceId || "" : "",
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
text: row.textContent || "",
})),
treeEvents: window.__MNOTE_TASK433_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, mindmapIds, tableIds) {
for (const mindmapId of mindmapIds.filter(Boolean)) {
for (const documentId of documentIds.filter(Boolean)) {
await archiveMindmap(request, documentId, mindmapId).catch(() => null);
}
}
for (const tableId of tableIds.filter(Boolean)) {
await archiveTable(request, tableId).catch(() => null);
}
if (workspaceId) {
await emptyMindmapTrash(request, workspaceId).catch(() => null);
await emptyTableTrash(request, workspaceId).catch(() => null);
}
for (const documentId of documentIds.filter(Boolean)) {
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
}
if (workspaceId) {
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
}
}
(async () => {
const stamp = Date.now();
const email = `mnote.stage8.mindtable.${stamp}@example.com`;
const prefix = `TEST-10REVIEW-07-P8-MT-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
authBaseUrl: AUTH_BASE_URL,
email,
prefix,
fixture: {},
navigationEvents: [],
treeEventRequests: [],
steps: [],
};
const browser = await chromium.launch({ headless: true });
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const requestA = contextA.request;
const fileTreeB = await contextB.newPage();
const trashB = await contextB.newPage();
let workspaceId = "";
const cleanupDocumentIds = [];
const cleanupMindmapIds = [];
const cleanupTableIds = [];
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 {
for (const requestContext of [requestA, contextB.request]) {
await authenticate(requestContext, email, `stage8-mindtable-${stamp}`);
}
const root = await createPage(requestA, null, `${prefix}-root`);
workspaceId = root.workspaceId;
cleanupDocumentIds.push(root.documentId);
result.fixture.rootId = root.documentId;
result.fixture.workspaceId = workspaceId;
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 mindmapId = `mind_stage8_${stamp}_lifecycle`;
await createMindmap(requestA, workspaceId, root.documentId, mindmapId, `${prefix}-mind-lifecycle`);
cleanupMindmapIds.push(mindmapId);
result.fixture.mindmapId = mindmapId;
await waitForVisible(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 创建后");
await snapshotStep(result, "mindmap-create-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveMindmap(requestA, root.documentId, mindmapId);
await waitForDetached(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 删除后");
await waitForVisible(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 删除后");
await snapshotStep(result, "mindmap-archive-visible-on-b", fileTreeB, trashB, navigationStart);
await restoreMindmap(requestA, root.documentId, mindmapId);
await waitForVisible(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 恢复后");
await waitForDetached(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 恢复后");
await snapshotStep(result, "mindmap-restore-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveMindmap(requestA, root.documentId, mindmapId);
await waitForVisible(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 再次删除后");
await purgeMindmap(requestA, root.documentId, mindmapId);
await waitForDetached(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 彻底删除后");
await waitForDetached(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 彻底删除后");
await snapshotStep(result, "mindmap-purge-visible-on-b", fileTreeB, trashB, navigationStart);
const tableId = await createTable(requestA, root.documentId, `${prefix}-table-lifecycle`);
cleanupTableIds.push(tableId);
result.fixture.tableId = tableId;
await waitForVisible(fileTreeB, assetRowSelector(tableId), "B 文件树 table 创建后");
await snapshotStep(result, "table-create-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveTable(requestA, tableId);
await waitForDetached(fileTreeB, assetRowSelector(tableId), "B 文件树 table 删除后");
await waitForVisible(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 删除后");
await snapshotStep(result, "table-archive-visible-on-b", fileTreeB, trashB, navigationStart);
await restoreTable(requestA, tableId);
await waitForVisible(fileTreeB, assetRowSelector(tableId), "B 文件树 table 恢复后");
await waitForDetached(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 恢复后");
await snapshotStep(result, "table-restore-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveTable(requestA, tableId);
await waitForVisible(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 再次删除后");
await purgeTable(requestA, tableId);
await waitForDetached(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 彻底删除后");
await waitForDetached(fileTreeB, assetRowSelector(tableId), "B 文件树 table 彻底删除后");
await snapshotStep(result, "table-purge-visible-on-b", fileTreeB, trashB, navigationStart);
const emptyMindmapId = `mind_stage8_${stamp}_empty`;
await createMindmap(requestA, workspaceId, root.documentId, emptyMindmapId, `${prefix}-mind-empty`);
const emptyTableId = await createTable(requestA, root.documentId, `${prefix}-table-empty`);
cleanupMindmapIds.push(emptyMindmapId);
cleanupTableIds.push(emptyTableId);
result.fixture.emptyMindmapId = emptyMindmapId;
result.fixture.emptyTableId = emptyTableId;
await waitForVisible(fileTreeB, assetRowSelector(emptyMindmapId), "B 文件树 empty mindmap 创建后");
await waitForVisible(fileTreeB, assetRowSelector(emptyTableId), "B 文件树 empty table 创建后");
await archiveMindmap(requestA, root.documentId, emptyMindmapId);
await archiveTable(requestA, emptyTableId);
await waitForVisible(trashB, trashResourceRowSelector("mindmap", emptyMindmapId), "B 垃圾箱 empty mindmap 删除后");
await waitForVisible(trashB, trashResourceRowSelector("table", emptyTableId), "B 垃圾箱 empty table 删除后");
await emptyMindmapTrash(requestA, workspaceId);
await emptyTableTrash(requestA, workspaceId);
await waitForDetached(trashB, trashResourceRowSelector("mindmap", emptyMindmapId), "B 垃圾箱 empty mindmap 清空后");
await waitForDetached(trashB, trashResourceRowSelector("table", emptyTableId), "B 垃圾箱 empty table 清空后");
await waitForDetached(fileTreeB, assetRowSelector(emptyMindmapId), "B 文件树 empty mindmap 清空后");
await waitForDetached(fileTreeB, assetRowSelector(emptyTableId), "B 文件树 empty table 清空后");
await snapshotStep(result, "mindmap-table-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, cleanupDocumentIds, cleanupMindmapIds, cleanupTableIds).catch((error) => {
result.cleanupError = error instanceof Error ? error.message : String(error);
});
await browser.close().catch(() => undefined);
await writeResult(result);
}
})();