deer-flow/frontend/tests/unit/core/extensions/actions.dom.test.tsx
Wenchao An e2f19d8335
feat(plugins): full-stack plugin APIs and bookmarks (#5647)
* feat(plugins): add full-stack contributions and bookmarks example

* ci(plugins): provision bookmark gateway for browser tests

* fix(plugins): authenticate module downloads through configured backend

* fix(plugins): isolate contributions and localize extension UI

* fix(plugins): preserve bookmark agent routing and contain async callbacks

* fix(plugins): pin durable batch workers to app extension snapshots
2026-09-22 11:18:57 +08:00

175 lines
5.1 KiB
TypeScript

import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
import { cleanup, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ConversationExtensionActions } from "@/components/workspace/conversation-extension-actions";
import type { ConversationActionGroup } from "@/core/extensions/contracts";
import type { LoadedContribution } from "@/core/extensions/registry";
import { enUS } from "@/core/i18n";
import type { AgentThread } from "@/core/threads/types";
const state = rs.hoisted(() => ({ entries: [] as LoadedContribution[] }));
rs.mock("@/core/extensions/hooks", () => ({
useFrontendExtensions: () => ({ data: state.entries }),
useFrontendServices: () => ({}),
}));
rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({ t: enUS, locale: "en-US" }),
}));
rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" }));
rs.mock("@/core/api/fetcher", () => ({ fetch: rs.fn() }));
const action = {
id: "save",
label: "Save",
icon: "bookmark",
available: () => true,
execute: async () => undefined,
};
const group = { label: "Healthy actions", icon: "bookmark", actions: [action] };
function entry(namespace: string, factory: () => unknown): LoadedContribution {
return {
namespace,
title: namespace,
description: "",
module: namespace,
entry: null,
settings: { enabled: true },
extension: {
apiVersion: 1,
module: namespace,
conversationActions: factory as () => ConversationActionGroup,
},
};
}
beforeEach(() => {
rs.spyOn(console, "warn").mockImplementation(() => undefined);
});
afterEach(() => {
cleanup();
rs.restoreAllMocks();
});
const invalid: [string, () => unknown][] = [
[
"factory throws",
() => {
throw new Error("plugin error");
},
],
["missing actions", () => ({ label: "Broken", icon: "bookmark" })],
["actions is not an array", () => ({ ...group, actions: {} })],
["invalid group label", () => ({ ...group, label: {} })],
["invalid group icon", () => ({ ...group, icon: {} })],
[
"invalid action label",
() => ({ ...group, actions: [{ ...action, label: {} }] }),
],
["duplicate action ids", () => ({ ...group, actions: [action, action] })],
[
"missing executor",
() => ({ ...group, actions: [{ ...action, execute: null }] }),
],
[
"missing availability",
() => ({ ...group, actions: [{ ...action, available: null }] }),
],
[
"availability throws",
() => ({
...group,
actions: [
{
...action,
available: () => {
throw new Error("policy failed");
},
},
],
}),
],
[
"availability is asynchronous",
() => ({ ...group, actions: [{ ...action, available: async () => true }] }),
],
];
for (const [name, factory] of invalid) {
test(`contains ${name} without losing the other plugin or conversation`, () => {
state.entries = [entry("broken", factory), entry("healthy", () => group)];
render(
<TooltipProvider>
<p>Conversation remains usable</p>
<ConversationExtensionActions
context={{ thread: { thread_id: "test" } as AgentThread }}
/>
</TooltipProvider>,
);
expect(screen.getByText("Conversation remains usable")).toBeDefined();
expect(screen.getAllByRole("button")).toHaveLength(1);
expect(
screen.getByRole("button", { name: "Healthy actions" }),
).toBeDefined();
});
}
test("empty, hidden and absent action groups leave healthy actions available", () => {
state.entries = [
entry("absent", () => undefined),
entry("empty", () => ({ ...group, actions: [] })),
entry("hidden", () => ({
...group,
actions: [{ ...action, available: () => false }],
})),
entry("healthy", () => group),
];
render(
<TooltipProvider>
<ConversationExtensionActions
context={{ thread: { thread_id: "test" } as AgentThread }}
/>
</TooltipProvider>,
);
expect(screen.getAllByRole("button")).toHaveLength(1);
expect(console.warn).not.toHaveBeenCalled();
});
test("render uses validated label snapshots instead of rereading plugin getters", () => {
let reads = 0;
state.entries = [
entry("healthy", () => ({
...group,
get label() {
if (++reads > 1) throw new Error("getter read twice");
return "Healthy actions";
},
})),
];
render(
<TooltipProvider>
<ConversationExtensionActions
context={{ thread: { thread_id: "test" } as AgentThread }}
/>
</TooltipProvider>,
);
expect(screen.getByRole("button", { name: "Healthy actions" })).toBeDefined();
expect(reads).toBe(1);
});
test("unknown icon names including prototype properties use the fallback icon", () => {
state.entries = [
entry("healthy", () => ({
...group,
icon: "__proto__",
actions: [{ ...action, icon: "constructor" }],
})),
];
render(
<TooltipProvider>
<ConversationExtensionActions
context={{ thread: { thread_id: "test" } as AgentThread }}
/>
</TooltipProvider>,
);
expect(screen.getByRole("button", { name: "Healthy actions" })).toBeDefined();
});