test: 收口本地文件夹垃圾箱 A3 smoke
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 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 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(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,
|
||||
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)}`);
|
||||
|
||||
// 归档非 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,
|
||||
assetTrashPath,
|
||||
});
|
||||
|
||||
// 验证 trash-index.json 包含两个 entry
|
||||
const entriesBefore = readTrashIndex(root);
|
||||
assert.ok(entriesBefore[mdDocId], `trash-index 应包含 MD entry: ${mdDocId}`);
|
||||
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}`);
|
||||
|
||||
// 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 },
|
||||
);
|
||||
|
||||
// 现在重新获取 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 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 resolvedMdTrashPath = path.resolve(root, mdTrashPath);
|
||||
assert.equal(fs.existsSync(resolvedMdTrashPath), false, `清空页面垃圾箱后 trash 文件应删除: ${resolvedMdTrashPath}`);
|
||||
|
||||
// 导航断言:清空操作不应触发页面导航
|
||||
const navAfterEmptyDocs = navigationEvents.length;
|
||||
const emptyDocsNavDelta = navAfterEmptyDocs - navBeforeEmptyDocs;
|
||||
assert.equal(emptyDocsNavDelta, 0,
|
||||
`清空页面垃圾箱后应无页面导航,实际发生 ${emptyDocsNavDelta} 次`);
|
||||
|
||||
evidence.emptyDocuments = {
|
||||
ok: true,
|
||||
navDelta: emptyDocsNavDelta,
|
||||
dialogAccepted: true,
|
||||
mdEntryClean: mdEntryAfterEmptyDocs === null,
|
||||
mdFileGone: !fs.existsSync(resolvedMdTrashPath),
|
||||
};
|
||||
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(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);
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task475-local-folder-markdown-trash-lifecycle-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
|
||||
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:task475",
|
||||
ownerId: "user_real",
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "tree_commands", "markdown_edit"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function postTreeCommand(payload) {
|
||||
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(payload),
|
||||
});
|
||||
const json = await response.json().catch(async () => ({ text: await response.text() }));
|
||||
assert.equal(response.status, 200, `${payload.action} failed: ${JSON.stringify(json)}`);
|
||||
return json;
|
||||
}
|
||||
|
||||
async function runStep(label, action) {
|
||||
const detail = await action();
|
||||
return { label, detail: detail || null };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-md-trash-smoke-"));
|
||||
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
|
||||
writeWorkspaceManifest(root);
|
||||
fs.writeFileSync(path.join(root, "README.md"), "# Local Root\n", "utf8");
|
||||
|
||||
// 创建 loose Markdown 页面(非 bundle)
|
||||
const mdFileName = "test-page.md";
|
||||
const mdContent = "# Test Page\n\nThis is a test Markdown page for trash lifecycle.\n";
|
||||
const sourceMdPath = path.join(root, "docs", mdFileName);
|
||||
fs.writeFileSync(sourceMdPath, mdContent, "utf8");
|
||||
|
||||
const rootUri = fileUrl(root);
|
||||
// Loose Markdown 的 documentId 编码:local-md:{encoded_relative_path}
|
||||
// `~2F` 是 URL 编码的 `/` 的替代分隔符(由 encode_local_id_segment 产生)
|
||||
const documentId = "local-md:docs~2Ftest-page.md";
|
||||
// trash 路径由 next_available_path 生成,文件名取 file_stem_title 即去掉 .md 扩展名
|
||||
const trashFileName = "test-page.md";
|
||||
const trashPath = path.join(root, ".mnote", "trash", trashFileName);
|
||||
const trashIndexPath = path.join(root, ".mnote", "trash-index.json");
|
||||
|
||||
const steps = [];
|
||||
try {
|
||||
// ===== Step 1: delete -> archive to local trash =====
|
||||
steps.push(await runStep("loose Markdown delete 进入本地回收站", async () => {
|
||||
const result = await postTreeCommand({
|
||||
action: "delete",
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
documentId,
|
||||
});
|
||||
const exec = result.result?.execution || {};
|
||||
// 当前 trash_local_markdown_page 不返回 canonicalCommand,
|
||||
// 资源 lifecycle 中非 md 文件会返回 "tree.resource.archive",
|
||||
// 此处记录真实值,不强制断言不存在或特定值
|
||||
const canonicalCommand = exec.canonicalCommand;
|
||||
// 源文件消失
|
||||
assert.equal(fs.existsSync(sourceMdPath), false, "Delete 后源 .md 文件应消失");
|
||||
// trash 中出现对应文件
|
||||
assert.equal(fs.existsSync(trashPath), true, "Delete 后文件应进入 .mnote/trash");
|
||||
assert.equal(exec.resourceKind, "markdown", "delete 返回 resourceKind 应为 markdown");
|
||||
// trash-index.json 记录 local-md:* entry
|
||||
assert.ok(fs.existsSync(trashIndexPath), "trash-index.json 应存在");
|
||||
const index = fs.readFileSync(trashIndexPath, "utf8");
|
||||
assert.match(index, /local-md:docs~2Ftest-page\.md/, "trash index 应记录 local-md entry");
|
||||
return { documentId, resourceKind: exec.resourceKind, canonicalCommand, trashIndexContains: true };
|
||||
}));
|
||||
|
||||
// ===== Step 2: restore -> 恢复回原路径 =====
|
||||
steps.push(await runStep("loose Markdown restore 回原路径", async () => {
|
||||
const result = await postTreeCommand({
|
||||
action: "restore",
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
documentId,
|
||||
});
|
||||
const exec = result.result?.execution || {};
|
||||
// 源 .md 恢复
|
||||
assert.equal(fs.existsSync(sourceMdPath), true, "restore 后源 .md 文件应恢复");
|
||||
assert.equal(fs.readFileSync(sourceMdPath, "utf8"), mdContent, "restore 后文件内容应不变");
|
||||
// trash 文件消失
|
||||
assert.equal(fs.existsSync(trashPath), false, "restore 后 trash 文件应消失");
|
||||
// index 清理
|
||||
const index = fs.readFileSync(trashIndexPath, "utf8");
|
||||
assert.doesNotMatch(index, /local-md:docs~2Ftest-page\.md/, "restore 后 trash index 应清理 entry");
|
||||
return { documentId: exec.documentId, previousDocumentId: exec.previousDocumentId };
|
||||
}));
|
||||
|
||||
// ===== Step 3: 再次 delete =====
|
||||
steps.push(await runStep("loose Markdown 再次 delete 进回收站", async () => {
|
||||
const result = await postTreeCommand({
|
||||
action: "delete",
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
documentId,
|
||||
});
|
||||
const exec = result.result?.execution || {};
|
||||
assert.equal(fs.existsSync(sourceMdPath), false, "再次 delete 后源 .md 文件应消失");
|
||||
assert.equal(fs.existsSync(trashPath), true, "再次 delete 后 trash 文件应出现");
|
||||
const index = fs.readFileSync(trashIndexPath, "utf8");
|
||||
assert.match(index, /local-md:docs~2Ftest-page\.md/, "再次 delete 后 trash index 应恢复记录");
|
||||
return { resourceKind: exec.resourceKind };
|
||||
}));
|
||||
|
||||
// ===== Step 4: purge -> 永久删除 =====
|
||||
steps.push(await runStep("loose Markdown purge 永久删除", async () => {
|
||||
const result = await postTreeCommand({
|
||||
action: "purge",
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
documentId,
|
||||
});
|
||||
const exec = result.result?.execution || {};
|
||||
// 源路径与 trash 路径均不存在
|
||||
assert.equal(fs.existsSync(sourceMdPath), false, "purge 后源 .md 文件不应存在");
|
||||
assert.equal(fs.existsSync(trashPath), false, "purge 后 trash 文件不应存在");
|
||||
// index 清理
|
||||
const index = fs.readFileSync(trashIndexPath, "utf8");
|
||||
assert.doesNotMatch(index, /local-md:docs~2Ftest-page\.md/, "purge 后 trash index 应清理 entry");
|
||||
return { ok: exec.ok };
|
||||
}));
|
||||
|
||||
// ===== Step 5: 完整周期后再次创建+delete+purge,确保 trash-index.json 整体结构仍合法 =====
|
||||
steps.push(await runStep("重新创建并清理验证 trash-index.json 完整性", async () => {
|
||||
fs.writeFileSync(sourceMdPath, "# Second\n", "utf8");
|
||||
await postTreeCommand({
|
||||
action: "delete",
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
documentId,
|
||||
});
|
||||
await postTreeCommand({
|
||||
action: "purge",
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
documentId,
|
||||
});
|
||||
// trash-index.json 应仍是合法 JSON
|
||||
const indexRaw = fs.readFileSync(trashIndexPath, "utf8");
|
||||
assert.doesNotThrow(() => JSON.parse(indexRaw), "trash-index.json 应为合法 JSON");
|
||||
return { trashIndexValid: true };
|
||||
}));
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
task: "task475-local-folder-markdown-trash-lifecycle-smoke",
|
||||
baseUrl: BASE_URL,
|
||||
root,
|
||||
documentId,
|
||||
sourceType: "loose Markdown page (non-bundle)",
|
||||
note: "loose Markdown 当前不返回 canonicalCommand(trash_local_markdown_page 无此字段),bundled Markdown case 未覆盖(bundle 涉及目录整体移动/恢复,Rust 单测覆盖更充分)",
|
||||
steps,
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(`task475 local folder Markdown trash lifecycle smoke passed: ${RESULT_PATH}`);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
const result = {
|
||||
ok: false,
|
||||
task: "task475-local-folder-markdown-trash-lifecycle-smoke",
|
||||
baseUrl: BASE_URL,
|
||||
error: error && error.stack ? error.stack : String(error),
|
||||
};
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.error(error && error.stack ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user