提交当前顶层 mnote Git 工作区,范围集中在 04-tree-domain 的 resource/trash/filetree 生命周期、mnote-web resource_trash 路由、Convex/Next 兼容接口、sidebar/file-tree 客户端适配、smoke 脚本与对应设计/bug 记录。 不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。
531 lines
18 KiB
TypeScript
531 lines
18 KiB
TypeScript
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<string, unknown>;
|
|
resolve: (runtimeResult?: Record<string, unknown> | 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<string, unknown>;
|
|
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(
|
|
<TreeShellRustDomShellHost
|
|
mode="filetree"
|
|
surfaceTestId="sidebar-file-tree-shell"
|
|
workspaceId="ws_1"
|
|
activeDocumentId={input.activeDocumentId ?? null}
|
|
inlineFileTreeItems={fileTreeItems}
|
|
onFileTreeDeleteSelection={input.onFileTreeDeleteSelection}
|
|
onTreeMutation={input.onTreeMutation}
|
|
/>,
|
|
);
|
|
});
|
|
}
|
|
|
|
async function flushRuntimeDispatch() {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
}
|
|
|
|
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"');
|
|
});
|
|
|
|
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",
|
|
});
|
|
});
|
|
});
|