Files
mnote/wolai-frontend/src/components/sidebar/tree-shell-host.test.tsx
T

371 lines
12 KiB
TypeScript
Raw Normal View History

2026-04-28 16:30:51 +08:00
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
2026-05-13 22:43:16 +08:00
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TreeShellRustDomShellHost } from "./tree-shell-dom-host";
2026-04-28 16:30:51 +08:00
import { TreeShellHost } from "./tree-shell-host";
2026-05-13 22:43:16 +08:00
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
2026-04-28 16:30:51 +08:00
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
2026-05-13 22:43:16 +08:00
type PendingRuntimeRequest = {
request: Record<string, unknown>;
resolve: (runtimeResult?: Record<string, unknown> | null) => void;
};
2026-04-28 16:30:51 +08:00
describe("tree-shell-host", () => {
let container: HTMLDivElement;
let root: Root;
let previousLegacyFlag: string | undefined;
2026-05-13 22:43:16 +08:00
let originalFetch: typeof fetch | undefined;
let pendingRuntimeRequests: PendingRuntimeRequest[];
2026-04-28 16:30:51 +08:00
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);
2026-05-13 22:43:16 +08:00
originalFetch = global.fetch;
pendingRuntimeRequests = [];
global.fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
const request = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
return new Promise((resolve) => {
pendingRuntimeRequests.push({
request,
resolve: (runtimeResult = null) => {
resolve({
ok: true,
json: async () => runtimeResult,
} as Response);
},
});
});
}) as typeof fetch;
2026-04-28 16:30:51 +08:00
});
afterEach(() => {
2026-05-13 22:43:16 +08:00
pendingRuntimeRequests.splice(0).forEach(({ resolve }) => resolve(null));
2026-04-28 16:30:51 +08:00
act(() => {
root.unmount();
});
container.remove();
2026-05-13 22:43:16 +08:00
if (originalFetch) {
global.fetch = originalFetch;
} else {
delete (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch;
}
2026-04-28 16:30:51 +08:00
if (previousLegacyFlag === undefined) {
delete process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST;
} else {
process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST = previousLegacyFlag;
}
});
2026-05-13 22:43:16 +08:00
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",
},
},
];
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;
} = {}) {
act(() => {
root.render(
<TreeShellRustDomShellHost
mode="filetree"
surfaceTestId="sidebar-file-tree-shell"
workspaceId="ws_1"
activeDocumentId={input.activeDocumentId ?? null}
inlineFileTreeItems={fileTreeItems}
onFileTreeDeleteSelection={input.onFileTreeDeleteSelection}
/>,
);
});
}
async function flushRuntimeDispatch() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
2026-04-28 16:30:51 +08:00
function renderHost() {
act(() => {
root.render(
<TreeShellHost
mode="page"
surfaceTestId="sidebar-page-tree-shell"
rendererFamily="rust_family"
workspaceId="ws_1"
>
<div data-testid="legacy-react-fallback"> React fallback</div>
</TreeShellHost>,
);
});
}
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"');
});
2026-05-13 22:43:16 +08:00
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"]);
});
2026-04-28 16:30:51 +08:00
});