#!/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 = "task428-filetree-bulk-delete-selection-smoke"; const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").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 批量删除兼容路径,不代表 local-first 默认 File Tree 主链。 const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_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 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 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 cssEscape(value) { return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } 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 fileRoot = document.getElementById("sidebar-file-tree-root"); const visible = (node) => node instanceof HTMLElement && !node.hidden && getComputedStyle(node).display !== "none" && getComputedStyle(node).visibility !== "hidden" && node.getClientRects().length > 0; 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 waitForRow(page, selector, label) { await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => { const text = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => ""); throw new Error(`${label} 未出现: ${error.message}; filetree=${text.slice(0, 2000)}`); }); } async function expandDocIfNeeded(page, documentId) { const selector = docRowSelector(documentId); await waitForRow(page, selector, `页面 ${documentId}`); const expanded = await page.locator(selector).first().getAttribute("aria-expanded").catch(() => null); if (expanded === "true") return; const clicked = await page.locator(`${selector} [data-testid="filetree-toggle"]`).first().click({ timeout: 2_000 }).then(() => true).catch(() => false); if (!clicked) { await page.locator(selector).first().dblclick({ timeout: 2_000 }).catch(() => undefined); } } async function selectedRows(page) { return page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]').evaluateAll((rows) => rows.map((row) => ({ rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "", assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "", documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "", text: row.textContent || "", })), ); } async function clickRow(page, selector, options = {}) { await page.locator(selector).first().click({ timeout: UI_TIMEOUT_MS, ...options }); } async function rightClickRow(page, selector) { await page.locator(selector).first().click({ button: "right", timeout: UI_TIMEOUT_MS }); } async function closeContextMenu(page) { await page.keyboard.press("Escape").catch(() => undefined); await page.waitForTimeout(150); } async function deleteSelectionAndCaptureConfirm(page, key, focusSelector) { const target = focusSelector ? page.locator(focusSelector).first() : null; if (focusSelector) { await target.focus({ timeout: UI_TIMEOUT_MS }); } const dialogPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => { const message = dialog.message(); await dialog.accept(); return message; }); if (target) { await target.press(key, { timeout: UI_TIMEOUT_MS }); } else { await page.keyboard.press(key); } return await dialogPromise.catch((error) => { throw new Error(`${key} 未触发删除确认弹窗: ${error instanceof Error ? error.message : String(error)}`); }); } async function deleteSelectionAndCaptureConfirmAndAlert(page, key, focusSelector) { const confirmPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => { const message = dialog.message(); await dialog.accept(); return message; }); await page.locator(focusSelector).first().press(key, { timeout: UI_TIMEOUT_MS }); const confirm = await confirmPromise.catch((error) => { throw new Error(`${key} 未触发删除确认弹窗: ${error instanceof Error ? error.message : String(error)}`); }); const alert = await page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => { const message = dialog.message(); await dialog.accept(); return message; }).catch((error) => { throw new Error(`${key} 未触发失败摘要弹窗: ${error instanceof Error ? error.message : String(error)}`); }); return { confirm, alert }; } async function expectDetached(page, selector, label) { await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => { const state = await selectedRows(page).catch(() => []); throw new Error(`${label} 未从 filetree 消失: ${error.message}; selection=${JSON.stringify(state)}`); }); } 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; assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`); return result.documentId; } async function seedFixture(context, request, stamp) { 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 rootId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-root-${stamp}`); const childId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-child-${stamp}`, rootId); const siblingId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-sibling-${stamp}`); const failureDocId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-failure-doc-${stamp}`); const resourceDocId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-resources-${stamp}`); const fileAssetA = `asset_p4_a_${stamp}`; const fileAssetB = `asset_p4_b_${stamp}`; const failureAssetId = `asset_p4_failure_${stamp}`; const mindmapId = `mind_p4_${stamp}`; for (const [assetId, fileName] of [ [fileAssetA, `TEST-10REVIEW-07-P4-file-a-${stamp}.txt`], [fileAssetB, `TEST-10REVIEW-07-P4-file-b-${stamp}.txt`], [failureAssetId, `TEST-10REVIEW-07-P4-failure-file-${stamp}.txt`], ]) { 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: fileName, file_size: 12, mime_type: "text/plain", }, }); } await convexCall(context, "mutation", "mindmaps:put", { docId: resourceDocId, mindmapId, data: { root: { data: { text: `TEST-10REVIEW-07-P4-mind-${stamp}` }, children: [] } }, createOnly: true, }); const table = await convexCall(context, "mutation", "tables:create", { userId, workspaceId, documentId: resourceDocId, title: `TEST-10REVIEW-07-P4-table-${stamp}`, schema: {}, snapshot: null, }); await convexCall(context, "mutation", "tables:update", { userId, tableId: table.id, rows: [{ cells: ["p4-row"] }], }); return { userId, workspaceId, rootId, childId, siblingId, failureDocId, resourceDocId, fileAssetA, fileAssetB, failureAssetId, mindmapId, tableId: table.id, }; } async function assertTrashState(context, fixture, stage) { const trashedDocs = await convexCall(context, "query", "documents:listTrashedByWorkspace", { workspaceId: fixture.workspaceId, }); for (const documentId of stage.documentIds || []) { assert(trashedDocs.some((doc) => doc.id === documentId), `${stage.name}: 页面 ${documentId} 未进入垃圾箱`); } for (const assetId of stage.fileAssetIds || []) { const row = await convexCall(context, "query", "mediaAssets:getById", { userId: fixture.userId, id: assetId }); assert(row?.deleted_at, `${stage.name}: 附件 ${assetId} 未进入垃圾箱`); } if (stage.mindmapId) { const mindmaps = await convexCall(context, "query", "mindmaps:listByWorkspace", { workspaceId: fixture.workspaceId, includeDeleted: true, }); assert( mindmaps.some((row) => row.mindmap_id === stage.mindmapId && row.deleted_at), `${stage.name}: mindmap ${stage.mindmapId} 未进入垃圾箱`, ); } if (stage.tableId) { const tables = await convexCall(context, "query", "tables:listByWorkspaceForSearch", { userId: fixture.userId, workspaceId: fixture.workspaceId, includeArchived: true, limit: 100, }); assert(tables.some((row) => row.id === stage.tableId && row.deleted_at), `${stage.name}: table ${stage.tableId} 未进入垃圾箱`); } } async function cleanup(context, request, fixture) { if (!fixture?.workspaceId) return; await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null); await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null); await requestJson(request, "/api/mindmap-trash/empty", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null); await requestJson(request, "/api/tables/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null); if (fixture.resourceDocId) { if (fixture.failureAssetId) { await requestJson(request, "/api/media/batch", { method: "POST", data: { action: "delete", assetIds: [fixture.failureAssetId] }, }).catch(() => null); await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null); } await requestJson(request, "/api/documents/purge", { method: "POST", data: { documentId: fixture.resourceDocId } }).catch(() => null); } await convexCall(context, "query", "users:currentUser", {}).catch(() => null); } async function main() { const stamp = Date.now(); const result = { ok: false, task: TASK, baseUrl: BASE_URL, convexUrl: CONVEX_URL, email: `mnote.filetree.${stamp}@example.com`, requests: [], confirms: [], fixture: null, }; const browser = await chromium.launch({ headless: true }); const context = await browser.newContext(); const page = await context.newPage(); const request = context.request; let fixture = null; page.on("request", (req) => { const url = req.url(); if ( url.includes("/api/tree/filetree/delete-preflight") || url.includes("/api/tree/commands") || url.includes("/api/media/batch") || url.includes("/api/mindmap/") || url.includes("/api/tables/") ) { result.requests.push({ method: req.method(), url, body: req.postData() || null, }); } }); try { await requestJson(request, "/api/auth", { method: "POST", data: { action: "auth:signIn", args: { provider: "password", params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `filetree-${stamp}` }, }, }, }); await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS }); fixture = await seedFixture(context, request, stamp); result.fixture = fixture; await openDocumentFileTree(page, fixture.workspaceId, fixture.resourceDocId); for (const documentId of [fixture.rootId, fixture.siblingId, fixture.failureDocId, fixture.resourceDocId]) { await waitForRow(page, docRowSelector(documentId), `页面 ${documentId}`); } await expandDocIfNeeded(page, fixture.rootId); await waitForRow(page, docRowSelector(fixture.childId), `子页面 ${fixture.childId}`); await expandDocIfNeeded(page, fixture.resourceDocId); for (const assetId of [fixture.fileAssetA, fixture.fileAssetB, fixture.failureAssetId, fixture.mindmapId, fixture.tableId]) { await waitForRow(page, assetRowSelector(assetId), `资源 ${assetId}`); } const accel = process.platform === "darwin" ? "Meta" : "Control"; await clickRow(page, docRowSelector(fixture.rootId)); await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] }); let selected = await selectedRows(page); assert(selected.length >= 2, `Ctrl/Cmd 多选后选区数量异常: ${JSON.stringify(selected)}`); await clickRow(page, docRowSelector(fixture.rootId)); await clickRow(page, docRowSelector(fixture.childId), { modifiers: ["Shift"] }); selected = await selectedRows(page); assert(selected.length >= 2, `Shift 范围选择后选区数量异常: ${JSON.stringify(selected)}`); await clickRow(page, docRowSelector(fixture.rootId)); await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] }); const beforeContextSelected = await selectedRows(page); await rightClickRow(page, assetRowSelector(fixture.fileAssetA)); const afterContextSelected = await selectedRows(page); assert( afterContextSelected.length === beforeContextSelected.length, `右键已选项不应清空多选: before=${JSON.stringify(beforeContextSelected)} after=${JSON.stringify(afterContextSelected)}`, ); await closeContextMenu(page); await rightClickRow(page, assetRowSelector(fixture.fileAssetB)); selected = await selectedRows(page); assert( selected.length === 1 && selected[0].assetId === fixture.fileAssetB, `右键未选项应切换 action target: ${JSON.stringify(selected)}`, ); await closeContextMenu(page); await clickRow(page, docRowSelector(fixture.rootId)); await clickRow(page, docRowSelector(fixture.childId), { modifiers: [accel] }); await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] }); await clickRow(page, assetRowSelector(fixture.mindmapId), { modifiers: [accel] }); await clickRow(page, assetRowSelector(fixture.tableId), { modifiers: [accel] }); selected = await selectedRows(page); assert(selected.length >= 5, `Delete 前混合选区数量异常: ${JSON.stringify(selected)}`); const deleteConfirm = await deleteSelectionAndCaptureConfirm(page, "Delete", assetRowSelector(fixture.tableId)); result.confirms.push({ key: "Delete", message: deleteConfirm }); assert(deleteConfirm.includes("1 个页面"), `Delete 确认文案缺少页面计数: ${deleteConfirm}`); assert(deleteConfirm.includes("1 个附件"), `Delete 确认文案缺少附件计数: ${deleteConfirm}`); assert(deleteConfirm.includes("1 个思维导图"), `Delete 确认文案缺少思维导图计数: ${deleteConfirm}`); assert(deleteConfirm.includes("1 个在线表格"), `Delete 确认文案缺少在线表格计数: ${deleteConfirm}`); await expectDetached(page, docRowSelector(fixture.rootId), "root 页面"); await expectDetached(page, docRowSelector(fixture.childId), "child 页面"); await expectDetached(page, assetRowSelector(fixture.fileAssetA), "普通附件 A"); await expectDetached(page, assetRowSelector(fixture.mindmapId), "mindmap"); await expectDetached(page, assetRowSelector(fixture.tableId), "table"); await assertTrashState(context, fixture, { name: "Delete", documentIds: [fixture.rootId, fixture.childId], fileAssetIds: [fixture.fileAssetA], mindmapId: fixture.mindmapId, tableId: fixture.tableId, }); await clickRow(page, docRowSelector(fixture.siblingId)); await clickRow(page, assetRowSelector(fixture.fileAssetB), { modifiers: [accel] }); selected = await selectedRows(page); assert(selected.length >= 2, `Backspace 前混合选区数量异常: ${JSON.stringify(selected)}`); const backspaceConfirm = await deleteSelectionAndCaptureConfirm(page, "Backspace", assetRowSelector(fixture.fileAssetB)); result.confirms.push({ key: "Backspace", message: backspaceConfirm }); assert(backspaceConfirm.includes("1 个页面"), `Backspace 确认文案缺少页面计数: ${backspaceConfirm}`); assert(backspaceConfirm.includes("1 个附件"), `Backspace 确认文案缺少附件计数: ${backspaceConfirm}`); await expectDetached(page, docRowSelector(fixture.siblingId), "sibling 页面"); await expectDetached(page, assetRowSelector(fixture.fileAssetB), "普通附件 B"); await assertTrashState(context, fixture, { name: "Backspace", documentIds: [fixture.siblingId], fileAssetIds: [fixture.fileAssetB], }); await clickRow(page, docRowSelector(fixture.failureDocId)); await clickRow(page, assetRowSelector(fixture.failureAssetId), { modifiers: [accel] }); let mediaBatchFailed = false; await page.route("**/api/media/batch", async (route) => { const postData = route.request().postData() || ""; if (postData.includes(fixture.failureAssetId)) { mediaBatchFailed = true; await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "task428 forced media failure" }), }); return; } await route.continue(); }); const failureDialogs = await deleteSelectionAndCaptureConfirmAndAlert(page, "Delete", assetRowSelector(fixture.failureAssetId)); await page.unroute("**/api/media/batch").catch(() => undefined); result.confirms.push({ key: "Delete-partial", message: failureDialogs.confirm }); result.partialFailureAlert = failureDialogs.alert; assert(mediaBatchFailed, "部分失败场景未拦截到 media batch 请求"); assert(failureDialogs.alert.includes("部分对象删除失败"), `失败摘要文案不正确: ${failureDialogs.alert}`); await expectDetached(page, docRowSelector(fixture.failureDocId), "partial failure 页面"); await waitForRow(page, assetRowSelector(fixture.failureAssetId), "partial failure 附件应保留"); await assertTrashState(context, fixture, { name: "PartialFailure", documentIds: [fixture.failureDocId], }); const failureAsset = await convexCall(context, "query", "mediaAssets:getById", { userId: fixture.userId, id: fixture.failureAssetId, }); assert(failureAsset && !failureAsset.deleted_at, "部分失败后附件不应被错误移入垃圾箱"); result.ok = true; await writeResult(result); } catch (error) { result.error = error instanceof Error ? error.stack || error.message : String(error); result.selectedRows = await selectedRows(page).catch(() => []); result.filetreeText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => ""); await writeResult(result); throw error; } finally { await cleanup(context, request, fixture).catch((error) => { console.warn(`清理 task428 临时数据失败: ${error instanceof Error ? error.message : String(error)}`); }); await browser.close(); } } main().catch((error) => { console.error(error); process.exit(1); });