#!/usr/bin/env node "use strict"; const { loginViaAuthForm } = require('./lib/browser-auth-login'); const assert = require("node:assert"); const fs = require("node:fs/promises"); const path = require("node:path"); const { chromium } = require("playwright"); const ROOT = path.resolve(__dirname, ".."); const BASE_URL = (process.env.MNOTE_UI_BASE_URL || process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); const OUT_DIR = path.join(ROOT, "tmp", "task442-trash-modal-workbench-smoke"); async function quickLogin(page) { // 7-76 P0: 标准表单登录(无测试快速登录按钮) const base = (typeof BASE_URL !== "undefined" && BASE_URL) || (typeof baseUrl !== "undefined" && baseUrl) || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; const timeout = (typeof UI_TIMEOUT_MS !== "undefined" && UI_TIMEOUT_MS) || (typeof TIMEOUT !== "undefined" && TIMEOUT) || 30_000; if (!String(page.url() || "").includes("/auth")) { await page.goto(String(base).replace(/\/+$/, "") + "/auth", { waitUntil: "commit", timeout, }); } await loginViaAuthForm(page, { baseUrl: base, timeoutMs: timeout, gotoAuth: false, }); await page .waitForURL((url) => !String(url).includes("/auth"), { timeout }) .catch(() => {}); } async function readState(page) { return page.evaluate(() => { const fileRoot = document.getElementById("sidebar-file-tree-root"); const activeRow = document.querySelector("#sidebar-file-tree-root .tree-row[data-selected='true'], #sidebar-tree-root .tree-row[data-active='true']"); const modal = document.querySelector('[data-testid="mnote-trash-modal"]'); const panel = document.querySelector('[data-testid="mnote-trash-modal"] .mnote-trash-modal__panel'); const workbench = document.querySelector('[data-testid="mnote-trash-modal"] [data-testid="mnote-trash-workbench"]'); const modalRect = modal instanceof HTMLElement ? modal.getBoundingClientRect() : null; const panelRect = panel instanceof HTMLElement ? panel.getBoundingClientRect() : null; return { url: window.location.href, pathname: window.location.pathname, modalOpen: modal instanceof HTMLElement, workbenchVisible: workbench instanceof HTMLElement, panelRect: panelRect ? { left: panelRect.left, right: panelRect.right, top: panelRect.top, bottom: panelRect.bottom, width: panelRect.width, height: panelRect.height, } : null, viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, modalRect: modalRect ? { left: modalRect.left, right: modalRect.right, top: modalRect.top, bottom: modalRect.bottom, } : null, fileScrollTop: fileRoot instanceof HTMLElement ? fileRoot.scrollTop : null, activeRowId: activeRow instanceof HTMLElement ? activeRow.getAttribute("data-row-id") || activeRow.getAttribute("data-node-id") || "" : "", bodyShell: document.body.getAttribute("data-mnote-shell") || "", modalRole: modal instanceof HTMLElement ? modal.getAttribute("role") || "" : "", }; }); } async function requestJson(request, pathName, data) { const response = await request.fetch(`${BASE_URL}${pathName}`, { method: "POST", headers: { "content-type": "application/json" }, data, timeout: UI_TIMEOUT_MS, }); const payload = await response.json().catch(() => null); if (!response.ok()) { throw new Error(`${pathName} failed ${response.status()}: ${JSON.stringify(payload)}`); } return payload; } async function treeCommand(request, body) { const payload = await requestJson(request, "/api/tree/commands", body); assert(payload?.result, `tree command 缺少 result: ${JSON.stringify(payload)}`); return payload.result; } async function readWorkspaceId(page) { const workspaceId = await page.evaluate(() => { const node = document.querySelector("#sidebar-file-tree-root[data-workspace-id], #sidebar-tree-root[data-workspace-id], [data-workspace-id]"); return node instanceof HTMLElement ? node.getAttribute("data-workspace-id") || "" : ""; }); assert(workspaceId && workspaceId !== "default" && !workspaceId.startsWith("local:"), `缺少真实云空间 workspaceId: ${workspaceId}`); return workspaceId; } async function main() { await fs.mkdir(OUT_DIR, { recursive: true }); const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); const page = await context.newPage(); const requests = []; page.on("request", (request) => { if (request.url().includes("/trash")) requests.push({ url: request.url(), method: request.method() }); }); const result = { ok: false, baseUrl: BASE_URL, workspaceId: null, archivedDocumentId: null, before: null, afterOpen: null, afterRestore: null, afterClose: null, requests }; try { await quickLogin(page); await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="wolai-sidebar"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); result.workspaceId = await readWorkspaceId(page); const created = await treeCommand(context.request, { action: "create", workspaceId: result.workspaceId, parentId: null, title: `TRASH-MODAL-${Date.now().toString().slice(-6)}`, }); result.archivedDocumentId = created.documentId; assert(result.archivedDocumentId, `创建临时页面缺少 documentId: ${JSON.stringify(created)}`); await treeCommand(context.request, { action: "archive", workspaceId: result.workspaceId, documentId: result.archivedDocumentId, }); result.before = await readState(page); const trashEntry = page.locator('[data-testid="mnote-sidebar-trash-entry"], .wolai-sidebar-footer .wolai-footer-entry[href="/trash"]').first(); await trashEntry.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await trashEntry.click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-trash-modal"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-trash-modal"] [data-testid="mnote-trash-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); result.afterOpen = await readState(page); assert.equal(result.afterOpen.url, result.before.url, "点击垃圾箱应打开弹窗,不应离开当前 URL"); assert.equal(result.afterOpen.modalOpen, true, "应出现垃圾箱弹窗"); assert.equal(result.afterOpen.workbenchVisible, true, "弹窗内应复用 mnote-trash-workbench"); assert.equal(result.afterOpen.modalRole, "dialog", "垃圾箱弹窗应使用 dialog role"); assert(result.afterOpen.panelRect, "垃圾箱弹窗应包含面板"); assert(Math.abs((result.afterOpen.panelRect.left + result.afterOpen.panelRect.right) / 2 - result.afterOpen.viewportWidth / 2) <= 8, `垃圾箱面板应居中显示: ${JSON.stringify(result.afterOpen.panelRect)}`); assert(result.afterOpen.panelRect.top > 20 && result.afterOpen.panelRect.bottom < result.afterOpen.viewportHeight - 20, `垃圾箱面板应保留上下留白: ${JSON.stringify(result.afterOpen.panelRect)}`); const trashRow = page.locator(`[data-testid="mnote-trash-modal"] [data-trash-row="document"][data-document-id="${result.archivedDocumentId}"]`); await trashRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await trashRow.locator('[data-trash-action="restore"]').click({ timeout: UI_TIMEOUT_MS }); await trashRow.waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }); result.afterRestore = await readState(page); assert.equal(result.afterRestore.url, result.before.url, "弹窗内恢复页面后仍应停留在原 URL"); await page.locator('[data-testid="mnote-trash-modal-close"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-trash-modal"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }); result.afterClose = await readState(page); assert.equal(result.afterClose.url, result.before.url, "关闭垃圾箱弹窗后仍应停留在原 URL"); assert.equal(result.afterClose.fileScrollTop, result.before.fileScrollTop, "关闭弹窗后 File Tree scrollTop 不应被无关重置"); assert.equal(result.afterClose.activeRowId, result.before.activeRowId, "关闭弹窗后 active/selected row 不应被无关重置"); result.ok = true; } finally { if (result.workspaceId && result.archivedDocumentId) { await treeCommand(context.request, { action: "archive", workspaceId: result.workspaceId, documentId: result.archivedDocumentId, }).catch(() => {}); await treeCommand(context.request, { action: "purge", workspaceId: result.workspaceId, documentId: result.archivedDocumentId, }).catch(() => {}); } await fs.writeFile(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); await browser.close().catch(() => {}); } console.log(JSON.stringify(result, null, 2)); } main().catch((error) => { console.error(error); process.exit(1); });