feat(tree): close rust family shell cutover

This commit is contained in:
lix-2026
2026-04-28 16:30:51 +08:00
parent 4ab36a9386
commit 7965c6c107
75 changed files with 9721 additions and 1174 deletions
@@ -1,10 +1,97 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import { SidebarTreeSurface, TreePickerSurface, type TreeRendererFamily } from "./tree-shell-surface";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type RuntimeRequest = {
requestId: string;
mode: "page" | "fileTree" | "picker";
environment?: Record<string, unknown>;
state?: Record<string, unknown>;
action?: Record<string, unknown>;
};
type TreeShellRuntimeTestGlobal = typeof globalThis & {
__MNOTE_TREE_SHELL_RUNTIME__?: {
reduceTreeShellRuntime: Mock;
};
};
function reduceTreeRuntimeForTest(request: RuntimeRequest) {
if (request.mode === "fileTree") {
const state = request.state ?? {};
const action = request.action ?? {};
const env = request.environment ?? {};
const rows = Array.isArray(env.rows) ? env.rows as Array<Record<string, unknown>> : [];
const rowId = typeof action.targetRowId === "string" ? action.targetRowId : null;
const targetRow = rows.find((row) => row.rowId === rowId) ?? {};
const target = {
kind: targetRow.rowKind ?? "doc",
documentId: targetRow.documentId ?? null,
assetId: targetRow.assetId ?? null,
};
const hostEvents =
action.kind === "dispatchInternalDrop"
? [{
kind: "fileTreeInternalDrop",
targetRowId: rowId,
target,
rowIds: Array.isArray(action.rowIds) ? action.rowIds : [],
copy: action.copy === true,
}]
: action.kind === "dispatchExternalDrop"
? [{
kind: "fileTreeExternalDrop",
targetRowId: rowId,
target,
fileCount: action.fileCount ?? 0,
}]
: [];
return {
requestId: request.requestId,
mode: "fileTree",
state: { mode: "fileTree", state },
domPatches: [{ kind: "fileTreeState" }],
hostEvents,
commandEvents: [],
};
}
return {
requestId: request.requestId,
mode: request.mode,
state: { mode: request.mode, state: request.state ?? {} },
domPatches: [],
hostEvents: [],
commandEvents: [],
};
}
function mockTreeRuntimeReducer() {
const reduceTreeShellRuntime = vi.fn(async (request: RuntimeRequest) =>
reduceTreeRuntimeForTest(request),
);
(globalThis as TreeShellRuntimeTestGlobal).__MNOTE_TREE_SHELL_RUNTIME__ = {
reduceTreeShellRuntime,
};
return reduceTreeShellRuntime;
}
async function waitForRuntimeReducer(reducerMock: Mock) {
for (let index = 0; index < 10; index += 1) {
if (reducerMock.mock.calls.length > 0) {
return;
}
await act(async () => {
await Promise.resolve();
});
}
}
describe("tree-shell-surface", () => {
let container: HTMLDivElement;
let root: Root;
@@ -20,6 +107,7 @@ describe("tree-shell-surface", () => {
root.unmount();
});
container.remove();
delete (globalThis as TreeShellRuntimeTestGlobal).__MNOTE_TREE_SHELL_RUNTIME__;
});
function renderPageSurface(rendererFamily: TreeRendererFamily, focusedDocumentId?: string) {
@@ -43,41 +131,41 @@ describe("tree-shell-surface", () => {
});
}
it("page tree surface 在 rust_family 下可切到同源 iframe host", () => {
it("page tree surface 在 rust_family 下默认切到 Rust/WASM DOM shell host", () => {
renderPageSurface("rust_family");
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
const rustHost = container.querySelector(
'[data-testid="sidebar-page-tree-shell-rust-host"]',
);
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
const domHost = container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]');
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_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-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeArtifact"');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
expect(domHost).not.toBeNull();
expect(domHost?.getAttribute("data-tree-runtime-artifact-host")).toBe("rust_tree_shell_runtime_artifact_v1");
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
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="page-tree-renderer-removed"]')).toBeNull();
});
it("page tree surface 在 rust_family 下应把 focusedDocumentId 作为宿主状态暴露,并使用 postMessage patch 同步 iframe", () => {
it("page tree surface 在 rust_family 下应把 focusedDocumentId 暴露到宿主 DOM 状态", () => {
renderPageSurface("rust_family", "doc_focus");
const iframe = container.querySelector(
'[data-testid="sidebar-page-tree-shell-rust-iframe"]',
) as HTMLIFrameElement | null;
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
const domHost = container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]');
expect(surface?.getAttribute("data-page-tree-focused-id")).toBe("doc_focus");
expect(iframe?.getAttribute("src")).toBeNull();
expect(iframe?.getAttribute("srcdoc")).toContain('"focusedDocumentId":"doc_focus"');
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).toBeNull();
});
it("page tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
it("page tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => {
await act(async () => {
root.render(
<SidebarTreeSurface
@@ -97,8 +185,9 @@ describe("tree-shell-surface", () => {
});
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).not.toBeNull();
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]')).not.toBeNull();
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
});
@@ -126,7 +215,7 @@ describe("tree-shell-surface", () => {
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).not.toBeNull();
});
it("file tree surface 在 rust_family 下也应走同一 host 选择契约", () => {
it("file tree surface 在 rust_family 下也应走默认 Rust/WASM DOM shell host", () => {
act(() => {
root.render(
<SidebarTreeSurface
@@ -149,21 +238,23 @@ describe("tree-shell-surface", () => {
const rustHost = container.querySelector(
'[data-testid="sidebar-file-tree-shell-rust-host"]',
);
const iframe = container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]');
const domHost = container.querySelector('[data-testid="sidebar-file-tree-shell-dom-host"]');
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_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-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeArtifact"');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
expect(domHost).not.toBeNull();
expect(domHost?.getAttribute("data-tree-runtime-artifact-host")).toBe("rust_tree_shell_runtime_artifact_v1");
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull();
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
});
it("file tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
it("file tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => {
await act(async () => {
root.render(
<SidebarTreeSurface
@@ -183,8 +274,9 @@ describe("tree-shell-surface", () => {
});
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).not.toBeNull();
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-dom-host"]')).not.toBeNull();
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
});
@@ -212,10 +304,50 @@ describe("tree-shell-surface", () => {
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).not.toBeNull();
});
it("file tree surface 应把 iframe 内部拖放与外部文件拖放桥接回宿主回调", async () => {
it("file tree surface 应把 DOM shell 内部拖放与外部文件拖放桥接回宿主回调", async () => {
const runtimeReducerMock = mockTreeRuntimeReducer();
const onInternalDrop = vi.fn();
const onDropFiles = vi.fn();
const droppedFile = new File(["bridge"], "bridge.txt", { type: "text/plain" });
const dataTransfer = {
files: [] as File[],
types: ["application/x-mnote-file-tree", "text/plain"],
dropEffect: "move",
getData: (type: string) =>
type === "application/x-mnote-file-tree" || type === "text/plain"
? JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds: ["doc:doc_source"] })
: "",
setData: vi.fn(),
} as unknown as DataTransfer;
const externalDataTransfer = {
files: [droppedFile],
types: ["Files"],
dropEffect: "copy",
getData: () => "",
setData: vi.fn(),
} as unknown as DataTransfer;
const targetProjectionItem: KernelFileTreeProjectionItem = {
rowId: "doc:doc_target",
nodeId: "doc_target",
parentNodeId: null,
projectionKind: "file_tree",
title: "目标页面",
depth: 0,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
nodeType: "page",
rowKind: "document",
iconHint: "page",
capabilities: ["open", "select", "context-menu"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_target",
workspaceId: "ws_1",
iconHint: "page",
},
};
await act(async () => {
root.render(
@@ -225,6 +357,7 @@ describe("tree-shell-surface", () => {
workspaceId="ws_1"
treeShellEnabled
rows={[]}
treeShellItems={[targetProjectionItem]}
activeId=""
onRowClick={() => undefined}
onRowDoubleClick={() => undefined}
@@ -237,45 +370,33 @@ describe("tree-shell-surface", () => {
);
});
const iframe = container.querySelector(
'[data-testid="sidebar-file-tree-shell-rust-iframe"]',
const targetRow = container.querySelector(
'[data-testid="filetree-doc-row"][data-document-id="doc_target"]',
);
expect(iframe).not.toBeNull();
Object.defineProperty(iframe, "contentWindow", {
configurable: true,
value: window,
});
expect(targetRow).not.toBeNull();
await act(async () => {
window.dispatchEvent(
new MessageEvent("message", {
data: {
channel: "sidebar-file-tree-shell",
type: "tree.filetree.internal-drop",
rowIds: ["doc:doc_source"],
copy: false,
rowId: "doc:doc_target",
rowKind: "doc",
documentId: "doc_target",
},
source: window,
}),
);
window.dispatchEvent(
new MessageEvent("message", {
data: {
channel: "sidebar-file-tree-shell",
type: "tree.filetree.external-drop",
documentId: "doc_target",
rowId: "doc:doc_target",
rowKind: "doc",
files: [droppedFile],
},
source: window,
}),
);
const internalDropEvent = new Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(internalDropEvent, "dataTransfer", {
configurable: true,
value: dataTransfer,
});
targetRow?.dispatchEvent(internalDropEvent);
const externalDropEvent = new Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(externalDropEvent, "dataTransfer", {
configurable: true,
value: externalDataTransfer,
});
targetRow?.dispatchEvent(externalDropEvent);
});
await waitForRuntimeReducer(runtimeReducerMock);
expect(runtimeReducerMock).toHaveBeenCalledWith(
expect.objectContaining({
mode: "fileTree",
action: expect.objectContaining({ kind: "dispatchInternalDrop" }),
}),
);
expect(onInternalDrop).toHaveBeenCalledWith({
targetDocumentId: "doc_target",
targetRowId: "doc:doc_target",
@@ -293,7 +414,7 @@ describe("tree-shell-surface", () => {
});
});
it("picker surface 在 rust_family 下也应挂到同一 host 边界", async () => {
it("picker surface 在 rust_family 下也应挂到默认 Rust/WASM DOM shell host", async () => {
const onPick = vi.fn();
await act(async () => {
@@ -312,19 +433,24 @@ describe("tree-shell-surface", () => {
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
const domHost = container.querySelector('[data-testid="tree-picker-surface-dom-host"]');
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_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-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull();
expect(domHost).not.toBeNull();
expect(domHost?.getAttribute("data-tree-runtime-artifact-host")).toBe("rust_tree_shell_runtime_artifact_v1");
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull();
expect(container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_1"]')).not.toBeNull();
expect(onPick).not.toHaveBeenCalled();
});
it("picker 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
it("picker 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => {
await act(async () => {
root.render(
<TreePickerSurface
@@ -346,11 +472,12 @@ describe("tree-shell-surface", () => {
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
const domHost = container.querySelector('[data-testid="tree-picker-surface-dom-host"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull();
expect(domHost).not.toBeNull();
expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
});
});