Files
mnote/scripts/task474-local-folder-empty-trash-ui-smoke.js
T

516 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});