import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TreeShellRustDomShellHost } from "./tree-shell-dom-host"; import { TreeShellHost } from "./tree-shell-host"; import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree"; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; type PendingRuntimeRequest = { request: Record; resolve: (runtimeResult?: Record | null) => void; }; describe("tree-shell-host", () => { let container: HTMLDivElement; let root: Root; let previousLegacyFlag: string | undefined; let originalFetch: typeof fetch | undefined; let pendingRuntimeRequests: PendingRuntimeRequest[]; beforeEach(() => { previousLegacyFlag = process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST; delete process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); originalFetch = global.fetch; pendingRuntimeRequests = []; global.fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { const request = JSON.parse(String(init?.body ?? "{}")) as Record; return new Promise((resolve) => { pendingRuntimeRequests.push({ request, resolve: (runtimeResult = null) => { resolve({ ok: true, json: async () => runtimeResult, } as Response); }, }); }); }) as typeof fetch; }); afterEach(() => { pendingRuntimeRequests.splice(0).forEach(({ resolve }) => resolve(null)); act(() => { root.unmount(); }); container.remove(); if (originalFetch) { global.fetch = originalFetch; } else { delete (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch; } if (previousLegacyFlag === undefined) { delete process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST; } else { process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST = previousLegacyFlag; } }); const fileTreeItems: KernelFileTreeProjectionItem[] = [ { projectionKind: "file_tree", rowId: "index:doc_1", rowKind: "index", nodeId: "doc_1:index", parentNodeId: null, title: "Doc 1 索引", depth: 0, childCount: 0, position: 0, expandedByDefault: false, iconHint: "page", capabilities: ["select"], resourceMeta: { resourceKind: "document", documentId: "doc_1", workspaceId: "ws_1", iconHint: "page", }, }, { projectionKind: "file_tree", rowId: "index:doc_2", rowKind: "index", nodeId: "doc_2:index", parentNodeId: null, title: "Doc 2 索引", depth: 0, childCount: 0, position: 1, expandedByDefault: false, iconHint: "page", capabilities: ["select"], resourceMeta: { resourceKind: "document", documentId: "doc_2", workspaceId: "ws_1", iconHint: "page", }, }, { projectionKind: "file_tree", rowId: "index:doc_3", rowKind: "index", nodeId: "doc_3:index", parentNodeId: null, title: "Doc 3 索引", depth: 0, childCount: 0, position: 2, expandedByDefault: false, iconHint: "page", capabilities: ["select"], resourceMeta: { resourceKind: "document", documentId: "doc_3", workspaceId: "ws_1", iconHint: "page", }, }, { projectionKind: "file_tree", rowId: "asset:asset_pdf", rowKind: "asset", nodeId: "asset_pdf", parentNodeId: "doc_2", title: "附件.pdf", depth: 1, childCount: 0, position: 3, expandedByDefault: false, iconHint: "pdf", capabilities: ["select"], resourceMeta: { resourceKind: "pdf", documentId: "doc_2", assetId: "asset_pdf", workspaceId: "ws_1", iconHint: "pdf", }, }, ]; function queryFileTreeRow(rowId: string) { return container.querySelector(`[data-row-id="${rowId}"]`) as HTMLElement | null; } function expectSelectedRowIds(rowIds: string[]) { const selected = Array.from(container.querySelectorAll('[data-shell-mode="filetree"][data-selected="true"]')) .map((element) => element.getAttribute("data-row-id")) .filter((rowId): rowId is string => Boolean(rowId)); expect(selected).toEqual(rowIds); } function renderDomHost(input: { activeDocumentId?: string | null; onFileTreeDeleteSelection?: (payload: { selectedRowIds: string[]; anchorRowId: string | null; focusedRowId: string | null; }) => void; onTreeMutation?: (payload: any) => void; } = {}) { act(() => { root.render( , ); }); } async function flushRuntimeDispatch() { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); } function renderHost() { act(() => { root.render(
旧 React fallback
, ); }); } it("rust_family 默认选择 Rust/WASM DOM shell host,不能进入 iframe_srcdoc", () => { renderHost(); const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]'); const rustHost = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-host"]'); const domHost = container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]'); expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host"); expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); expect(rustHost?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host"); expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm"); expect(domHost?.getAttribute("data-tree-runtime-artifact-host")).toBe("rust_tree_shell_runtime_artifact_v1"); expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).toBeNull(); expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull(); expect(container.querySelector('[data-testid="legacy-react-fallback"]')).toBeNull(); }); it("只有显式 legacy flag 才允许进入旧 TreeShellIframeHost", async () => { process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST = "1"; await act(async () => { renderHost(); await Promise.resolve(); }); const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]'); const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]'); expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_runtime_artifact_host"); expect(container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]')).toBeNull(); expect(iframe).not.toBeNull(); expect(iframe?.getAttribute("data-tree-browser-bridge")).toBe("iframe_srcdoc"); expect(iframe?.getAttribute("srcdoc")).toContain('"browserBridge":"iframe_srcdoc"'); }); it("filetree DOM host 应先本地提交 Ctrl/Shift selection,再异步等 runtime 对账", async () => { renderDomHost(); const row2 = queryFileTreeRow("index:doc_2"); const row3 = queryFileTreeRow("index:doc_3"); expect(row2).not.toBeNull(); expect(row3).not.toBeNull(); act(() => { row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true })); }); expectSelectedRowIds(["index:doc_2"]); act(() => { row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, shiftKey: true })); }); expectSelectedRowIds(["index:doc_2", "index:doc_3"]); }); it("旧 runtime selection 回写不应覆盖更新后的多选,Delete 应使用当前稳定 selection", async () => { const handleDeleteSelection = vi.fn(); renderDomHost({ onFileTreeDeleteSelection: handleDeleteSelection }); const row2 = queryFileTreeRow("index:doc_2"); const row3 = queryFileTreeRow("index:doc_3"); expect(row2).not.toBeNull(); expect(row3).not.toBeNull(); act(() => { row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true })); }); await flushRuntimeDispatch(); act(() => { row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true })); }); expectSelectedRowIds(["index:doc_2", "index:doc_3"]); await flushRuntimeDispatch(); act(() => { pendingRuntimeRequests[0]?.resolve({ requestId: String(pendingRuntimeRequests[0]?.request.requestId ?? "filetree-stale"), mode: "fileTree", state: { mode: "fileTree", state: { activeRowId: null, selection: { selectedRowIds: ["index:doc_2"], anchorRowId: "index:doc_2", focusedRowId: "index:doc_2", }, dragRowIds: [], dragEffect: null, dropTargetRowId: null, }, }, }); }); await flushRuntimeDispatch(); expectSelectedRowIds(["index:doc_2", "index:doc_3"]); act(() => { row3?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Delete" })); }); await flushRuntimeDispatch(); expect(handleDeleteSelection).toHaveBeenCalledTimes(1); expect(handleDeleteSelection.mock.calls[0]?.[0]).toMatchObject({ selectedRowIds: ["index:doc_2", "index:doc_3"], anchorRowId: "index:doc_3", focusedRowId: "index:doc_3", }); }); it("Backspace 也应复用当前稳定多选并触发删除回调", async () => { const handleDeleteSelection = vi.fn(); renderDomHost({ onFileTreeDeleteSelection: handleDeleteSelection }); const row2 = queryFileTreeRow("index:doc_2"); const row3 = queryFileTreeRow("index:doc_3"); expect(row2).not.toBeNull(); expect(row3).not.toBeNull(); act(() => { row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true })); }); await flushRuntimeDispatch(); act(() => { row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true })); }); expectSelectedRowIds(["index:doc_2", "index:doc_3"]); act(() => { row3?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Backspace" })); }); await flushRuntimeDispatch(); expect(handleDeleteSelection).toHaveBeenCalledTimes(1); expect(handleDeleteSelection.mock.calls[0]?.[0]).toMatchObject({ selectedRowIds: ["index:doc_2", "index:doc_3"], anchorRowId: "index:doc_3", focusedRowId: "index:doc_3", }); }); it("点击行内更多操作时不应因为事件冒泡把多选压成单选", async () => { renderDomHost(); const row2 = queryFileTreeRow("index:doc_2"); const row3 = queryFileTreeRow("index:doc_3"); const row3Menu = container.querySelector( '[data-row-id="index:doc_3"] [data-testid="filetree-action-menu"]', ) as HTMLElement | null; expect(row2).not.toBeNull(); expect(row3).not.toBeNull(); expect(row3Menu).not.toBeNull(); act(() => { row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true })); }); act(() => { row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true })); }); expectSelectedRowIds(["index:doc_2", "index:doc_3"]); act(() => { row3Menu?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushRuntimeDispatch(); expectSelectedRowIds(["index:doc_2", "index:doc_3"]); }); it("activeDocumentId 变化时,已有 filetree selection 不应被无条件重置", () => { renderDomHost(); const row2 = queryFileTreeRow("index:doc_2"); const row3 = queryFileTreeRow("index:doc_3"); expect(row2).not.toBeNull(); expect(row3).not.toBeNull(); act(() => { row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true })); }); act(() => { row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true })); }); expectSelectedRowIds(["index:doc_2", "index:doc_3"]); renderDomHost({ activeDocumentId: "doc_1" }); expectSelectedRowIds(["index:doc_2", "index:doc_3"]); }); it("F2 应进入 filetree 行内重命名,并用 tree.node.rename 提交页面标题", async () => { const handleTreeMutation = vi.fn(); renderDomHost({ onTreeMutation: handleTreeMutation }); const row2 = queryFileTreeRow("index:doc_2"); expect(row2).not.toBeNull(); act(() => { row2?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "F2" })); }); await flushRuntimeDispatch(); const input = container.querySelector('.tree-rename-input[data-rename-id="index:doc_2"]') as HTMLInputElement | null; expect(input).not.toBeNull(); act(() => { input!.value = "Doc 2 Renamed"; input!.dispatchEvent(new Event("input", { bubbles: true })); }); await act(async () => { input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); await Promise.resolve(); }); const renameRequest = pendingRuntimeRequests.find( (entry) => (entry.request as { action?: unknown }).action === "rename", ); expect(renameRequest?.request).toMatchObject({ action: "rename", documentId: "doc_2", title: "Doc 2 Renamed", workspaceId: "ws_1", }); await act(async () => { renameRequest?.resolve({ result: { documentId: "doc_2", title: "Doc 2 Renamed", workspaceId: "ws_1", updatedAt: "2026-05-15T00:00:00Z", }, }); await Promise.resolve(); }); expect(handleTreeMutation).toHaveBeenCalledWith( expect.objectContaining({ type: "tree.node.renamed", documentId: "doc_2", title: "Doc 2 Renamed", }), ); }); it("F2 行内重命名应在输入框内阻断空名、非法字符和同级重名", async () => { renderDomHost(); const row2 = queryFileTreeRow("index:doc_2"); expect(row2).not.toBeNull(); act(() => { row2?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "F2" })); }); await flushRuntimeDispatch(); const input = container.querySelector('.tree-rename-input[data-rename-id="index:doc_2"]') as HTMLInputElement | null; expect(input).not.toBeNull(); act(() => { input!.value = " "; input!.dispatchEvent(new Event("input", { bubbles: true })); }); await act(async () => { input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); await Promise.resolve(); }); expect(container.querySelector('[data-testid="tree-rename-validation"]')?.textContent).toContain("名称不能为空"); expect(input?.getAttribute("aria-invalid")).toBe("true"); expect(pendingRuntimeRequests.some((entry) => (entry.request as { action?: unknown }).action === "rename")).toBe(false); act(() => { input!.value = "非法/名称"; input!.dispatchEvent(new Event("input", { bubbles: true })); }); await act(async () => { input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); await Promise.resolve(); }); expect(container.querySelector('[data-testid="tree-rename-validation"]')?.textContent).toContain("不能包含"); expect(pendingRuntimeRequests.some((entry) => (entry.request as { action?: unknown }).action === "rename")).toBe(false); act(() => { input!.value = "Doc 1 索引"; input!.dispatchEvent(new Event("input", { bubbles: true })); }); await act(async () => { input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); await Promise.resolve(); }); expect(container.querySelector('[data-testid="tree-rename-validation"]')?.textContent).toContain("同级已存在"); expect(pendingRuntimeRequests.some((entry) => (entry.request as { action?: unknown }).action === "rename")).toBe(false); }); it("F2 重命名附件未输入扩展名时应保留原扩展名", async () => { renderDomHost(); const assetRow = queryFileTreeRow("asset:asset_pdf"); expect(assetRow).not.toBeNull(); act(() => { assetRow?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "F2" })); }); await flushRuntimeDispatch(); const input = container.querySelector('.tree-rename-input[data-rename-id="asset:asset_pdf"]') as HTMLInputElement | null; expect(input).not.toBeNull(); act(() => { input!.value = "附件新名"; input!.dispatchEvent(new Event("input", { bubbles: true })); }); await act(async () => { input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); await Promise.resolve(); }); const renameRequest = pendingRuntimeRequests.find((entry) => { const request = entry.request as { action?: unknown; assetIds?: unknown }; return request.action === "rename" && Array.isArray(request.assetIds); }); expect(renameRequest?.request).toMatchObject({ action: "rename", assetIds: ["asset_pdf"], newName: "附件新名.pdf", }); }); });