feat(plugins): plugin system with extension points, consent, Wasm runtime and plugin API 1.0 (#971)

A plugin system built on WebAssembly components (wasmtime, WASI p2), with a
typed WIT API (`openidea:[email protected]`) and a Rust SDK, shipped as a preview.

- Extension points declared in `plugin.toml` (commands, languages, themes,
  icon themes, menus, key bindings, status items, settings, views, custom
  editors, file icons and templates, run detectors, editor providers),
  validated before anything takes effect and projected onto the workbench's
  own registries: plugin themes and file icon themes are chosen in
  Settings › Appearance and Quick Switch, plugin shortcuts are edited in
  Settings › Keymap, plugin problems are grouped in Settings › Inspections.
- Capabilities with risk levels and consent at install, pinned `process:`
  programs, secrets in the OS keychain.
- Runtime: one thread per active plugin, fuel/time/memory budgets, soft and
  hard deadlines on the editing path, cancellation, crash backoff, idle
  reclaim, a precompiled-component cache; remote projects run code plugins on
  `openidea-server`.
- Web views and custom editors with a sandboxed page, a document bridge
  joined to the text editor's undo history, and split or preview layouts.
- Distribution: signed `.oiplugin` packages, a static marketplace index with
  revocation, versioned installs with rollback, install from a `.zip` or a
  folder. The release workflow signs and publishes the official plugins and
  their index, which released builds use as the default marketplace.
- Official plugins: String Tools, Key Promoter, Git Toolbox, AI Completion,
  Checkstyle, MyBatis Navigator, CSV Editor, Solidity, Material Icons, Harbor
  Night, Excalidraw, Graphviz, Regex Tester, License Header.

Closes #732

Co-authored-by: Deng Liu <[email protected]>
Co-authored-by: florentvdry <[email protected]>
Цей коміт міститься в:
authored and GitHub committed 2026-11-04 02:54:31 +08:00
1 джерело cfdbd3872a
коміт 1c5a92f952
450 files changed
+47018 -3058

No files matched your search

+383 -38
Переглянути файл
@@ -1,32 +1,43 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { PluginInfo } from "@/bindings";
import { _setActiveEditor } from "@/editor/api";
import { executeAction, getAction } from "@/platform/actions";
import { executeAction, executeActionFrom, getAction, registerAction } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
import { applyThemeAttributes, useThemeOverride } from "@/workbench/theme";
import { createMemoryPluginsBackend, sampleCatalog, setPluginsBackend, type PluginsBackend } from "../backend";
import {
applyPluginTheme,
applyTextToActiveEditor,
followThemeSetting,
PLUGIN_THEME_STYLE_ID,
handlePluginEvent,
pluginActionId,
pluginActions,
startPluginBridge,
syncProjection,
} from "../bridge";
import { iconResolver } from "../iconThemes";
import { markerIcon, placeMarkers } from "../lineMarkerGutter";
import { SecretsDetails } from "../secrets";
import { usePluginStatusItems } from "../runtimeUi";
import { useTasks } from "@/workbench/tasks";
import { defineBuiltinUiPoints } from "../points";
import { clearUiContributions, defineUiPoint, syncUiContributions } from "../projection";
import { catalogState, filterCatalog, filterPlugins, NO_REPOSITORY, PluginsPage } from "../PluginsPage";
import {
compareVersions,
installFromDisk,
refreshPlugins,
runPluginCommand,
selectPluginTheme,
setAllInstalledEnabled,
setConsentPrompt,
setPluginEnabled,
usePlugins,
type ConsentRequest,
} from "../store";
type Note = Parameters<NotificationService["notify"]>[0];
@@ -48,10 +59,23 @@ function plugin(partial: Partial<PluginInfo> & Pick<PluginInfo, "id">): PluginIn
status: "active",
error: null,
hasCode: true,
permissions: [],
commands: [],
languages: [],
themes: [],
capabilities: [],
contributions: [],
activation: [],
warnings: [],
stats: {
activations: 0,
activationMs: 0,
calls: 0,
errors: 0,
maxCallMs: 0,
p95CallMs: 0,
slowCalls: 0,
crashes: 0,
reclaims: 0,
},
publisher: null,
previousVersion: null,
...partial,
};
}
@@ -63,13 +87,17 @@ function editor(doc: string, anchor: number, head = anchor, readOnly = false) {
return view;
}
let undefinePoints: () => void = () => {};
beforeEach(() => {
undefinePoints = defineBuiltinUiPoints();
notes = [];
provideNotificationService({ notify: (n) => void notes.push(n) });
backend = createMemoryPluginsBackend();
setPluginsBackend(backend);
usePlugins.setState({
plugins: [],
ui: [],
activeTheme: null,
loaded: false,
loading: false,
@@ -82,6 +110,9 @@ beforeEach(() => {
});
afterEach(() => {
clearUiContributions();
undefinePoints();
setConsentPrompt(null);
setPluginsBackend(null);
_setActiveEditor(null);
view?.destroy();
@@ -90,31 +121,124 @@ afterEach(() => {
});
describe("plugin actions", () => {
it("maps commands of running plugins to plugin.<plugin>.<command> actions", () => {
const actions = pluginActions([
plugin({ id: "a", name: "Alpha", commands: [{ id: "go", title: "Alpha: Go", category: null }] }),
plugin({ id: "b", status: "disabled", commands: [{ id: "x", title: "X", category: "Cat" }] }),
plugin({ id: "c", status: "failed", commands: [{ id: "y", title: "Y", category: null }] }),
]);
expect(actions.map((a) => a.id)).toEqual(["plugin.a.go"]);
expect(actions[0]!.category).toBe("Alpha");
it("projects command contributions to plugin.<plugin>.<command> actions", () => {
const cmd = (pluginId: string, id: string, extra: Record<string, unknown> = {}) => ({
pluginId,
point: "commands",
key: id,
decl: { id, title: `T ${id}`, ...extra },
});
syncUiContributions(
[cmd("a", "go"), cmd("a", "when", { when: "editorFocus", category: "Cat" }), cmd("gone", "x")],
[plugin({ id: "a", name: "Alpha" })],
);
expect(getAction("plugin.a.go")).toMatchObject({ title: "T go", category: "Alpha" });
expect(getAction("plugin.a.when")).toMatchObject({ when: "editorFocus", category: "Cat" });
expect(getAction("plugin.gone.x")).toBeUndefined();
expect(pluginActionId("p", "c.d")).toBe("plugin.p.c.d");
// Changed declarations are re-projected; removed ones disposed.
syncUiContributions([cmd("a", "go", { title: "Renamed" })], [plugin({ id: "a", name: "Alpha" })]);
expect(getAction("plugin.a.go")?.title).toBe("Renamed");
expect(getAction("plugin.a.when")).toBeUndefined();
});
it("isolates a failing projection (a defect) from the other items", () => {
const errors: unknown[] = [];
const original = console.error;
console.error = (...args: unknown[]) => void errors.push(args);
const live: string[] = [];
const undefine = defineUiPoint<{ n: number }>("test.items", {
project: ({ decl }) => {
if (decl.n === 2) throw new Error("bug");
live.push(String(decl.n));
return () => live.splice(live.indexOf(String(decl.n)), 1);
},
});
const item = (n: number) => ({ pluginId: "a", point: "test.items", key: String(n), decl: { n } });
syncUiContributions([item(1), item(2), item(3)], [plugin({ id: "a" })]);
expect(live).toEqual(["1", "3"]);
expect(errors).toHaveLength(1);
undefine();
expect(live).toEqual([]);
console.error = original;
});
it("keeps the registry in sync and runs commands through the backend", async () => {
// The bridge defines the points itself (main window).
undefinePoints();
undefinePoints = () => {};
const stop = startPluginBridge();
await refreshPlugins();
expect(getAction("plugin.hello.insert-date")?.title).toBe("Hello: Insert Date");
expect(getAction("plugin.hello.word-count")).toBeDefined();
const v = editor("ab", 1);
// Edits come back as document patches; here the command's result shows it ran.
const ran: unknown[] = [];
const run = backend.runCommand;
backend.runCommand = async (...args) => {
const out = await run(...args);
ran.push(out);
return out;
};
editor("ab", 1);
await act(() => executeAction("plugin.hello.insert-date"));
expect(v.state.doc.toString()).toMatch(/^a\d{4}-\d{2}-\d{2}b$/);
expect(ran).toEqual([expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/)]);
await setPluginEnabled("hello", false);
expect(getAction("plugin.hello.insert-date")).toBeUndefined();
stop();
});
it("reports actions to on-event:action-performed plugins once the project opens and when plugins change", async () => {
// Like `authorize_command`: refused until the window's project is open and trusted.
let kinds: Awaited<ReturnType<PluginsBackend["eventKinds"]>> = [];
const posted: unknown[] = [];
setPluginsBackend({
...backend,
eventKinds: async () => {
if (!useProject.getState().project?.trusted) throw new Error("no project is open in this window");
return kinds;
},
postEvents: async (events) => void posted.push(...events),
});
const offAction = registerAction({ id: "test.columnMode", title: "Column Mode", run: () => {} });
const performed = (n: number) =>
Array.from({ length: n }, () => ({
kind: "actionPerformed",
value: { action: "test.columnMode", title: "Column Mode", source: "menu", shortcut: null },
}));
undefinePoints();
undefinePoints = () => {};
kinds = ["action-performed"];
const stop = startPluginBridge();
try {
// Asked at startup, before the project is open: refused.
await new Promise((r) => setTimeout(r, 20));
await executeActionFrom("menu", "test.columnMode");
await new Promise((r) => setTimeout(r, 600));
expect(posted).toEqual([]);
useProject.setState({ project: { root: "/p", trusted: true } as never });
await new Promise((r) => setTimeout(r, 20));
await executeActionFrom("menu", "test.columnMode");
await waitFor(() => expect(posted).toEqual(performed(1)));
// Declared kinds change with the installed plugins (`commandsChanged`).
kinds = [];
handlePluginEvent({ type: "commandsChanged" });
await new Promise((r) => setTimeout(r, 60));
await executeActionFrom("menu", "test.columnMode");
kinds = ["action-performed"];
handlePluginEvent({ type: "commandsChanged" });
await new Promise((r) => setTimeout(r, 60));
await executeActionFrom("menu", "test.columnMode");
await waitFor(() => expect(posted).toEqual(performed(2)));
} finally {
stop();
offAction();
useProject.setState({ project: null });
}
});
});
describe("store", () => {
@@ -150,9 +274,73 @@ describe("store", () => {
expect(usePlugins.getState().activeTheme).toBeNull();
});
it("filters by name, id, author and command title", () => {
it("applies the plugin theme the Appearance setting selects, and clears it for a built-in one", async () => {
const stop = [syncProjection(), followThemeSetting()];
try {
useProject.setState({ settings: { appearance: { theme: "plugin:theme-harbor-night/harbor-night" } } as never });
await refreshPlugins();
await waitFor(() => expect(usePlugins.getState().activeTheme?.theme.id).toBe("harbor-night"));
// A built-in theme picked anywhere (here the session override) wins over it.
act(() => useThemeOverride.setState({ theme: "solarized-dark" }));
await waitFor(() => expect(usePlugins.getState().activeTheme).toBeNull());
act(() => useThemeOverride.setState({ theme: null }));
await waitFor(() => expect(usePlugins.getState().activeTheme?.theme.id).toBe("harbor-night"));
} finally {
stop.forEach((s) => s());
useThemeOverride.setState({ theme: null });
useProject.setState({ settings: null as never });
}
});
it("installs from a folder, its plugin.toml, or a package file unpacked first", async () => {
setConsentPrompt(async () => ({ optional: [] }));
for (const [picked, unpacked] of [
["plugins/hello", null],
["plugins/hello/plugin.toml", null],
["C:\\plugins\\hello\\Plugin.TOML", null],
["/downloads/hello.zip", "/downloads/hello.zip"],
["/downloads/hello-1.0.0.oiplugin", "/downloads/hello-1.0.0.oiplugin"],
] as const) {
const unpack = vi.fn(async () => "plugins/hello");
const preview = vi.fn();
const backend = createMemoryPluginsBackend([], sampleCatalog());
setPluginsBackend({
...backend,
unpackPackage: unpack,
previewInstall: (path) => {
preview(path);
return backend.previewInstall("plugins/hello");
},
installDir: (_path, consent) => backend.installDir("plugins/hello", consent),
});
expect(await installFromDisk(picked)).toBe("hello");
expect(unpack.mock.calls).toEqual(unpacked ? [[unpacked]] : []);
const folder = unpacked ? "plugins/hello" : picked.replace(/[\\/]plugin\.toml$/i, "");
expect(preview).toHaveBeenCalledWith(folder);
usePlugins.setState({ plugins: [] });
}
});
it("asks for consent before installing and passes the answer on", async () => {
const asked: ConsentRequest[] = [];
setConsentPrompt(async (req) => {
asked.push(req);
return { optional: [] };
});
setPluginsBackend(createMemoryPluginsBackend([], sampleCatalog()));
expect(await installFromDisk("plugins/hello")).toBe("hello");
expect(asked[0]).toMatchObject({ pluginId: "hello", kind: "install", unsigned: true });
expect(asked[0]!.capabilities.map((c) => c.id)).toContain("editor.write");
setConsentPrompt(async () => null);
setPluginsBackend(createMemoryPluginsBackend([], sampleCatalog()));
expect(await installFromDisk("plugins/hello")).toBeNull();
expect(usePlugins.getState().plugins).toEqual([]);
});
it("filters by name, id, author and contribution", () => {
const list = [
plugin({ id: "a", name: "Alpha", commands: [{ id: "c", title: "Count Words", category: null }] }),
plugin({ id: "a", name: "Alpha", contributions: [{ point: "commands", label: "Count Words" }] }),
plugin({ id: "b", name: "Beta", authors: ["Zed"] }),
];
expect(filterPlugins(list, "count").map((p) => p.id)).toEqual(["a"]);
@@ -199,22 +387,44 @@ describe("bridge events", () => {
useThemeOverride.setState({ theme: null });
});
it("inserts at the cursor and replaces selections", () => {
const v = editor("hello world", 6, 11);
handlePluginEvent({ type: "replaceSelection", pluginId: "p", text: "there" });
expect(v.state.doc.toString()).toBe("hello there");
expect(v.state.selection.main).toMatchObject({ from: 6, to: 11 });
handlePluginEvent({ type: "insertText", pluginId: "p", text: "!" });
expect(v.state.doc.toString()).toBe("hello !");
expect(v.state.selection.main.head).toBe(7);
it("runs commands with the focused editor's path, language and UTF-16 selections", async () => {
const calls: unknown[] = [];
setPluginsBackend({
...backend,
runCommand: async (...args) => {
calls.push(args);
return null;
},
});
editor("ab\n😀cd", 3, 7);
await runPluginCommand("p", "c");
expect(calls).toEqual([
[
"p",
"c",
null,
{
path: "/p/a.txt",
language: "plaintext",
selections: [{ start: { line: 1, col: 0 }, end: { line: 1, col: 4 } }],
},
],
]);
});
it("warns instead of editing read-only or missing editors", () => {
expect(applyTextToActiveEditor("x", false, "p")).toBe(false);
const v = editor("abc", 0, 0, true);
expect(applyTextToActiveEditor("x", false, "p")).toBe(false);
expect(v.state.doc.toString()).toBe("abc");
expect(notes.map((n) => n.level)).toEqual(["warning", "warning"]);
it("shows status items and tasks of running plugin code", () => {
const item = { text: "main ↑1", tooltip: "Branch", command: "refresh" };
handlePluginEvent({ type: "statusItemChanged", pluginId: "git", id: "branch", item });
expect(usePluginStatusItems.getState().items.get("git/branch")).toEqual({ pluginId: "git", item });
handlePluginEvent({ type: "statusItemChanged", pluginId: "git", id: "branch", item: null });
expect(usePluginStatusItems.getState().items.size).toBe(0);
handlePluginEvent({ type: "taskStarted", pluginId: "git", taskId: "7", title: "Fetching", cancellable: true });
handlePluginEvent({ type: "taskProgress", pluginId: "git", taskId: "7", done: 1, total: 4, message: "origin" });
const task = useTasks.getState().tasks.find((t) => t.id === "plugin:7")!;
expect([task.title, task.fraction, task.message, !!task.cancel]).toEqual(["Fetching", 0.25, "origin", true]);
handlePluginEvent({ type: "taskEnded", pluginId: "git", taskId: "7" });
expect(useTasks.getState().tasks.find((t) => t.id === "plugin:7")?.done).toBe(true);
});
it("shows plugin notifications and tracks theme changes", () => {
@@ -235,11 +445,12 @@ describe("PluginsPage", () => {
// Sorted as returned; first one selected by default.
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Hello");
expect(screen.getByText("plugin.hello.insert-date")).toBeInTheDocument();
expect(screen.getByText("Insert text into the active editor")).toBeInTheDocument();
expect(screen.getByText("Change text in the editor")).toBeInTheDocument();
expect(screen.getByText("Medium")).toBeInTheDocument();
await user.click(screen.getByRole("checkbox", { name: "Disable Hello" }));
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Enable Hello" })).toBeInTheDocument());
expect(screen.getByRole("button", { name: "Run Hello: Insert Date" })).toBeDisabled();
expect(screen.queryByRole("button", { name: "Run Hello: Insert Date" })).not.toBeInTheDocument();
await user.click(within(list).getByText("Harbor Night Theme"));
await user.click(screen.getByRole("button", { name: "Apply theme Harbor Night" }));
@@ -248,7 +459,7 @@ describe("PluginsPage", () => {
await user.type(screen.getByLabelText("Filter plugins"), "toy");
await waitFor(() => expect(within(list).getAllByRole("listitem")).toHaveLength(1));
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Toy Language");
expect(screen.getByText(".toy")).toBeInTheDocument();
expect(screen.getByText("Toy (.toy)")).toBeInTheDocument();
});
it("restarts a failed plugin instead of disabling it", async () => {
@@ -256,7 +467,7 @@ describe("PluginsPage", () => {
const user = userEvent.setup();
render(<PluginsPage />);
await user.click(await screen.findByRole("button", { name: "Restart" }));
await waitFor(() => expect(usePlugins.getState().plugins[0]).toMatchObject({ enabled: true, status: "active" }));
await waitFor(() => expect(usePlugins.getState().plugins[0]).toMatchObject({ enabled: true, status: "inactive" }));
});
it("uninstalls after confirmation", async () => {
@@ -299,6 +510,140 @@ describe("PluginsPage", () => {
});
});
describe("secrets", () => {
it("stores and removes a declared secret without showing its value", async () => {
const user = userEvent.setup();
const items = [{ pluginId: "ai", point: "secrets", key: "api-key", decl: { name: "api-key", label: "API key" } }];
render(<SecretsDetails plugin={plugin({ id: "ai" })} items={items} />);
expect(await screen.findByText("Not set")).toBeInTheDocument();
await user.type(screen.getByLabelText("Value of API key"), "sk-1{Enter}");
await waitFor(() => expect(screen.getByText("Stored")).toBeInTheDocument());
expect(screen.getByLabelText("Value of API key")).toHaveValue("");
expect(await backend.secrets("ai")).toEqual([{ name: "api-key", set: true }]);
await user.click(screen.getByRole("button", { name: "Remove API key" }));
await waitFor(() => expect(screen.getByText("Not set")).toBeInTheDocument());
expect(await backend.secrets("ai")).toEqual([]);
});
});
describe("plugin line markers", () => {
it("places markers on existing lines and maps their icons", () => {
const doc = EditorState.create({ doc: "a\nbb\nccc" }).doc;
const marker = (line: number, icon: string | null) => ({ pluginId: "m", line, icon, tooltip: "t", target: null });
expect(placeMarkers(doc, [marker(1, "run"), marker(7, null)]).map((m) => m.pos)).toEqual([2]);
expect(markerIcon("run")).toBe("gutter-run");
expect(markerIcon("implemented")).toBe("gutter-implemented");
expect(markerIcon(null)).toBe("gutter-implementing");
expect(markerIcon("unknown")).toBe("gutter-implementing");
});
});
describe("marketplace", () => {
it("lists the index's plugins and installs one through the install page", async () => {
const asked: ConsentRequest[] = [];
setConsentPrompt(async (req) => {
asked.push(req);
return { optional: [] };
});
const base = createMemoryPluginsBackend([], sampleCatalog());
const downloads: string[] = [];
setPluginsBackend({
...base,
marketUrl: async () => "https://plugins.example/index.json",
market: async () => [
{
id: "hello",
name: "Hello",
version: "1.0.0",
description: "From the marketplace",
authors: [],
publisher: "acme",
download: "hello.oiplugin",
digest: "x",
capabilities: ["editor.write"],
installed: null,
},
],
// The memory backend installs its sample packages by path.
marketDownload: async (id) => {
downloads.push(id);
return "plugins/hello";
},
});
const user = userEvent.setup();
render(<PluginsPage />);
await user.click(screen.getByRole("tab", { name: /Available/ }));
const list = await screen.findByRole("list", { name: "Available plugins" });
await waitFor(() => expect(within(list).getByText("Marketplace")).toBeInTheDocument());
expect(within(list).queryByText(new RegExp(NO_REPOSITORY))).not.toBeInTheDocument();
await user.click(within(list).getAllByText("Hello")[0]!);
expect(screen.getByText("Signed by acme")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Install" }));
await waitFor(() => expect(usePlugins.getState().plugins.map((p) => p.id)).toEqual(["hello"]));
expect(downloads).toEqual(["hello"]);
expect(asked[0]).toMatchObject({ pluginId: "hello", kind: "install" });
});
it("offers to roll back an update", async () => {
const base = createMemoryPluginsBackend([
plugin({ id: "p", name: "P", version: "2.0.0", previousVersion: "1.0.0" }),
]);
const rolled: string[] = [];
setPluginsBackend({
...base,
rollBack: async (id) => {
rolled.push(id);
return plugin({ id, name: "P", version: "1.0.0" });
},
});
const user = userEvent.setup();
render(<PluginsPage />);
expect(await screen.findByText("Unsigned")).toBeInTheDocument();
await user.click(await screen.findByRole("button", { name: "Roll Back to 1.0.0" }));
await waitFor(() => expect(rolled).toEqual(["p"]));
expect(notes.at(-1)).toMatchObject({ title: "Rolled back P to 1.0.0" });
});
});
describe("icon themes", () => {
it("resolves file names, then the longest extension, then the default icon", () => {
const theme = iconResolver(
{
file: "file",
fileNames: { "cargo.toml": "cargo" },
fileExtensions: { "d.ts": "dts", ts: "ts", toml: "toml" },
},
(icon) => `ext:p/t/${icon}`,
(name) => name !== "ext:p/t/toml",
);
expect(theme.file("Cargo.toml")).toBe("ext:p/t/cargo");
expect(theme.file("a.d.ts")).toBe("ext:p/t/dts");
expect(theme.file("a.ts")).toBe("ext:p/t/ts");
expect(theme.file("x.toml")).toBe("ext:p/t/file");
// No folder icons in this theme: folders keep the built-in ones.
expect(theme.folder("src", false)).toBeNull();
});
it("resolves folders by name, then the default (expanded) folder icon", () => {
const theme = iconResolver(
{
file: null,
folder: "folder",
folderExpanded: "folder-open",
fileNames: {},
fileExtensions: {},
folderNames: { src: "folder-src" },
},
(icon) => `ext:p/t/${icon}`,
() => true,
);
expect(theme.folder("SRC", true)).toBe("ext:p/t/folder-src");
expect(theme.folder("lib", false)).toBe("ext:p/t/folder");
expect(theme.folder("lib", true)).toBe("ext:p/t/folder-open");
expect(theme.file("a.txt")).toBeNull();
});
});
describe("catalog helpers", () => {
it("compares versions numerically", () => {
expect(compareVersions("1.10.0", "1.9.2")).toBe(1);
+283
Переглянути файл
@@ -0,0 +1,283 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { View, ViewEvent } from "@/bindings";
import { registerAction } from "@/platform/actions";
import { bindingsFor, effectiveBindings, registerExtensionBindings, useKeymap } from "@/platform/keymap";
import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend";
import { defineBuiltinUiPoints } from "../points";
import { clearUiContributions, syncUiContributions } from "../projection";
import { pluginMenuItems, withPluginSetting } from "../uiPoints";
import { matchesPattern } from "../views/customEditor";
import { FRAME_CSP, FRAME_TAG, frameDocument, frameTheme, type FrameTheme } from "../views/frame";
import { PluginView, treeNodes, wantedViewport } from "../views/PluginView";
import { bumpView } from "../views/viewStore";
let undefinePoints: () => void = () => {};
beforeEach(() => {
undefinePoints = defineBuiltinUiPoints();
});
afterEach(() => {
clearUiContributions();
undefinePoints();
setPluginsBackend(null);
});
function backendWith(render: (view: string) => View, events: ViewEvent[] = []): PluginsBackend {
const base = createMemoryPluginsBackend([]);
return {
...base,
renderView: vi.fn(async (_p: string, view: string) => render(view)),
viewEvent: vi.fn(async (_p: string, _v: string, e: ViewEvent) => void events.push(e)),
};
}
describe("view helpers", () => {
it("builds the tree from the flat item list", () => {
const item = (key: string, parent: string | null = null) => ({
key,
parent,
label: key,
icon: null,
detail: null,
expandable: false,
expanded: false,
});
const roots = treeNodes([item("a"), item("a/1", "a"), item("b"), item("x", "missing")]);
expect(roots.map((n) => n.item.key)).toEqual(["a", "b", "x"]);
expect(roots[0]!.children.map((n) => n.item.key)).toEqual(["a/1"]);
});
it("asks for the visible table rows plus some around them", () => {
expect(wantedViewport(0, 20, 10)).toEqual({ firstRow: 0, rows: 10 });
expect(wantedViewport(1000, 20, 5000)).toEqual({ firstRow: 950, rows: 120 });
});
it("confines HTML bodies: CSP without network, a message bridge", () => {
const doc = frameDocument("<p>hi</p>", { dark: true, vars: {} });
expect(doc).toContain(`content="${FRAME_CSP}"`);
expect(FRAME_CSP).toContain("default-src 'none'");
expect(FRAME_CSP).not.toMatch(/https?:|connect-src/);
expect(doc).toContain("window.openidea = Object.freeze");
expect(doc.indexOf("Content-Security-Policy")).toBeLessThan(doc.indexOf("<p>hi</p>"));
});
it("reads the frame theme from the IDE tokens, resolved and sanitised", () => {
const root = document.documentElement;
const before = root.dataset.theme;
root.style.setProperty("--fg", "#112233");
root.style.setProperty("--font-ui", "Inter, sans-serif");
root.style.setProperty("--font-mono", "x}</style><script>alert(1)</script>");
try {
root.dataset.theme = "light";
const light = frameTheme();
expect(light.dark).toBe(false);
expect(light.vars["--openidea-fg"]).toBe("rgb(17, 34, 51)");
expect(light.vars["--openidea-font-ui"]).toBe("Inter, sans-serif");
expect(light.vars).not.toHaveProperty("--openidea-font-mono");
root.dataset.theme = "dark";
root.dataset.contrast = "high";
expect(frameTheme().dark).toBe(true);
} finally {
for (const p of ["--fg", "--font-ui", "--font-mono"]) root.style.removeProperty(p);
delete root.dataset.contrast;
if (before === undefined) delete root.dataset.theme;
else root.dataset.theme = before;
}
});
it("puts the theme into the frame document without letting a value end the script", () => {
const doc = frameDocument("<p>hi</p>", { dark: true, vars: { "--openidea-fg": "</script><b>" } });
expect(doc).toContain('<html data-theme="dark">');
expect(doc).not.toContain("</script><b>");
expect(doc).toContain("\\u003c/script>");
});
it("applies posted themes inside the frame, replacing the previous variables", () => {
const doc = frameDocument("", {
dark: false,
vars: { "--openidea-fg": "rgb(1, 2, 3)", "--openidea-accent": "red" },
});
const script = /<script>([\s\S]*)<\/script>/.exec(doc)![1]!;
const root = document.documentElement;
const before = root.dataset.theme;
const seen: unknown[] = [];
try {
new Function(script)();
const api = (
window as unknown as { openidea: { theme: FrameTheme; onThemeChange(fn: (t: FrameTheme) => void): void } }
).openidea;
expect(root.style.getPropertyValue("--openidea-fg")).toBe("rgb(1, 2, 3)");
expect(api.theme.dark).toBe(false);
api.onThemeChange((t) => seen.push(t));
// jsdom's postMessage has no `source`, so dispatch the event as the parent would.
const send = (t: unknown) =>
window.dispatchEvent(new MessageEvent("message", { data: { tag: FRAME_TAG, theme: t }, source: window }));
send({ dark: true, vars: { "--openidea-fg": "rgb(9, 9, 9)", "--not-ours": "x" } });
expect(root.dataset.theme).toBe("dark");
expect(root.style.getPropertyValue("--openidea-fg")).toBe("rgb(9, 9, 9)");
expect(root.style.getPropertyValue("--openidea-accent")).toBe("");
expect(root.style.getPropertyValue("--not-ours")).toBe("");
expect(seen).toHaveLength(1);
} finally {
for (const p of ["--openidea-fg", "--openidea-accent"]) root.style.removeProperty(p);
delete (window as unknown as { openidea?: unknown }).openidea;
if (before === undefined) delete root.dataset.theme;
else root.dataset.theme = before;
}
});
it("matches custom editor file name patterns", () => {
expect(matchesPattern("*.csv", "People.CSV")).toBe(true);
expect(matchesPattern("*.{csv,tsv}", "a.tsv")).toBe(true);
expect(matchesPattern("data?.csv", "data1.csv")).toBe(true);
expect(matchesPattern("*.csv", "a.csv.bak")).toBe(false);
});
it("stores plugin settings under the plugin's id, dropping defaults", () => {
const one = withPluginSetting(undefined, "p.x", "watch", false);
expect(one).toEqual({ "p.x": { watch: false } });
expect(withPluginSetting(one, "p.x", "watch", null)).toEqual({});
});
});
describe("plugin keymaps", () => {
afterEach(() => useKeymap.setState({ extensions: {}, overrides: {} }));
it("adds a plugin's shortcut below the IDE's, never taking a used keystroke", () => {
const taken = effectiveBindings()[0]!;
const offFree = registerExtensionBindings("plugin.p.free", ["Ctrl+Alt+Shift+F11"]);
const offTaken = registerExtensionBindings("plugin.p.taken", [taken[0]]);
expect(bindingsFor("plugin.p.free")).toEqual(["Ctrl+Alt+Shift+F11"]);
expect(bindingsFor("plugin.p.taken")).toEqual([]);
useKeymap.setState({ overrides: { "plugin.p.free": [] } });
expect(bindingsFor("plugin.p.free")).toEqual([]);
offFree();
offTaken();
expect(useKeymap.getState().extensions).toEqual({});
});
});
describe("plugin menus", () => {
it("projects context menu entries for their command's action", () => {
const off = registerAction({ id: "plugin.p.check", title: "Check", run: () => {} });
const plugin = {
id: "p",
name: "P",
status: "active",
enabled: true,
} as unknown as Parameters<typeof syncUiContributions>[1][number];
syncUiContributions(
[
{
pluginId: "p",
point: "menus",
key: "projectView.context/check",
decl: { menu: "projectView.context", command: "check" },
},
{
pluginId: "p",
point: "menus",
key: "editor.context/check",
decl: { menu: "editor.context", command: "check", when: "false" },
},
],
[plugin],
);
expect(pluginMenuItems("projectView", ["/p/a.java"])).toEqual([
{ kind: "action", action: "plugin.p.check", arg: "/p/a.java" },
]);
expect(pluginMenuItems("editor", ["/p/a.java"])).toEqual([]);
off();
});
});
describe("PluginView", () => {
it("renders a tree and sends expand / activate", async () => {
const events: ViewEvent[] = [];
const tree: View = {
toolbar: [{ id: "refresh", label: "Refresh", icon: "refresh", enabled: true }],
body: {
kind: "tree",
value: [
{
key: "ns",
parent: null,
label: "default",
icon: null,
detail: "3 pods",
expandable: true,
expanded: false,
},
],
},
status: "1 namespace",
};
setPluginsBackend(backendWith(() => tree, events));
const user = userEvent.setup();
render(<PluginView pluginId="jobs" view="jobs" label="Jobs" />);
expect(await screen.findByText("default")).toBeInTheDocument();
expect(screen.getByText("1 namespace")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(events).toContainEqual({ kind: "button", id: "refresh" }));
});
it("edits a table cell and renders again when the plugin refreshes", async () => {
const events: ViewEvent[] = [];
let city = "London";
const table = (): View => ({
toolbar: [],
body: {
kind: "table",
value: { columns: ["name", "city"], totalRows: 1, firstRow: 0, rows: [["Ada", city]], editable: true },
},
status: null,
});
const backend = backendWith(table, events);
setPluginsBackend(backend);
const user = userEvent.setup();
render(<PluginView pluginId="csv" view="table" doc={7} />);
await user.dblClick(await screen.findByText("London"));
const input = screen.getByLabelText("Cell value");
await user.clear(input);
await user.type(input, "Paris{Enter}");
await waitFor(() => expect(events).toContainEqual({ kind: "cell", row: 0, column: 1, value: "Paris" }));
expect(backend.viewEvent).toHaveBeenCalledWith("csv", "table", expect.anything(), 7);
city = "Paris";
bumpView("csv", "table");
expect(await screen.findByText("Paris")).toBeInTheDocument();
});
it("keeps an HTML body loaded across theme switches and posts the new theme to it", async () => {
const html: View = { toolbar: [], body: { kind: "html", value: "<p>chart</p>" }, status: null };
setPluginsBackend(backendWith(() => html, []));
const root = document.documentElement;
const before = root.dataset.theme;
root.dataset.theme = "light";
try {
const { container } = render(<PluginView pluginId="charts" view="chart" />);
await waitFor(() => expect(container.querySelector("iframe")).not.toBeNull());
const frame = container.querySelector("iframe")!;
const srcDoc = frame.getAttribute("srcdoc");
expect(srcDoc).toContain('<html data-theme="light">');
const post = vi.spyOn(frame.contentWindow!, "postMessage");
root.dataset.theme = "dark";
await waitFor(() =>
expect(post).toHaveBeenCalledWith({ tag: FRAME_TAG, theme: expect.objectContaining({ dark: true }) }, "*"),
);
expect(frame.getAttribute("srcdoc")).toBe(srcDoc);
} finally {
if (before === undefined) delete root.dataset.theme;
else root.dataset.theme = before;
}
});
it("shows a message when the plugin fails", async () => {
const base = createMemoryPluginsBackend([]);
setPluginsBackend({ ...base, renderView: () => Promise.reject(new Error("boom")) });
render(<PluginView pluginId="x" view="v" />);
expect(await screen.findByText("boom")).toBeInTheDocument();
});
});
+278
Переглянути файл
@@ -0,0 +1,278 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, waitFor } from "@testing-library/react";
import type { PluginInfo, ViewEvent } from "@/bindings";
import { addFileIconProvider, fileIconName, registerExternalIcons } from "@/ui";
import { addContributedFileTemplate, fileTemplates, isBuiltinFileTemplate } from "@/features/templates/files/store";
import { createMemoryPluginsBackend, setPluginsBackend, webSchemeUrl } from "../backend";
import { usePlugins } from "../store";
import { FRAME_TAG, frameDocument } from "../views/frame";
import {
WebView,
minimalChange,
validChanges,
type DocumentState,
type FrameDocument,
type TextChange,
} from "../views/WebView";
afterEach(() => {
setPluginsBackend(null);
usePlugins.setState({ plugins: [] });
});
/** An in-memory document with the custom editor session's versioning. */
function memoryDocument(text: string) {
let version = 0;
const listeners = new Set<(s: DocumentState) => void>();
const apply = (changes: readonly TextChange[]) => {
for (const c of [...changes].reverse()) text = text.slice(0, c.from) + c.insert + text.slice(c.to);
version++;
};
const doc: FrameDocument & { external(changes: TextChange[]): void; text(): string } = {
read: () => ({ version, text, readOnly: false, name: "a.draw" }),
edit: (at, changes) => {
if (at !== version) throw new Error("stale");
apply(changes);
return version;
},
subscribe: (l) => {
listeners.add(l);
return () => listeners.delete(l);
},
external: (changes) => {
apply(changes);
for (const l of listeners) l({ version, text });
},
text: () => text,
};
return doc;
}
describe("web view helpers", () => {
it("turns a replacement into the smallest change", () => {
expect(minimalChange("abcdef", "abXYef")).toEqual({ from: 2, to: 4, insert: "XY" });
expect(minimalChange("aaa", "aaaa")).toEqual({ from: 3, to: 3, insert: "a" });
expect(minimalChange("abc", "")).toEqual({ from: 0, to: 3, insert: "" });
expect(minimalChange("same", "same")).toBeNull();
});
it("accepts only sorted, in-range, non-overlapping edits", () => {
expect(
validChanges(
[
{ from: 0, to: 1, insert: "x" },
{ from: 2, to: 2, insert: "y" },
],
3,
),
).toHaveLength(2);
expect(() =>
validChanges(
[
{ from: 2, to: 2, insert: "" },
{ from: 0, to: 1, insert: "" },
],
3,
),
).toThrow();
expect(() => validChanges([{ from: 0, to: 4, insert: "" }], 3)).toThrow();
expect(() => validChanges([{ from: 0.5, to: 1, insert: "" }], 3)).toThrow();
expect(() => validChanges("nope", 3)).toThrow();
});
it("spells scheme URLs the way each webview expects", () => {
expect(webSchemeUrl("acme.draw/abc/web/index.html", false)).toBe(
"openidea-plugin://localhost/acme.draw/abc/web/index.html",
);
expect(webSchemeUrl("p/r/web/a b.html", true)).toBe("http://openidea-plugin.localhost/p/r/web/a%20b.html");
});
});
describe("frame bridge", () => {
it("asks the workbench for the document and edits it, and hears of changes made elsewhere", async () => {
const script = /<script>([\s\S]*)<\/script>/.exec(frameDocument("", { dark: true, vars: {} }))![1]!;
const sent: Array<{ call: number; op: string; args: unknown }> = [];
// In jsdom the frame is its own parent: catch what the bridge posts "up".
const post = vi.spyOn(window, "postMessage").mockImplementation((m: unknown) => {
const msg = m as { call?: number; op?: string; args?: unknown };
if (typeof msg.call === "number") sent.push(msg as (typeof sent)[number]);
});
const reply = (data: object) =>
window.dispatchEvent(new MessageEvent("message", { data: { tag: FRAME_TAG, ...data }, source: window }));
try {
new Function(script)();
const api = (window as unknown as { openidea: Record<string, never> }).openidea as unknown as {
document: {
get(): Promise<unknown>;
edit(v: number, e: TextChange[]): Promise<{ version: number }>;
replace(v: number, t: string): Promise<unknown>;
onChange(fn: (s: DocumentState) => void): void;
};
saveFile(o: { name: string; data: string }): Promise<string | null>;
};
const got = api.document.get();
expect(sent.at(-1)).toMatchObject({ op: "document.get" });
reply({ reply: sent.at(-1)!.call, value: { version: 3, text: "abc", readOnly: false } });
await expect(got).resolves.toEqual({ version: 3, text: "abc", readOnly: false });
const edited = api.document.edit(3, [{ from: 0, to: 1, insert: "X" }]);
expect(sent.at(-1)).toMatchObject({ op: "document.edit", args: { version: 3 } });
reply({ reply: sent.at(-1)!.call, error: "stale" });
await expect(edited).rejects.toThrow("stale");
const saved = api.saveFile({ name: "a.svg", data: "<svg/>" });
await waitFor(() => expect(sent.at(-1)).toMatchObject({ op: "saveFile", args: { name: "a.svg" } }));
expect(new TextDecoder().decode((sent.at(-1)!.args as { bytes: Uint8Array }).bytes)).toBe("<svg/>");
reply({ reply: sent.at(-1)!.call, value: "/tmp/a.svg" });
await expect(saved).resolves.toBe("/tmp/a.svg");
const changes: DocumentState[] = [];
api.document.onChange((s) => changes.push(s));
reply({ document: { version: 4, text: "xyz" } });
expect(changes).toEqual([{ version: 4, text: "xyz" }]);
} finally {
post.mockRestore();
delete (window as unknown as { openidea?: unknown }).openidea;
}
});
});
describe("WebView", () => {
function setup(hasCode: boolean) {
const events: ViewEvent[] = [];
setPluginsBackend({
...createMemoryPluginsBackend([]),
webPageUrl: async () => "about:blank",
viewEvent: vi.fn(async (_p: string, _v: string, e: ViewEvent) => void events.push(e)),
});
usePlugins.setState({
plugins: [{ id: "acme.draw", hasCode, capabilities: [] } as unknown as PluginInfo],
});
const doc = memoryDocument("hello world");
const view = render(<WebView pluginId="acme.draw" view="draw" label="Draw" document={doc} doc={7} />);
return { events, doc, view };
}
async function frameOf(container: HTMLElement) {
const frame = await waitFor(() => {
const f = container.querySelector("iframe");
expect(f).not.toBeNull();
return f!;
});
const posted: Array<Record<string, unknown>> = [];
vi.spyOn(frame.contentWindow!, "postMessage").mockImplementation((m: unknown) => void posted.push(m as never));
const send = (data: object) =>
window.dispatchEvent(
new MessageEvent("message", { data: { tag: FRAME_TAG, ...data }, source: frame.contentWindow }),
);
return { frame, posted, send };
}
it("loads the page sandboxed, from its URL, without clipboard access unless granted", async () => {
const { view } = setup(false);
const { frame } = await frameOf(view.container);
expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
expect(frame.getAttribute("src")).toBe("about:blank");
expect(frame.getAttribute("allow")).toBeNull();
});
it("serves the document to the page: versioned edits, replace, stale versions, outside changes", async () => {
const { view, doc } = setup(false);
const { posted, send } = await frameOf(view.container);
const replyTo = async (id: number) =>
waitFor(() => {
const r = posted.find((m) => m.reply === id);
expect(r).toBeDefined();
return r!;
});
send({ call: 1, op: "document.get" });
expect((await replyTo(1)).value).toEqual({ version: 0, text: "hello world", readOnly: false, name: "a.draw" });
send({ call: 2, op: "document.edit", args: { version: 0, edits: [{ from: 0, to: 5, insert: "HELLO" }] } });
expect((await replyTo(2)).value).toEqual({ version: 1 });
expect(doc.text()).toBe("HELLO world");
send({ call: 3, op: "document.edit", args: { version: 0, edits: [] } });
expect((await replyTo(3)).error).toBe("stale");
send({ call: 4, op: "document.replace", args: { version: 1, text: "HELLO there" } });
expect((await replyTo(4)).value).toEqual({ version: 2 });
expect(doc.text()).toBe("HELLO there");
send({ call: 5, op: "document.edit", args: { version: 2, edits: [{ from: 0, to: 99, insert: "" }] } });
expect(String((await replyTo(5)).error)).toContain("in range");
// Changes made elsewhere reach the page; its own are not echoed back.
const before = posted.filter((m) => m.document).length;
doc.external([{ from: 0, to: 0, insert: "> " }]);
await waitFor(() => expect(posted.filter((m) => m.document)).toHaveLength(before + 1));
expect(posted.at(-1)).toMatchObject({ document: { version: 3, text: "> HELLO there" } });
send({ call: 6, op: "nope" });
expect(String((await replyTo(6)).error)).toContain("unknown request");
});
it("follows the installed package: a new revision reloads the page, an unchanged one keeps it", async () => {
let url = "openidea-plugin://localhost/acme.draw/r1/web/index.html";
setPluginsBackend({ ...createMemoryPluginsBackend([]), webPageUrl: async () => url });
const info = (version: string) =>
({ id: "acme.draw", version, hasCode: false, capabilities: [] }) as unknown as PluginInfo;
usePlugins.setState({ plugins: [info("1.0.0")] });
const view = render(<WebView pluginId="acme.draw" view="draw" label="Draw" />);
const { frame } = await frameOf(view.container);
expect(frame.getAttribute("src")).toContain("/r1/");
// Plugins refreshed, same package: the same frame, not reloaded.
usePlugins.setState({ plugins: [info("1.0.0")] });
await new Promise((r) => setTimeout(r, 0));
expect(view.container.querySelector("iframe")).toBe(frame);
expect(frame.getAttribute("src")).toContain("/r1/");
url = "openidea-plugin://localhost/acme.draw/r2/web/index.html";
usePlugins.setState({ plugins: [info("1.0.1")] });
await waitFor(() => expect(view.container.querySelector("iframe")?.getAttribute("src")).toContain("/r2/"));
});
it("forwards the page's messages to the plugin's code only when it has code", async () => {
for (const hasCode of [false, true]) {
const { view, events } = setup(hasCode);
const { send } = await frameOf(view.container);
send({ text: "hi" });
await new Promise((r) => setTimeout(r, 0));
expect(events).toEqual(hasCode ? [{ kind: "message", text: "hi" }] : []);
view.unmount();
}
});
});
describe("contributed file icons and templates", () => {
it("resolves a plugin's file icon before the built-in one, and stops when removed", () => {
const builtin = fileIconName("a.excalidraw");
const unregister = registerExternalIcons([["ext:fileIcon/acme/draw", "<svg viewBox='0 0 16 16'/>"]]);
const remove = addFileIconProvider((name) => (name.endsWith(".excalidraw") ? "ext:fileIcon/acme/draw" : null));
try {
expect(fileIconName("a.excalidraw")).toBe("ext:fileIcon/acme/draw");
expect(fileIconName("a.txt")).not.toBe("ext:fileIcon/acme/draw");
} finally {
remove();
unregister();
}
expect(fileIconName("a.excalidraw")).toBe(builtin);
});
it("adds a plugin's template to the New menu like a built-in", () => {
const remove = addContributedFileTemplate({
id: "plugin.acme.draw",
name: "Drawing",
icon: "file-text",
extension: "draw",
nameStyle: "asis",
kinds: [{ id: "file", label: "Drawing", body: "{}" }],
});
expect(fileTemplates().some((t) => t.id === "plugin.acme.draw")).toBe(true);
expect(isBuiltinFileTemplate("plugin.acme.draw")).toBe(true);
remove();
expect(fileTemplates().some((t) => t.id === "plugin.acme.draw")).toBe(false);
});
});