241 lines
10 KiB
JavaScript
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);
|
|
});
|