#!/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 = "task429-trash-restore-location-reveal-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 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; } 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 postTreeCommand(request, data) { return await requestJson(request, "/api/tree/commands", { method: "POST", data, }); } function pageTreeRowSelector(documentId) { const escaped = String(documentId).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); return `[data-testid="page-tree-row"][data-node-id="${escaped}"], [data-testid="wolai-sidebar-row"][data-node-id="${escaped}"], #sidebar-file-tree-root .tree-row[data-document-id="${escaped}"], #sidebar-file-tree-root .tree-row[data-doc-id="${escaped}"]`; } async function waitForPageTreeRow(page, documentId, label) { await page.locator(pageTreeRowSelector(documentId)).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => { const text = await page.locator('[data-testid="sidebar-page-tree-shell"], #sidebar-file-tree-root, [data-testid="wolai-sidebar"]').innerText({ timeout: 3_000 }).catch(() => ""); throw new Error(`${label} 未出现在侧边栏树: ${error.message}; tree=${text.slice(0, 1600)}`); }); } 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 clickTrashButton(page) { if (await page.getByRole("heading", { name: "垃圾桶" }).isVisible().catch(() => false)) { return; } await page.getByRole("button", { name: /^垃圾桶\b/ }).first().click({ timeout: UI_TIMEOUT_MS }); await page.getByRole("heading", { name: "垃圾桶" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); } async function restoreTrashItemWithDialogs(page, title, expectFallbackAlert = false) { const confirmPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => { const message = dialog.message(); await dialog.accept(); return message; }); const clicked = await page.evaluate((targetTitle) => { const rows = Array.from(document.querySelectorAll("div")); for (const row of rows) { if (!row.textContent?.includes(targetTitle)) continue; const button = Array.from(row.querySelectorAll("button")).find((candidate) => candidate.textContent?.trim() === "恢复"); if (button instanceof HTMLButtonElement) { button.click(); return true; } } return false; }, title); assert(clicked, `未找到垃圾桶恢复按钮: ${title}`); const dialogs = [await confirmPromise]; if (expectFallbackAlert) { const alertMessage = await page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => { const message = dialog.message(); await dialog.accept(); return message; }); dialogs.push(alertMessage); assert(dialogs.some((message) => message.includes("原父页面已不存在")), `缺少 fallback 提示: ${JSON.stringify(dialogs)}`); } return dialogs; } async function listDocuments(context, workspaceId) { return await convexCall(context, "query", "documents:listByWorkspace", { workspaceId }); } async function cleanup(request, workspaceId, rootId, orphanId) { if (!workspaceId) return; if (orphanId) { await postTreeCommand(request, { action: "archive", workspaceId, documentId: orphanId }).catch(() => null); } if (rootId) { await postTreeCommand(request, { action: "archive", workspaceId, documentId: rootId }).catch(() => null); } await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null); } async function main() { const stamp = Date.now(); const email = `mnote.restore.${stamp}@example.com`; const username = `restore-${stamp}`; const parentTitle = `TEST-10REVIEW-07-P6-parent-${stamp}`; const childTitle = `TEST-10REVIEW-07-P6-child-${stamp}`; const siblingTitle = `TEST-10REVIEW-07-P6-sibling-${stamp}`; const orphanTitle = `TEST-10REVIEW-07-P6-orphan-${stamp}`; const orphanId = `tree_restore_orphan_${stamp}`; const missingParentId = `tree_missing_parent_${stamp}`; const result = { ok: false, task: TASK, baseUrl: BASE_URL, convexUrl: CONVEX_URL, email, dialogs: [], }; const browser = await chromium.launch({ headless: true }); const context = await browser.newContext(); const page = await context.newPage(); const request = context.request; let workspaceId = null; let parentId = null; 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: "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"); parentId = await createPage(request, workspaceId, parentTitle); const childId = await createPage(request, workspaceId, childTitle, parentId); const siblingId = await createPage(request, workspaceId, siblingTitle, parentId); await convexCall(context, "mutation", "documents:create", { id: orphanId, workspaceId, parentId: missingParentId, title: orphanTitle, accessScope: "private", content: [], }); await page.goto(`${BASE_URL}/documents/${encodeURIComponent(parentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS, }); await waitForPageTreeRow(page, parentId, "父页面"); await waitForPageTreeRow(page, childId, "子页面"); await waitForPageTreeRow(page, siblingId, "兄弟页面"); await postTreeCommand(request, { action: "archive", workspaceId, documentId: childId }); await page.locator(pageTreeRowSelector(childId)).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }); await postTreeCommand(request, { action: "archive", workspaceId, documentId: orphanId }); const restorePayload = await postTreeCommand(request, { action: "restore", workspaceId, documentId: childId }); result.restorePayload = restorePayload.result || restorePayload; await waitForPageTreeRow(page, childId, "恢复后的子页面"); await page.waitForFunction( (documentId) => { const row = document.querySelector(`[data-testid="page-tree-row"][data-node-id="${documentId}"], [data-testid="wolai-sidebar-row"][data-node-id="${documentId}"], #sidebar-file-tree-root .tree-row[data-document-id="${documentId}"], #sidebar-file-tree-root .tree-row[data-doc-id="${documentId}"]`); return row instanceof HTMLElement && row.getClientRects().length > 0; }, childId, { timeout: UI_TIMEOUT_MS }, ); let docs = await listDocuments(context, workspaceId); const childDoc = docs.find((doc) => doc.id === childId); const siblingDoc = docs.find((doc) => doc.id === siblingId); assert.equal(childDoc?.parent_id, parentId, "恢复后的子页面应回到原父页面"); assert.equal(childDoc?.sort_order, 0, "恢复后的子页面应回到原排序位"); assert.equal(siblingDoc?.sort_order, 1, "原排序位被恢复页面占用时,兄弟页面应后移"); const fallbackPayload = await postTreeCommand(request, { action: "restore", workspaceId, documentId: orphanId }); result.fallbackPayload = fallbackPayload.result || fallbackPayload; assert.equal( result.fallbackPayload?.execution?.restore_location?.fallback_reason, "parent_missing_or_deleted", `fallback restore 应返回 parent_missing_or_deleted: ${JSON.stringify(result.fallbackPayload)}`, ); await waitForPageTreeRow(page, orphanId, "fallback 恢复后的孤儿页面"); docs = await listDocuments(context, workspaceId); const orphanDoc = docs.find((doc) => doc.id === orphanId); assert.equal(orphanDoc?.parent_id ?? null, null, "原父页面不存在时应恢复到根目录"); const order = await page.evaluate( ({ child, sibling }) => Array.from(document.querySelectorAll('[data-testid="page-tree-row"], [data-testid="wolai-sidebar-row"], #sidebar-file-tree-root .tree-row')) .map((row) => row instanceof HTMLElement ? row.dataset.nodeId : "") .filter((id) => id === child || id === sibling) .filter((id, index, list) => list.indexOf(id) === index), { child: childId, sibling: siblingId }, ); assert.deepEqual(order, [childId, siblingId], `页面树顺序应为 child -> sibling: ${JSON.stringify(order)}`); result.ok = true; result.workspaceId = workspaceId; result.parentId = parentId; result.childId = childId; result.siblingId = siblingId; result.orphanId = orphanId; await writeResult(result); } catch (error) { result.error = error instanceof Error ? error.stack || error.message : String(error); result.pageTreeText = await page.locator('[data-testid="sidebar-page-tree-shell"], #sidebar-file-tree-root, [data-testid="wolai-sidebar"]').innerText({ timeout: 3_000 }).catch(() => ""); await writeResult(result); throw error; } finally { await cleanup(request, workspaceId, parentId, orphanId).catch((error) => { console.warn(`清理 task429 临时数据失败: ${error instanceof Error ? error.message : String(error)}`); }); await browser.close(); } } main().catch((error) => { console.error(error); process.exit(1); });