);
@@ -89,25 +171,22 @@ export function ExtensionGallery({ query = "" }: { query?: string }) {
);
return (
- {reload}
+
+
+ {t.extensions.deploymentHint}
+
+ {reload}
+
{status}
-
- {t.extensions.deploymentHint}
-
{visible.map((entry) => {
const loaded = entry.loaded;
- const Icon = extensionIcon(loaded?.extension?.icon ?? entry.icon);
return (
-
-
- }
+ icon={
}
label={
loaded?.error
? t.extensions.moduleUnavailable
diff --git a/frontend/tests/e2e/bookmark-plugin.spec.ts b/frontend/tests/e2e/bookmark-plugin.spec.ts
index ba1522de38..7148630c4d 100644
--- a/frontend/tests/e2e/bookmark-plugin.spec.ts
+++ b/frontend/tests/e2e/bookmark-plugin.spec.ts
@@ -4,12 +4,28 @@ import { mkdir } from "node:fs/promises";
import { createServer } from "node:net";
import path from "node:path";
-import { expect, test } from "@playwright/test";
+import { expect, test, type Locator, type Page } from "@playwright/test";
import { extensionCatalog } from "../../src/core/extensions/catalog";
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
+// Found by computed style, so renaming the scroller's classes cannot break it.
+function capabilityCenterScroller(page: Page) {
+ return page
+ .getByRole("heading", { name: "Capability Center" })
+ .evaluateHandle((node) => {
+ let scroller = node.parentElement;
+ while (scroller && getComputedStyle(scroller).overflowY !== "auto") {
+ scroller = scroller.parentElement;
+ }
+ if (!scroller) {
+ throw new Error("Capability Center has no scrolling ancestor");
+ }
+ return scroller;
+ });
+}
+
let gateway: ChildProcess;
let gatewayURL: string;
test.beforeAll(async ({ request }) => {
@@ -578,6 +594,15 @@ for (const locale of ["en-US", "zh-CN"]) {
{ exact: true },
),
).toBeVisible();
+ await expect(
+ page.getByText("deerflow-extension-agent-teams", { exact: true }),
+ ).toBeVisible();
+ // The search only filters the directory, so the detail view drops it.
+ await expect(
+ page.getByPlaceholder(
+ zh ? "按名称或用途搜索扩展" : "Search extensions by name or purpose",
+ ),
+ ).toHaveCount(0);
await expect(
page.getByRole("link", {
name: zh ? "查看安装说明" : "Installation guide",
@@ -663,6 +688,108 @@ for (const locale of ["en-US", "zh-CN"]) {
});
}
+test("extension detail fits a narrow viewport with its tab in view", async ({
+ page,
+}) => {
+ mockLangGraphAPI(page);
+ await page.setViewportSize({ width: 375, height: 812 });
+ await page.goto(
+ "/workspace/capabilities?tab=extensions&extension=community.agent-teams",
+ );
+ await expect(
+ page.getByRole("heading", { name: "Agent teams", exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("tab", { name: "Extensions", exact: true }),
+ ).toBeInViewport({ ratio: 1 });
+ await expect(
+ page.getByRole("button", {
+ name: "Reload extensions (refresh page)",
+ exact: true,
+ }),
+ ).toBeVisible();
+ const scroller = await capabilityCenterScroller(page);
+ expect(
+ await scroller.evaluate((node) => node.scrollWidth - node.clientWidth),
+ ).toBe(0);
+ const note = page.getByRole("note");
+ const noteBox = await note.boundingBox();
+ const textBox = await note
+ .getByText(
+ "This entry comes from the repository extension catalog. Ask your deployment administrator to confirm its installation and activation status.",
+ { exact: true },
+ )
+ .boundingBox();
+ expect(textBox!.x + textBox!.width).toBeLessThanOrEqual(
+ noteBox!.x + noteBox!.width,
+ );
+});
+
+test("capability tabs realign on resize without moving the page", async ({
+ page,
+}) => {
+ mockLangGraphAPI(page);
+ await page.setViewportSize({ width: 375, height: 480 });
+ await page.goto("/workspace/capabilities?tab=extensions");
+ const extensions = page.getByRole("tab", {
+ name: "Extensions",
+ exact: true,
+ });
+ const strip = page
+ .getByRole("tablist")
+ .filter({ has: extensions })
+ .locator("..");
+ const clippedBy = async (tab: Locator) => {
+ const [tabBox, stripBox] = await Promise.all([
+ tab.boundingBox(),
+ strip.boundingBox(),
+ ]);
+ return Math.max(
+ stripBox!.x - tabBox!.x,
+ tabBox!.x + tabBox!.width - stripBox!.x - stripBox!.width,
+ );
+ };
+ await expect(extensions).toBeInViewport({ ratio: 1 });
+
+ // A manual swipe stays where the user left it.
+ await strip.evaluate((node) => {
+ node.scrollLeft = 0;
+ });
+ await page.evaluate(
+ () =>
+ new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
+ ),
+ );
+ expect(await strip.evaluate((node) => node.scrollLeft)).toBe(0);
+ expect(await clippedBy(extensions)).toBeGreaterThan(0);
+
+ // Scrolled past the strip, a resize realigns it and leaves the page alone.
+ const scroller = await capabilityCenterScroller(page);
+ await scroller.evaluate((node) => {
+ node.scrollTop = node.scrollHeight;
+ });
+ await expect(strip).not.toBeInViewport();
+ await page.setViewportSize({ width: 320, height: 480 });
+ await expect.poll(() => clippedBy(extensions)).toBeLessThan(1);
+ await expect(strip).not.toBeInViewport();
+
+ // The same holds for a tab clipped on the other side.
+ await page.setViewportSize({ width: 375, height: 480 });
+ await page.goto("/workspace/capabilities?tab=plugins");
+ const tools = page.getByRole("tab", {
+ name: "Tools & integrations",
+ exact: true,
+ });
+ await expect(tools).toBeInViewport({ ratio: 1 });
+ await strip.evaluate((node) => {
+ node.scrollLeft = node.scrollWidth;
+ });
+ expect(await clippedBy(tools)).toBeGreaterThan(0);
+ await page.setViewportSize({ width: 320, height: 480 });
+ await expect.poll(() => clippedBy(tools)).toBeLessThan(1);
+});
+
test("unregistered plugin pages stay unavailable", async ({ page }) => {
mockLangGraphAPI(page);
await page.goto("/workspace/extensions/missing.plugin/library");
diff --git a/frontend/tests/unit/components/workspace/capabilities/extension-gallery.dom.test.tsx b/frontend/tests/unit/components/workspace/capabilities/extension-gallery.dom.test.tsx
new file mode 100644
index 0000000000..ea1f67937f
--- /dev/null
+++ b/frontend/tests/unit/components/workspace/capabilities/extension-gallery.dom.test.tsx
@@ -0,0 +1,130 @@
+import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
+import { cleanup, render, screen, within } from "@testing-library/react";
+
+const galleryMock = rs.hoisted(() => ({
+ search: "",
+ loaded: [] as unknown[],
+ replace: rs.fn(),
+}));
+
+rs.mock("next/navigation", () => ({
+ usePathname: () => "/workspace/capabilities",
+ useRouter: () => ({ replace: galleryMock.replace }),
+ useSearchParams: () => new URLSearchParams(galleryMock.search),
+}));
+rs.mock("@/core/extensions/hooks", () => ({
+ useFrontendExtensions: () => ({
+ data: galleryMock.loaded,
+ isPending: false,
+ isError: false,
+ refetch: rs.fn(),
+ }),
+}));
+rs.mock("@/core/i18n/hooks", () => ({
+ useI18n: () => ({ locale: "en-US", t: enUS }),
+}));
+
+import { ExtensionGallery } from "@/components/workspace/capabilities/extension-gallery";
+import { extensionCatalog } from "@/core/extensions/catalog";
+import { enUS } from "@/core/i18n/locales/en-US";
+
+const copy = enUS.extensions;
+
+function contribution(overrides: Record
) {
+ return {
+ namespace: "community.bookmarks",
+ module: null,
+ entry: null,
+ title: "Loaded title",
+ description: "Loaded description",
+ settings: { enabled: true },
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ galleryMock.search = "";
+ galleryMock.loaded = [];
+ galleryMock.replace.mockClear();
+});
+afterEach(cleanup);
+
+describe("extension detail", () => {
+ it("shows identity, catalog status and the guide for a catalog entry", () => {
+ galleryMock.search = "tab=extensions&extension=community.agent-teams";
+ render();
+
+ expect(
+ screen.getByRole("heading", { name: copy.catalog.agentTeams.title }),
+ ).toBeTruthy();
+ expect(screen.getByText(copy.catalog.agentTeams.description)).toBeTruthy();
+ expect(screen.getByText("deerflow-extension-agent-teams")).toBeTruthy();
+ expect(screen.getByRole("button", { name: copy.all })).toBeTruthy();
+ expect(screen.getByRole("button", { name: copy.reloadAll })).toBeTruthy();
+
+ const notice = screen.getByRole("note");
+ expect(within(notice).getByText(copy.catalogHint)).toBeTruthy();
+ expect(
+ within(notice)
+ .getByRole("link", { name: copy.installationGuide })
+ .getAttribute("href"),
+ ).toMatch(/deerflow-extension-agent-teams#readme$/);
+ });
+
+ it.each([
+ ["enabled", { settings: { enabled: true } }, copy.enabledManaged],
+ ["disabled", { settings: { enabled: false } }, copy.disabledManaged],
+ ])("reports a loaded %s extension as a note", (_name, overrides, text) => {
+ galleryMock.search = "tab=extensions&extension=community.bookmarks";
+ galleryMock.loaded = [contribution(overrides)];
+ render();
+
+ expect(within(screen.getByRole("note")).getByText(text)).toBeTruthy();
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+
+ it("raises a module failure as an alert", () => {
+ galleryMock.search = "tab=extensions&extension=community.bookmarks";
+ galleryMock.loaded = [contribution({ error: "boom" })];
+ render();
+
+ expect(
+ within(screen.getByRole("alert")).getByText(copy.moduleUnavailable),
+ ).toBeTruthy();
+ expect(screen.queryByRole("note")).toBeNull();
+ });
+
+ it("omits package and guide for an extension outside the catalog", () => {
+ galleryMock.search = "tab=extensions&extension=acme.private";
+ galleryMock.loaded = [contribution({ namespace: "acme.private" })];
+ render();
+
+ expect(screen.getByRole("heading", { name: "Loaded title" })).toBeTruthy();
+ expect(screen.queryByRole("link")).toBeNull();
+ expect(screen.queryByText(/deerflow-extension-/)).toBeNull();
+ });
+
+ it("keeps the way back when the extension is unknown", () => {
+ galleryMock.search = "tab=extensions&extension=missing.plugin";
+ render();
+
+ expect(screen.getByText(copy.notInstalled)).toBeTruthy();
+ expect(screen.getByRole("button", { name: copy.all })).toBeTruthy();
+ expect(screen.queryByRole("note")).toBeNull();
+ });
+});
+
+describe("extension directory", () => {
+ it("keeps the reload action beside the deployment hint", () => {
+ galleryMock.search = "tab=extensions";
+ render();
+
+ const reload = screen.getByRole("button", { name: copy.reloadAll });
+ expect(reload.parentElement).toBe(
+ screen.getByText(copy.deploymentHint).parentElement,
+ );
+ expect(screen.getAllByRole("article")).toHaveLength(
+ extensionCatalog.length,
+ );
+ });
+});