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