Files
mnote/scripts/task471-local-folder-bulk-resource-trash-smoke.js
T

241 lines
10 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const { loginViaAuthForm } = require('./lib/browser-auth-login');
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 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 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) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId: "local-ws:user_real:task471",
ownerId: "user_real",
createdAt: new Date().toISOString(),
capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
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(() => {});
}
function readTrashIndex(root) {
const indexPath = path.join(root, ".mnote", "trash-index.json");
if (!fs.existsSync(indexPath)) return [];
const raw = fs.readFileSync(indexPath, "utf8");
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
if (parsed && typeof parsed === "object" && parsed.entries && typeof parsed.entries === "object") {
return Object.entries(parsed.entries).map(([id, entry]) => ({ id, ...entry }));
}
if (parsed && typeof parsed === "object") {
return Object.entries(parsed).map(([id, entry]) => ({ id, ...entry }));
}
return [];
}
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task471-bulk-trash-"));
const rootUri = fileUrl(root);
const assets = [
{ assetId: "local-file:docs/附件-a.txt", filePath: path.join(root, "docs", "附件-a.txt") },
{ assetId: "local-file:docs/附件-b.json", filePath: path.join(root, "docs", "附件-b.json") },
];
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
writeWorkspaceManifest(root);
fs.writeFileSync(path.join(root, "README.md"), "# Bulk Resource Trash\n", "utf8");
fs.writeFileSync(assets[0].filePath, "asset a\n", "utf8");
fs.writeFileSync(assets[1].filePath, "{\"asset\":\"b\"}\n", "utf8");
const browser = await chromium.launch({
headless: true,
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 treeCommands = [];
const treeCommandResponses = [];
const unexpectedDialogs = [];
page.on("request", (request) => {
if (!request.url().includes("/api/tree/commands")) return;
const body = request.postDataJSON?.() || null;
treeCommands.push({ method: request.method(), body });
});
page.on("response", async (response) => {
if (!response.url().includes("/api/tree/commands")) return;
let body = "";
try {
body = await response.text();
} catch (_) {}
treeCommandResponses.push({ status: response.status(), body });
});
try {
await quickLogin(page);
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
url.searchParams.set("treeView", "filetree");
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const docsRow = page.locator('#sidebar-file-tree-root .tree-row[data-local-relative-path="docs"]').first();
if (await docsRow.isVisible({ timeout: UI_TIMEOUT_MS }).catch(() => false)) {
const expanded = await docsRow.getAttribute("aria-expanded").catch(() => null);
if (expanded !== "true") {
await docsRow.locator('[data-rust-action="toggle"]').click({ timeout: UI_TIMEOUT_MS });
}
}
const selectors = assets.map((asset) => `#sidebar-file-tree-root .tree-row[data-asset-id="${asset.assetId}"]`);
await page.locator(selectors[0]).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator(selectors[1]).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const rowIds = await Promise.all(selectors.map((selector) =>
page.locator(selector).first().getAttribute("data-row-id", { timeout: UI_TIMEOUT_MS }),
));
assert(rowIds.every(Boolean), `资源行应有 data-row-id: ${JSON.stringify(rowIds)}`);
await page.locator(`${selectors[0]} .tree-link`).click({
timeout: UI_TIMEOUT_MS,
});
await page.locator(`${selectors[1]} .tree-link`).click({
modifiers: [process.platform === "darwin" ? "Meta" : "Control"],
timeout: UI_TIMEOUT_MS,
});
const selectedBefore = await page.locator('#sidebar-file-tree-root .tree-row[data-selected="true"]').evaluateAll((rows) =>
rows.map((row) => row.getAttribute("data-row-id") || ""),
);
assert.deepEqual(
selectedBefore.sort(),
rowIds.slice().sort(),
`bulk delete 前应选中两个资源行: ${JSON.stringify(selectedBefore)}`,
);
const dialogPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
const message = dialog.message();
await dialog.accept();
return message;
});
await page.locator(selectors[1]).click({
button: "right",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.mnote-tree-context-menu__item[data-action="delete-trash"]').click({ timeout: UI_TIMEOUT_MS });
const confirmText = await dialogPromise;
assert.match(confirmText, /2 个附件/, `右键 bulk delete 确认文案应包含附件数量: ${confirmText}`);
page.on("dialog", async (dialog) => {
unexpectedDialogs.push(dialog.message());
await dialog.accept().catch(() => undefined);
});
await page.waitForFunction(() => {
const status = document.documentElement.getAttribute("data-mnote-filetree-last-action-status");
return document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied") === "true"
|| status === "failed";
}, null, {
timeout: UI_TIMEOUT_MS,
});
const actionState = await page.evaluate(() => ({
action: document.documentElement.getAttribute("data-mnote-filetree-last-action"),
status: document.documentElement.getAttribute("data-mnote-filetree-last-action-status"),
rowId: document.documentElement.getAttribute("data-mnote-filetree-last-action-row-id"),
applied: document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied"),
batchId: document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-batch-id"),
batchRefresh: document.documentElement.getAttribute("data-mnote-filetree-batch-refresh-applied"),
}));
assert.equal(actionState.action, "bulk-delete", `bulk delete 应记录 action 名: ${JSON.stringify(actionState)}`);
assert.deepEqual(unexpectedDialogs, [], `bulk delete 不应出现失败 alert: ${JSON.stringify({ unexpectedDialogs, treeCommands, treeCommandResponses })}`);
assert.equal(actionState.status, "archived", `bulk delete 应记录 undo/archive 状态: ${JSON.stringify(actionState)}`);
assert.equal(actionState.rowId, rowIds[1], `bulk delete 应记录触发目标 row: ${JSON.stringify(actionState)}`);
for (const asset of assets) {
await page.locator(`#sidebar-file-tree-root .tree-row[data-asset-id="${asset.assetId}"]`).waitFor({
state: "detached",
timeout: UI_TIMEOUT_MS,
});
assert.equal(fs.existsSync(asset.filePath), false, `${asset.assetId} 应移入垃圾箱`);
}
const archiveCommands = treeCommands.filter((entry) => entry.body && entry.body.action === "archive");
assert.equal(archiveCommands.length, 2, `bulk delete 应发出两个 archive tree command: ${JSON.stringify(treeCommands)}`);
assert(actionState.batchId, `bulk delete 应生成 batch id: ${JSON.stringify(actionState)}`);
assert.equal(actionState.batchRefresh, actionState.batchId, `bulk delete 应按 batch 完成后统一刷新: ${JSON.stringify(actionState)}`);
assert.deepEqual(
Array.from(new Set(archiveCommands.map((entry) => entry.body.batchId))).sort(),
[actionState.batchId],
`archive command 应共享同一个 batchId: ${JSON.stringify(archiveCommands)}`,
);
assert.deepEqual(
archiveCommands.map((entry) => entry.body.documentId).sort(),
assets.map((asset) => asset.assetId).sort(),
`archive command 应覆盖两个资源: ${JSON.stringify(archiveCommands)}`,
);
const trashIds = readTrashIndex(root).map((entry) => entry.id || entry.assetId).sort();
assert.deepEqual(
trashIds.filter((id) => assets.some((asset) => asset.assetId === id)),
assets.map((asset) => asset.assetId).sort(),
`trash index 应包含两个资源: ${JSON.stringify(trashIds)}`,
);
console.log(JSON.stringify({ ok: true, root, archived: rowIds }, null, 2));
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
fs.rmSync(root, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});