#!/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 = "task430-vscode-explorer-stage7-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(""); 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 requestJsonAllowError(request, requestPath, init = {}) { let response; try { response = await request.fetch(`${BASE_URL}${requestPath}`, { ...init, headers: { ...(init.data !== undefined ? { "content-type": "application/json" } : {}), ...(init.headers || {}), }, timeout: 20_000, }); } catch (error) { return { ok: false, status: 0, payload: { error: error instanceof Error ? error.message : String(error), }, }; } const text = await response.text(); let payload = null; try { payload = text ? JSON.parse(text) : null; } catch { payload = text; } return { ok: response.ok(), status: response.status(), 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)}"]`; } 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 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 readDocument(context, workspaceId, documentId) { const docs = await convexCall(context, "query", "documents:listByWorkspace", { workspaceId }); return docs.find((doc) => doc.id === documentId) || null; } async function waitForAssetFileName(context, userId, assetId, fileName) { const deadline = Date.now() + UI_TIMEOUT_MS; let lastAsset = null; while (Date.now() < deadline) { lastAsset = await convexCall(context, "query", "mediaAssets:getById", { userId, id: assetId }); if (lastAsset?.file_name === fileName) { return lastAsset; } await new Promise((resolve) => setTimeout(resolve, 250)); } assert.equal(lastAsset?.file_name, fileName, "F2 inline rename 附件后 Convex 文件名未更新"); return lastAsset; } async function renameRowWithF2(page, selector, nextTitle) { await page.locator(selector).first().click({ timeout: UI_TIMEOUT_MS }); await page.locator(selector).first().press("F2", { timeout: UI_TIMEOUT_MS }); const input = page.locator(`${selector} .tree-rename-input`).first(); await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await input.fill(nextTitle, { timeout: UI_TIMEOUT_MS }); await input.press("Enter", { timeout: UI_TIMEOUT_MS }); await page.locator(selector).first().getByText(nextTitle, { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); } async function visibleContextMenuLabels(page, selector) { await page.locator(selector).first().click({ button: "right", timeout: UI_TIMEOUT_MS }); await page.waitForTimeout(250); const labels = ["New File", "New Folder", "Paste Into", "Refresh", "Collapse All", "Copy Path", "Reveal"]; const visible = []; for (const label of labels) { if (await page.getByText(label, { exact: true }).first().isVisible().catch(() => false)) { visible.push(label); } } const disabledTitles = await page.evaluate(() => Array.from(document.querySelectorAll("button[disabled][title]")) .map((button) => button instanceof HTMLButtonElement ? button.title : "") .filter(Boolean), ); await page.keyboard.press("Escape").catch(() => undefined); return { visible, disabledTitles }; } async function runDropPreflight(request, payload) { return await requestJsonAllowError(request, "/api/tree/filetree/drop-preflight", { method: "POST", data: payload, }); } function preflightRow({ rowId, rowKind, documentId, assetId = null, assetDocumentId = null, assetType = null, storagePath = null }) { return { rowId, rowKind, documentId, assetId, assetDocumentId, assetType, storagePath }; } async function cleanup(request, workspaceId, ids) { if (!workspaceId) return; for (const documentId of ids.filter(Boolean)) { await requestJson(request, "/api/tree/commands", { method: "POST", data: { action: "archive", workspaceId, documentId }, }).catch(() => null); } await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null); await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null); } async function runOptional(result, area, fn) { try { const details = await fn(); result.checks.push({ area, ok: true, details }); return details; } catch (error) { const reason = error instanceof Error ? error.message : String(error); result.skipped.push({ area, reason }); result.checks.push({ area, ok: false, skipped: true, reason }); return null; } } async function main() { const stamp = Date.now(); const prefix = `TEST-10REVIEW-07-P7-${stamp}`; const result = { ok: false, task: TASK, baseUrl: BASE_URL, convexUrl: CONVEX_URL, email: `mnote.stage7.${stamp}@example.com`, prefix, requests: [], checks: [], skipped: [], }; const browser = await chromium.launch({ headless: true }); const context = await browser.newContext(); const page = await context.newPage(); const request = context.request; let workspaceId = null; const cleanupDocIds = []; page.on("request", (req) => { const url = req.url(); if ( url.includes("/api/tree/commands") || url.includes("/api/tree/filetree/drop-preflight") || url.includes("/api/media/batch") ) { 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: `stage7-${stamp}` }, }, }, }); await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS }); 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", {}); workspaceId = workspaces.activeWorkspaceId; assert(workspaceId, "缺少隔离 workspaceId"); const parentA = await createPage(request, workspaceId, `${prefix}-parent-a`); const parentB = await createPage(request, workspaceId, `${prefix}-parent-b`); const child = await createPage(request, workspaceId, `${prefix}-child`, parentA); const renameDoc = await createPage(request, workspaceId, `${prefix}-rename-doc`, parentA); const resourceDoc = await createPage(request, workspaceId, `${prefix}-resource-doc`); cleanupDocIds.push(parentA, parentB, child, renameDoc, resourceDoc); const assetId = `asset_p7_${stamp}`; await convexCall(context, "mutation", "mediaAssets:create", { userId, asset: { id: assetId, workspace_id: workspaceId, document_id: resourceDoc, asset_type: "file", file_url: null, thumbnail_url: null, storage_id: null, bucket: null, storage_path: null, file_name: `${prefix}-file.txt`, file_size: 12, mime_type: "text/plain", }, }); await openDocumentFileTree(page, workspaceId, resourceDoc); for (const documentId of [parentA, parentB, resourceDoc]) { await waitForRow(page, docRowSelector(documentId), `页面 ${documentId}`); } await expandDocIfNeeded(page, parentA); await waitForRow(page, docRowSelector(child), `子页面 ${child}`); await waitForRow(page, docRowSelector(renameDoc), `重命名页面 ${renameDoc}`); await expandDocIfNeeded(page, resourceDoc); await waitForRow(page, assetRowSelector(assetId), `附件 ${assetId}`); await runOptional(result, "f2-inline-rename-doc", async () => { const renamedDocTitle = `${prefix}-renamed-doc`; await renameRowWithF2(page, docRowSelector(renameDoc), renamedDocTitle); const renamedDoc = await readDocument(context, workspaceId, renameDoc); assert.equal(renamedDoc?.title, renamedDocTitle, "F2 inline rename 页面后 Convex 标题未更新"); return { documentId: renameDoc, title: renamedDocTitle }; }); await runOptional(result, "f2-inline-rename-asset", async () => { const renamedAssetTitle = `${prefix}-renamed-file.txt`; await renameRowWithF2(page, assetRowSelector(assetId), renamedAssetTitle); await waitForAssetFileName(context, userId, assetId, renamedAssetTitle); return { assetId, fileName: renamedAssetTitle }; }); const menu = await visibleContextMenuLabels(page, docRowSelector(parentA)); result.contextMenu = menu; const expectedMenuLabels = ["New File", "New Folder", "Paste Into", "Refresh", "Collapse All", "Copy Path", "Reveal"]; const missingMenuLabels = expectedMenuLabels.filter((label) => !menu.visible.includes(label)); if (missingMenuLabels.length > 0) { result.skipped.push({ area: "context-menu-minimum", reason: `当前真实 filetree 右键菜单未暴露这些 React Sidebar 菜单项: ${missingMenuLabels.join(", ")}`, }); result.checks.push({ area: "context-menu-minimum", ok: false, skipped: true, reason: `当前真实 filetree 右键菜单未暴露这些 React Sidebar 菜单项: ${missingMenuLabels.join(", ")}`, }); } assert( menu.visible.length === 0 || menu.disabledTitles.some((title) => title.includes("右键 Paste Into") || title.includes("文件夹")), `右键菜单禁用态缺少可解释原因: ${JSON.stringify(menu)}`, ); if (missingMenuLabels.length === 0) { result.checks.push({ area: "context-menu-minimum", ok: true, details: menu, }); } const accel = process.platform === "darwin" ? "Meta" : "Control"; await runOptional(result, "cut-paste-move", async () => { await page.locator(docRowSelector(child)).first().click({ timeout: UI_TIMEOUT_MS }); await page.locator(docRowSelector(child)).first().press(`${accel}+X`, { timeout: UI_TIMEOUT_MS }); await page.locator(docRowSelector(parentB)).first().click({ timeout: UI_TIMEOUT_MS }); const moveRequestCount = result.requests.length; await page.locator(docRowSelector(parentB)).first().press(`${accel}+V`, { timeout: UI_TIMEOUT_MS }); await page.waitForFunction( ({ selector, expectedParent }) => { const row = document.querySelector(selector); return row instanceof HTMLElement && row.textContent?.includes(expectedParent); }, { selector: docRowSelector(parentB), expectedParent: `${prefix}-parent-b` }, { timeout: UI_TIMEOUT_MS }, ).catch(() => undefined); await page.waitForTimeout(800); const movedChild = await readDocument(context, workspaceId, child); assert.equal(movedChild?.parent_id, parentB, "Ctrl/Cmd+X 后 Ctrl/Cmd+V 应移动页面到目标父页面"); const moveRequests = result.requests.slice(moveRequestCount).filter((entry) => entry.body?.includes('"action":"move"')); assert(moveRequests.length > 0, "Cut/Paste move 未捕获到 tree move 请求"); return { documentId: child, parentId: parentB, moveRequestCount: moveRequests.length }; }); const childAfterCutPaste = await readDocument(context, workspaceId, child); const childParentId = childAfterCutPaste?.parent_id ?? parentA; const rows = [ preflightRow({ rowId: `doc:${parentA}`, rowKind: "doc", documentId: parentA }), preflightRow({ rowId: `doc:${parentB}`, rowKind: "doc", documentId: parentB }), preflightRow({ rowId: `doc:${child}`, rowKind: "doc", documentId: child }), preflightRow({ rowId: `doc:${renameDoc}`, rowKind: "doc", documentId: renameDoc }), preflightRow({ rowId: `asset:${assetId}`, rowKind: "asset", documentId: resourceDoc, assetId, assetDocumentId: resourceDoc, assetType: "file", }), ]; const documentParents = [ { documentId: parentA, parentId: null }, { documentId: parentB, parentId: null }, { documentId: child, parentId: childParentId }, { documentId: renameDoc, parentId: parentA }, { documentId: resourceDoc, parentId: null }, ]; const selfDrop = await runDropPreflight(request, { workspaceId, copy: false, targetDocumentId: child, targetRowId: `doc:${child}`, focusedRowId: `doc:${child}`, activeDocumentId: resourceDoc, rowIds: [`doc:${child}`], rows, documentParents, }); const parentToChildDrop = await runDropPreflight(request, { workspaceId, copy: false, targetDocumentId: renameDoc, targetRowId: `doc:${renameDoc}`, focusedRowId: `doc:${renameDoc}`, activeDocumentId: resourceDoc, rowIds: [`doc:${parentA}`], rows, documentParents, }); const copyDrop = await runDropPreflight(request, { workspaceId, copy: true, targetDocumentId: parentB, targetRowId: `doc:${parentB}`, focusedRowId: `doc:${parentB}`, activeDocumentId: resourceDoc, rowIds: [`doc:${renameDoc}`], rows, documentParents, }); if ([selfDrop, parentToChildDrop, copyDrop].some((entry) => entry.status === 0 || entry.status === 404)) { result.skipped.push({ area: "dnd-preflight-guard", reason: "当前 3000 入口未暴露或已断开 /api/tree/filetree/drop-preflight,无法稳定自动化 DnD preflight guard;未把入口失败伪造成通过。", }); result.checks.push({ area: "dnd-preflight-guard", ok: false, skipped: true, reason: "当前 3000 入口未暴露或已断开 /api/tree/filetree/drop-preflight", }); } else { assert(!selfDrop.ok, `拖到自身应被 preflight 拒绝: ${JSON.stringify(selfDrop)}`); assert(!parentToChildDrop.ok, `父拖子应被 preflight 拒绝: ${JSON.stringify(parentToChildDrop)}`); assert(copyDrop.ok && copyDrop.payload?.plan?.copy === true, `copy modifier preflight 应保留 copy=true: ${JSON.stringify(copyDrop)}`); result.checks.push({ area: "dnd-preflight-guard", ok: true, details: { selfDropStatus: selfDrop.status, parentToChildDropStatus: parentToChildDrop.status, copyDropPlan: copyDrop.payload?.plan ?? null, }, }); } result.dndPreflight = { selfDropStatus: selfDrop.status, parentToChildDropStatus: parentToChildDrop.status, copyDropPlan: copyDrop.payload?.plan ?? null, }; result.skipped.push({ area: "dnd-readonly-conflict", reason: "主 Sidebar Convex filetree 当前 smoke 未构造 readonly source 与真实重名冲突确认弹窗;已有 local-folder smoke 和 bridge preflight 单测覆盖,仍需后续端到端矩阵补齐。", }); result.ok = true; result.workspaceId = workspaceId; result.fixture = { parentA, parentB, child, renameDoc, resourceDoc, assetId }; await writeResult(result); } catch (error) { result.error = error instanceof Error ? error.stack || error.message : String(error); result.filetreeText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => ""); await writeResult(result); throw error; } finally { await cleanup(request, workspaceId, cleanupDocIds).catch((error) => { console.warn(`清理 task430 临时数据失败: ${error instanceof Error ? error.message : String(error)}`); }); await browser.close(); } } main().catch((error) => { console.error(error); process.exit(1); });