#!/usr/bin/env node "use strict"; /** * task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js * * 用途:验证 local folder 非 md 资源 trash workbench 的 restore 流程: * 1. trash workbench 的 refresh() 是否触发浏览器导航(应否: fetch 替换 innerHTML) * 2. restore 后源文件是否恢复(文件系统断言) * 3. restore 后 filetree 是否最终显示恢复行(轮询更新) * 4. restore 后是否设置了 focus / reveal(预期:当前无 focus,记录缺口) * * 不修改 Rust / SSR 代码。只验证现有行为、记录证据缺口。 * * Usage: * node scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js * * Env: * MNOTE_WEB_SMOKE_BASE_URL — 3000 地址,默认 http://127.0.0.1:3000 * MNOTE_SMOKE_UI_TIMEOUT_MS — UI 等待超时,默认 30_000 */ const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { chromium } = require("playwright"); const TASK = "task473-local-folder-trash-restore-no-refresh-focus-gap-smoke"; const 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 OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"] .find((candidate) => fs.existsSync(candidate)); function fileUrl(localPath) { return `file://${localPath}`; } function writeWorkspaceManifest(root) { const metadataDir = path.join(root, ".mnote"); fs.mkdirSync(metadataDir, { recursive: true }); fs.writeFileSync( path.join(metadataDir, "workspace.json"), `${JSON.stringify({ workspaceId: `local-ws:user_real:${TASK}`, ownerId: "user_real", createdAt: new Date().toISOString(), capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"], }, null, 2)}\n`, "utf8", ); } /** * Tree command 快捷请求 */ async function postTreeCommand(rootUri, action, documentId) { const response = await fetch(`${BASE_URL}/api/tree/commands`, { method: "POST", headers: { "content-type": "application/json", "x-mnote-actor-id": "user_real", "x-mnote-actor-type": "user", }, body: JSON.stringify({ action, sourceKind: "local_folder", rootUri, documentId, }), }); const payload = await response.json().catch(async () => ({ text: await response.text() })); assert.equal(response.status, 200, `${action} failed: ${JSON.stringify(payload)}`); return payload; } /** * 快速登录(测试账号) */ async function quickLogin(page) { await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); if (await quickLoginButton.count()) { await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }); } } /** * 构建 filetree 页 URL */ function treeUrl(root, mode) { const url = new URL(`${BASE_URL}/`); url.searchParams.set("treeView", mode || "filetree"); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", fileUrl(root)); return url.toString(); } /** * 构建 trash 页 URL */ function trashUrl(root) { const url = new URL(`${BASE_URL}/trash`); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", fileUrl(root)); return url.toString(); } /** * non-md 资源在 filetree 中的 row-id * 基于 task435 / task464 使用的 data-row-id 格式 */ function assetRowId(relativePath) { return `local:asset:${relativePath}`; } /** * filetree 中等待某 row 可见 */ async function waitForFileTreeRow(page, rowId) { await page.locator(`.tree-row[data-row-id="${rowId}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); } /** * filetree 中等待某 row 消失 */ async function waitForFileTreeRowGone(page, rowId) { await page.locator(`.tree-row[data-row-id="${rowId}"]`).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS, }); } /** * 等待 filetree 容器可见 */ async function waitForFileTreeRoot(page) { await page.waitForFunction( () => { const root = document.getElementById("sidebar-file-tree-root"); return root && root.getClientRects().length > 0; }, { timeout: UI_TIMEOUT_MS }, ); } /** * 等待 trash workbench 可见 */ async function waitForTrashWorkbench(page, rootUri) { const url = trashUrl(rootUri); await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-trash-workbench"][data-trash-source-kind="local_folder"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); } /** * 检查 row 是否有 focus / active / selected 属性 */ function checkFocusEvidence(page, rowId) { return page.evaluate((id) => { const row = document.querySelector(`.tree-row[data-row-id="${id}"]`); if (!row) return { found: false, reason: "row_not_in_dom" }; const attrs = {}; for (const attr of ["data-active", "data-selected", "data-focused", "class"]) { const val = row.getAttribute(attr); if (val !== null) attrs[attr] = val; } // 检查是否在视口中(粗略) const rect = row.getBoundingClientRect(); const inViewport = rect.top >= -50 && rect.left >= -50 && rect.bottom <= (window.innerHeight + 50) && rect.right <= (window.innerWidth + 50); return { found: true, attrs, inViewport, tagName: row.tagName }; }, rowId); } async function main() { const root = fs.mkdtempSync(path.join(os.tmpdir(), `mnote-${TASK}-`)); const rootUri = fileUrl(root); const assetRelativePath = "docs/report.txt"; const assetId = `local-file:${assetRelativePath}`; const sourcePath = path.join(root, assetRelativePath); const assetRowIdStr = assetRowId(assetRelativePath); // 准备 workspace fs.mkdirSync(path.join(root, "docs"), { recursive: true }); writeWorkspaceManifest(root); fs.writeFileSync(path.join(root, "README.md"), "# Local Root\n", "utf8"); fs.writeFileSync(sourcePath, "task473 non-md asset body\n", "utf8"); const browser = await chromium.launch({ headless: true, ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), }); const context = await browser.newContext({ viewport: { width: 1360, height: 900 }, extraHTTPHeaders: { "x-mnote-actor-id": "user_real", "x-mnote-actor-type": "user", }, }); const page = await context.newPage(); // 导航事件记录 const navigationEvents = []; page.on("framenavigated", (frame) => { if (frame === page.mainFrame()) { navigationEvents.push({ url: frame.url(), timestamp: Date.now() }); } }); const evidence = { ok: false, task: TASK, baseUrl: BASE_URL, root, assetRelativePath, assetId, steps: [], focusAfterRestore: null, navEventsAfterRestore: [], restoreNavFree: null, fileReappearsInFiletree: null, }; try { // === 步骤1:登录并打开 filetree 页 === await quickLogin(page); await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForFileTreeRoot(page); // 等待初始文件加载 await waitForFileTreeRow(page, "local:markdown:README.md"); await waitForFileTreeRow(page, "local:folder:docs"); await waitForFileTreeRow(page, assetRowIdStr); evidence.steps.push({ step: 1, label: "登录并打开 filetree", ok: true }); // === 步骤2:API archive(删除进入 trash)=== const archiveResult = await postTreeCommand(rootUri, "delete", assetId); assert.equal(archiveResult.result?.execution?.canonicalCommand, "tree.resource.archive"); assert.equal(fs.existsSync(sourcePath), false, "archive 后源文件应移入 .mnote/trash"); evidence.steps.push({ step: 2, label: "API archive 非 md 文件到 trash", ok: true, canonicalCommand: archiveResult.result?.execution?.canonicalCommand, }); // === 步骤3:等待 filetree 更新(轮询 1200ms)=== await waitForFileTreeRowGone(page, assetRowIdStr); evidence.steps.push({ step: 3, label: "filetree 中文件行消失(轮询更新)", ok: true }); // === 步骤4:打开 trash 页,验证条目可见 === await waitForTrashWorkbench(page, root); const trashRow = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${assetId}"]`); await trashRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); evidence.steps.push({ step: 4, label: "trash 页显示被删文件行", ok: true }); // === 步骤5:记录 restore 前的导航事件基线,然后点击恢复 === const navBeforeRestore = navigationEvents.length; const restoreButton = trashRow.getByRole("button", { name: "恢复" }); await restoreButton.click({ timeout: UI_TIMEOUT_MS }); // 等待 trash 行消失(refresh() 后应替换 innerHTML,不会触发页面导航) await trashRow.waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async () => { // refresh() 完成后新渲染的行会移除旧 DOM,新行也可能出现; // 如果旧引用还在,等最多 3s 再看 await page.waitForTimeout(3000); }); const navAfterRestore = navigationEvents.length; const restoreNavDelta = navAfterRestore - navBeforeRestore; evidence.restoreNavFree = { navBeforeRestore, navAfterRestore, delta: restoreNavDelta }; evidence.navEventsAfterRestore = navigationEvents.slice(navBeforeRestore); // 断言:restore 操作不应触发页面导航 / reload assert.equal( restoreNavDelta, 0, `restore 后应无页面导航,实际发生 ${restoreNavDelta} 次导航: ${JSON.stringify(evidence.navEventsAfterRestore)}`, ); evidence.steps.push({ step: 5, label: "trash 恢复后无页面导航(no-refresh 验证通过)", ok: true, navDelta: restoreNavDelta, }); // === 步骤6:验证文件系统恢复 === await page.waitForTimeout(500); // 等待文件写完成 assert.equal(fs.existsSync(sourcePath), true, "restore 后源文件应恢复到原路径"); evidence.steps.push({ step: 6, label: "文件系统验证文件已恢复", ok: true }); // === 步骤7:返回 filetree 页,验证文件行重现 === // 先检查 trash 页的 status bar 是否有"已恢复项目"提示 const statusText = await page.locator('[data-testid="mnote-trash-status"]').textContent({ timeout: 3000 }).catch(() => ""); evidence.steps.push({ step: 7, label: "trash 页 status bar 检查", ok: statusText.includes("已恢复") || statusText.length === 0, statusText, note: statusText.includes("已恢复") ? "status 提示正确" : "status 提示缺失或无内容", }); // 回到 filetree 页 — 这一步本身是页面导航(用户主动操作) const navBeforeFiletree = navigationEvents.length; await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForFileTreeRoot(page); // 等待轮询检测到变化后文件行重现(最长 1200ms polling + 180ms debounce) try { await waitForFileTreeRow(page, assetRowIdStr); evidence.fileReappearsInFiletree = true; } catch (err) { // 可能轮询还未触发;再等一轮 await page.waitForTimeout(2000); try { await waitForFileTreeRow(page, assetRowIdStr); evidence.fileReappearsInFiletree = true; } catch { evidence.fileReappearsInFiletree = false; } } assert.equal( evidence.fileReappearsInFiletree, true, `restore 后 filetree 应通过轮询显示文件行(row-id: ${assetRowIdStr})`, ); evidence.steps.push({ step: 8, label: "回到 filetree 页面后文件行重现(轮询更新)", ok: evidence.fileReappearsInFiletree, }); // === 步骤9:检查 restore 后是否有 focus/reveal === const focusData = await checkFocusEvidence(page, assetRowIdStr); evidence.focusAfterRestore = focusData; const hasFocusEvidence = focusData.found && ( focusData.attrs?.["data-active"] === "true" || focusData.attrs?.["data-selected"] === "true" || focusData.attrs?.["data-focused"] === "true" || /\bis-active\b|\bis-selected\b|\bis-focused\b/.test(String(focusData.attrs?.class || "")) ); const noFocusEvidence = !hasFocusEvidence; evidence.steps.push({ step: 9, label: "检查 restore 后 focus/reveal 证据", ok: !noFocusEvidence, // 期望有focus,但当前预期是没有 -> 记录缺口 focusData, gap: noFocusEvidence ? "restore 后未发现 focus/active/selected 属性,selectSidebarFileTreeDocument 未被调用" : null, note: noFocusEvidence ? "【证据缺口】restore 后 filetree 未对恢复行设置 focus/active/selected" : "restore 后有 focus 证据", }); evidence.ok = true; } catch (error) { evidence.error = error instanceof Error ? error.stack || error.message : String(error); // 尝试读取当前 filetree 文本以便诊断 evidence.pageText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3000 }).catch(() => ""); throw error; } finally { // 写结果文件 fs.mkdirSync(OUTPUT_DIR, { recursive: true }); fs.writeFileSync(RESULT_PATH, `${JSON.stringify(evidence, null, 2)}\n`, "utf8"); console.log(JSON.stringify({ ok: evidence.ok, resultPath: RESULT_PATH, focusAfterRestore: evidence.focusAfterRestore }, null, 2)); await context.close().catch(() => undefined); await browser.close().catch(() => undefined); if (!process.env.MNOTE_KEEP_SMOKE_TMP) { fs.rmSync(root, { recursive: true, force: true }); } } } main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });