2026-05-08 00:41:03 +08:00
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
const fs = require("fs");
|
|
|
|
|
const os = require("os");
|
|
|
|
|
const path = require("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);
|
|
|
|
|
|
|
|
|
|
function assert(condition, message) {
|
|
|
|
|
if (!condition) {
|
|
|
|
|
throw new Error(message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fileUrl(localPath) {
|
|
|
|
|
return `file://${localPath}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function treeUrl(root, mode) {
|
2026-05-09 19:05:06 +08:00
|
|
|
const url = new URL(`${BASE_URL}/`);
|
|
|
|
|
url.searchParams.set("treeView", mode);
|
2026-05-08 00:41:03 +08:00
|
|
|
url.searchParams.set("sourceKind", "local_folder");
|
|
|
|
|
url.searchParams.set("rootUri", fileUrl(root));
|
|
|
|
|
return url.toString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function documentUrl(root, documentId) {
|
|
|
|
|
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(documentId)}`);
|
|
|
|
|
url.searchParams.set("sourceKind", "local_folder");
|
|
|
|
|
url.searchParams.set("rootUri", fileUrl(root));
|
|
|
|
|
return url.toString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function convexTreeUrl(mode = "filetree") {
|
2026-05-09 19:05:06 +08:00
|
|
|
const url = new URL(`${BASE_URL}/`);
|
|
|
|
|
url.searchParams.set("treeView", mode);
|
2026-05-08 00:41:03 +08:00
|
|
|
url.searchParams.set("workspaceId", "ws_demo");
|
|
|
|
|
url.searchParams.set("sourceKind", "convex_workspace");
|
|
|
|
|
return url.toString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForText(page, text) {
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
(expectedText) => Array.from(document.querySelectorAll("body *")).some((element) => {
|
|
|
|
|
const textContent = element.textContent ? element.textContent.trim() : "";
|
|
|
|
|
if (textContent !== expectedText) return false;
|
|
|
|
|
const style = window.getComputedStyle(element);
|
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
|
|
|
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
|
|
|
|
}),
|
|
|
|
|
text,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForContextMenu(page) {
|
2026-05-09 19:05:06 +08:00
|
|
|
const menu = page.locator('[data-testid="mnote-tree-context-menu"]').first();
|
2026-05-08 00:41:03 +08:00
|
|
|
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
return menu;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function clickMenuAction(page, action) {
|
2026-05-09 19:05:06 +08:00
|
|
|
await page.locator(`[data-testid="mnote-tree-context-menu"] [data-menu-action="${action}"]`).click({ timeout: UI_TIMEOUT_MS });
|
2026-05-08 00:41:03 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function expectMenuAction(page, action, options = {}) {
|
2026-05-09 19:05:06 +08:00
|
|
|
const item = page.locator(`[data-testid="mnote-tree-context-menu"] [data-menu-action="${action}"]`).first();
|
2026-05-08 00:41:03 +08:00
|
|
|
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const disabled = await item.isDisabled();
|
|
|
|
|
if (options.disabled !== undefined) {
|
|
|
|
|
assert(disabled === options.disabled, `${action} 菜单项 disabled=${disabled} 不符合预期`);
|
|
|
|
|
}
|
|
|
|
|
if (options.reasonIncludes) {
|
|
|
|
|
const reason = await item.getAttribute("data-disabled-reason");
|
|
|
|
|
assert((reason || "").includes(options.reasonIncludes), `${action} 菜单项应展示禁用原因`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function closeContextMenu(page) {
|
|
|
|
|
await page.keyboard.press("Escape");
|
|
|
|
|
try {
|
2026-05-09 19:05:06 +08:00
|
|
|
await page.locator('[data-testid="mnote-tree-context-menu"]').waitFor({ state: "detached", timeout: 1_000 });
|
2026-05-08 00:41:03 +08:00
|
|
|
} catch {
|
|
|
|
|
await page.evaluate(() => {
|
2026-05-09 19:05:06 +08:00
|
|
|
document.querySelectorAll('[data-testid="mnote-tree-context-menu"]').forEach((element) => element.remove());
|
2026-05-08 00:41:03 +08:00
|
|
|
});
|
2026-05-09 19:05:06 +08:00
|
|
|
await page.locator('[data-testid="mnote-tree-context-menu"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
|
2026-05-08 00:41:03 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 07:38:45 +08:00
|
|
|
async function dispatchFolderContextMenu(page, rowSelector) {
|
|
|
|
|
await page.evaluate(
|
|
|
|
|
({ rowSelector }) => {
|
|
|
|
|
const row = document.querySelector(rowSelector);
|
|
|
|
|
if (!(row instanceof HTMLElement)) throw new Error("filetree folder row 不存在");
|
|
|
|
|
row.dispatchEvent(new MouseEvent("contextmenu", {
|
|
|
|
|
bubbles: true,
|
|
|
|
|
cancelable: true,
|
|
|
|
|
clientX: 24,
|
|
|
|
|
clientY: 64,
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
{ rowSelector },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
async function dispatchRootContextMenu(page) {
|
|
|
|
|
await page.evaluate(() => {
|
2026-05-09 19:05:06 +08:00
|
|
|
const root = document.querySelector("#sidebar-file-tree-root .tree-root");
|
2026-05-08 00:41:03 +08:00
|
|
|
if (!(root instanceof HTMLElement)) throw new Error("filetree root 不存在");
|
|
|
|
|
root.dispatchEvent(new MouseEvent("contextmenu", {
|
|
|
|
|
bubbles: true,
|
|
|
|
|
cancelable: true,
|
|
|
|
|
clientX: 24,
|
|
|
|
|
clientY: 64,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForPreflight(page, titleText) {
|
|
|
|
|
const preflight = page.locator('[data-testid="tree-preflight"]').first();
|
|
|
|
|
await preflight.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
if (titleText) {
|
|
|
|
|
await preflight.getByText(titleText, { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
}
|
|
|
|
|
return preflight;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function cancelPreflight(page) {
|
|
|
|
|
await page.locator('[data-testid="tree-preflight"] [data-role="cancel"]').click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator('[data-testid="tree-preflight"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function confirmPreflight(page) {
|
|
|
|
|
await page.locator('[data-testid="tree-preflight"] [data-role="confirm"]').click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator('[data-testid="tree-preflight"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function dispatchInternalDrop(page, sourceRowSelector, targetRowSelector, options = {}) {
|
|
|
|
|
await page.evaluate(
|
|
|
|
|
({ sourceRowSelector, targetRowSelector, altKey }) => {
|
|
|
|
|
const source = document.querySelector(sourceRowSelector);
|
|
|
|
|
const target = document.querySelector(targetRowSelector);
|
|
|
|
|
if (!(source instanceof HTMLElement) || !(target instanceof HTMLElement)) {
|
|
|
|
|
throw new Error("拖拽目标不存在");
|
|
|
|
|
}
|
|
|
|
|
const dataTransfer = new DataTransfer();
|
|
|
|
|
source.dispatchEvent(new DragEvent("dragstart", { bubbles: true, cancelable: true, dataTransfer }));
|
|
|
|
|
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer, altKey }));
|
|
|
|
|
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer, altKey }));
|
|
|
|
|
source.dispatchEvent(new DragEvent("dragend", { bubbles: true, cancelable: true, dataTransfer }));
|
|
|
|
|
},
|
|
|
|
|
{ sourceRowSelector, targetRowSelector, altKey: Boolean(options.altKey) },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function dispatchInternalDragOver(page, sourceRowSelector, targetRowSelector, ratio) {
|
|
|
|
|
return await page.evaluate(
|
|
|
|
|
({ sourceRowSelector, targetRowSelector, ratio }) => {
|
|
|
|
|
const source = document.querySelector(sourceRowSelector);
|
|
|
|
|
const target = document.querySelector(targetRowSelector);
|
|
|
|
|
if (!(source instanceof HTMLElement) || !(target instanceof HTMLElement)) {
|
|
|
|
|
throw new Error("拖拽目标不存在");
|
|
|
|
|
}
|
|
|
|
|
const rect = target.getBoundingClientRect();
|
|
|
|
|
const dataTransfer = new DataTransfer();
|
|
|
|
|
source.dispatchEvent(new DragEvent("dragstart", { bubbles: true, cancelable: true, dataTransfer }));
|
|
|
|
|
target.dispatchEvent(new DragEvent("dragover", {
|
|
|
|
|
bubbles: true,
|
|
|
|
|
cancelable: true,
|
|
|
|
|
dataTransfer,
|
|
|
|
|
clientX: rect.left + rect.width / 2,
|
|
|
|
|
clientY: rect.top + rect.height * ratio,
|
|
|
|
|
}));
|
|
|
|
|
return {
|
|
|
|
|
dropTarget: target.dataset.dropTarget || "",
|
|
|
|
|
dropPosition: target.dataset.dropPosition || "",
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
{ sourceRowSelector, targetRowSelector, ratio },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function dispatchExternalFileDrop(page, targetRowSelector, fileName, text) {
|
|
|
|
|
await page.evaluate(
|
|
|
|
|
({ targetRowSelector, fileName, text }) => {
|
|
|
|
|
const target = document.querySelector(targetRowSelector);
|
|
|
|
|
if (!(target instanceof HTMLElement)) {
|
|
|
|
|
throw new Error("外部拖入目标不存在");
|
|
|
|
|
}
|
|
|
|
|
const dataTransfer = new DataTransfer();
|
|
|
|
|
dataTransfer.items.add(new File([text], fileName, { type: "text/plain" }));
|
|
|
|
|
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
|
|
|
|
|
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
|
|
|
|
|
},
|
|
|
|
|
{ targetRowSelector, fileName, text },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForHostMessage(page, type, predicate = () => true) {
|
|
|
|
|
return await page.waitForFunction(
|
|
|
|
|
({ type }) => {
|
|
|
|
|
const messages = Array.isArray(window.__mnoteHostMessages) ? window.__mnoteHostMessages : [];
|
|
|
|
|
return messages.find((message) => message && message.type === type) || null;
|
|
|
|
|
},
|
|
|
|
|
{ type },
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
).then(async (handle) => {
|
|
|
|
|
const message = await handle.evaluate((value) => {
|
|
|
|
|
const files = Array.from(value?.files || []).map((file) => ({
|
|
|
|
|
name: file?.name || "",
|
|
|
|
|
size: Number.isFinite(file?.size) ? file.size : 0,
|
|
|
|
|
type: file?.type || "",
|
|
|
|
|
}));
|
|
|
|
|
return {
|
|
|
|
|
...value,
|
|
|
|
|
files,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
assert(predicate(message), `${type} host message 不符合预期: ${JSON.stringify(message)}`);
|
|
|
|
|
return message;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function dispatchForeignInternalDrop(page, targetRowSelector) {
|
|
|
|
|
await page.evaluate(
|
|
|
|
|
({ targetRowSelector }) => {
|
|
|
|
|
const target = document.querySelector(targetRowSelector);
|
|
|
|
|
if (!(target instanceof HTMLElement)) {
|
|
|
|
|
throw new Error("跨 workspace 拖拽目标不存在");
|
|
|
|
|
}
|
|
|
|
|
const dataTransfer = new DataTransfer();
|
|
|
|
|
const payload = JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds: ["foreign:workspace-row"] });
|
|
|
|
|
dataTransfer.setData("application/x-mnote-filetree-row-ids", payload);
|
|
|
|
|
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
|
|
|
|
|
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
|
|
|
|
|
},
|
|
|
|
|
{ targetRowSelector },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function run() {
|
|
|
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-folder-smoke-"));
|
|
|
|
|
fs.mkdirSync(path.join(root, "docs"));
|
|
|
|
|
fs.mkdirSync(path.join(root, "docs", "nested"));
|
|
|
|
|
fs.mkdirSync(path.join(root, "readonly-dir"));
|
|
|
|
|
fs.mkdirSync(path.join(root, ".git"));
|
|
|
|
|
fs.mkdirSync(path.join(root, "node_modules"));
|
|
|
|
|
fs.mkdirSync(path.join(root, ".mnote"));
|
|
|
|
|
fs.writeFileSync(path.join(root, "README.md"), "---\ntitle: Frontmatter Title\n---\n# Ignored\n正文\n", "utf8");
|
|
|
|
|
fs.writeFileSync(path.join(root, "docs", "child.md"), "# Child H1\n", "utf8");
|
|
|
|
|
fs.writeFileSync(path.join(root, "docs", "nested", "deep.md"), "# Deep Page\n", "utf8");
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
path.join(root, "docs", "blocks.md"),
|
2026-05-09 19:05:06 +08:00
|
|
|
"---\ntitle: Complex Title\n---\n# Complex H1\n\nparagraph with `inline code`, **bold**, *italic*, ~~strike~~, and [Link](https://example.com).\n\n- Bullet item\n1. Numbered item\n- [ ] Unchecked item\n- [x] Checked item\n> Quote item\n```js\nconsole.log('hi')\n```\n---\n[Spec](assets/spec.pdf)\n\n| Name | Value |\n| --- | --- |\n| Mark | `cell` |\n",
|
2026-05-08 00:41:03 +08:00
|
|
|
"utf8",
|
|
|
|
|
);
|
|
|
|
|
fs.writeFileSync(path.join(root, "image.png"), "png", "utf8");
|
|
|
|
|
fs.writeFileSync(path.join(root, ".git", "hidden.md"), "# hidden\n", "utf8");
|
|
|
|
|
fs.writeFileSync(path.join(root, "node_modules", "hidden.md"), "# hidden\n", "utf8");
|
|
|
|
|
fs.chmodSync(path.join(root, "readonly-dir"), 0o555);
|
|
|
|
|
|
|
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
|
|
|
const context = await browser.newContext({
|
|
|
|
|
viewport: { width: 1280, height: 860 },
|
|
|
|
|
extraHTTPHeaders: {
|
|
|
|
|
"x-mnote-actor-id": "user_real",
|
|
|
|
|
"x-mnote-actor-type": "user",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const page = await context.newPage();
|
|
|
|
|
const requests = [];
|
2026-05-16 07:38:45 +08:00
|
|
|
const navigationEvents = [];
|
|
|
|
|
page.on("framenavigated", (frame) => {
|
|
|
|
|
if (frame === page.mainFrame()) {
|
|
|
|
|
navigationEvents.push({
|
|
|
|
|
url: frame.url(),
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-08 00:41:03 +08:00
|
|
|
page.on("request", (request) => {
|
2026-05-09 19:05:06 +08:00
|
|
|
if (
|
|
|
|
|
request.url().includes("/api/tree/commands") ||
|
|
|
|
|
request.url().includes("/api/documents/title") ||
|
|
|
|
|
request.url().includes("/api/documents/save") ||
|
2026-05-27 11:31:12 +08:00
|
|
|
request.url().includes("/api/documents/options") ||
|
|
|
|
|
request.url().includes("/api/ui/preferences")
|
2026-05-09 19:05:06 +08:00
|
|
|
) {
|
2026-05-08 00:41:03 +08:00
|
|
|
requests.push({
|
|
|
|
|
url: request.url(),
|
|
|
|
|
method: request.method(),
|
|
|
|
|
body: request.postData() || "",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await page.goto(documentUrl(root, "local-md:README.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await waitForText(page, "Frontmatter Title");
|
|
|
|
|
const dialogs = [];
|
|
|
|
|
page.on("dialog", async (dialog) => {
|
|
|
|
|
dialogs.push(dialog.type());
|
|
|
|
|
await dialog.dismiss().catch(() => {});
|
|
|
|
|
});
|
|
|
|
|
await page.locator('[data-mnote-action="open-local-folder"]').click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
assert(dialogs.length === 0, "打开本地文件夹入口不能再依赖 prompt/alert/confirm 原生弹窗");
|
|
|
|
|
await page.locator('[data-testid="mnote-local-folder-path-input"]').fill(root);
|
|
|
|
|
await page.locator('[data-testid="mnote-local-folder-open-confirm"]').click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.waitForURL(
|
|
|
|
|
(url) =>
|
|
|
|
|
url.pathname === "/" &&
|
|
|
|
|
url.searchParams.get("treeView") === "filetree" &&
|
|
|
|
|
url.searchParams.get("sourceKind") === "local_folder" &&
|
|
|
|
|
url.searchParams.get("rootUri") === fileUrl(root),
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
await waitForText(page, "README.md");
|
|
|
|
|
const recentLocalRoots = await page.evaluate(() => {
|
|
|
|
|
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
|
|
|
|
|
return JSON.parse(raw);
|
|
|
|
|
});
|
|
|
|
|
assert(recentLocalRoots[0] === fileUrl(root), "打开本地文件夹入口应记录最近 rootUri");
|
|
|
|
|
|
|
|
|
|
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await waitForText(page, "README.md");
|
|
|
|
|
await waitForText(page, "docs");
|
|
|
|
|
await waitForText(page, "image.png");
|
|
|
|
|
assert(!(await page.getByText(".git", { exact: true }).isVisible().catch(() => false)), ".git 不应出现在 Explorer");
|
|
|
|
|
assert(!(await page.getByText("node_modules", { exact: true }).isVisible().catch(() => false)), "node_modules 不应出现在 Explorer");
|
|
|
|
|
assert(await page.locator('[data-row-kind="folder"][data-row-id="local:folder:docs"]').count() === 1, "目录 rowKind 应保持 folder");
|
|
|
|
|
assert(await page.locator('[data-row-kind="markdown"][data-row-id="local:markdown:README.md"]').count() === 1, "Markdown rowKind 应保持 markdown");
|
|
|
|
|
|
|
|
|
|
await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await waitForText(page, "Frontmatter Title");
|
|
|
|
|
await waitForText(page, "Child H1");
|
|
|
|
|
assert(!(await page.getByText("image.png", { exact: true }).isVisible().catch(() => false)), "page_tree 不应显示普通文件");
|
|
|
|
|
|
|
|
|
|
await page.goto(documentUrl(root, "local-md:README.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await waitForText(page, "Frontmatter Title");
|
|
|
|
|
await waitForText(page, "正文");
|
|
|
|
|
const defaultHeadingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").first().evaluate((node) =>
|
|
|
|
|
window.getComputedStyle(node, "::before").content
|
|
|
|
|
);
|
|
|
|
|
assert(defaultHeadingBefore === "none", `本地 Markdown 默认不应开启标题自动编号,实际 ::before=${defaultHeadingBefore}`);
|
|
|
|
|
await page.evaluate(async ({ rootUri }) => {
|
|
|
|
|
const common = {
|
|
|
|
|
documentId: "local-md:README.md",
|
|
|
|
|
sourceKind: "local_folder",
|
|
|
|
|
rootUri,
|
|
|
|
|
};
|
|
|
|
|
const titleResponse = await fetch("/api/documents/title", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "content-type": "application/json" },
|
2026-05-11 13:16:34 +08:00
|
|
|
body: JSON.stringify({
|
|
|
|
|
...common,
|
|
|
|
|
title: "Browser Saved Title",
|
|
|
|
|
commandName: "page.head.updateTitle",
|
|
|
|
|
}),
|
2026-05-08 00:41:03 +08:00
|
|
|
});
|
|
|
|
|
if (!titleResponse.ok) throw new Error(`title_failed_${titleResponse.status}`);
|
|
|
|
|
const saveResponse = await fetch("/api/documents/save", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
...common,
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
id: "h1",
|
|
|
|
|
type: "heading",
|
|
|
|
|
props: { level: 1 },
|
|
|
|
|
content: [{ type: "text", text: "Browser Saved Heading" }],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "p1",
|
|
|
|
|
type: "paragraph",
|
|
|
|
|
content: [{ type: "text", text: "Browser saved body" }],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
blockCount: 2,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
if (!saveResponse.ok) throw new Error(`save_failed_${saveResponse.status}`);
|
|
|
|
|
const optionsResponse = await fetch("/api/documents/options", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ ...common, options: { wideLayout: true, showToc: false, showHeadingNumbers: true } }),
|
|
|
|
|
});
|
|
|
|
|
if (!optionsResponse.ok) throw new Error(`options_failed_${optionsResponse.status}`);
|
|
|
|
|
}, { rootUri: fileUrl(root) });
|
|
|
|
|
assert(fs.readFileSync(path.join(root, "README.md"), "utf8").includes("Browser saved body"), "浏览器保存应写回 Markdown 正文");
|
2026-05-27 11:31:12 +08:00
|
|
|
assert(!fs.existsSync(path.join(root, ".mnote", "page-options.json")), "浏览器页面设置保存不应继续写入 .mnote/page-options.json");
|
2026-05-08 00:41:03 +08:00
|
|
|
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await waitForText(page, "Browser Saved Title");
|
|
|
|
|
await waitForText(page, "Browser saved body");
|
|
|
|
|
const enabledHeadingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").first().evaluate((node) =>
|
|
|
|
|
window.getComputedStyle(node, "::before").content
|
|
|
|
|
);
|
2026-05-27 11:31:12 +08:00
|
|
|
assert(enabledHeadingBefore !== "none", "SQLite 偏好开启后应显示标题自动编号");
|
2026-05-08 00:41:03 +08:00
|
|
|
await page.goto(documentUrl(root, "local-md:docs~2Fblocks.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await waitForText(page, "Complex Title");
|
|
|
|
|
await waitForText(page, "Bullet item");
|
|
|
|
|
await waitForText(page, "Numbered item");
|
|
|
|
|
await waitForText(page, "Quote item");
|
|
|
|
|
await waitForText(page, "console.log('hi')");
|
|
|
|
|
await waitForText(page, "Spec");
|
2026-05-09 19:05:06 +08:00
|
|
|
await page.locator(".ProseMirror").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const checkboxStates = await page.locator(".ProseMirror input[type='checkbox']").evaluateAll((nodes) =>
|
|
|
|
|
nodes.map((node) => Boolean(node.checked))
|
|
|
|
|
);
|
|
|
|
|
assert(checkboxStates.length === 2, `本地 Markdown 应渲染两个 checkbox,实际 ${checkboxStates.length}`);
|
|
|
|
|
assert(checkboxStates.includes(true), "本地 Markdown 应渲染 checked checkbox");
|
|
|
|
|
assert(checkboxStates.includes(false), "本地 Markdown 应渲染 unchecked checkbox");
|
|
|
|
|
assert(await page.locator(".ProseMirror p code", { hasText: "inline code" }).count() >= 1, "inline code 应渲染为 code mark");
|
|
|
|
|
assert(await page.locator(".ProseMirror strong", { hasText: "bold" }).count() >= 1, "strong 应渲染为 strong 标签");
|
|
|
|
|
assert(await page.locator(".ProseMirror em", { hasText: "italic" }).count() >= 1, "em 应渲染为 em 标签");
|
|
|
|
|
assert(await page.locator(".ProseMirror s, .ProseMirror del", { hasText: "strike" }).count() >= 1, "strike 应渲染为删除线标签");
|
|
|
|
|
const linkTexts = await page.locator(".ProseMirror a").evaluateAll((nodes) => nodes.map((node) => node.textContent?.trim() || ""));
|
|
|
|
|
assert(linkTexts.includes("Link"), `普通链接应渲染为 a 标签,实际 ${JSON.stringify(linkTexts)}`);
|
|
|
|
|
assert(linkTexts.includes("Spec"), `附件链接应渲染为 a 标签,实际 ${JSON.stringify(linkTexts)}`);
|
|
|
|
|
assert(await page.locator(".ProseMirror table td code", { hasText: "cell" }).count() >= 1, "table cell inline code 应保留 code mark");
|
|
|
|
|
|
|
|
|
|
if (process.env.MNOTE_TASK163_RUN_EXTENDED_TREE_OPS !== "1") {
|
|
|
|
|
const filetreeRows = await page.locator('#sidebar-file-tree-root [data-testid="filetree-asset-row"]').evaluateAll((nodes) =>
|
|
|
|
|
nodes.map((node) => ({
|
|
|
|
|
rowId: node.getAttribute("data-row-id") || "",
|
|
|
|
|
rowKind: node.getAttribute("data-row-kind") || "",
|
|
|
|
|
}))
|
|
|
|
|
);
|
|
|
|
|
assert(filetreeRows.some((row) => row.rowId === "local:folder:docs" && row.rowKind === "folder"), "当前主入口下的 filetree 容器应继续渲染 docs folder 行");
|
|
|
|
|
assert(filetreeRows.some((row) => row.rowId === "local:markdown:README.md" && row.rowKind === "markdown"), "当前主入口下的 filetree 容器应继续渲染 README markdown 行");
|
|
|
|
|
const pageAggregateResult = await page.evaluate(async ({ rootUri }) => {
|
|
|
|
|
const response = await fetch(`/api/page-aggregate/${encodeURIComponent("local-md:docs~2Fblocks.md")}?sourceKind=local_folder&rootUri=${encodeURIComponent(rootUri)}`, {
|
|
|
|
|
headers: { accept: "application/json" },
|
|
|
|
|
});
|
|
|
|
|
const payload = await response.json().catch(() => null);
|
|
|
|
|
return {
|
|
|
|
|
ok: response.ok,
|
|
|
|
|
status: response.status,
|
|
|
|
|
payload,
|
|
|
|
|
};
|
|
|
|
|
}, { rootUri: fileUrl(root) });
|
|
|
|
|
assert(pageAggregateResult.ok, `page aggregate 路由应返回 200,实际 ${pageAggregateResult.status}`);
|
|
|
|
|
assert(pageAggregateResult.payload?.schema === "mnote.page_aggregate.v1", `page aggregate schema 应保持稳定,实际 ${JSON.stringify(pageAggregateResult.payload)}`);
|
|
|
|
|
assert(pageAggregateResult.payload?.result?.title === "Complex Title", "page aggregate 应继续返回本地 Markdown 标题");
|
|
|
|
|
assert(requests.some((request) => request.body.includes('"sourceKind":"local_folder"')), "本地 Markdown 保存写操作必须透传 sourceKind=local_folder");
|
|
|
|
|
await browser.close();
|
|
|
|
|
fs.chmodSync(path.join(root, "readonly-dir"), 0o755);
|
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
|
|
|
console.log("task163 local markdown parser browser smoke passed");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const readmeRow = '.tree-row[data-row-id="local:markdown:README.md"]';
|
|
|
|
|
const docsRow = '.tree-row[data-row-id="local:folder:docs"]';
|
|
|
|
|
const nestedRow = '.tree-row[data-row-id="local:folder:docs/nested"]';
|
|
|
|
|
const readonlyRow = '.tree-row[data-row-id="local:folder:readonly-dir"]';
|
|
|
|
|
let imageRow = '.tree-row[data-row-id="local:asset:image.png"]';
|
|
|
|
|
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
|
2026-05-16 07:38:45 +08:00
|
|
|
await dispatchFolderContextMenu(page, docsRow);
|
2026-05-08 00:41:03 +08:00
|
|
|
await waitForContextMenu(page);
|
|
|
|
|
await expectMenuAction(page, "newPage", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "newFolder", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "upload", { disabled: true });
|
|
|
|
|
await expectMenuAction(page, "refresh", { disabled: false });
|
|
|
|
|
await closeContextMenu(page);
|
|
|
|
|
await page.locator(readmeRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await waitForContextMenu(page);
|
|
|
|
|
await expectMenuAction(page, "open", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "rename", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "copy", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "cut", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "paste", { disabled: true });
|
|
|
|
|
await expectMenuAction(page, "delete", { disabled: false });
|
|
|
|
|
await closeContextMenu(page);
|
|
|
|
|
await page.locator(docsRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await waitForContextMenu(page);
|
|
|
|
|
await expectMenuAction(page, "newPage", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "newFolder", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "paste", { disabled: true });
|
|
|
|
|
await expectMenuAction(page, "rename", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "delete", { disabled: true, reasonIncludes: "目录删除" });
|
|
|
|
|
await closeContextMenu(page);
|
|
|
|
|
await page.locator(imageRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await waitForContextMenu(page);
|
|
|
|
|
await expectMenuAction(page, "open", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "rename", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "copy", { disabled: true });
|
|
|
|
|
await expectMenuAction(page, "cut", { disabled: true });
|
|
|
|
|
await expectMenuAction(page, "delete", { disabled: true });
|
|
|
|
|
await expectMenuAction(page, "download", { disabled: true });
|
|
|
|
|
await clickMenuAction(page, "rename");
|
|
|
|
|
let assetRenameInput = page.locator(".tree-rename-input").first();
|
|
|
|
|
await assetRenameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await assetRenameInput.fill("renamed-image");
|
|
|
|
|
await page.keyboard.press("Enter");
|
|
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
assert(fs.existsSync(path.join(root, "renamed-image.png")), "asset rename 应通过同一 inline rename 写入本地文件系统");
|
|
|
|
|
imageRow = '.tree-row[data-row-id="local:asset:renamed-image.png"]';
|
|
|
|
|
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
|
2026-05-16 07:38:45 +08:00
|
|
|
await dispatchFolderContextMenu(page, docsRow);
|
2026-05-08 00:41:03 +08:00
|
|
|
await waitForContextMenu(page);
|
|
|
|
|
await clickMenuAction(page, "newFolder");
|
|
|
|
|
let createRenameInput = page.locator(".tree-rename-input").first();
|
|
|
|
|
await createRenameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await createRenameInput.fill("新建资料");
|
|
|
|
|
await page.keyboard.press("Enter");
|
|
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
assert(fs.existsSync(path.join(root, "新建资料")), "根菜单新建文件夹后应进入 inline rename 并写入本地目录");
|
|
|
|
|
|
|
|
|
|
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(docsRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await waitForContextMenu(page);
|
|
|
|
|
await clickMenuAction(page, "newPage");
|
|
|
|
|
createRenameInput = page.locator(".tree-rename-input").first();
|
|
|
|
|
await createRenameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await createRenameInput.fill("菜单新页面");
|
|
|
|
|
await page.keyboard.press("Enter");
|
|
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
assert(fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "文件夹菜单新建页面后应进入 inline rename 并写入本地 Markdown");
|
|
|
|
|
|
|
|
|
|
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const createdPageRow = '.tree-row[data-row-id="local:markdown:docs/菜单新页面.md"]';
|
|
|
|
|
await page.locator(createdPageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(createdPageRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.keyboard.press("Delete");
|
|
|
|
|
await waitForPreflight(page, "删除预检");
|
|
|
|
|
await confirmPreflight(page);
|
|
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
assert(!fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "确认 Delete preflight 后本地 Markdown 原路径应消失");
|
|
|
|
|
assert(fs.existsSync(path.join(root, ".mnote", "trash", "菜单新页面.md")), "确认 Delete preflight 后本地 Markdown 应进入 .mnote/trash");
|
|
|
|
|
const trashIndex = JSON.parse(fs.readFileSync(path.join(root, ".mnote", "trash-index.json"), "utf8"));
|
|
|
|
|
const createdTrashEntry = Object.values(trashIndex.entries || {}).find((entry) => entry.originalRelativePath === "docs/菜单新页面.md");
|
|
|
|
|
assert(createdTrashEntry && createdTrashEntry.documentId, "trash-index 应记录被删除页面的 documentId");
|
|
|
|
|
await page.evaluate(async ({ rootUri, documentId }) => {
|
|
|
|
|
const response = await fetch("/api/tree/commands", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
action: "restore",
|
|
|
|
|
sourceKind: "local_folder",
|
|
|
|
|
rootUri,
|
|
|
|
|
documentId,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) throw new Error(`restore_failed_${response.status}`);
|
|
|
|
|
}, { rootUri: fileUrl(root), documentId: createdTrashEntry.documentId });
|
|
|
|
|
await page.waitForTimeout(300);
|
|
|
|
|
assert(fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "local restore 应从 .mnote/trash 恢复 Markdown");
|
|
|
|
|
await page.evaluate(async ({ rootUri, documentId }) => {
|
|
|
|
|
const response = await fetch("/api/tree/commands", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
action: "purge",
|
|
|
|
|
sourceKind: "local_folder",
|
|
|
|
|
rootUri,
|
|
|
|
|
documentId,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) throw new Error(`purge_failed_${response.status}`);
|
|
|
|
|
}, { rootUri: fileUrl(root), documentId: createdTrashEntry.documentId });
|
|
|
|
|
await page.waitForTimeout(300);
|
|
|
|
|
assert(!fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "local purge 应永久删除已恢复的 Markdown");
|
|
|
|
|
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
|
|
|
|
|
assert((await dispatchInternalDragOver(page, readmeRow, docsRow, 0.05)).dropPosition === "before", "内部拖拽应显示 before indicator");
|
|
|
|
|
assert((await dispatchInternalDragOver(page, readmeRow, docsRow, 0.5)).dropPosition === "inside", "内部拖拽应显示 inside indicator");
|
|
|
|
|
assert((await dispatchInternalDragOver(page, readmeRow, docsRow, 0.95)).dropPosition === "after", "内部拖拽应显示 after indicator");
|
|
|
|
|
await dispatchInternalDragOver(page, readmeRow, nestedRow, 0.5);
|
|
|
|
|
await page.locator('.tree-row[data-row-id="local:markdown:docs/nested/deep.md"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const illegalDropRequestCount = requests.length;
|
|
|
|
|
await dispatchInternalDrop(page, readmeRow, readmeRow);
|
|
|
|
|
await dispatchInternalDrop(page, docsRow, nestedRow);
|
|
|
|
|
await dispatchForeignInternalDrop(page, docsRow);
|
|
|
|
|
await page.waitForTimeout(500);
|
|
|
|
|
assert(requests.length === illegalDropRequestCount, "自拖自身、拖到后代、跨 workspace 不应发 execute 请求");
|
|
|
|
|
|
|
|
|
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
|
|
|
|
await page.locator(docsRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(nestedRow).click({ modifiers: [modifier], timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const redundantMoveRequestCount = requests.length;
|
|
|
|
|
await dispatchInternalDrop(page, docsRow, readmeRow);
|
|
|
|
|
await waitForPreflight(page, "移动预检");
|
|
|
|
|
await confirmPreflight(page);
|
|
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
const redundantMoveRequests = requests.slice(redundantMoveRequestCount).filter((request) => request.body.includes('"action":"move"'));
|
|
|
|
|
assert(redundantMoveRequests.length === 1, "多选父子节点 move 时应过滤子节点重复执行");
|
|
|
|
|
assert(redundantMoveRequests[0].body.includes("local:node:docs"), "多选父子节点 move 应保留父节点作为唯一执行项");
|
|
|
|
|
assert(!redundantMoveRequests[0].body.includes("local:node:docs/nested"), "多选父子节点 move 不应重复执行子节点");
|
|
|
|
|
|
|
|
|
|
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
|
|
|
|
|
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(docsRow).click({ modifiers: [modifier], timeout: UI_TIMEOUT_MS });
|
|
|
|
|
assert(await page.locator(`${readmeRow}[data-selected="true"]`).count() === 1, "Ctrl/Cmd+Click 后原选区应保留");
|
|
|
|
|
assert(await page.locator(`${docsRow}[data-selected="true"]`).count() === 1, "Ctrl/Cmd+Click 后目标行应加入选区");
|
|
|
|
|
await page.locator(docsRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
assert(await page.locator(`${readmeRow}[data-selected="true"]`).count() === 1, "已选区内右键不应清空多选");
|
|
|
|
|
await waitForContextMenu(page);
|
|
|
|
|
await expectMenuAction(page, "copy", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "cut", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "delete", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "moveTo", { disabled: true });
|
|
|
|
|
await closeContextMenu(page);
|
|
|
|
|
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(nestedRow).click({ modifiers: ["Shift"], timeout: UI_TIMEOUT_MS });
|
|
|
|
|
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() >= 2, "Shift+Click 应形成范围选区");
|
|
|
|
|
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(imageRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
assert(await page.locator(`${imageRow}[data-selected="true"]`).count() === 1, "未选项右键应切换到该行");
|
|
|
|
|
assert(await page.locator(`${readmeRow}[data-selected="true"]`).count() === 0, "未选项右键应清理旧选区");
|
|
|
|
|
await closeContextMenu(page);
|
|
|
|
|
await page.evaluate(() => {
|
2026-05-09 19:05:06 +08:00
|
|
|
const root = document.querySelector("#sidebar-file-tree-root .tree-root");
|
2026-05-08 00:41:03 +08:00
|
|
|
if (!(root instanceof HTMLElement)) throw new Error("filetree root 不存在");
|
|
|
|
|
root.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
|
|
|
|
|
});
|
|
|
|
|
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() === 0, "空白区点击应清空 selection/focus");
|
|
|
|
|
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A");
|
|
|
|
|
const visibleFileTreeRows = await page.locator('.tree-row[data-shell-mode="filetree"]').count();
|
|
|
|
|
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() === visibleFileTreeRows, "Ctrl/Cmd+A 应选择全部可见 filetree 行");
|
|
|
|
|
|
|
|
|
|
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const deletePreflightRequestCount = requests.length;
|
|
|
|
|
await page.keyboard.press("Delete");
|
|
|
|
|
const deletePreflight = await waitForPreflight(page, "删除预检");
|
|
|
|
|
assert(await deletePreflight.getByText("影响:", { exact: false }).isVisible(), "Delete preflight 应说明影响集合");
|
|
|
|
|
await cancelPreflight(page);
|
|
|
|
|
await page.waitForTimeout(300);
|
|
|
|
|
assert(requests.length === deletePreflightRequestCount, "取消 Delete preflight 后不应发 execute 请求");
|
|
|
|
|
assert(fs.existsSync(path.join(root, "README.md")), "取消 Delete preflight 后文件不应被移动到 trash");
|
|
|
|
|
|
|
|
|
|
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.keyboard.press("F2");
|
|
|
|
|
const renameInput = page.locator(".tree-rename-input").first();
|
|
|
|
|
await renameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await renameInput.fill("README Renamed");
|
|
|
|
|
await page.keyboard.press("Enter");
|
|
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
assert(fs.existsSync(path.join(root, "README Renamed.md")), "F2 inline rename 应真实重命名 Markdown 文件");
|
|
|
|
|
|
|
|
|
|
const renamedRow = '.tree-row[data-row-id="local:markdown:README Renamed.md"]';
|
|
|
|
|
await page.locator(renamedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(renamedRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.keyboard.press(process.platform === "darwin" ? "Meta+X" : "Control+X");
|
|
|
|
|
assert(await page.locator(`${renamedRow}[data-cut="true"]`).count() === 1, "Ctrl/Cmd+X 后应出现 cut decoration");
|
|
|
|
|
await page.locator(docsRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const pasteCancelRequestCount = requests.length;
|
|
|
|
|
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
|
|
|
|
|
const pastePreflight = await waitForPreflight(page, "粘贴移动预检");
|
|
|
|
|
assert(await pastePreflight.getByText("命名冲突策略", { exact: false }).isVisible(), "Paste preflight 应展示命名冲突策略");
|
|
|
|
|
await cancelPreflight(page);
|
|
|
|
|
await page.waitForTimeout(300);
|
|
|
|
|
assert(requests.length === pasteCancelRequestCount, "取消 Paste preflight 后不应发 execute 请求");
|
|
|
|
|
assert(fs.existsSync(path.join(root, "README Renamed.md")), "取消 Paste preflight 后文件应留在原目录");
|
|
|
|
|
await page.locator(docsRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
|
|
|
|
|
await waitForPreflight(page, "粘贴移动预检");
|
|
|
|
|
await confirmPreflight(page);
|
|
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
assert(fs.existsSync(path.join(root, "docs", "README Renamed.md")), "剪切粘贴应真实移动 Markdown 文件");
|
|
|
|
|
|
|
|
|
|
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await dispatchInternalDrop(page, '.tree-row[data-row-id="local:markdown:docs/README Renamed.md"]', '.tree-row[data-row-id="local:folder:docs"]', { altKey: true });
|
|
|
|
|
const copyPreflight = await waitForPreflight(page, "复制预检");
|
|
|
|
|
assert(await copyPreflight.getByText("命名冲突策略", { exact: false }).isVisible(), "内部复制 drop preflight 应展示命名冲突策略");
|
|
|
|
|
await confirmPreflight(page);
|
|
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
assert(fs.existsSync(path.join(root, "docs", "README Renamed 2.md")), "Alt 内部拖拽应真实复制 Markdown 文件");
|
|
|
|
|
|
|
|
|
|
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const externalCancelRequestCount = requests.length;
|
|
|
|
|
await dispatchExternalFileDrop(page, '.tree-row[data-row-id="local:folder:docs"]', "drop-cancel.txt", "取消外部拖入");
|
|
|
|
|
await waitForPreflight(page, "外部拖入预检");
|
|
|
|
|
await cancelPreflight(page);
|
|
|
|
|
await page.waitForTimeout(300);
|
|
|
|
|
assert(requests.length === externalCancelRequestCount, "取消外部 File drop preflight 后不应发 execute 请求");
|
|
|
|
|
assert(!fs.existsSync(path.join(root, "docs", "drop-cancel.txt")), "取消外部 File drop preflight 后不应写入文件");
|
|
|
|
|
await dispatchExternalFileDrop(page, '.tree-row[data-row-id="local:folder:docs"]', "drop-real.txt", "真实外部拖入");
|
|
|
|
|
const externalPreflight = await waitForPreflight(page, "外部拖入预检");
|
|
|
|
|
assert(await externalPreflight.getByText("drop-real.txt", { exact: false }).isVisible(), "外部 drop preflight 应展示文件名");
|
|
|
|
|
await confirmPreflight(page);
|
|
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
assert(fs.existsSync(path.join(root, "docs", "drop-real.txt")), "外部 File drop 应真实写入本地目录");
|
|
|
|
|
await dispatchExternalFileDrop(page, '.tree-row[data-row-id="local:markdown:docs/README Renamed.md"]', "drop-on-md.txt", "拖到 Markdown 行");
|
|
|
|
|
const externalMdPreflight = await waitForPreflight(page, "外部拖入预检");
|
|
|
|
|
assert(await externalMdPreflight.getByText("README Renamed.md 的父目录", { exact: false }).isVisible(), "drop 到 md 文件时 preflight 应说明目标归属为父目录");
|
|
|
|
|
await confirmPreflight(page);
|
|
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
assert(fs.existsSync(path.join(root, "docs", "drop-on-md.txt")), "drop 到 md 文件时应写入该 md 的父目录");
|
2026-05-24 13:59:54 +08:00
|
|
|
assert(!fs.existsSync(path.join(root, "drop-on-md.txt")), "drop 到 Markdown 行时不应回退写入 root");
|
2026-05-08 00:41:03 +08:00
|
|
|
const readonlyDropRequestCount = requests.length;
|
|
|
|
|
await dispatchExternalFileDrop(page, readonlyRow, "readonly-drop.txt", "readonly");
|
|
|
|
|
await waitForPreflight(page, "操作预检失败");
|
|
|
|
|
assert(await page.getByText("只读", { exact: false }).first().isVisible(), "readonly 目标应展示统一预检失败原因");
|
|
|
|
|
await confirmPreflight(page);
|
|
|
|
|
await page.waitForTimeout(300);
|
|
|
|
|
assert(requests.length === readonlyDropRequestCount, "readonly 目标预检失败后不应发 execute 请求");
|
|
|
|
|
assert(!fs.existsSync(path.join(root, "readonly-dir", "readonly-drop.txt")), "readonly 目标预检失败后不应写入文件");
|
|
|
|
|
|
|
|
|
|
await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
2026-05-16 07:38:45 +08:00
|
|
|
let watcherNavigationStart = navigationEvents.length;
|
2026-05-08 00:41:03 +08:00
|
|
|
fs.writeFileSync(path.join(root, "docs", "watcher-added.md"), "# Watcher Added\n", "utf8");
|
|
|
|
|
await waitForText(page, "Watcher Added");
|
2026-05-16 07:38:45 +08:00
|
|
|
assert(navigationEvents.length === watcherNavigationStart, "外部新增 Markdown 后 page tree 不应发生浏览器导航或 reload");
|
2026-05-08 00:41:03 +08:00
|
|
|
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
2026-05-16 07:38:45 +08:00
|
|
|
watcherNavigationStart = navigationEvents.length;
|
2026-05-08 00:41:03 +08:00
|
|
|
fs.writeFileSync(path.join(root, "docs", "watcher-asset.txt"), "watcher asset", "utf8");
|
|
|
|
|
await waitForText(page, "watcher-asset.txt");
|
2026-05-16 07:38:45 +08:00
|
|
|
assert(navigationEvents.length === watcherNavigationStart, "外部新增非 md 资源后 filetree 不应发生浏览器导航或 reload");
|
|
|
|
|
watcherNavigationStart = navigationEvents.length;
|
2026-05-08 00:41:03 +08:00
|
|
|
fs.rmSync(path.join(root, "docs", "watcher-asset.txt"));
|
|
|
|
|
await page.getByText("watcher-asset.txt", { exact: true }).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
|
2026-05-16 07:38:45 +08:00
|
|
|
assert(navigationEvents.length === watcherNavigationStart, "外部删除非 md 资源后 filetree 不应发生浏览器导航或 reload");
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
await page.goto(convexTreeUrl("filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await waitForText(page, "工作区首页");
|
|
|
|
|
await waitForText(page, "封面.png");
|
|
|
|
|
assert(!(await page.getByText("Watcher Added", { exact: true }).isVisible().catch(() => false)), "切回 Convex 后不应显示旧 local watcher 内容");
|
|
|
|
|
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() === 0, "切回 Convex 后旧 local selection 不应污染新 source");
|
|
|
|
|
await dispatchRootContextMenu(page);
|
|
|
|
|
await waitForContextMenu(page);
|
|
|
|
|
await expectMenuAction(page, "newPage", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "newFolder", { disabled: true, reasonIncludes: "Convex workspace" });
|
|
|
|
|
await closeContextMenu(page);
|
|
|
|
|
await page.getByText("工作区首页", { exact: true }).click({ button: "right", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await waitForContextMenu(page);
|
|
|
|
|
await expectMenuAction(page, "open", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "rename", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "copy", { disabled: false });
|
|
|
|
|
await expectMenuAction(page, "paste", { disabled: true });
|
|
|
|
|
await closeContextMenu(page);
|
|
|
|
|
const convexRootRow = '.tree-row[data-row-id="doc:page_root"]';
|
|
|
|
|
const convexChildRow = '.tree-row[data-row-id="doc:page_child"]';
|
|
|
|
|
const convexAssetFolderRow = '.tree-row[data-row-id="asset-folder:mind_1"]';
|
|
|
|
|
await page.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(convexChildRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(convexAssetFolderRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
|
|
|
|
|
assert((await dispatchInternalDragOver(page, convexChildRow, convexRootRow, 0.05)).dropPosition === "before", "Convex document 拖拽应显示 before indicator");
|
|
|
|
|
assert((await dispatchInternalDragOver(page, convexChildRow, convexRootRow, 0.5)).dropPosition === "inside", "Convex document 拖拽应显示 inside indicator");
|
|
|
|
|
assert((await dispatchInternalDragOver(page, convexChildRow, convexRootRow, 0.95)).dropPosition === "after", "Convex document 拖拽应显示 after indicator");
|
|
|
|
|
assert((await dispatchInternalDragOver(page, convexChildRow, convexAssetFolderRow, 0.5)).dropPosition === "inside", "Convex asset-folder 拖拽应显示 inside indicator");
|
|
|
|
|
|
|
|
|
|
await page.locator(convexChildRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.keyboard.press(process.platform === "darwin" ? "Meta+C" : "Control+C");
|
|
|
|
|
await page.locator(convexRootRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const convexCopyRequestCount = requests.length;
|
|
|
|
|
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
|
|
|
|
|
await waitForPreflight(page, "粘贴复制预检");
|
|
|
|
|
await confirmPreflight(page);
|
|
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
const convexCopyRequests = requests.slice(convexCopyRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
|
|
|
|
|
assert(convexCopyRequests.some((request) => request.body.includes('"action":"copy"')), "Convex copy paste 应通过同一 command endpoint 发送 copy action");
|
|
|
|
|
|
|
|
|
|
await page.goto(convexTreeUrl("filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(convexChildRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(convexChildRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.keyboard.press(process.platform === "darwin" ? "Meta+X" : "Control+X");
|
|
|
|
|
await page.locator(convexRootRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const convexMoveRequestCount = requests.length;
|
|
|
|
|
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
|
|
|
|
|
await waitForPreflight(page, "粘贴移动预检");
|
|
|
|
|
await confirmPreflight(page);
|
|
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
const convexMoveRequests = requests.slice(convexMoveRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
|
|
|
|
|
assert(convexMoveRequests.some((request) => request.body.includes('"action":"move"')), "Convex cut paste 应通过同一 command endpoint 发送 move action");
|
|
|
|
|
await page.goto(convexTreeUrl("filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(convexChildRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.locator(convexChildRow).click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const convexDeleteRequestCount = requests.length;
|
|
|
|
|
await page.keyboard.press("Delete");
|
|
|
|
|
await waitForPreflight(page, "删除预检");
|
|
|
|
|
await confirmPreflight(page);
|
|
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
const convexDeleteRequests = requests.slice(convexDeleteRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
|
|
|
|
|
assert(convexDeleteRequests.some((request) => request.body.includes('"action":"delete"')), "Convex Delete 应通过同一 command endpoint 发送 delete action");
|
|
|
|
|
const convexRestoreRequestCount = requests.length;
|
|
|
|
|
await page.evaluate(async () => {
|
|
|
|
|
const response = await fetch("/api/tree/commands", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
action: "restore",
|
|
|
|
|
sourceKind: "convex_workspace",
|
|
|
|
|
workspaceId: "ws_demo",
|
|
|
|
|
documentId: "page_child",
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) throw new Error(`convex_restore_failed_${response.status}`);
|
|
|
|
|
});
|
|
|
|
|
await page.evaluate(async () => {
|
|
|
|
|
const response = await fetch("/api/tree/commands", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
action: "purge",
|
|
|
|
|
sourceKind: "convex_workspace",
|
|
|
|
|
workspaceId: "ws_demo",
|
|
|
|
|
documentId: "page_child",
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) throw new Error(`convex_purge_failed_${response.status}`);
|
|
|
|
|
});
|
|
|
|
|
const convexRestorePurgeRequests = requests.slice(convexRestoreRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
|
|
|
|
|
assert(convexRestorePurgeRequests.some((request) => request.body.includes('"action":"restore"')), "Convex restore 应继续走 tree.node.restore command");
|
|
|
|
|
assert(convexRestorePurgeRequests.some((request) => request.body.includes('"action":"purge"')), "Convex purge 应继续走 tree.node.purge command");
|
|
|
|
|
|
|
|
|
|
await page.setContent(`
|
|
|
|
|
<!doctype html>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<script>
|
|
|
|
|
window.__mnoteHostMessages = [];
|
|
|
|
|
window.addEventListener("message", (event) => {
|
|
|
|
|
window.__mnoteHostMessages.push(event.data);
|
|
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
<iframe id="tree-host" src="${convexTreeUrl("filetree")}"></iframe>
|
|
|
|
|
`);
|
|
|
|
|
const treeFrameLocator = page.frameLocator("#tree-host");
|
|
|
|
|
await treeFrameLocator.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const treeFrameElement = await page.locator("#tree-host").elementHandle({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const treeFrame = treeFrameElement ? await treeFrameElement.contentFrame() : null;
|
|
|
|
|
assert(treeFrame, "Convex iframe tree shell 应加载成功");
|
|
|
|
|
await treeFrame.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await treeFrame.locator(convexAssetFolderRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await dispatchExternalFileDrop(treeFrame, convexRootRow, "convex-doc-drop.txt", "上传到 Convex document");
|
|
|
|
|
await treeFrame.locator('[data-testid="tree-preflight"]').getByText("外部拖入预检", { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await treeFrame.locator('[data-testid="tree-preflight"]').getByText("上传到对象存储", { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await treeFrame.locator('[data-testid="tree-preflight"] [data-role="confirm"]').click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const convexDocDropMessage = await waitForHostMessage(page, "tree.filetree.external-drop", (message) =>
|
|
|
|
|
message.documentId === "page_root" &&
|
|
|
|
|
message.rowKind === "document" &&
|
|
|
|
|
Array.isArray(message.files) &&
|
|
|
|
|
message.files.length === 1 &&
|
|
|
|
|
message.files[0].name === "convex-doc-drop.txt"
|
|
|
|
|
);
|
|
|
|
|
assert(convexDocDropMessage.fileCount === undefined || convexDocDropMessage.fileCount === 1, "Convex document 外部 drop host message 应保留文件数量");
|
|
|
|
|
await page.evaluate(() => {
|
|
|
|
|
window.__mnoteHostMessages = [];
|
|
|
|
|
});
|
|
|
|
|
await dispatchExternalFileDrop(treeFrame, convexAssetFolderRow, "convex-asset-folder-drop.txt", "上传到 Convex asset folder");
|
|
|
|
|
await treeFrame.locator('[data-testid="tree-preflight"]').getByText("外部拖入预检", { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await treeFrame.locator('[data-testid="tree-preflight"] [data-role="confirm"]').click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await waitForHostMessage(page, "tree.filetree.external-drop", (message) =>
|
|
|
|
|
message.assetId === "mind_1" &&
|
|
|
|
|
message.rowKind === "asset_folder" &&
|
|
|
|
|
Array.isArray(message.files) &&
|
|
|
|
|
message.files.some((file) => file.name === "convex-asset-folder-drop.txt")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert(requests.some((request) => request.body.includes('"sourceKind":"local_folder"')), "写操作必须透传 sourceKind=local_folder");
|
|
|
|
|
assert(requests.some((request) => request.body.includes('"action":"move"')), "剪切粘贴应走统一 move action");
|
|
|
|
|
assert(requests.some((request) => request.body.includes('"action":"copy"')), "内部 copy drop 应走统一 copy action");
|
|
|
|
|
assert(requests.some((request) => request.body.includes('"action":"dropFiles"')), "外部文件 drop 应走统一 dropFiles action");
|
|
|
|
|
|
|
|
|
|
await browser.close();
|
|
|
|
|
fs.chmodSync(path.join(root, "readonly-dir"), 0o755);
|
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
|
|
|
console.log("task163 local folder unified tree browser smoke passed");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
await browser.close().catch(() => undefined);
|
|
|
|
|
console.error(`local root preserved for debugging: ${root}`);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
run().catch((error) => {
|
|
|
|
|
console.error(error);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
});
|