"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) {
  const url = new URL(`${BASE_URL}/`);
  url.searchParams.set("treeView", mode);
  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") {
  const url = new URL(`${BASE_URL}/`);
  url.searchParams.set("treeView", mode);
  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) {
  const menu = page.locator('[data-testid="mnote-tree-context-menu"]').first();
  await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
  return menu;
}

async function clickMenuAction(page, action) {
  await page.locator(`[data-testid="mnote-tree-context-menu"] [data-menu-action="${action}"]`).click({ timeout: UI_TIMEOUT_MS });
}

async function expectMenuAction(page, action, options = {}) {
  const item = page.locator(`[data-testid="mnote-tree-context-menu"] [data-menu-action="${action}"]`).first();
  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 {
    await page.locator('[data-testid="mnote-tree-context-menu"]').waitFor({ state: "detached", timeout: 1_000 });
  } catch {
    await page.evaluate(() => {
      document.querySelectorAll('[data-testid="mnote-tree-context-menu"]').forEach((element) => element.remove());
    });
    await page.locator('[data-testid="mnote-tree-context-menu"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
  }
}

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 },
  );
}

async function dispatchRootContextMenu(page) {
  await page.evaluate(() => {
    const root = document.querySelector("#sidebar-file-tree-root .tree-root");
    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"),
    "---\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",
    "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 = [];
  const navigationEvents = [];
  page.on("framenavigated", (frame) => {
    if (frame === page.mainFrame()) {
      navigationEvents.push({
        url: frame.url(),
        timestamp: Date.now(),
      });
    }
  });
  page.on("request", (request) => {
    if (
      request.url().includes("/api/tree/commands") ||
      request.url().includes("/api/documents/title") ||
      request.url().includes("/api/documents/save") ||
      request.url().includes("/api/documents/options") ||
      request.url().includes("/api/ui/preferences")
    ) {
      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" },
        body: JSON.stringify({
          ...common,
          title: "Browser Saved Title",
          commandName: "page.head.updateTitle",
        }),
      });
      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 正文");
    assert(!fs.existsSync(path.join(root, ".mnote", "page-options.json")), "浏览器页面设置保存不应继续写入 .mnote/page-options.json");
    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
    );
    assert(enabledHeadingBefore !== "none", "SQLite 偏好开启后应显示标题自动编号");
    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");
    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;
    }

    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 });

    await dispatchFolderContextMenu(page, docsRow);
    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 });

    await dispatchFolderContextMenu(page, docsRow);
    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(() => {
      const root = document.querySelector("#sidebar-file-tree-root .tree-root");
      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 的父目录");
    assert(!fs.existsSync(path.join(root, "drop-on-md.txt")), "drop 到 Markdown 行时不应回退写入 root");
    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 });
    let watcherNavigationStart = navigationEvents.length;
    fs.writeFileSync(path.join(root, "docs", "watcher-added.md"), "# Watcher Added\n", "utf8");
    await waitForText(page, "Watcher Added");
    assert(navigationEvents.length === watcherNavigationStart, "外部新增 Markdown 后 page tree 不应发生浏览器导航或 reload");
    await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
    watcherNavigationStart = navigationEvents.length;
    fs.writeFileSync(path.join(root, "docs", "watcher-asset.txt"), "watcher asset", "utf8");
    await waitForText(page, "watcher-asset.txt");
    assert(navigationEvents.length === watcherNavigationStart, "外部新增非 md 资源后 filetree 不应发生浏览器导航或 reload");
    watcherNavigationStart = navigationEvents.length;
    fs.rmSync(path.join(root, "docs", "watcher-asset.txt"));
    await page.getByText("watcher-asset.txt", { exact: true }).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
    assert(navigationEvents.length === watcherNavigationStart, "外部删除非 md 资源后 filetree 不应发生浏览器导航或 reload");

    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);
});
