diff --git a/frontend/.gitignore b/frontend/.gitignore index 5a3f95b8bd..b95574eb02 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -10,6 +10,9 @@ lerna-debug.log* dist/ dist-ssr/ *.local +*.tsbuildinfo +/vite.config.d.ts +/vite.config.js # Editor .vscode/* diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index bd671ae684..11d41bc5b6 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -120,9 +120,9 @@ test.describe("Accessibility", () => { }); }); - // Navigate to targets, set active, return to chat so input is enabled - await page.getByTitle("Targets").click(); - await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 }); + // Navigate to the registry, set active, return to chat so input is enabled + await page.getByTitle("Registry").click(); + await expect(page.getByText("Target Registry")).toBeVisible({ timeout: 10000 }); const setActiveBtn = page.getByRole("button", { name: /set active/i }); await expect(setActiveBtn).toBeVisible({ timeout: 5000 }); await setActiveBtn.click(); @@ -146,8 +146,8 @@ test.describe("Accessibility", () => { const chatBtn = page.getByTitle("Chat"); await expect(chatBtn).toBeVisible(); - // Targets button - const configBtn = page.getByTitle("Targets"); + // Registry button + const configBtn = page.getByTitle("Registry"); await expect(configBtn).toBeVisible(); // Theme toggle button (now a menu trigger with "Theme: " title) @@ -263,9 +263,9 @@ test.describe("Accessibility", () => { }); }); - // Navigate to targets, set active, return to chat so input is enabled - await page.getByTitle("Targets").click(); - await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 }); + // Navigate to the registry, set active, return to chat so input is enabled + await page.getByTitle("Registry").click(); + await expect(page.getByText("Target Registry")).toBeVisible({ timeout: 10000 }); const setActiveBtn = page.getByRole("button", { name: /set active/i }); await expect(setActiveBtn).toBeVisible({ timeout: 5000 }); await setActiveBtn.click(); @@ -309,9 +309,9 @@ test.describe("Accessibility", () => { }); }); - // Navigate to targets - await page.getByTitle("Targets").click(); - await expect(page.getByText("Target Configuration")).toBeVisible(); + // Navigate to the registry + await page.getByTitle("Registry").click(); + await expect(page.getByText("Target Registry")).toBeVisible(); // Table should exist const table = page.getByRole("table"); @@ -333,7 +333,7 @@ test.describe("Accessibility", () => { const views = [ { button: "History", heading: "History" }, - { button: "Targets", heading: "Target Configuration" }, + { button: "Registry", heading: "Target Registry" }, { button: "Chat", heading: "Chat" }, ]; @@ -378,9 +378,9 @@ test.describe("Accessibility", () => { }); }); - await page.getByRole("button", { name: "Targets" }).click(); + await page.getByRole("button", { name: "Registry" }).click(); await expect( - page.getByRole("heading", { level: 1, name: "Target Configuration" }) + page.getByRole("heading", { level: 1, name: "Target Registry" }) ).toBeVisible(); await expect(page.getByRole("button", { name: "Refresh" })).toBeEnabled(); await expectMinimumTouchTarget(page.getByRole("button", { name: "Refresh" })); diff --git a/frontend/e2e/chat.spec.ts b/frontend/e2e/chat.spec.ts index c9011f9d18..535badd85e 100644 --- a/frontend/e2e/chat.spec.ts +++ b/frontend/e2e/chat.spec.ts @@ -132,11 +132,11 @@ async function mockBackendAPIs(page: Page) { }); } -/** Navigate to targets, set the mock target as active, then return to chat. */ +/** Navigate to the target registry, set the mock target as active, then return to chat. */ async function activateMockTarget(page: Page) { - // Click Targets button in sidebar - await page.getByTitle("Targets").click(); - await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 }); + // Click the Registry button in the sidebar + await page.getByTitle("Registry").click(); + await expect(page.getByText("Target Registry")).toBeVisible({ timeout: 10000 }); // Set the mock target active const setActiveBtn = page.getByRole("button", { name: /set active/i }); @@ -855,8 +855,8 @@ test.describe("Target type scenarios", () => { }); await page.goto("/"); - await page.getByTitle("Targets").click(); - await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 }); + await page.getByTitle("Registry").click(); + await expect(page.getByText("Target Registry")).toBeVisible({ timeout: 10000 }); await expect(page.locator("table").getByText("OpenAIChatTarget")).toBeVisible(); await expect(page.locator("table").getByText("OpenAIImageTarget")).toBeVisible(); @@ -880,7 +880,7 @@ test.describe("Target type scenarios", () => { }); await page.goto("/"); - await page.getByTitle("Targets").click(); + await page.getByTitle("Registry").click(); await expect(page.getByText("dall-e-3")).toBeVisible({ timeout: 10000 }); // Activate the DALL-E target (second row) diff --git a/frontend/e2e/config.spec.ts b/frontend/e2e/config.spec.ts index 6115e7ccc4..887dff94a5 100644 --- a/frontend/e2e/config.spec.ts +++ b/frontend/e2e/config.spec.ts @@ -153,11 +153,11 @@ async function expectWithin( ); } -/** Navigate to the targets view. */ +/** Navigate to the target registry. */ async function goToTargets(page: Page) { await page.goto("/"); - await page.getByTitle("Targets").click(); - await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 }); + await page.getByTitle("Registry").click(); + await expect(page.getByText("Target Registry")).toBeVisible({ timeout: 10000 }); } async function selectTargetType( @@ -177,7 +177,7 @@ async function selectTargetType( // Tests // --------------------------------------------------------------------------- -test.describe("Target Configuration Page", () => { +test.describe("Target Registry Page", () => { test("should show loading state then target list", async ({ page }) => { await page.route(/\/api\/targets/, async (route) => { // Small delay to see spinner @@ -422,7 +422,7 @@ test.describe("Create Target Dialog", () => { }); }); -test.describe("Responsive Target Configuration", () => { +test.describe("Responsive Target Registry", () => { for (const viewport of RESPONSIVE_VIEWPORTS) { test(`should contain configuration actions at ${viewport.name} width`, async ({ page, @@ -552,7 +552,7 @@ test.describe("Target Config ↔ Chat Navigation", () => { await expect(page.getByTestId("no-target-banner")).toBeVisible(); // Go to targets, set a target - await page.getByTitle("Targets").click(); + await page.getByTitle("Registry").click(); await expect(page.getByText("gpt-4o")).toBeVisible({ timeout: 10000 }); await page.getByRole("button", { name: /set active/i }).first().click(); diff --git a/frontend/e2e/converters.spec.ts b/frontend/e2e/converters.spec.ts index a1776d76e0..0aff0a8cde 100644 --- a/frontend/e2e/converters.spec.ts +++ b/frontend/e2e/converters.spec.ts @@ -407,10 +407,10 @@ async function mockBackendAPIs(page: Page) { }); } -/** Navigate to targets, set the mock target as active, then return to chat. */ +/** Navigate to the target registry, set the mock target as active, then return to chat. */ async function activateMockTarget(page: Page) { - await page.getByTitle("Targets").click(); - await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 }); + await page.getByTitle("Registry").click(); + await expect(page.getByText("Target Registry")).toBeVisible({ timeout: 10000 }); const setActiveBtn = page.getByRole("button", { name: /set active/i }); await expect(setActiveBtn).toBeVisible({ timeout: 5000 }); diff --git a/frontend/e2e/errors.spec.ts b/frontend/e2e/errors.spec.ts index 98effe3192..b43706e569 100644 --- a/frontend/e2e/errors.spec.ts +++ b/frontend/e2e/errors.spec.ts @@ -223,10 +223,10 @@ async function mockAllAPIs( }); } -/** Navigate to targets, set mock target active, return to chat. */ +/** Navigate to the target registry, set mock target active, return to chat. */ async function activateMockTarget(page: Page) { - await page.getByTitle("Targets").click(); - await expect(page.getByText("Target Configuration")).toBeVisible({ + await page.getByTitle("Registry").click(); + await expect(page.getByText("Target Registry")).toBeVisible({ timeout: 10000, }); const setActiveBtn = page.getByRole("button", { name: /set active/i }); diff --git a/frontend/e2e/flows.spec.ts b/frontend/e2e/flows.spec.ts index 688ff02e6c..6ccfa394e3 100644 --- a/frontend/e2e/flows.spec.ts +++ b/frontend/e2e/flows.spec.ts @@ -196,13 +196,13 @@ async function createConversation( return body.conversation_id; } -/** Activate an exact target instance via the Targets view. */ +/** Activate an exact target instance through the target registry. */ async function activateTarget( page: Page, targetRegistryName: string, ): Promise { - await page.getByTitle("Targets").click(); - await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10_000 }); + await page.getByTitle("Registry").click(); + await expect(page.getByText("Target Registry")).toBeVisible({ timeout: 10_000 }); const row = page.getByTestId(`target-row-${targetRegistryName}`); await expect(row).toBeVisible({ timeout: 10_000 }); const setActiveButton = row.getByRole("button", { name: /set active/i }); diff --git a/frontend/e2e/history.spec.ts b/frontend/e2e/history.spec.ts index d3f178ed9b..b6acaee3ac 100644 --- a/frontend/e2e/history.spec.ts +++ b/frontend/e2e/history.spec.ts @@ -382,8 +382,8 @@ test.describe("Attack History empty state", () => { await expect(configureTargetButton).toBeFocused(); await configureTargetButton.press("Enter"); - await expect(page).toHaveURL(/\/targets$/); - await expect(page.getByRole("heading", { level: 1, name: "Target Configuration" })).toBeVisible(); + await expect(page).toHaveURL(/\/registry\/targets$/); + await expect(page.getByRole("heading", { level: 1, name: "Target Registry" })).toBeVisible(); await page.goBack(); await expect(page).toHaveURL(/\/history\/attacks$/); diff --git a/frontend/e2e/onboarding-tour.spec.ts b/frontend/e2e/onboarding-tour.spec.ts index b0d3efa615..d7cd261794 100644 --- a/frontend/e2e/onboarding-tour.spec.ts +++ b/frontend/e2e/onboarding-tour.spec.ts @@ -14,7 +14,7 @@ test.describe("Onboarding tour", () => { await dialog.getByRole("button", { name: "Next", exact: true }).click(); await expect(dialog).toContainText( - "target selection happens in Targets" + "target selection happens in the Target Registry" ); await expect(dialog).toContainText("choose Configure a target"); await expect(dialog).toContainText("use Set Active there"); @@ -23,13 +23,13 @@ test.describe("Onboarding tour", () => { await page .getByRole("button", { name: "Configure a target", exact: true }) .click(); - await expect(page).toHaveURL(/\/targets$/); + await expect(page).toHaveURL(/\/registry\/targets$/); await expect( - page.getByRole("heading", { name: "Target Configuration" }) + page.getByRole("heading", { name: "Target Registry" }) ).toBeVisible(); await expect(dialog).toBeVisible(); await expect(dialog).toContainText( - "target selection happens in Targets" + "target selection happens in the Target Registry" ); await dialog.getByRole("button", { name: "Back", exact: true }).click(); @@ -42,7 +42,7 @@ test.describe("Onboarding tour", () => { await page .getByRole("button", { name: "Configure a target", exact: true }) .click(); - await expect(page).toHaveURL(/\/targets$/); + await expect(page).toHaveURL(/\/registry\/targets$/); await expect(dialog).toBeVisible(); await dialog.getByRole("button", { name: "Next", exact: true }).click(); @@ -91,10 +91,10 @@ test.describe("Onboarding tour", () => { await page.goto("/"); await page - .getByRole("button", { name: "Targets", exact: true }) + .getByRole("button", { name: "Registry", exact: true }) .click(); await expect( - page.getByRole("heading", { name: "Target Configuration" }) + page.getByRole("heading", { name: "Target Registry" }) ).toBeVisible(); await page.getByRole("button", { name: "Set Active", exact: true }).click(); await page.getByRole("button", { name: "Home", exact: true }).click(); @@ -106,15 +106,15 @@ test.describe("Onboarding tour", () => { await dialog.getByRole("button", { name: "Next", exact: true }).click(); await expect(dialog).toContainText("target currently active for Chat"); - await expect(dialog).toContainText("use Set Active in Targets"); + await expect(dialog).toContainText("use Set Active in the Target Registry"); await expect(page.locator('[data-tour="target-card"]')).toBeVisible(); await page .getByRole("button", { name: "Manage targets", exact: true }) .click(); - await expect(page).toHaveURL(/\/targets$/); + await expect(page).toHaveURL(/\/registry\/targets$/); await expect( - page.getByRole("heading", { name: "Target Configuration" }) + page.getByRole("heading", { name: "Target Registry" }) ).toBeVisible(); await expect(dialog).toBeVisible(); await expect(dialog).toContainText("target currently active for Chat"); @@ -168,7 +168,7 @@ test.describe("Onboarding tour", () => { .getByRole("button", { name: "Configure a target", exact: true }) .click(); - await expect(page).toHaveURL(/\/targets$/); + await expect(page).toHaveURL(/\/registry\/targets$/); await expect(dialog).toBeVisible(); await page.getByRole("button", { name: "Set Active", exact: true }).click(); await expect(page.getByText("Active", { exact: true }).first()).toBeVisible(); diff --git a/frontend/e2e/registry.spec.ts b/frontend/e2e/registry.spec.ts new file mode 100644 index 0000000000..c935d29375 --- /dev/null +++ b/frontend/e2e/registry.spec.ts @@ -0,0 +1,204 @@ +import { expect, test, type Page } from "@playwright/test"; + +interface RegisteredConverter { + converter_id: string; + identifier: { + class_name: string; + class_module: string; + hash: string; + pyrit_version: string; + supported_input_types: string[]; + supported_output_types: string[]; + }; + is_llm_based: boolean; + description?: string; +} + +const CONVERTER_TYPES = { + items: [ + { + converter_type: "CaesarConverter", + supported_input_types: ["text"], + supported_output_types: ["text"], + parameters: [ + { + name: "caesar_offset", + type_name: "int", + required: true, + default: null, + choices: null, + description: "Offset for the cipher.", + }, + ], + is_llm_based: false, + description: "Applies a Caesar cipher.", + }, + { + converter_type: "PersuasionConverter", + supported_input_types: ["text"], + supported_output_types: ["text"], + parameters: [ + { + name: "converter_target", + type_name: "PromptTarget", + required: true, + default: null, + choices: null, + reference_type: "target", + description: "The target used to rewrite prompts.", + }, + ], + is_llm_based: true, + description: "Rewrites prompts.", + }, + ...Array.from({ length: 16 }, (_, index) => ({ + converter_type: `ViewportConverter${index}`, + supported_input_types: ["text"], + supported_output_types: ["text"], + parameters: [], + is_llm_based: false, + description: `Viewport test converter ${index}.`, + })), + ], +}; + +async function installRegistryMocks(page: Page): Promise { + let registeredConverters: RegisteredConverter[] = []; + + await page.route(/\/api\/auth\/config$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ auth_enabled: false }), + }); + }); + await page.route(/\/api\/auth\/access$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ isAdmin: true }), + }); + }); + await page.route(/\/api\/version$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ version: "0.0.0" }), + }); + }); + await page.route(/\/api\/health$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ status: "healthy" }), + }); + }); + await page.route(/\/api\/targets(?:\?.*)?$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { + target_registry_name: "rewrite-target", + identifier: { + class_name: "OpenAIChatTarget", + class_module: "pyrit.prompt_target", + hash: "target-hash", + pyrit_version: "0.0.0", + }, + }, + ], + pagination: { limit: 200, has_more: false }, + }), + }); + }); + await page.route(/\/api\/converters\/types$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(CONVERTER_TYPES), + }); + }); + await page.route(/\/api\/converters\/[^/]+$/, async (route) => { + if (route.request().method() !== "DELETE") { + await route.fallback(); + return; + } + const converterId = decodeURIComponent(route.request().url().split("/").pop() ?? ""); + registeredConverters = registeredConverters.filter( + (converter) => converter.converter_id !== converterId, + ); + await route.fulfill({ status: 204 }); + }); + await page.route(/\/api\/converters$/, async (route) => { + if (route.request().method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items: registeredConverters }), + }); + return; + } + + const body = JSON.parse(route.request().postData() ?? "{}"); + const converterType = CONVERTER_TYPES.items.find( + (item) => item.converter_type === body.type, + ); + const converter: RegisteredConverter = { + converter_id: body.name, + identifier: { + class_name: body.type, + class_module: `pyrit.converter.${body.type}`, + hash: `${body.name}-hash`, + pyrit_version: "0.0.0", + supported_input_types: converterType?.supported_input_types ?? [], + supported_output_types: converterType?.supported_output_types ?? [], + }, + is_llm_based: converterType?.is_llm_based ?? false, + description: converterType?.description, + }; + registeredConverters.push(converter); + await route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify(converter), + }); + }); +} + +test.describe("Converter Registry", () => { + test.beforeEach(async ({ page }) => { + await installRegistryMocks(page); + await page.goto("/registry/converters"); + }); + + test("adds and removes a named converter without an active action", async ({ page }) => { + await expect(page.getByRole("heading", { name: "Converter Registry" })).toBeVisible(); + await expect(page.getByRole("button", { name: /set active/i })).toHaveCount(0); + + await page.getByRole("button", { name: "New Converter" }).click(); + await page.getByRole("combobox", { name: "Converter type" }).click(); + await page.getByTestId("converter-type-option-CaesarConverter").click(); + await page.getByLabel("Registry name").fill("caesar-custom"); + await page.getByLabel("caesar_offset *").fill("5"); + await page.getByRole("button", { name: "Add Converter" }).click(); + + await expect(page.getByText("caesar-custom")).toBeVisible(); + await page.getByRole("button", { name: "Remove caesar-custom" }).click(); + await page.getByRole("button", { name: "Remove", exact: true }).click(); + + await expect(page.getByText("caesar-custom")).toHaveCount(0); + await expect(page.getByText("No Converters Registered")).toBeVisible(); + }); + + test("uses the available viewport height for the converter type list", async ({ page }) => { + await page.getByRole("button", { name: "New Converter" }).click(); + await page.getByRole("combobox", { name: "Converter type" }).click(); + + const listbox = page.getByRole("listbox"); + await expect(listbox).toBeVisible(); + const bounds = await listbox.boundingBox(); + expect(bounds?.height).toBeGreaterThan(300); + }); +}); diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts index 5845ad54ee..aaa4105f4c 100644 --- a/frontend/e2e/touch-targets.spec.ts +++ b/frontend/e2e/touch-targets.spec.ts @@ -375,7 +375,7 @@ async function expectNoDocumentOverflow(page: Page): Promise { } async function startChatWithMessages(page: Page): Promise { - await page.getByRole("button", { name: "Targets", exact: true }).click(); + await page.getByRole("button", { name: "Registry", exact: true }).click(); await expect(page.getByText("gpt-4o-mobile")).toBeVisible(); await page.getByRole("button", { name: "Set Active" }).first().click(); await page.getByRole("button", { name: "Chat", exact: true }).click(); @@ -400,7 +400,7 @@ test.describe("Mobile touch targets", () => { }) => { await page.setViewportSize({ width: 320, height: 568 }); await page.goto("/"); - await page.getByRole("button", { name: "Targets", exact: true }).click(); + await page.getByRole("button", { name: "Registry", exact: true }).click(); await expect(page.getByText("gpt-4o-mobile")).toBeVisible(); await page.getByRole("button", { name: "Set Active" }).first().click(); await page.getByRole("button", { name: "Chat", exact: true }).click(); @@ -422,7 +422,7 @@ test.describe("Mobile touch targets", () => { await expectNoDocumentOverflow(page); }); - test("keeps Home, Targets, and History controls at least 44px", async ({ + test("keeps Home, Registry, and History controls at least 44px", async ({ page, }) => { await page.goto("/"); @@ -444,7 +444,7 @@ test.describe("Mobile touch targets", () => { await expectNoDocumentOverflow(page); await page - .getByRole("button", { name: "Targets", exact: true }) + .getByRole("button", { name: "Registry", exact: true }) .click(); await expect(page.getByText("gpt-4o-mobile")).toBeVisible(); @@ -520,7 +520,7 @@ test.describe("Mobile touch targets", () => { page.getByTestId("toggle-objective-header-btn") ).toBeVisible(); - await page.getByRole("button", { name: "Targets", exact: true }).click(); + await page.getByRole("button", { name: "Registry", exact: true }).click(); await expect(page.getByText("gpt-4o-mobile")).toBeVisible(); await page.getByRole("button", { name: "Set Active" }).first().click(); await page.goBack(); @@ -709,7 +709,7 @@ test("preserves compact desktop controls and existing sidebar dimensions", async ); await page - .getByRole("button", { name: "Targets", exact: true }) + .getByRole("button", { name: "Registry", exact: true }) .click(); await expect(page.getByText("gpt-4o-mobile")).toBeVisible(); await expectCompactDesktopTarget( diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7904c315cc..c1fb4bf753 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -62,6 +62,12 @@ jest.mock("./services/api", () => ({ listTargets: jest.fn(), getTarget: jest.fn(), }, + convertersApi: { + listConverters: jest.fn().mockResolvedValue({ items: [] }), + listConverterTypes: jest.fn().mockResolvedValue({ items: [] }), + createConverter: jest.fn(), + deleteConverter: jest.fn(), + }, versionApi: { getVersion: jest.fn().mockResolvedValue({ version: "1.0.0" }), }, @@ -103,8 +109,8 @@ jest.mock("./components/Layout/MainLayout", () => { - ) : ( - )} @@ -319,8 +325,8 @@ jest.mock("./components/Home/Home", () => {
{activeTarget ? "yes" : "no"} {JSON.stringify(labels)} -
@@ -402,7 +408,7 @@ jest.mock("./components/History/ScenarioHistory", () => { describe("App", () => { // App reads the active view from the URL, so every render needs a router. - // initialPath lets a test deep-link straight to a view (e.g. "/targets"). + // initialPath lets a test deep-link straight to a view. function renderApp(initialPath = "/") { return render( @@ -441,15 +447,36 @@ describe("App", () => { }); it("renders the view named by the initial URL", () => { - renderApp("/targets"); + renderApp("/registry/targets"); expect(screen.getByTestId("main-layout")).toHaveAttribute( "data-current-view", - "targets" + "registry" ); expect(screen.getByTestId("target-config")).toBeInTheDocument(); }); + it("redirects /registry to the target registry", async () => { + renderApp("/registry"); + + expect(await screen.findByTestId("target-config")).toBeInTheDocument(); + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "registry" + ); + }); + + it("renders the converter registry from its direct URL", async () => { + renderApp("/registry/converters"); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "registry" + ); + expect(await screen.findByRole("heading", { name: "Converter Registry" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Converters" })).toHaveAttribute("aria-selected", "true"); + }); + it("renders configuration at /config", () => { renderApp("/config"); @@ -579,14 +606,14 @@ describe("App", () => { expect(screen.getByTestId("scenario-detail-labels-json")).toHaveTextContent("operator"); }); - it("navigates from scenario detail to targets when it requests it", () => { + it("navigates from scenario detail to the registry when it requests it", () => { renderApp("/scanner/foundry.red_team_agent"); fireEvent.click(screen.getByTestId("scenario-detail-go-config")); expect(screen.getByTestId("main-layout")).toHaveAttribute( "data-current-view", - "targets" + "registry" ); expect(screen.getByTestId("target-config")).toBeInTheDocument(); }); @@ -613,19 +640,19 @@ describe("App", () => { expect(screen.getByTestId("chat-window")).toBeInTheDocument(); }); - it("switches to targets view", () => { + it("switches to the target registry view", () => { renderApp(); fireEvent.click(screen.getByTestId("nav-config")); expect(screen.getByTestId("main-layout")).toHaveAttribute( "data-current-view", - "targets" + "registry" ); expect(screen.getByTestId("target-config")).toBeInTheDocument(); }); - it("switches back to chat from targets", () => { + it("switches back to chat from the registry", () => { renderApp(); fireEvent.click(screen.getByTestId("nav-config")); @@ -701,14 +728,14 @@ describe("App", () => { expect(screen.getByTestId("conversation-id")).toHaveTextContent("none"); }); - it("sets active target from targets page and passes to chat", () => { + it("sets an active target from the registry and passes it to chat", () => { renderApp(); // Switch to chat and confirm no target initially fireEvent.click(screen.getByTestId("nav-chat")); expect(screen.getByTestId("has-target")).toHaveTextContent("no"); - // Switch to targets and set target + // Switch to the registry and set a target fireEvent.click(screen.getByTestId("nav-config")); fireEvent.click(screen.getByTestId("set-target")); @@ -729,13 +756,13 @@ describe("App", () => { expect(screen.getByTestId("attack-history")).toBeInTheDocument(); }); - it("navigates from empty history to targets when no target is active", () => { + it("navigates from empty history to the registry when no target is active", () => { renderApp("/history/attacks"); expect(screen.getByTestId("history-has-target")).toHaveTextContent("no"); fireEvent.click(screen.getByTestId("history-configure-target")); - expect(screen.getByTestId("main-layout")).toHaveAttribute("data-current-view", "targets"); + expect(screen.getByTestId("main-layout")).toHaveAttribute("data-current-view", "registry"); expect(screen.getByTestId("target-config")).toBeInTheDocument(); }); @@ -786,14 +813,14 @@ describe("App", () => { await waitFor(() => expect(screen.getByTestId("conversation-id")).toHaveTextContent("home-conv-1")); }); - it("navigates to targets from the home view", () => { + it("navigates to the registry from the home view", () => { renderApp(); fireEvent.click(screen.getByTestId("home-go-config")); expect(screen.getByTestId("main-layout")).toHaveAttribute( "data-current-view", - "targets" + "registry" ); expect(screen.getByTestId("target-config")).toBeInTheDocument(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 20fff30058..ee226e6c67 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,8 @@ import ChatWindow from './components/Chat/ChatWindow' import AttackNotFound from './components/Chat/AttackNotFound' import Home from './components/Home/Home' import TargetConfig from './components/Config/TargetConfig' +import ConverterRegistry from './components/Registry/ConverterRegistry' +import RegistryLayout from './components/Registry/RegistryLayout' import Configuration from './components/Configuration/Configuration' import AttackHistory from './components/History/AttackHistory' import HistoryPage from './components/History/HistoryPage' @@ -58,7 +60,8 @@ const VIEW_PATHS: Record = { home: '/', chat: '/chat', history: HISTORY_ATTACKS_PATH, - targets: '/targets', + registry: '/registry/targets', + targets: '/registry/targets', scenarios: '/scanner', configuration: '/config', } @@ -73,6 +76,9 @@ function viewFromPath(pathname: string): ViewName { if (pathname === '/history' || pathname.startsWith('/history/') || pathname.startsWith('/scanner-history/')) { return 'history' } + if (pathname.startsWith('/registry')) { + return 'registry' + } if ( pathname === VIEW_PATHS.scenarios || pathname.startsWith(`${VIEW_PATHS.scenarios}/`) @@ -605,15 +611,19 @@ function App() { path="/attacks/:attackId/conversations/:conversationId" element={chatElement} /> - - } - /> + }> + } /> + + } + /> + } /> + } /> { ); expect( - screen.getByRole("heading", { level: 1, name: "Target Configuration" }) + screen.getByRole("heading", { level: 1, name: "Target Registry" }) ).toBeInTheDocument(); expect(screen.getByText("Loading targets...")).toBeInTheDocument(); }); diff --git a/frontend/src/components/Config/TargetConfig.tsx b/frontend/src/components/Config/TargetConfig.tsx index fc46bd61ea..45de1c0b78 100644 --- a/frontend/src/components/Config/TargetConfig.tsx +++ b/frontend/src/components/Config/TargetConfig.tsx @@ -76,7 +76,7 @@ export default function TargetConfig({ activeTarget, onSetActiveTarget }: Target
- Target Configuration + Target Registry Manage targets for attack sessions. Select a target to use in the chat view. diff --git a/frontend/src/components/History/AttackHistory.test.tsx b/frontend/src/components/History/AttackHistory.test.tsx index c989f5005d..8ed3d72833 100644 --- a/frontend/src/components/History/AttackHistory.test.tsx +++ b/frontend/src/components/History/AttackHistory.test.tsx @@ -126,7 +126,7 @@ describe('AttackHistory', () => { expect(screen.queryByRole('button', { name: 'Start attack' })).not.toBeInTheDocument() await user.click(configureTargetButton) - expect(onNavigate).toHaveBeenCalledWith('targets') + expect(onNavigate).toHaveBeenCalledWith('registry') }) it('should guide users with an active target to start an attack', async () => { diff --git a/frontend/src/components/History/AttackHistory.tsx b/frontend/src/components/History/AttackHistory.tsx index 12b7e83dd3..d3f6579bcc 100644 --- a/frontend/src/components/History/AttackHistory.tsx +++ b/frontend/src/components/History/AttackHistory.tsx @@ -265,7 +265,7 @@ export default function AttackHistory({ text: 'Configure a target before starting an attack.', label: 'Configure target', icon: , - view: 'targets' as const, + view: 'registry' as const, } return ( diff --git a/frontend/src/components/Home/Home.test.tsx b/frontend/src/components/Home/Home.test.tsx index 84e29dca62..8fd8f2b5ed 100644 --- a/frontend/src/components/Home/Home.test.tsx +++ b/frontend/src/components/Home/Home.test.tsx @@ -98,12 +98,12 @@ describe("Home", () => { await waitFor(() => expect(mockListAttacks).toHaveBeenCalled()); }); - it("navigates to config when 'Configure a target' is clicked", async () => { + it("navigates to the registry when 'Configure a target' is clicked", async () => { const user = userEvent.setup(); const onNavigate = jest.fn(); render(); await user.click(screen.getByTestId("home-configure-target-btn")); - expect(onNavigate).toHaveBeenCalledWith("targets"); + expect(onNavigate).toHaveBeenCalledWith("registry"); }); it("shows the empty state when there are no attacks", async () => { diff --git a/frontend/src/components/Home/Home.tsx b/frontend/src/components/Home/Home.tsx index 2e700b2da0..fb2b6ba9c3 100644 --- a/frontend/src/components/Home/Home.tsx +++ b/frontend/src/components/Home/Home.tsx @@ -191,7 +191,7 @@ export default function Home({ appearance="primary" icon={} iconPosition="after" - onClick={() => onNavigate('targets')} + onClick={() => onNavigate('registry')} data-testid="home-configure-target-btn" > {activeTarget ? 'Manage targets' : 'Configure a target'} diff --git a/frontend/src/components/Layout/MainLayout.test.tsx b/frontend/src/components/Layout/MainLayout.test.tsx index 8a35251f7e..bb46cc539f 100644 --- a/frontend/src/components/Layout/MainLayout.test.tsx +++ b/frontend/src/components/Layout/MainLayout.test.tsx @@ -26,7 +26,7 @@ jest.mock("../Sidebar/Navigation", () => { }) => { return (
- +
); }; diff --git a/frontend/src/components/Registry/ConverterRegistry.test.tsx b/frontend/src/components/Registry/ConverterRegistry.test.tsx new file mode 100644 index 0000000000..8202b1a86d --- /dev/null +++ b/frontend/src/components/Registry/ConverterRegistry.test.tsx @@ -0,0 +1,107 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import { convertersApi } from '@/services/api' + +import ConverterRegistry from './ConverterRegistry' + +jest.mock('@/services/api', () => ({ + convertersApi: { + listConverters: jest.fn(), + deleteConverter: jest.fn(), + }, +})) + +jest.mock('./CreateConverterDialog', () => ({ + __esModule: true, + default: ({ open }: { open: boolean }) => open ?
Create converter
: null, +})) + +const mockedConvertersApi = convertersApi as jest.Mocked +const converter = { + converter_id: 'base64-default', + identifier: { + class_name: 'Base64Converter', + class_module: 'pyrit.converter.Base64Converter', + hash: 'hash', + pyrit_version: '0.0.0', + supported_input_types: ['text'], + supported_output_types: ['text'], + encoding_func: 'b64encode', + }, + is_llm_based: false, +} + +function renderRegistry() { + return render( + + + , + ) +} + +describe('ConverterRegistry', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedConvertersApi.listConverters.mockResolvedValue({ items: [converter] }) + mockedConvertersApi.deleteConverter.mockResolvedValue() + }) + + it('lists registered converter instances and configuration', async () => { + renderRegistry() + + expect(screen.getByText('Loading converters...')).toBeInTheDocument() + expect(await screen.findByText('base64-default')).toBeInTheDocument() + expect(screen.getByText('Base64Converter')).toBeInTheDocument() + expect(screen.getByText('encoding_func: b64encode')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /set active/i })).not.toBeInTheDocument() + }) + + it('shows an empty state', async () => { + mockedConvertersApi.listConverters.mockResolvedValue({ items: [] }) + renderRegistry() + + expect(await screen.findByText('No Converters Registered')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Create First Converter' })).toBeInTheDocument() + }) + + it('shows an error and retries on refresh', async () => { + mockedConvertersApi.listConverters + .mockRejectedValueOnce(new Error('registry unavailable')) + .mockResolvedValueOnce({ items: [converter] }) + const user = userEvent.setup() + renderRegistry() + + expect(await screen.findByText(/registry unavailable/i)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Refresh' })) + + expect(await screen.findByText('base64-default')).toBeInTheDocument() + expect(mockedConvertersApi.listConverters).toHaveBeenCalledTimes(2) + }) + + it('opens the shared create dialog', async () => { + const user = userEvent.setup() + renderRegistry() + await screen.findByText('base64-default') + + await user.click(screen.getByRole('button', { name: 'New Converter' })) + + expect(screen.getByRole('dialog')).toHaveTextContent('Create converter') + }) + + it('confirms removal and refreshes the registry', async () => { + mockedConvertersApi.listConverters + .mockResolvedValueOnce({ items: [converter] }) + .mockResolvedValueOnce({ items: [] }) + const user = userEvent.setup() + renderRegistry() + await screen.findByText('base64-default') + + await user.click(screen.getByRole('button', { name: 'Remove base64-default' })) + await user.click(screen.getByRole('button', { name: 'Remove' })) + + expect(mockedConvertersApi.deleteConverter).toHaveBeenCalledWith('base64-default') + expect(await screen.findByText('No Converters Registered')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Registry/ConverterRegistry.tsx b/frontend/src/components/Registry/ConverterRegistry.tsx new file mode 100644 index 0000000000..855612b684 --- /dev/null +++ b/frontend/src/components/Registry/ConverterRegistry.tsx @@ -0,0 +1,239 @@ +import { useCallback, useEffect, useState } from 'react' + +import { + Badge, + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + Spinner, + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, + Text, +} from '@fluentui/react-components' +import { AddRegular, ArrowSyncRegular, DeleteRegular } from '@fluentui/react-icons' + +import { convertersApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { ConverterIdentifier, ConverterInstance } from '@/types' + +import CreateConverterDialog from './CreateConverterDialog' +import { useConverterRegistryStyles } from './Registry.styles' + +const IDENTIFIER_FIELDS = new Set([ + 'class_name', + 'class_module', + 'hash', + 'pyrit_version', + 'eval_hash', + 'children', + 'attributes', + 'supported_input_types', + 'supported_output_types', +]) + +function formatParameters(identifier: ConverterIdentifier): string { + const parameters = Object.entries(identifier) + .filter(([key, value]) => !IDENTIFIER_FIELDS.has(key) && value != null) + .map(([key, value]) => `${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}`) + return parameters.join('\n') || '—' +} + +interface DataTypeBadgesProps { + dataTypes: string[] | null | undefined +} + +function DataTypeBadges({ dataTypes }: DataTypeBadgesProps) { + const styles = useConverterRegistryStyles() + if (!dataTypes?.length) return + return ( +
+ {dataTypes.map((dataType) => ( + {dataType.replace('_path', '')} + ))} +
+ ) +} + +export default function ConverterRegistry() { + const styles = useConverterRegistryStyles() + const [converters, setConverters] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [createOpen, setCreateOpen] = useState(false) + const [converterToRemove, setConverterToRemove] = useState(null) + const [removing, setRemoving] = useState(false) + + const loadConverters = useCallback(async () => { + setLoading(true) + setError(null) + try { + const response = await convertersApi.listConverters() + setConverters(response.items) + } catch (err) { + setError(toApiError(err).detail) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + Promise.resolve().then(() => { + void loadConverters() + }) + }, [loadConverters]) + + const removeConverter = async () => { + if (!converterToRemove) return + setRemoving(true) + setError(null) + try { + await convertersApi.deleteConverter(converterToRemove.converter_id) + setConverterToRemove(null) + await loadConverters() + } catch (err) { + setError(toApiError(err).detail) + } finally { + setRemoving(false) + } + } + + return ( +
+
+
+ Converter Registry + Manage configured converter instances. +
+
+ + +
+
+ + {loading && ( +
+ +
+ )} + {!loading && error && ( +
+ Error: {error} +
+ )} + {!loading && !error && converters.length === 0 && ( +
+ No Converters Registered + Add a configured converter to the registry. + +
+ )} + {!loading && !error && converters.length > 0 && ( +
+ + + + Registry Name + Type + Inputs + Outputs + Parameters + Actions + + + + {converters.map((converter) => ( + + {converter.converter_id} + + {converter.identifier.class_name} + {converter.is_llm_based && LLM} + + + + + + + + + {formatParameters(converter.identifier)} + + + + + + ))} + +
+
+ )} + + setCreateOpen(false)} + onCreated={() => { + setCreateOpen(false) + void loadConverters() + }} + /> + + { if (!data.open && !removing) setConverterToRemove(null) }} + > + + + Remove converter? + + {converterToRemove + ? `Remove "${converterToRemove.converter_id}" from the converter registry?` + : ''} + + + + + + + + +
+ ) +} diff --git a/frontend/src/components/Registry/CreateConverterDialog.test.tsx b/frontend/src/components/Registry/CreateConverterDialog.test.tsx new file mode 100644 index 0000000000..ded90c5b8f --- /dev/null +++ b/frontend/src/components/Registry/CreateConverterDialog.test.tsx @@ -0,0 +1,248 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import { convertersApi, targetsApi } from '@/services/api' + +import CreateConverterDialog from './CreateConverterDialog' + +jest.mock('@/services/api', () => ({ + convertersApi: { + listConverterTypes: jest.fn(), + listConverters: jest.fn(), + createConverter: jest.fn(), + }, + targetsApi: { + listTargets: jest.fn(), + }, +})) + +const mockedConvertersApi = convertersApi as jest.Mocked +const mockedTargetsApi = targetsApi as jest.Mocked + +const converterTypes = { + items: [ + { + converter_type: 'CaesarConverter', + supported_input_types: ['text'], + supported_output_types: ['text'], + parameters: [ + { + name: 'caesar_offset', + type_name: 'int', + required: true, + default: null, + choices: null, + description: 'Offset for the cipher.', + }, + ], + is_llm_based: false, + description: 'Applies a Caesar cipher.', + }, + ], +} + +async function selectConverterType(converterType: string) { + const user = userEvent.setup() + await user.click(await screen.findByRole('combobox', { name: /^converter type$/i })) + await user.click(screen.getByTestId(`converter-type-option-${converterType}`)) +} + +function renderDialog( + props: Partial> = {}, +) { + return render( + + + , + ) +} + +describe('CreateConverterDialog', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedConvertersApi.listConverterTypes.mockResolvedValue(converterTypes) + mockedConvertersApi.listConverters.mockResolvedValue({ items: [] }) + mockedTargetsApi.listTargets.mockResolvedValue({ + items: [], + pagination: { limit: 200, has_more: false }, + }) + }) + + it('loads converter classes from registry type metadata', async () => { + const user = userEvent.setup() + renderDialog() + + await user.click(await screen.findByRole('combobox', { name: /^converter type$/i })) + expect(screen.getByTestId('converter-type-option-CaesarConverter')).toHaveTextContent( + 'Applies a Caesar cipher.', + ) + expect(screen.getByRole('group', { name: 'Text to Text' })).toBeInTheDocument() + expect(mockedConvertersApi.listConverterTypes).toHaveBeenCalledTimes(1) + }) + + it('prefills an editable registry name from the selected type', async () => { + const user = userEvent.setup() + renderDialog() + await selectConverterType('CaesarConverter') + + const nameInput = screen.getByLabelText(/registry name/i) + expect(nameInput).toHaveValue('CaesarConverter') + await user.clear(nameInput) + await user.type(nameInput, 'caesar-custom') + expect(nameInput).toHaveValue('caesar-custom') + }) + + it('requires constructor parameters before creating', async () => { + const user = userEvent.setup() + renderDialog() + await selectConverterType('CaesarConverter') + await user.click(screen.getByRole('button', { name: 'Add Converter' })) + + expect(screen.getByText('Required')).toBeInTheDocument() + expect(mockedConvertersApi.createConverter).not.toHaveBeenCalled() + }) + + it('offers upload or server path input for every Path parameter', async () => { + mockedConvertersApi.listConverterTypes.mockResolvedValue({ + items: [{ + converter_type: 'PathConverter', + supported_input_types: ['text'], + supported_output_types: ['text'], + parameters: [{ + name: 'source', + type_name: 'Path', + required: true, + default: null, + choices: null, + description: 'Input asset.', + }], + is_llm_based: false, + description: 'Uses an input asset.', + }], + }) + renderDialog() + + await selectConverterType('PathConverter') + + expect(screen.getByLabelText('source *')).toHaveAttribute( + 'placeholder', + 'Upload a file or enter a server path', + ) + expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument() + }) + + it('creates a named converter through the registry API', async () => { + const onCreated = jest.fn() + mockedConvertersApi.createConverter.mockResolvedValue({ + converter_id: 'caesar-custom', + identifier: { + class_name: 'CaesarConverter', + class_module: 'pyrit.converter', + hash: 'caesar-hash', + pyrit_version: '0.0.0', + }, + }) + const user = userEvent.setup() + renderDialog({ onCreated }) + await selectConverterType('CaesarConverter') + const nameInput = screen.getByLabelText(/registry name/i) + await user.clear(nameInput) + await user.type(nameInput, 'caesar-custom') + await user.type(screen.getByLabelText(/caesar_offset/i), '5') + await user.click(screen.getByRole('button', { name: 'Add Converter' })) + + expect(mockedConvertersApi.createConverter).toHaveBeenCalledWith({ + name: 'caesar-custom', + type: 'CaesarConverter', + params: { caesar_offset: '5' }, + }) + expect(onCreated).toHaveBeenCalledWith('caesar-custom') + }) + + it('selects a registered target for a target reference parameter', async () => { + mockedConvertersApi.listConverterTypes.mockResolvedValue({ + items: [ + { + converter_type: 'PersuasionConverter', + supported_input_types: ['text'], + supported_output_types: ['text'], + parameters: [ + { + name: 'converter_target', + type_name: 'PromptTarget', + required: true, + default: null, + choices: null, + reference_type: 'target', + description: 'The target used to rewrite prompts.', + }, + ], + is_llm_based: true, + description: 'Rewrites prompts.', + }, + ], + }) + mockedTargetsApi.listTargets.mockResolvedValue({ + items: [ + { + target_registry_name: 'rewrite-target', + identifier: { + class_name: 'OpenAIChatTarget', + class_module: 'pyrit.prompt_target', + hash: 'target-hash', + pyrit_version: '0.0.0', + }, + }, + ], + pagination: { limit: 200, has_more: false }, + }) + mockedConvertersApi.createConverter.mockResolvedValue({ + converter_id: 'persuasion', + identifier: { + class_name: 'PersuasionConverter', + class_module: 'pyrit.converter', + hash: 'persuasion-hash', + pyrit_version: '0.0.0', + }, + }) + const user = userEvent.setup() + renderDialog() + + await selectConverterType('PersuasionConverter') + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getAllByText('Rewrites prompts.')).not.toHaveLength(0) + expect(screen.getAllByText('LLM')).not.toHaveLength(0) + await user.selectOptions( + screen.getByRole('combobox', { name: /converter_target/i }), + 'rewrite-target', + ) + await user.click(screen.getByRole('button', { name: 'Add Converter' })) + + expect(mockedConvertersApi.createConverter).toHaveBeenCalledWith({ + name: 'PersuasionConverter', + type: 'PersuasionConverter', + params: { converter_target: 'rewrite-target' }, + }) + }) + + it('shows duplicate-name errors from the registry', async () => { + mockedConvertersApi.createConverter.mockRejectedValue( + new Error("Converter instance 'CaesarConverter' already exists"), + ) + const user = userEvent.setup() + renderDialog() + await selectConverterType('CaesarConverter') + await user.type(screen.getByLabelText(/caesar_offset/i), '5') + await user.click(screen.getByRole('button', { name: 'Add Converter' })) + + expect( + await screen.findByText(/already exists/i), + ).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Registry/CreateConverterDialog.tsx b/frontend/src/components/Registry/CreateConverterDialog.tsx new file mode 100644 index 0000000000..349facebbc --- /dev/null +++ b/frontend/src/components/Registry/CreateConverterDialog.tsx @@ -0,0 +1,474 @@ +import { useEffect, useMemo, useState } from 'react' + +import { + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + Dropdown, + Field, + Input, + MessageBar, + MessageBarBody, + Option, + OptionGroup, + Select, + Spinner, + Switch, + Text, +} from '@fluentui/react-components' + +import { convertersApi, targetsApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { ConverterInstance, ConverterTypeEntry, Parameter, TargetInstance } from '@/types' + +import { useCreateConverterDialogStyles } from './Registry.styles' + +const HIDDEN_CONVERTER_TYPES = new Set(['SelectiveTextConverter']) + +function formatDataType(dataType: string): string { + const value = dataType.replace('_path', '').replace(/_/g, ' ') + return value.charAt(0).toUpperCase() + value.slice(1) +} + +function getModalityLabel(converterType: ConverterTypeEntry): string { + const inputs = converterType.supported_input_types.length > 0 + ? converterType.supported_input_types.map(formatDataType).join(', ') + : 'Any' + const outputs = converterType.supported_output_types.length > 0 + ? converterType.supported_output_types.map(formatDataType).join(', ') + : 'Any' + return `${inputs} to ${outputs}` +} + +interface CreateConverterDialogProps { + open: boolean + onClose: () => void + onCreated: (converterId: string) => void +} + +interface ParameterInputProps { + parameter: Parameter + referenceOptions: Array<{ name: string; type: string }> + value: string + showError: boolean + onChange: (value: string) => void + onBrowse: () => void +} + +function parameterDefaultValue(parameter: Parameter): string { + if (Array.isArray(parameter.default)) { + return parameter.default.join(', ') + } + return parameter.default ?? '' +} + +function ParameterInput({ + parameter, + referenceOptions, + value, + showError, + onChange, + onBrowse, +}: ParameterInputProps) { + const styles = useCreateConverterDialogStyles() + const label = `${parameter.name}${parameter.required ? ' *' : ''}` + + if (parameter.reference_type) { + return ( + + + + ) + } + + if (parameter.type_name === 'bool') { + const checked = (value || parameterDefaultValue(parameter) || 'false').toLowerCase() === 'true' + return ( + + onChange(data.checked ? 'true' : 'false')} + /> + + ) + } + + if (parameter.choices?.length) { + return ( + + + + ) + } + + const isFile = parameter.type_name === 'Path' + || /path|file/i.test(parameter.name) + || /path|file/i.test(parameter.description ?? '') + + return ( + + {isFile ? ( +
+ onChange(data.value)} + /> + +
+ ) : ( + onChange(data.value)} + /> + )} +
+ ) +} + +export default function CreateConverterDialog({ + open, + onClose, + onCreated, +}: CreateConverterDialogProps) { + const styles = useCreateConverterDialogStyles() + const [converterTypes, setConverterTypes] = useState([]) + const [targets, setTargets] = useState([]) + const [converters, setConverters] = useState([]) + const [selectedType, setSelectedType] = useState('') + const [registryName, setRegistryName] = useState('') + const [nameEdited, setNameEdited] = useState(false) + const [parameterValues, setParameterValues] = useState>({}) + const [loading, setLoading] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [showValidation, setShowValidation] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!open) return + let cancelled = false + Promise.resolve() + .then(() => { + if (cancelled) return null + setLoading(true) + setError(null) + return Promise.all([ + convertersApi.listConverterTypes(), + targetsApi.listTargets(200), + convertersApi.listConverters(), + ]) + }) + .then((responses) => { + if (!responses) return + const [response, targetResponse, converterResponse] = responses + if (!cancelled) { + setConverterTypes( + response.items.filter((item) => !HIDDEN_CONVERTER_TYPES.has(item.converter_type)), + ) + setTargets(targetResponse.items) + setConverters(converterResponse.items) + } + }) + .catch((err) => { + if (!cancelled) { + setConverterTypes([]) + setTargets([]) + setConverters([]) + setError(toApiError(err).detail) + } + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { cancelled = true } + }, [open]) + + const selectedConverterType = useMemo( + () => converterTypes.find((item) => item.converter_type === selectedType), + [converterTypes, selectedType], + ) + const groupedConverterTypes = useMemo(() => { + const groups = new Map() + for (const converterType of converterTypes) { + const label = getModalityLabel(converterType) + groups.set(label, [...(groups.get(label) ?? []), converterType]) + } + return [...groups.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([label, items]) => ({ + label, + items: [...items].sort((left, right) => + left.converter_type.localeCompare(right.converter_type)), + })) + }, [converterTypes]) + + const referenceOptions = (parameter: Parameter): Array<{ name: string; type: string }> => { + if (parameter.reference_type === 'target') { + return targets.map((target) => ({ + name: target.target_registry_name, + type: target.identifier.class_name, + })) + } + if (parameter.reference_type === 'converter') { + return converters.map((converter) => ({ + name: converter.converter_id, + type: converter.identifier.class_name, + })) + } + return [] + } + + const reset = () => { + setSelectedType('') + setRegistryName('') + setNameEdited(false) + setParameterValues({}) + setShowValidation(false) + setError(null) + } + + const close = () => { + reset() + onClose() + } + + const selectType = (converterType: string) => { + setSelectedType(converterType) + if (!nameEdited) setRegistryName(converterType) + const typeEntry = converterTypes.find((item) => item.converter_type === converterType) + setParameterValues( + Object.fromEntries( + (typeEntry?.parameters ?? []) + .filter((parameter) => parameter.default != null) + .map((parameter) => [parameter.name, parameterDefaultValue(parameter)]), + ), + ) + setShowValidation(false) + setError(null) + } + + const browse = (parameterName: string) => { + const input = document.createElement('input') + input.type = 'file' + input.onchange = () => { + const file = input.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = () => { + setParameterValues((current) => ({ + ...current, + [parameterName]: String(reader.result ?? ''), + })) + } + reader.readAsDataURL(file) + } + input.click() + } + + const submit = async () => { + const missingParameters = (selectedConverterType?.parameters ?? []).some( + (parameter) => parameter.required + && !parameter.default + && !parameterValues[parameter.name]?.trim(), + ) + if (!selectedType || !registryName.trim() || missingParameters) { + setShowValidation(true) + return + } + + setSubmitting(true) + setError(null) + try { + const response = await convertersApi.createConverter({ + name: registryName.trim(), + type: selectedType, + params: parameterValues, + }) + reset() + onCreated(response.converter_id) + } catch (err) { + setError(toApiError(err).detail) + } finally { + setSubmitting(false) + } + } + + return ( + { if (!data.open) close() }}> + + + Add Converter + +
{ + event.preventDefault() + void submit() + }} + > + {error && ( + + {error} + + )} + {loading && } + {!loading && converterTypes.length === 0 && !error && ( + No converter types are available. + )} + {!loading && converterTypes.length > 0 && ( + <> + + { + if (data.optionValue) selectType(data.optionValue) + }} + > + {groupedConverterTypes.map((group) => ( + + {group.items.map((item) => { + const description = item.description || 'No description is available.' + const accessibleDescription = [ + item.converter_type, + description, + group.label, + item.is_llm_based ? 'LLM' : undefined, + ].filter((value): value is string => Boolean(value)).join('. ') + + return ( + + ) + })} + + ))} + + + {selectedConverterType && ( +
+
+ {selectedConverterType.converter_type} + {selectedConverterType.is_llm_based && ( + LLM + )} +
+ {selectedConverterType.description || 'No description is available.'} + + {getModalityLabel(selectedConverterType)} + +
+ )} + + { + setRegistryName(data.value) + setNameEdited(true) + }} + /> + +
+ {selectedConverterType?.parameters.map((parameter) => ( +
+ setParameterValues((current) => ({ + ...current, + [parameter.name]: value, + }))} + onBrowse={() => browse(parameter.name)} + /> +
+ ))} +
+ + )} + +
+ + + + +
+
+
+ ) +} diff --git a/frontend/src/components/Registry/Registry.styles.ts b/frontend/src/components/Registry/Registry.styles.ts new file mode 100644 index 0000000000..47e424e048 --- /dev/null +++ b/frontend/src/components/Registry/Registry.styles.ts @@ -0,0 +1,196 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { + MINIMUM_TOUCH_TARGET_SIZE, + NARROW_VIEWPORT_QUERY, + TOUCH_INPUT_QUERY, +} from '@/styles/touchTargets' + +export const useRegistryLayoutStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + height: '100%', + minWidth: 0, + }, + tabs: { + flexShrink: 0, + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalXXL} 0`, + backgroundColor: tokens.colorNeutralBackground2, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + [NARROW_VIEWPORT_QUERY]: { + paddingLeft: tokens.spacingHorizontalM, + paddingRight: tokens.spacingHorizontalM, + }, + }, + content: { + flex: 1, + minHeight: 0, + }, +}) + +export const useConverterRegistryStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + height: '100%', + minWidth: 0, + padding: tokens.spacingVerticalXXL, + overflow: 'auto', + backgroundColor: tokens.colorNeutralBackground2, + [NARROW_VIEWPORT_QUERY]: { + padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`, + }, + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: tokens.spacingVerticalM, + marginBottom: tokens.spacingVerticalXL, + }, + headerText: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + }, + actions: { + display: 'flex', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalS, + }, + action: { + [NARROW_VIEWPORT_QUERY]: { + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, + [TOUCH_INPUT_QUERY]: { + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, + }, + state: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: tokens.spacingVerticalM, + padding: tokens.spacingVerticalXXXL, + textAlign: 'center', + }, + error: { + color: tokens.colorPaletteRedForeground1, + }, + tableContainer: { + width: '100%', + overflowX: 'auto', + }, + table: { + minWidth: '780px', + backgroundColor: tokens.colorNeutralBackground1, + }, + nameCell: { + fontFamily: tokens.fontFamilyMonospace, + overflowWrap: 'anywhere', + }, + parameters: { + whiteSpace: 'pre-wrap', + overflowWrap: 'anywhere', + }, + typeList: { + display: 'flex', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalXS, + }, + deleteButton: { + color: tokens.colorPaletteRedForeground1, + }, +}) + +export const useCreateConverterDialogStyles = makeStyles({ + surface: { + width: 'min(560px, calc(100vw - 32px))', + maxWidth: '560px', + maxHeight: 'calc(100vh - 32px)', + }, + content: { + maxHeight: '70vh', + overflowY: 'auto', + }, + form: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + }, + typeDropdown: { + width: '100%', + }, + typeListbox: { + maxHeight: 'calc(100vh - 10rem)', + overflowY: 'auto', + }, + typeOption: { + display: 'flex', + flexDirection: 'column', + width: '100%', + minWidth: 0, + gap: tokens.spacingVerticalXXS, + whiteSpace: 'normal', + }, + typeOptionHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + minWidth: 0, + gap: tokens.spacingHorizontalS, + }, + typeDescription: { + color: tokens.colorNeutralForeground3, + }, + selectedTypeSummary: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + padding: tokens.spacingVerticalS, + borderRadius: tokens.borderRadiusMedium, + border: `1px solid ${tokens.colorNeutralStroke1}`, + backgroundColor: tokens.colorNeutralBackground2, + }, + selectedTypeHeader: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalXS, + }, + typeMetadata: { + color: tokens.colorNeutralForeground3, + }, + llmBadge: { + display: 'inline-block', + padding: `0 ${tokens.spacingHorizontalXXS}`, + borderRadius: tokens.borderRadiusSmall, + backgroundColor: tokens.colorPalettePurpleBackground2, + color: tokens.colorPalettePurpleForeground2, + fontSize: tokens.fontSizeBase100, + fontWeight: tokens.fontWeightSemibold as unknown as string, + }, + parameterGrid: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + }, + parameterRow: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + }, + fileRow: { + display: 'flex', + gap: tokens.spacingHorizontalS, + }, + fileInput: { + flex: 1, + }, + errorText: { + color: tokens.colorPaletteRedForeground1, + }, +}) diff --git a/frontend/src/components/Registry/RegistryLayout.test.tsx b/frontend/src/components/Registry/RegistryLayout.test.tsx new file mode 100644 index 0000000000..22675f8564 --- /dev/null +++ b/frontend/src/components/Registry/RegistryLayout.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { MemoryRouter, Route, Routes } from 'react-router' + +import RegistryLayout from './RegistryLayout' + +function renderLayout(initialPath = '/registry/targets') { + return render( + + + + }> + Target registry content
} /> + Converter registry content
} /> + + + + , + ) +} + +describe('RegistryLayout', () => { + it('shows target and converter registry tabs', () => { + renderLayout() + + expect(screen.getByRole('tab', { name: 'Targets' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: 'Converters' })).toHaveAttribute('aria-selected', 'false') + expect(screen.getByText('Target registry content')).toBeInTheDocument() + }) + + it('navigates between registry sections', async () => { + const user = userEvent.setup() + renderLayout() + + await user.click(screen.getByRole('tab', { name: 'Converters' })) + + expect(screen.getByRole('tab', { name: 'Converters' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByText('Converter registry content')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Registry/RegistryLayout.tsx b/frontend/src/components/Registry/RegistryLayout.tsx new file mode 100644 index 0000000000..e35e52fe0d --- /dev/null +++ b/frontend/src/components/Registry/RegistryLayout.tsx @@ -0,0 +1,28 @@ +import { Tab, TabList } from '@fluentui/react-components' +import { Outlet, useLocation, useNavigate } from 'react-router' + +import { useRegistryLayoutStyles } from './Registry.styles' + +export default function RegistryLayout() { + const styles = useRegistryLayoutStyles() + const location = useLocation() + const navigate = useNavigate() + const selectedTab = location.pathname.endsWith('/converters') ? 'converters' : 'targets' + + return ( +
+ navigate(`/registry/${String(data.value)}`)} + aria-label="Registry sections" + > + Targets + Converters + +
+ +
+
+ ) +} diff --git a/frontend/src/components/Scenarios/ScenarioDetail.test.tsx b/frontend/src/components/Scenarios/ScenarioDetail.test.tsx index 79070b17bc..b18b183c91 100644 --- a/frontend/src/components/Scenarios/ScenarioDetail.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioDetail.test.tsx @@ -249,7 +249,7 @@ describe('ScenarioDetail', () => { expect(await screen.findByTestId('scenario-target-select')).toBeInTheDocument() }) - it('estimates without a target and directs to Targets before launch', async () => { + it('estimates without a target and directs to the registry before launch', async () => { jest.useFakeTimers() const onNavigate = jest.fn() mockListTargets.mockResolvedValueOnce({ items: [], pagination: { limit: 200, has_more: false } }) @@ -270,7 +270,7 @@ describe('ScenarioDetail', () => { ) expect(within(screen.getByTestId('run-estimate')).getByText('8')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Configure target to launch' })) - expect(onNavigate).toHaveBeenCalledWith('targets') + expect(onNavigate).toHaveBeenCalledWith('registry') }) it('defaults the target selector to the active target when it is among the fetched targets', async () => { diff --git a/frontend/src/components/Scenarios/ScenarioDetail.tsx b/frontend/src/components/Scenarios/ScenarioDetail.tsx index 3ec49ec5bc..9391904f56 100644 --- a/frontend/src/components/Scenarios/ScenarioDetail.tsx +++ b/frontend/src/components/Scenarios/ScenarioDetail.tsx @@ -978,7 +978,7 @@ function ScenarioLaunchForm({ appearance="secondary" icon={} type="button" - onClick={() => onNavigate('targets')} + onClick={() => onNavigate('registry')} > Configure target to launch diff --git a/frontend/src/components/Sidebar/Navigation.test.tsx b/frontend/src/components/Sidebar/Navigation.test.tsx index abdf322011..023cb99d66 100644 --- a/frontend/src/components/Sidebar/Navigation.test.tsx +++ b/frontend/src/components/Sidebar/Navigation.test.tsx @@ -62,10 +62,10 @@ describe("Navigation", () => { expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument(); }); - it("renders the targets button", () => { + it("renders the registry button", () => { renderWithProvider(); expect( - screen.getByRole("button", { name: "Targets" }) + screen.getByRole("button", { name: "Registry" }) ).toBeInTheDocument(); }); @@ -80,15 +80,15 @@ describe("Navigation", () => { expect(onNavigate).toHaveBeenCalledWith("chat"); }); - it("calls onNavigate with 'targets' when targets button is clicked", async () => { + it("calls onNavigate with 'registry' when registry button is clicked", async () => { const user = userEvent.setup(); const onNavigate = jest.fn(); renderWithProvider( ); - await user.click(screen.getByRole("button", { name: "Targets" })); - expect(onNavigate).toHaveBeenCalledWith("targets"); + await user.click(screen.getByRole("button", { name: "Registry" })); + expect(onNavigate).toHaveBeenCalledWith("registry"); }); it("navigates to configuration", async () => { @@ -138,7 +138,7 @@ describe("Navigation", () => { "Chat", "History", "Scanner", - "Targets", + "Registry", "Configuration", ]); }); diff --git a/frontend/src/components/Sidebar/Navigation.tsx b/frontend/src/components/Sidebar/Navigation.tsx index aad5a8a970..f3a8a0596c 100644 --- a/frontend/src/components/Sidebar/Navigation.tsx +++ b/frontend/src/components/Sidebar/Navigation.tsx @@ -17,7 +17,6 @@ import { ScriptRegular, WeatherMoonRegular, WeatherSunnyRegular, - TargetRegular, } from '@fluentui/react-icons' import { useTheme } from '../../hooks/useTheme' import type { ThemeMode } from '../../hooks/useTheme' @@ -27,6 +26,8 @@ export type ViewName = | 'home' | 'chat' | 'history' + | 'registry' + // Kept as an internal compatibility destination for the unchanged chat pane. | 'targets' | 'configuration' | 'scenarios' @@ -119,13 +120,13 @@ export default function Navigation({