#!/usr/bin/env node "use strict"; const { loginViaAuthForm } = require('./lib/browser-auth-login'); /** * task474-local-folder-empty-trash-ui-smoke.js * * 用途:验证 local folder trash workbench 的清空垃圾箱 UI 流程: * 1. 清空页面垃圾箱(empty documents) * 2. 清空资源垃圾箱(empty resources) * 断言:行消失、trash-index.json 清理、trash 文件删除、无页面导航 * * 不修改 Rust / SSR 代码。只验证现有行为、记录证据。 * * Usage: * node scripts/task474-local-folder-empty-trash-ui-smoke.js * * Env: * MNOTE_WEB_SMOKE_BASE_URL — 3000 地址,默认 http://127.0.0.1:3000 * MNOTE_SMOKE_UI_TIMEOUT_MS — UI 等待超时,默认 30_000 * MNOTE_KEEP_SMOKE_TMP — 设置后保留临时目录 */ 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 = "task474-local-folder-empty-trash-ui-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", ); } /** * 编码本地路径段,匹配 Rust encode_local_id_segment 逻辑 */ function encodeLocalIdSegment(value) { let encoded = ""; for (let i = 0; i < value.length; i++) { const ch = value[i]; const code = value.charCodeAt(i); if ((code >= 0x30 && code <= 0x39) || (code >= 0x41 && code <= 0x5A) || (code >= 0x61 && code <= 0x7A) || ch === "." || ch === "_" || ch === "-") { encoded += ch; } else { encoded += "~" + code.toString(16).toUpperCase().padStart(2, "0"); } } return encoded; } /** * 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) { // 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(() => {}); } /** * 构建 trash 页 URL */ function trashUrl(rootUri) { const url = new URL(`${BASE_URL}/trash`); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", rootUri); return url.toString(); } /** * 打开 trash 页并等待 workbench 可见 */ async function openTrash(page, rootUri) { await page.goto(trashUrl(rootUri), { 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, }); } /** * 读取 trash-index.json 的 entries map */ function readTrashIndex(root) { const indexPath = path.join(root, ".mnote", "trash-index.json"); if (!fs.existsSync(indexPath)) return {}; const raw = fs.readFileSync(indexPath, "utf8"); const data = JSON.parse(raw); return data.entries || data || {}; } /** * 读取 trash-index.json 中指定 entry */ function getTrashEntry(root, entryId) { const entries = readTrashIndex(root); return entries[entryId] || null; } /** * 从 trash-index.json 获取 entry 的 trashPath */ function getTrashFilePath(root, entryId) { const entry = getTrashEntry(root, entryId); if (!entry) return null; return entry.trashRelativePath || entry.trash_relative_path || entry.trashFilePath || entry.trashed_file_path || null; } async function main() { const root = fs.mkdtempSync(path.join(os.tmpdir(), `mnote-${TASK}-`)); const rootUri = fileUrl(root); // ── 测试数据:两个松散 Markdown 页面和非 md 资源 ── const mdRelativePath = "docs/test-page.md"; const mdDocId = `local-md:${encodeLocalIdSegment(mdRelativePath)}`; // local-md:docs~2Ftest-page.md const mdSourcePath = path.join(root, mdRelativePath); const md2RelativePath = "docs/second-page.md"; const md2DocId = `local-md:${encodeLocalIdSegment(md2RelativePath)}`; // local-md:docs~2Fsecond-page.md const md2SourcePath = path.join(root, md2RelativePath); const assetRelativePath = "docs/resource.txt"; const assetId = `local-file:${assetRelativePath}`; // local-file:docs/resource.txt const assetSourcePath = path.join(root, 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(mdSourcePath, "# Test Page\n\nPage content for empty-trash smoke.\n", "utf8"); fs.writeFileSync(md2SourcePath, "# Second Page\n\nSecond page content for multi-purge smoke.\n", "utf8"); fs.writeFileSync(assetSourcePath, "resource body for empty-trash smoke\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, mdRelativePath, mdDocId, md2RelativePath, md2DocId, assetRelativePath, assetId, steps: [], emptyDocuments: {}, emptyResources: {}, navEvents: [], }; try { // ════════════════════════════════════════════ // 步骤 1:登录并归档两个条目到垃圾箱 // ════════════════════════════════════════════ await quickLogin(page); // 归档 Markdown 页面 const mdArchiveResult = await postTreeCommand(rootUri, "delete", mdDocId); assert.equal(mdArchiveResult.result?.execution?.resourceKind, "markdown", `MD archive resourceKind should be markdown, got: ${JSON.stringify(mdArchiveResult)}`); assert.equal(fs.existsSync(mdSourcePath), false, "archive 后源 Markdown 文件应移入垃圾箱"); const mdTrashPath = mdArchiveResult.result?.execution?.trashPath || mdArchiveResult.trashPath || getTrashFilePath(root, mdDocId); assert.ok(mdTrashPath, `MD archive 应返回 trashPath, got: ${JSON.stringify(mdArchiveResult)}`); // 归档第二个 Markdown 页面,覆盖清空页面垃圾箱多条目串行 purge const md2ArchiveResult = await postTreeCommand(rootUri, "delete", md2DocId); assert.equal(md2ArchiveResult.result?.execution?.resourceKind, "markdown", `MD2 archive resourceKind should be markdown, got: ${JSON.stringify(md2ArchiveResult)}`); assert.equal(fs.existsSync(md2SourcePath), false, "archive 后第二个 Markdown 源文件应移入垃圾箱"); const md2TrashPath = md2ArchiveResult.result?.execution?.trashPath || md2ArchiveResult.trashPath || getTrashFilePath(root, md2DocId); assert.ok(md2TrashPath, `MD2 archive 应返回 trashPath, got: ${JSON.stringify(md2ArchiveResult)}`); // 归档非 md 资源 const assetArchiveResult = await postTreeCommand(rootUri, "delete", assetId); assert.equal(assetArchiveResult.result?.execution?.canonicalCommand, "tree.resource.archive", `Asset archive should be tree.resource.archive, got: ${JSON.stringify(assetArchiveResult)}`); assert.equal(fs.existsSync(assetSourcePath), false, "archive 后源文件应移入垃圾箱"); const assetTrashPath = assetArchiveResult.result?.execution?.trashPath || assetArchiveResult.trashPath || getTrashFilePath(root, assetId); assert.ok(assetTrashPath, `Asset archive 应返回 trashPath, got: ${JSON.stringify(assetArchiveResult)}`); evidence.steps.push({ step: 1, label: "通过 tree command 归档 Markdown 页面和非 md 资源到垃圾箱", ok: true, mdTrashPath, md2TrashPath, assetTrashPath, }); // 验证 trash-index.json 包含三个 entry const entriesBefore = readTrashIndex(root); assert.ok(entriesBefore[mdDocId], `trash-index 应包含 MD entry: ${mdDocId}`); assert.ok(entriesBefore[md2DocId], `trash-index 应包含第二个 MD entry: ${md2DocId}`); assert.ok(entriesBefore[assetId], `trash-index 应包含 asset entry: ${assetId}`); // ════════════════════════════════════════════ // 步骤 2:打开 trash 页,验证两行可见 // ════════════════════════════════════════════ const navBeforeTrashOpen = navigationEvents.length; await openTrash(page, rootUri); const trashOpenNavDelta = navigationEvents.length - navBeforeTrashOpen; // MD 行(页面垃圾箱) const mdTrashRow = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${mdDocId}"]`); await mdTrashRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const mdResourceKind = await mdTrashRow.getAttribute("data-resource-kind"); assert.equal(mdResourceKind, "markdown", `MD trash row data-resource-kind 应为 "markdown", 实际: ${mdResourceKind}`); const md2TrashRow = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${md2DocId}"]`); await md2TrashRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const md2ResourceKind = await md2TrashRow.getAttribute("data-resource-kind"); assert.equal(md2ResourceKind, "markdown", `第二个 MD trash row data-resource-kind 应为 "markdown", 实际: ${md2ResourceKind}`); // Asset 行(资源垃圾箱) const assetTrashRow = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${assetId}"]`); await assetTrashRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const assetResourceKind = await assetTrashRow.getAttribute("data-resource-kind"); assert.equal(assetResourceKind, "local_file", `Asset trash row data-resource-kind 应为 "local_file", 实际: ${assetResourceKind}`); // 验证按钮 enabled 状态 const emptyDocsButton = page.locator('[data-trash-action="local-empty-documents"]'); const emptyResourcesButton = page.locator('[data-trash-action="local-empty-resources"]'); await emptyDocsButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await emptyResourcesButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const docsButtonDisabled = await emptyDocsButton.isDisabled(); const resourcesButtonDisabled = await emptyResourcesButton.isDisabled(); assert.equal(docsButtonDisabled, false, "存在页面时清空页面垃圾箱按钮应 enabled"); assert.equal(resourcesButtonDisabled, false, "存在资源时清空资源垃圾箱按钮应 enabled"); evidence.steps.push({ step: 2, label: "打开 trash 页,两行可见,按钮 enabled", ok: true, trashOpenNavDelta, }); // ════════════════════════════════════════════ // 步骤 3:清空页面垃圾箱 // ════════════════════════════════════════════ const navBeforeEmptyDocs = navigationEvents.length; // 确认 dialog:清空后无法恢复,确定继续吗? page.once("dialog", (dialog) => { evidence.emptyDocuments.dialogText = dialog.message(); dialog.accept(); }); await emptyDocsButton.click({ timeout: UI_TIMEOUT_MS }); // 等待 document rows 消失(refresh() 替换 innerHTML 后文档行应被移除) // 由于 refresh() 以 fetch+DOMParser 方式替换 innerHTML,原来 DOM 引用会变为 detached await page.waitForFunction( (id) => !document.querySelector(`[data-trash-entry-id="${id}"]`), mdDocId, { timeout: UI_TIMEOUT_MS }, ); await page.waitForFunction( (id) => !document.querySelector(`[data-trash-entry-id="${id}"]`), md2DocId, { timeout: UI_TIMEOUT_MS }, ); // 现在重新获取 DOM 引用验证 const mdRowAfterEmptyDocs = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${mdDocId}"]`); const mdRowCountAfterEmptyDocs = await mdRowAfterEmptyDocs.count(); assert.equal(mdRowCountAfterEmptyDocs, 0, "清空页面垃圾箱后 MD 行应消失"); const md2RowAfterEmptyDocs = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${md2DocId}"]`); const md2RowCountAfterEmptyDocs = await md2RowAfterEmptyDocs.count(); assert.equal(md2RowCountAfterEmptyDocs, 0, "清空页面垃圾箱后第二个 MD 行应消失"); // 文档行消失后,资源行应仍然存在 const assetRowAfterEmptyDocs = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${assetId}"]`); const assetRowCountAfterEmptyDocs = await assetRowAfterEmptyDocs.count(); assert.equal(assetRowCountAfterEmptyDocs, 1, "清空页面垃圾箱后资源行应仍然存在"); // 验证按钮状态:文档按钮 disabled,资源按钮仍 enabled const docsButtonDisabledAfter = await page.locator('[data-trash-action="local-empty-documents"]').isDisabled(); assert.equal(docsButtonDisabledAfter, true, "清空页面垃圾箱后 empty-documents 按钮应 disabled"); // 验证文件系统:trash 文件和 trash-index const mdEntryAfterEmptyDocs = getTrashEntry(root, mdDocId); assert.equal(mdEntryAfterEmptyDocs, null, `清空页面垃圾箱后 trash-index 不应包含 MD entry: ${mdDocId}`); const md2EntryAfterEmptyDocs = getTrashEntry(root, md2DocId); assert.equal(md2EntryAfterEmptyDocs, null, `清空页面垃圾箱后 trash-index 不应包含第二个 MD entry: ${md2DocId}`); const resolvedMdTrashPath = path.resolve(root, mdTrashPath); assert.equal(fs.existsSync(resolvedMdTrashPath), false, `清空页面垃圾箱后 trash 文件应删除: ${resolvedMdTrashPath}`); const resolvedMd2TrashPath = path.resolve(root, md2TrashPath); assert.equal(fs.existsSync(resolvedMd2TrashPath), false, `清空页面垃圾箱后第二个 trash 文件应删除: ${resolvedMd2TrashPath}`); // 导航断言:清空操作不应触发页面导航 const navAfterEmptyDocs = navigationEvents.length; const emptyDocsNavDelta = navAfterEmptyDocs - navBeforeEmptyDocs; assert.equal(emptyDocsNavDelta, 0, `清空页面垃圾箱后应无页面导航,实际发生 ${emptyDocsNavDelta} 次`); evidence.emptyDocuments = { ok: true, navDelta: emptyDocsNavDelta, dialogAccepted: true, mdEntryClean: mdEntryAfterEmptyDocs === null, md2EntryClean: md2EntryAfterEmptyDocs === null, mdFileGone: !fs.existsSync(resolvedMdTrashPath), md2FileGone: !fs.existsSync(resolvedMd2TrashPath), }; evidence.steps.push({ step: 3, label: "清空页面垃圾箱——按钮 click、accept confirm、行消失、trash-index 清理、文件删除、无导航", ok: true, navDelta: emptyDocsNavDelta, }); // ════════════════════════════════════════════ // 步骤 4:清空资源垃圾箱 // ════════════════════════════════════════════ const navBeforeEmptyResources = navigationEvents.length; const resourceButtonAfterEmptyDocs = page.locator('[data-trash-action="local-empty-resources"]'); await resourceButtonAfterEmptyDocs.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const resButtonDisabledAfterDocs = await resourceButtonAfterEmptyDocs.isDisabled(); assert.equal(resButtonDisabledAfterDocs, false, "资源行仍存在时 empty-resources 按钮应 enabled"); // 接受确认 dialog page.once("dialog", (dialog) => { evidence.emptyResources.dialogText = dialog.message(); dialog.accept(); }); await resourceButtonAfterEmptyDocs.click({ timeout: UI_TIMEOUT_MS }); // 等待资源行消失 await page.waitForFunction( (id) => !document.querySelector(`[data-trash-entry-id="${id}"]`), assetId, { timeout: UI_TIMEOUT_MS }, ); const assetRowAfterEmptyResources = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${assetId}"]`); const assetRowCountAfterEmptyResources = await assetRowAfterEmptyResources.count(); assert.equal(assetRowCountAfterEmptyResources, 0, "清空资源垃圾箱后资源行应消失"); // 验证按钮状态 const resButtonDisabledAfter = await page.locator('[data-trash-action="local-empty-resources"]').isDisabled(); assert.equal(resButtonDisabledAfter, true, "清空资源垃圾箱后 empty-resources 按钮应 disabled"); // 验证文件系统 const assetEntryAfterEmptyResources = getTrashEntry(root, assetId); assert.equal(assetEntryAfterEmptyResources, null, `清空资源垃圾箱后 trash-index 不应包含 asset entry: ${assetId}`); const resolvedAssetTrashPath = path.resolve(root, assetTrashPath); assert.equal(fs.existsSync(resolvedAssetTrashPath), false, `清空资源垃圾箱后 trash 文件应删除: ${resolvedAssetTrashPath}`); // 导航断言 const navAfterEmptyResources = navigationEvents.length; const emptyResourcesNavDelta = navAfterEmptyResources - navBeforeEmptyResources; assert.equal(emptyResourcesNavDelta, 0, `清空资源垃圾箱后应无页面导航,实际发生 ${emptyResourcesNavDelta} 次`); evidence.emptyResources = { ok: true, navDelta: emptyResourcesNavDelta, dialogAccepted: true, assetEntryClean: assetEntryAfterEmptyResources === null, assetFileGone: !fs.existsSync(resolvedAssetTrashPath), }; evidence.steps.push({ step: 4, label: "清空资源垃圾箱——按钮 click、accept confirm、行消失、trash-index 清理、文件删除、无导航", ok: true, navDelta: emptyResourcesNavDelta, }); // ════════════════════════════════════════════ // 步骤 5:最终验证——trash-index.json 完全空 // ════════════════════════════════════════════ const entriesAfter = readTrashIndex(root); const entryKeys = Object.keys(entriesAfter); assert.equal(entryKeys.length, 0, `两次清空后 trash-index 应为空,实际有 ${entryKeys.length} 个 entry: ${JSON.stringify(entryKeys)}`); // 验证原始源文件不应恢复 assert.equal(fs.existsSync(mdSourcePath), false, "清空垃圾箱后 MD 源文件不应恢复"); assert.equal(fs.existsSync(md2SourcePath), false, "清空垃圾箱后第二个 MD 源文件不应恢复"); assert.equal(fs.existsSync(assetSourcePath), false, "清空垃圾箱后资源源文件不应恢复"); evidence.steps.push({ step: 5, label: "最终验证:trash-index 为空、源文件未恢复", ok: true, entryKeys, }); // 收集完整导航事件 evidence.navEvents = navigationEvents.map((e) => ({ url: e.url.length > 120 ? e.url.substring(0, 120) + "…" : e.url, timestamp: e.timestamp, })); evidence.ok = true; console.log(JSON.stringify({ ok: true, resultPath: RESULT_PATH, steps: evidence.steps.length }, null, 2)); } catch (error) { evidence.error = error instanceof Error ? error.stack || error.message : String(error); evidence.pageUrl = page.url(); evidence.pageText = await page.locator('[data-testid="mnote-trash-workbench"]').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, error: evidence.error || null }, 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); });