diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 60d96adc2f..22be7a91af 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -87,6 +87,8 @@ The export runs entirely in your browser and captures exactly what is shown in t The labels bar in the ribbon displays the current attack's labels (e.g., `operator`, `operation`). Labels are key-value pairs that help organize and filter attacks. You can add, edit, and remove labels inline. The `operator` and `operation` labels are required and cannot be removed. +Clicking the `operation` label opens a picker listing the operations already recorded in memory, so you can choose one without typing it from memory. Typing a name that doesn't exist yet offers to create it. Very long lists show the first 200 and say how many are left, so type to narrow them. The operation you pick is applied to attacks you start from then on; it does not change attacks that already exist. + #### Behavioral Guards CoPyRIT enforces several safety guards: diff --git a/frontend/e2e/labels-operation-picker.spec.ts b/frontend/e2e/labels-operation-picker.spec.ts new file mode 100644 index 0000000000..91369d467c --- /dev/null +++ b/frontend/e2e/labels-operation-picker.spec.ts @@ -0,0 +1,404 @@ +import { test, expect, type Page } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// The operation picker's size and placement are decided by Fluent's floating +// positioning at runtime. jsdom has no layout engine, so the unit suite cannot +// see any of it — several sizing regressions shipped past a green Jest run. +// These tests measure the rendered box in a real browser. +// --------------------------------------------------------------------------- + +const LIST_MAX_HEIGHT = 240; +const LONG_OPERATION = "op_2026_08_a_very_long_operation_name_that_would_be_clipped"; + +function operations(count: number): string[] { + return Array.from( + { length: count }, + (_, i) => `op_2026_08_run_${String(i).padStart(3, "0")}`, + ); +} + +async function setupMocks( + page: Page, + operationLabels: string[], + options: { versionDelayMs?: number; defaultLabels?: Record } = {}, +): Promise { + // Everything the app calls while booting, so the run does not depend on a + // dev-server proxy with no backend behind it. + await page.route(/\/api\//, async (route) => { + const path = new URL(route.request().url()).pathname.replace(/^\/api/, ""); + + if (path === "/health") { + return route.fulfill(json({ status: "healthy" })); + } + if (path === "/auth/config") { + return route.fulfill(json({ clientId: "", tenantId: "", allowedGroupIds: "" })); + } + if (path === "/version") { + if (options.versionDelayMs) { + await new Promise((resolve) => setTimeout(resolve, options.versionDelayMs)); + } + return route.fulfill(json({ + version: "picker-test", + display: "picker-test", + ...(options.defaultLabels ? { default_labels: options.defaultLabels } : {}), + })); + } + if (path === "/labels") { + return route.fulfill(json({ + source: "attacks", + labels: { operator: ["roakey"], operation: operationLabels }, + })); + } + if (path === "/attacks") { + return route.fulfill(json({ items: [], total: 0, limit: 5, offset: 0 })); + } + return route.fulfill(json({})); + }); +} + +function json(body: unknown) { + return { + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }; +} + +/** Opens the picker from the labels bar and returns the rendered listbox. */ +async function openOperationPicker(page: Page) { + await page.goto("/"); + const chip = page.getByTestId("label-operation"); + await expect(chip).toBeVisible(); + await chip.click(); + + const listbox = page.getByRole("listbox"); + await expect(listbox).toBeVisible(); + return listbox; +} + +test.describe("operation picker placement", () => { + test("caps the list height and anchors it to the input", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await setupMocks(page, operations(60)); + const listbox = await openOperationPicker(page); + + const box = (await listbox.boundingBox())!; + const input = (await page + .getByTestId("edit-label-operation") + .boundingBox())!; + + expect(box.height).toBeLessThanOrEqual(LIST_MAX_HEIGHT); + // Opens below the input and stays attached to it. + expect(box.y).toBeGreaterThanOrEqual(input.y + input.height); + expect(box.y - (input.y + input.height)).toBeLessThan(16); + + // The options that do not fit are reachable by scrolling, not lost. + const scroll = await listbox.evaluate((el) => ({ + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + })); + expect(scroll.scrollHeight).toBeGreaterThan(scroll.clientHeight); + }); + + test("keeps the list on screen when it opens above the input", async ({ + page, + }) => { + // Too little room below the labels bar, so Fluent flips the list upwards. + await page.setViewportSize({ width: 1280, height: 420 }); + await setupMocks(page, operations(60)); + const listbox = await openOperationPicker(page); + + const box = (await listbox.boundingBox())!; + const input = (await page + .getByTestId("edit-label-operation") + .boundingBox())!; + const viewport = page.viewportSize()!; + + expect(box.y).toBeLessThan(input.y); + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.y + box.height).toBeLessThanOrEqual(viewport.height); + }); + + test("keeps the whole editor inside the labels bar on a laptop screen", async ({ + page, + }) => { + // The card is narrowest just after the home grid splits into two columns, + // which is where an editor that cannot shrink loses its chevron. + await page.setViewportSize({ width: 1024, height: 800 }); + await setupMocks(page, ["op_alpha", "op_beta"]); + await openOperationPicker(page); + + // The input is sized inside the control, so measure the control itself — + // it is the part that carries the dropdown chevron. + const overhang = await page + .getByTestId("edit-label-operation") + .evaluate((input) => { + const control = input.parentElement!.getBoundingClientRect(); + const bar = input + .closest("[data-testid='labels-bar']")! + .getBoundingClientRect(); + return control.right - bar.right; + }); + + // Sub-pixel rounding is fine; a lost chevron is 27px. + expect(overhang).toBeLessThan(2); + }); + + test("sizes the list to its content so long names are not clipped", async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await setupMocks(page, [LONG_OPERATION, "op_short"]); + await openOperationPicker(page); + + const option = page.getByRole("option", { name: LONG_OPERATION }); + await expect(option).toBeVisible(); + + const overflow = await option.evaluate((el) => ({ + scrollWidth: el.scrollWidth, + clientWidth: el.clientWidth, + })); + expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth); + }); + + test("shrinks below the cap when the window is too short for it", async ({ + page, + }) => { + // Shorter than the 240px cap, so a flat cap would hang off the screen. + const viewportHeight = 200; + await page.setViewportSize({ width: 1280, height: viewportHeight }); + await setupMocks(page, operations(60)); + const listbox = await openOperationPicker(page); + + const box = (await listbox.boundingBox())!; + expect(box.height).toBeLessThan(LIST_MAX_HEIGHT); + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.y + box.height).toBeLessThanOrEqual(viewportHeight); + }); + + test("keeps the operation in use on the list, wherever it was chosen", async ({ + page, + }) => { + // The labels API only knows names that attacks have been stored under, so + // one chosen in the other labels bar — or before a refresh — is missing + // from this response. + await page.setViewportSize({ width: 1280, height: 800 }); + await page.addInitScript(() => { + window.localStorage.setItem( + "pyrit.globalLabels", + JSON.stringify({ operator: "roakey", operation: "op_chosen_elsewhere" }), + ); + }); + await setupMocks(page, ["op_alpha", "op_beta"]); + await openOperationPicker(page); + + await expect( + page.getByRole("option", { name: "op_chosen_elsewhere", exact: true }), + ).toBeVisible(); + + // ...and it must not offer to create the name that is already set. + await page.getByTestId("edit-label-operation").fill("op_chosen_elsewhere"); + await expect(page.getByRole("option", { name: /Create/ })).toHaveCount(0); + }); + + test("stays usable when memory holds far more operations than fit", async ({ + page, + }) => { + // The reason for the cap: Fluent renders every option as a real component, + // so an uncapped list stalls the tab. Measured before the cap, 50k options + // took ~27s for a single keystroke. + await page.setViewportSize({ width: 1280, height: 800 }); + await setupMocks(page, operations(600)); + const listbox = await openOperationPicker(page); + + await expect(listbox.getByRole("option").first()).toBeVisible(); + expect(await listbox.getByRole("option").count()).toBeLessThanOrEqual(210); + + // Typing has to stay responsive, which is the thing that was broken. + const started = Date.now(); + await page.getByTestId("edit-label-operation").fill("run_599"); + await expect( + page.getByRole("option", { name: "op_2026_08_run_599", exact: true }), + ).toBeVisible(); + expect(Date.now() - started).toBeLessThan(3000); + }); + + test("keeps the operation in use reachable past the end of a long list", async ({ + page, + }) => { + // The value in use goes to the front of the list. Cap the wrong end and it + // is the first thing to disappear — whether or not the request returned it. + await page.setViewportSize({ width: 1280, height: 800 }); + await page.addInitScript(() => { + window.localStorage.setItem( + "pyrit.globalLabels", + JSON.stringify({ operator: "roakey", operation: "op_chosen_elsewhere" }), + ); + }); + await setupMocks(page, operations(600)); + await openOperationPicker(page); + + const inUse = page.getByRole("option", { + name: "op_chosen_elsewhere", + exact: true, + }); + await expect(inUse).toBeVisible(); + await inUse.click(); + await expect(page.getByTestId("label-operation")).toContainText( + "op_chosen_elsewhere", + ); + }); + + test("keeps an operation the saved list already holds past the cap", async ({ + page, + }) => { + // The usual case: the operation in use is in the response, just not near + // the front of it. + const inUseName = "op_2026_08_run_400"; + await page.setViewportSize({ width: 1280, height: 800 }); + await page.addInitScript((name) => { + window.localStorage.setItem( + "pyrit.globalLabels", + JSON.stringify({ operator: "roakey", operation: name }), + ); + }, inUseName); + await setupMocks(page, operations(600)); + await openOperationPicker(page); + + await expect( + page.getByRole("option", { name: inUseName, exact: true }), + ).toHaveCount(1); + }); +}); + +test.describe("operation picker persistence", () => { + test("remembers the chosen operation across a refresh", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await setupMocks(page, ["op_alpha", "op_beta"]); + await openOperationPicker(page); + + await page.getByRole("option", { name: "op_beta", exact: true }).click(); + await expect(page.getByTestId("label-operation")).toContainText("op_beta"); + + await page.reload(); + + await expect(page.getByTestId("label-operation")).toContainText("op_beta"); + }); + + test("keeps an operation picked while the app was still starting up", async ({ + page, + }) => { + // The version request carries the backend's default labels and can land + // well after the bar is usable. + await page.setViewportSize({ width: 1280, height: 800 }); + await page.addInitScript(() => { + window.localStorage.setItem( + "pyrit.globalLabels", + JSON.stringify({ operator: "roakey", operation: "op_from_storage" }), + ); + }); + await setupMocks(page, ["op_from_storage", "op_picked_early"], { + versionDelayMs: 4000, + }); + await openOperationPicker(page); + + await page + .getByRole("option", { name: "op_picked_early", exact: true }) + .click(); + await expect(page.getByTestId("label-operation")).toContainText( + "op_picked_early", + ); + + // Let the slow response land; it must not undo the choice. + await page.waitForTimeout(5000); + await expect(page.getByTestId("label-operation")).toContainText( + "op_picked_early", + ); + }); + + test("keeps an operation picked before the backend's own default arrives", async ({ + page, + }) => { + // Nothing is stored, and the backend supplies its own `operation` default + // that lands after the bar is already usable. The only thing standing + // between the pick and that late response is that the value on screen is + // no longer the built-in placeholder. + await page.setViewportSize({ width: 1280, height: 800 }); + await setupMocks(page, ["op_alpha", "op_picked_early"], { + versionDelayMs: 4000, + defaultLabels: { operation: "op_configured" }, + }); + await openOperationPicker(page); + + await page + .getByRole("option", { name: "op_picked_early", exact: true }) + .click(); + await expect(page.getByTestId("label-operation")).toContainText( + "op_picked_early", + ); + + await page.waitForTimeout(5000); + await expect(page.getByTestId("label-operation")).toContainText( + "op_picked_early", + ); + // What is on screen is also what a refresh would restore. + expect( + await page.evaluate(() => + window.localStorage.getItem("pyrit.globalLabels"), + ), + ).toContain("op_picked_early"); + }); + + test("lets the backend still name the operator after you pick an operation", async ({ + page, + }) => { + // Picking an operation must not freeze the placeholder operator into + // storage, where it would outrank a deployment's configured default. + await page.setViewportSize({ width: 1280, height: 800 }); + await setupMocks(page, ["op_alpha", "op_beta"]); + await openOperationPicker(page); + + await page.getByRole("option", { name: "op_beta", exact: true }).click(); + await expect(page.getByTestId("label-operation")).toContainText("op_beta"); + + // A later visit, once the deployment configures an operator. + await page.unrouteAll({ behavior: "ignoreErrors" }); + await setupMocks(page, ["op_alpha", "op_beta"], { + defaultLabels: { operator: "configured_user" }, + }); + await page.reload(); + + await expect(page.getByTestId("label-operator")).toContainText( + "configured_user", + ); + await expect(page.getByTestId("label-operation")).toContainText("op_beta"); + }); + + test("lets the backend change a label it supplied, after you pick", async ({ + page, + }) => { + // The operator here came from the deployment's config, not from a choice, + // so picking an operation must not capture it as one. + await page.setViewportSize({ width: 1280, height: 800 }); + await setupMocks(page, ["op_alpha", "op_beta"], { + defaultLabels: { operator: "configured_day1" }, + }); + await openOperationPicker(page); + + await page.getByRole("option", { name: "op_beta", exact: true }).click(); + await expect(page.getByTestId("label-operator")).toContainText( + "configured_day1", + ); + + await page.unrouteAll({ behavior: "ignoreErrors" }); + await setupMocks(page, ["op_alpha", "op_beta"], { + defaultLabels: { operator: "configured_day2" }, + }); + await page.reload(); + + await expect(page.getByTestId("label-operator")).toContainText( + "configured_day2", + ); + await expect(page.getByTestId("label-operation")).toContainText("op_beta"); + }); +}); diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index bb8e88b004..cf730c7418 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -721,6 +721,48 @@ describe("App", () => { }); }); + it("prefers the labels you last picked over the backend defaults", async () => { + window.localStorage.setItem( + "pyrit.globalLabels", + JSON.stringify({ operator: "roakey", operation: "op_i_picked" }), + ); + mockedVersionApi.getVersion.mockResolvedValueOnce({ + version: "2.0.0", + default_labels: { operation: "op_from_backend", custom: "value" }, + }); + + renderApp(); + + await waitFor(() => { + const labels = screen.getByTestId("home-labels-json").textContent ?? ""; + expect(labels).toContain('"custom":"value"'); + }); + const labels = screen.getByTestId("home-labels-json").textContent ?? ""; + expect(labels).toContain('"operation":"op_i_picked"'); + }); + + it("still takes the operator from the signed-in account over a stored one", async () => { + window.localStorage.setItem( + "pyrit.globalLabels", + JSON.stringify({ operator: "stored_user", operation: "op_i_picked" }), + ); + mockGetActiveAccount.mockReturnValue({ username: "Real.User@contoso.com" }); + mockedVersionApi.getVersion.mockResolvedValueOnce({ + version: "2.0.0", + default_labels: { custom: "value" }, + }); + + renderApp(); + + await waitFor(() => { + const labels = screen.getByTestId("home-labels-json").textContent ?? ""; + expect(labels).toContain('"custom":"value"'); + }); + const labels = screen.getByTestId("home-labels-json").textContent ?? ""; + expect(labels).toContain('"operator":"real.user"'); + expect(labels).toContain('"operation":"op_i_picked"'); + }); + it("stores attack target when conversation is created with active target", () => { renderApp(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8e05c3af03..1d038c4e26 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ import { ErrorBoundary } from './components/ErrorBoundary' import { useAttackTargetResolution } from './hooks/useAttackTargetResolution' import { ConnectionHealthProvider, useConnectionHealth } from './hooks/useConnectionHealth' import { DEFAULT_GLOBAL_LABELS } from './components/Labels/labelDefaults' +import { readStoredGlobalLabels, persistGlobalLabels } from './components/Labels/labelStorage' import { filtersFromSearchParams, filtersToSearchParams } from './components/History/historyFilters' import type { ViewName } from './components/Sidebar/Navigation' import type { TargetInfo } from './types' @@ -105,7 +106,27 @@ function App() { const routeConversationId = conversationMatch?.params.conversationId ?? null const currentView: ViewName = routeAttackId !== null ? 'chat' : viewFromPath(location.pathname) - const [globalLabels, setGlobalLabels] = useState>({ ...DEFAULT_GLOBAL_LABELS }) + // Read once, before the effect below can overwrite what the user picked. + const [storedLabels] = useState(readStoredGlobalLabels) + const [globalLabels, setGlobalLabels] = useState>( + () => ({ ...DEFAULT_GLOBAL_LABELS, ...storedLabels }), + ) + + // What the app would show if the user had never touched anything: the + // built-in placeholders, then whatever the backend hands out. Only labels + // that differ from this are the user's own, and only those are worth + // keeping — otherwise a value that merely came from the config gets stored + // as a choice and outranks that same config from then on. + const unchosenLabels = useRef>({ ...DEFAULT_GLOBAL_LABELS }) + + const handleGlobalLabelsChange = useCallback((labels: Record) => { + setGlobalLabels(labels) + persistGlobalLabels( + Object.fromEntries( + Object.entries(labels).filter(([key, value]) => value !== unchosenLabels.current[key]), + ), + ) + }, []) // History filters live in the URL query string so they are shareable and // survive refresh. The breadcrumb ref remembers the last /history query so @@ -160,8 +181,25 @@ function App() { const account = instance.getActiveAccount?.() const alias = account?.username ? account.username.split('@')[0].toLowerCase() : null + unchosenLabels.current = { + ...DEFAULT_GLOBAL_LABELS, + ...defaultLabels, + ...(alias ? { operator: alias } : {}), + } + setGlobalLabels(prev => { - const next = { ...prev, ...defaultLabels } + const next = { ...prev } + for (const [key, value] of Object.entries(defaultLabels)) { + // These defaults only fill in what you have not chosen. `prev` + // already carries what was stored and anything picked while this + // request was in flight, so neither gets overwritten by a late + // response. + const untouched = prev[key] === DEFAULT_GLOBAL_LABELS[key] + if (!(key in storedLabels) && untouched) { + next[key] = value + } + } + // The signed-in account still decides who the operator is. if (alias) { next.operator = alias } @@ -171,7 +209,7 @@ function App() { initLabels() return () => { ignore = true } - }, [instance]) + }, [instance, storedLabels]) // Hydrate loadedAttack from the routed attack id. Depends on routeAttackId // ONLY, so switching conversations within an attack never refetches. @@ -351,7 +389,7 @@ function App() { onConversationCreated={handleConversationCreated} onSelectConversation={handleSelectConversation} labels={globalLabels} - onLabelsChange={setGlobalLabels} + onLabelsChange={handleGlobalLabelsChange} onNavigate={handleNavigate} attackLabels={readyAttack ? readyAttack.labels : null} attackTarget={readyAttack ? readyAttack.target : null} @@ -389,7 +427,7 @@ function App() { element={ { }) expect(screen.getByTestId('popover-label-extra')).toBeInTheDocument() }) + + describe('operation picker', () => { + const OPERATIONS = ['op_2026_07_grok_45', 'op_2026_08_probe', 'validate-button-test'] + + function renderWithOperations(onChange: jest.Mock, operations: string[] = OPERATIONS) { + mockedLabelsApi.getLabels.mockResolvedValue({ + source: 'attacks', + labels: { operation: operations, operator: ['alice'] }, + }) + render( + + + + ) + } + + it('should list every operation without clearing the current value first', async () => { + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + + expect(await screen.findByRole('option', { name: 'op_2026_08_probe' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'op_2026_07_grok_45' })).toBeInTheDocument() + const input = screen.getByTestId('edit-label-operation') as HTMLInputElement + expect(input.placeholder).toBe(DEFAULT_GLOBAL_LABELS.operation) + expect(input.value).toBe('') + }) + + it('should select an existing operation', async () => { + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(await screen.findByRole('option', { name: 'op_2026_08_probe' })) + + expect(onChange).toHaveBeenCalledWith({ + ...DEFAULT_GLOBAL_LABELS, + operation: 'op_2026_08_probe', + }) + }) + + it('should select an existing operation that predates the value rules', async () => { + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(await screen.findByRole('option', { name: 'validate-button-test' })) + + expect(onChange).toHaveBeenCalledWith({ + ...DEFAULT_GLOBAL_LABELS, + operation: 'validate-button-test', + }) + }) + + it('should filter the options by typed text', async () => { + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + await screen.findByRole('option', { name: 'op_2026_08_probe' }) + fireEvent.change(screen.getByTestId('edit-label-operation'), { target: { value: 'grok' } }) + + expect(await screen.findByRole('option', { name: 'op_2026_07_grok_45' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'op_2026_08_probe' })).not.toBeInTheDocument() + }) + + it('should create a new operation from typed text', async () => { + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + await screen.findByRole('option', { name: 'op_2026_08_probe' }) + fireEvent.change(screen.getByTestId('edit-label-operation'), { target: { value: 'op_2026_09_new' } }) + fireEvent.click(await screen.findByRole('option', { name: 'Create "op_2026_09_new"' })) + + expect(onChange).toHaveBeenCalledWith({ + ...DEFAULT_GLOBAL_LABELS, + operation: 'op_2026_09_new', + }) + }) + + it('should refuse to create a new operation that breaks the value rules', async () => { + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + await screen.findByRole('option', { name: 'op_2026_08_probe' }) + fireEvent.change(screen.getByTestId('edit-label-operation'), { target: { value: 'bad name!' } }) + + // The rules are stated while typing instead of offering a create that fails. + expect( + await screen.findByRole('option', { name: 'Only lowercase letters, numbers, underscores' }) + ).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'Create "bad name!"' })).not.toBeInTheDocument() + expect(onChange).not.toHaveBeenCalled() + }) + + it('should drop the rules note once the typed name becomes valid', async () => { + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + const input = await screen.findByTestId('edit-label-operation') + fireEvent.change(input, { target: { value: 'bad name!' } }) + await screen.findByRole('option', { name: 'Only lowercase letters, numbers, underscores' }) + + fireEvent.change(input, { target: { value: 'op_2026_09_ok' } }) + + expect(await screen.findByRole('option', { name: 'Create "op_2026_09_ok"' })).toBeInTheDocument() + expect( + screen.queryByRole('option', { name: 'Only lowercase letters, numbers, underscores' }) + ).not.toBeInTheDocument() + }) + + it('should commit the highlighted option with the keyboard', async () => { + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + const input = await screen.findByTestId('edit-label-operation') + // Narrow to a single option so the active option is unambiguous. + fireEvent.change(input, { target: { value: 'grok' } }) + await screen.findByRole('option', { name: 'op_2026_07_grok_45' }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(onChange).toHaveBeenCalledWith({ + ...DEFAULT_GLOBAL_LABELS, + operation: 'op_2026_07_grok_45', + }) + }) + + it('should dismiss the picker on Escape without committing', async () => { + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + const input = await screen.findByTestId('edit-label-operation') + fireEvent.keyDown(input, { key: 'Escape' }) + + await waitFor(() => { + expect(screen.queryByTestId('edit-label-operation')).not.toBeInTheDocument() + }) + expect(onChange).not.toHaveBeenCalled() + }) + + it('should offer creation when no operations exist yet', async () => { + const onChange = jest.fn() + renderWithOperations(onChange, []) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + expect(await screen.findByRole('option', { name: /type a name to create one/i })).toBeInTheDocument() + + fireEvent.change(screen.getByTestId('edit-label-operation'), { target: { value: 'op_first' } }) + fireEvent.click(await screen.findByRole('option', { name: 'Create "op_first"' })) + + expect(onChange).toHaveBeenCalledWith({ ...DEFAULT_GLOBAL_LABELS, operation: 'op_first' }) + }) + + it('should show a loading option while operations are still being fetched', async () => { + const onChange = jest.fn() + mockedLabelsApi.getLabels.mockImplementation(() => new Promise(() => {})) + render( + + + + ) + + fireEvent.click(screen.getByTestId('label-operation')) + + expect(await screen.findByRole('option', { name: /loading operations/i })).toBeInTheDocument() + }) + + it('should edit the operation from the popover list', async () => { + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('labels-icon-btn')) + fireEvent.click(await screen.findByTestId('popover-label-operation')) + + fireEvent.click(await screen.findByRole('option', { name: 'op_2026_08_probe' })) + + expect(onChange).toHaveBeenCalledWith({ + ...DEFAULT_GLOBAL_LABELS, + operation: 'op_2026_08_probe', + }) + }) + + it('should dismiss the picker when the user clicks away', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + await user.click(screen.getByTestId('label-operation')) + await screen.findByTestId('edit-label-operation') + await user.click(document.body) + + await waitFor(() => { + expect(screen.queryByTestId('edit-label-operation')).not.toBeInTheDocument() + }) + expect(onChange).not.toHaveBeenCalled() + }) + + it('should move focus into the picker so it can be driven by keyboard', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + // The chip must be a real, focusable control before it can be activated. + const chip = screen.getByTestId('label-operation') + expect(chip).toHaveAttribute('role', 'button') + expect(chip).toHaveAttribute('aria-label', expect.stringContaining(DEFAULT_GLOBAL_LABELS.operation)) + chip.focus() + expect(chip).toHaveFocus() + await user.keyboard('{Enter}') + + const input = await screen.findByTestId('edit-label-operation') + expect(await screen.findByRole('option', { name: 'op_2026_08_probe' })).toBeInTheDocument() + await waitFor(() => expect(input).toHaveFocus()) + }) + + it('should end the edit when the popover is dismissed', async () => { + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('labels-icon-btn')) + fireEvent.click(await screen.findByTestId('popover-label-operation')) + expect(await screen.findByTestId('edit-label-operation')).toBeInTheDocument() + + // Toggle the popover shut; the edit must not reappear on the inline chip. + fireEvent.click(screen.getByTestId('labels-icon-btn')) + + await waitFor(() => { + expect(screen.queryByTestId('edit-label-operation')).not.toBeInTheDocument() + }) + expect(screen.getByTestId('label-operation')).toBeInTheDocument() + }) + + it('should not commit an operation when the user tabs away', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.keyDown(screen.getByTestId('label-operation'), { key: 'Enter' }) + await screen.findByTestId('edit-label-operation') + await user.tab() + + expect(onChange).not.toHaveBeenCalled() + }) + + it('should let focus advance to the next control when tabbing away', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + mockedLabelsApi.getLabels.mockResolvedValue({ + source: 'attacks', + labels: { operation: OPERATIONS, operator: ['alice'] }, + }) + render( + + + + + ) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.keyDown(screen.getByTestId('label-operation'), { key: 'Enter' }) + await screen.findByTestId('edit-label-operation') + await user.tab() + + expect(onChange).not.toHaveBeenCalled() + await waitFor(() => expect(document.activeElement).not.toBe(document.body)) + }) + + it('should match existing operations regardless of their casing', async () => { + const onChange = jest.fn() + renderWithOperations(onChange, ['op_Legacy_Run']) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + const input = await screen.findByTestId('edit-label-operation') + + // A partial match still finds the differently-cased operation. + fireEvent.change(input, { target: { value: 'legacy' } }) + expect(await screen.findByRole('option', { name: 'op_Legacy_Run' })).toBeInTheDocument() + + // Typing its full name must not offer to create a case-duplicate. + fireEvent.change(input, { target: { value: 'op_legacy_run' } }) + expect(await screen.findByRole('option', { name: 'op_Legacy_Run' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'Create "op_legacy_run"' })).not.toBeInTheDocument() + }) + + it('should remove a custom label with the keyboard instead of starting an edit', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', labels: {} }) + render( + + + + ) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + screen.getByTestId('remove-label-team').focus() + await user.keyboard('{Enter}') + + expect(onChange).toHaveBeenCalledWith({ ...DEFAULT_GLOBAL_LABELS }) + expect(screen.queryByTestId('edit-label-team')).not.toBeInTheDocument() + }) + + it('should open the picker from the keyboard inside the popover', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('labels-icon-btn')) + const row = await screen.findByTestId('popover-label-operation') + expect(row).toHaveAttribute('role', 'button') + row.focus() + expect(row).toHaveFocus() + await user.keyboard(' ') + + expect(await screen.findByRole('option', { name: 'op_2026_08_probe' })).toBeInTheDocument() + }) + + it('should remove a custom label with the keyboard from the popover', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', labels: {} }) + render( + + + + ) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('labels-icon-btn')) + ;(await screen.findByTestId('popover-remove-label-team')).focus() + await user.keyboard('{Enter}') + + expect(onChange).toHaveBeenCalledWith({ ...DEFAULT_GLOBAL_LABELS }) + expect(screen.queryByTestId('edit-label-team')).not.toBeInTheDocument() + }) + + it('should say so when the operations could not be loaded', async () => { + const onChange = jest.fn() + mockedLabelsApi.getLabels.mockRejectedValue(new Error('boom')) + render( + + + + ) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + + expect( + await screen.findByRole('option', { name: /could not load existing operations/i }) + ).toBeInTheDocument() + expect(screen.queryByRole('option', { name: /no operations yet/i })).not.toBeInTheDocument() + }) + + it('should create a typed name with the keyboard', async () => { + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + const input = await screen.findByTestId('edit-label-operation') + fireEvent.change(input, { target: { value: 'op_2026_09_typed' } }) + await screen.findByRole('option', { name: 'Create "op_2026_09_typed"' }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(onChange).toHaveBeenCalledWith({ + ...DEFAULT_GLOBAL_LABELS, + operation: 'op_2026_09_typed', + }) + }) + + it('should keep a newly created operation in the list', async () => { + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.change(await screen.findByTestId('edit-label-operation'), { + target: { value: 'op_2026_09_fresh' }, + }) + fireEvent.click(await screen.findByRole('option', { name: 'Create "op_2026_09_fresh"' })) + + // Reopen: the name it just created has to still be selectable. + fireEvent.click(screen.getByTestId('label-operation')) + + expect(await screen.findByRole('option', { name: 'op_2026_09_fresh' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'Create "op_2026_09_fresh"' })).not.toBeInTheDocument() + }) + + it('should list the operation in use even when the saved list has not caught up', async () => { + // The labels bar in the ribbon and the one on Home each fetch their own + // list, so a name chosen in the other one is not in this response yet. + const onChange = jest.fn() + mockedLabelsApi.getLabels.mockResolvedValue({ + source: 'attacks', + labels: { operation: OPERATIONS, operator: ['alice'] }, + }) + render( + + + + ) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + + expect(await screen.findByRole('option', { name: 'op_chosen_elsewhere' })).toBeInTheDocument() + + // Typing it must not offer to create the name that is already set. + fireEvent.change(screen.getByTestId('edit-label-operation'), { + target: { value: 'op_chosen_elsewhere' }, + }) + expect( + screen.queryByRole('option', { name: 'Create "op_chosen_elsewhere"' }) + ).not.toBeInTheDocument() + }) + + it('should let you re-select the operation in use even if it breaks the naming rules', async () => { + // A legacy name can be in use without being in the labels API — from a + // config file, or a session where nothing was stored under it yet. + const onChange = jest.fn() + mockedLabelsApi.getLabels.mockResolvedValue({ + source: 'attacks', + labels: { operation: OPERATIONS, operator: ['alice'] }, + }) + render( + + + + ) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(await screen.findByRole('option', { name: 'legacy-op-name.2024' })) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ operation: 'legacy-op-name.2024' }) + ) + expect(screen.queryByText(/Only lowercase letters/)).not.toBeInTheDocument() + }) + + it('should not say there are no operations while showing the one in use', async () => { + const onChange = jest.fn() + mockedLabelsApi.getLabels.mockResolvedValue({ + source: 'attacks', + labels: { operation: [], operator: ['alice'] }, + }) + render( + + + + ) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + + expect(await screen.findByRole('option', { name: 'op_only_one' })).toBeInTheDocument() + expect(screen.queryByText(/No operations yet/)).not.toBeInTheDocument() + }) + + it('should keep saying the operations could not be loaded after one is created', async () => { + // A name created while the request was still in flight is a local + // value, not proof that the list arrived. + const onChange = jest.fn() + let rejectLabels: (reason: Error) => void = () => {} + mockedLabelsApi.getLabels.mockReturnValue( + new Promise((_resolve, reject) => { rejectLabels = reject }) + ) + render( + + + + ) + + fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.change(await screen.findByTestId('edit-label-operation'), { + target: { value: 'op_made_during_load' }, + }) + fireEvent.click(await screen.findByRole('option', { name: 'Create "op_made_during_load"' })) + + await act(async () => { + rejectLabels(new Error('boom')) + }) + + fireEvent.click(screen.getByTestId('label-operation')) + + expect( + await screen.findByRole('option', { name: /Could not load existing operations/ }) + ).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'op_made_during_load' })).toBeInTheDocument() + }) + + it('should still say the operations could not be loaded when one is already set', async () => { + // The value in use is listed, but that must not read as a loaded list. + const onChange = jest.fn() + mockedLabelsApi.getLabels.mockRejectedValue(new Error('boom')) + render( + + + + ) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + + expect( + await screen.findByRole('option', { name: /Could not load existing operations/ }) + ).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'op_already_set' })).toBeInTheDocument() + }) + + it('should keep the operation in use on the list when the list is capped', async () => { + // The value in use is put at the front of whatever the API returned, so + // a cap applied to the end of the list is exactly what would drop it. + const onChange = jest.fn() + const many = Array.from({ length: 250 }, (_, i) => `op_2026_08_run_${String(i).padStart(4, '0')}`) + mockedLabelsApi.getLabels.mockResolvedValue({ + source: 'attacks', + labels: { operation: many, operator: ['alice'] }, + }) + render( + + + + ) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + + const inUse = await screen.findByRole('option', { name: 'op_chosen_elsewhere' }) + expect(inUse).toBeInTheDocument() + + // And it is still selectable, not just present. + fireEvent.click(inUse) + expect(onChange).toHaveBeenCalledWith({ + ...DEFAULT_GLOBAL_LABELS, + operation: 'op_chosen_elsewhere', + }) + }) + + it('should keep the operation in use on a capped list that already contains it', async () => { + // The saved list usually does contain the operation in use, and it can + // sit anywhere in it — including past the cap. + const onChange = jest.fn() + const many = Array.from({ length: 250 }, (_, i) => `op_2026_08_run_${String(i).padStart(4, '0')}`) + mockedLabelsApi.getLabels.mockResolvedValue({ + source: 'attacks', + labels: { operation: many, operator: ['alice'] }, + }) + render( + + + + ) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + + // Listed once, not twice, even though it is also in the saved list. + expect(await screen.findAllByRole('option', { name: 'op_2026_08_run_0240' })).toHaveLength(1) + expect(screen.getByText('Showing 200 of 250 — type to narrow')).toBeInTheDocument() + }) + + it('should keep a name typed in full on a capped list', async () => { + // Every decoy contains the typed name, so the exact match sorts last and + // the cap would hide it — leaving Enter to commit a different operation. + const onChange = jest.fn() + const decoys = Array.from({ length: 250 }, (_, i) => `op_2026_08_run_042_${String(i).padStart(3, '0')}`) + renderWithOperations(onChange, [...decoys, 'run_042'].sort()) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.change(await screen.findByTestId('edit-label-operation'), { + target: { value: 'run_042' }, + }) + + const exact = await screen.findByRole('option', { name: 'run_042' }) + expect(exact).toBeInTheDocument() + // It is not offered for creation, because it already exists. + expect(screen.queryByRole('option', { name: 'Create "run_042"' })).not.toBeInTheDocument() + + fireEvent.click(exact) + expect(onChange).toHaveBeenCalledWith({ + ...DEFAULT_GLOBAL_LABELS, + operation: 'run_042', + }) + }) + + it('should show only the first page of a long list and say so', async () => { + const onChange = jest.fn() + const many = Array.from({ length: 250 }, (_, i) => `op_2026_08_run_${String(i).padStart(4, '0')}`) + renderWithOperations(onChange, many) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + await screen.findByRole('option', { name: 'op_2026_08_run_0000' }) + + expect(screen.getAllByRole('option')).toHaveLength(201) + expect(screen.getByText('Showing 200 of 250 — type to narrow')).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'op_2026_08_run_0249' })).not.toBeInTheDocument() + + // Typing narrows it below the cap, and then the note goes away. + fireEvent.change(screen.getByTestId('edit-label-operation'), { + target: { value: 'run_024' }, + }) + expect(await screen.findByRole('option', { name: 'op_2026_08_run_0249' })).toBeInTheDocument() + expect(screen.queryByText(/type to narrow/)).not.toBeInTheDocument() + }) + + it('should not offer the cap note as something to choose', async () => { + const onChange = jest.fn() + const many = Array.from({ length: 250 }, (_, i) => `op_2026_08_run_${String(i).padStart(4, '0')}`) + renderWithOperations(onChange, many) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + const note = await screen.findByRole('option', { name: /type to narrow/ }) + + expect(note).toHaveAttribute('aria-disabled', 'true') + fireEvent.click(note) + expect(onChange).not.toHaveBeenCalled() + }) + + it('should keep saying the operations could not be loaded while a name is typed', async () => { + // The note answers "why is this list empty"; typing does not answer it. + const onChange = jest.fn() + mockedLabelsApi.getLabels.mockRejectedValue(new Error('boom')) + render( + + + + ) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + const input = await screen.findByTestId('edit-label-operation') + + fireEvent.change(input, { target: { value: 'op_2026_09_typed' } }) + expect(await screen.findByRole('option', { name: 'Create "op_2026_09_typed"' })).toBeInTheDocument() + expect( + screen.getByRole('option', { name: /Could not load existing operations/ }) + ).toBeInTheDocument() + + fireEvent.change(input, { target: { value: 'op bad' } }) + expect(await screen.findByText(/Only lowercase letters/)).toBeInTheDocument() + expect( + screen.getByRole('option', { name: /Could not load existing operations/ }) + ).toBeInTheDocument() + }) + + it('should not offer a status note as something to choose', async () => { + // The notes share the option list with real values, so they have to be + // unselectable or one of them becomes the operation. + const onChange = jest.fn() + mockedLabelsApi.getLabels.mockRejectedValue(new Error('boom')) + render( + + + + ) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + const failed = await screen.findByRole('option', { + name: /Could not load existing operations/, + }) + expect(failed).toHaveAttribute('aria-disabled', 'true') + + fireEvent.change(await screen.findByTestId('edit-label-operation'), { + target: { value: 'op bad' }, + }) + const invalid = await screen.findByRole('option', { name: /Only lowercase letters/ }) + expect(invalid).toHaveAttribute('aria-disabled', 'true') + + fireEvent.click(failed) + fireEvent.click(invalid) + expect(onChange).not.toHaveBeenCalled() + }) + + it('should not say there are no operations while offering to create one', async () => { + const onChange = jest.fn() + mockedLabelsApi.getLabels.mockResolvedValue({ + source: 'attacks', + labels: { operation: [], operator: ['alice'] }, + }) + render( + + + + ) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operation')) + const input = await screen.findByTestId('edit-label-operation') + expect(await screen.findByText(/No operations yet/)).toBeInTheDocument() + + fireEvent.change(input, { target: { value: 'op_2026_09_first' } }) + expect(await screen.findByRole('option', { name: 'Create "op_2026_09_first"' })).toBeInTheDocument() + expect(screen.queryByText(/No operations yet/)).not.toBeInTheDocument() + + // Same while the typed name is one that cannot be created. + fireEvent.change(input, { target: { value: 'op bad' } }) + expect(await screen.findByText(/Only lowercase letters/)).toBeInTheDocument() + expect(screen.queryByText(/No operations yet/)).not.toBeInTheDocument() + }) + + it('should keep an operation created while the list was still loading', async () => { + const onChange = jest.fn() + let resolveLabels: (value: { source: string; labels: Record }) => void = () => {} + mockedLabelsApi.getLabels.mockReturnValue( + new Promise(resolve => { resolveLabels = resolve }) + ) + render( + + + + ) + + fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.change(await screen.findByTestId('edit-label-operation'), { + target: { value: 'op_made_while_loading' }, + }) + fireEvent.click(await screen.findByRole('option', { name: 'Create "op_made_while_loading"' })) + + // The response was in flight and cannot know about the name just created. + await act(async () => { + resolveLabels({ source: 'attacks', labels: { operation: ['op_from_server'] } }) + }) + + fireEvent.click(screen.getByTestId('label-operation')) + + expect(await screen.findByRole('option', { name: 'op_made_while_loading' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'op_from_server' })).toBeInTheDocument() + }) + + it('should keep the plain input for labels other than operation', async () => { + const onChange = jest.fn() + renderWithOperations(onChange) + await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('label-operator')) + + expect(await screen.findByTestId('edit-label-operator')).toBeInTheDocument() + expect(screen.queryByRole('option')).not.toBeInTheDocument() + }) + }) + }) diff --git a/frontend/src/components/Labels/LabelsBar.tsx b/frontend/src/components/Labels/LabelsBar.tsx index 5e6ba4408a..bdcd67aeaf 100644 --- a/frontend/src/components/Labels/LabelsBar.tsx +++ b/frontend/src/components/Labels/LabelsBar.tsx @@ -4,6 +4,8 @@ import { Button, Input, Badge, + Combobox, + Option, Tooltip, Popover, PopoverTrigger, @@ -18,16 +20,154 @@ import { labelsApi } from '../../services/api' import { useLabelsBarStyles } from './LabelsBar.styles' +const validateValue = (value: string): string | null => { + if (!value) return 'Value is required' + if (value !== value.toLowerCase()) return 'Values must be lowercase' + if (!/^[a-z0-9_]+$/.test(value)) return 'Only lowercase letters, numbers, underscores' + return null +} + const DUMMY_VALUES: Record = { operator: 'roakey', operation: 'op_trash_panda', } +// Fluent's listbox renders every option as a real component, so a long list +// stalls opening and typing. Past this many, you narrow the list by typing. +const MAX_LISTED = 200 + interface LabelsBarProps { labels: Record onLabelsChange: (labels: Record) => void } +interface OperationPickerProps { + currentValue: string + options: string[] + isLoading: boolean + loadFailed: boolean + onSelect: (operation: string) => void + onSearchChange: () => void + onDismiss: () => void + inputRef: React.Ref + className?: string + listboxClassName?: string + noteClassName?: string + noteErrorClassName?: string +} + +/** + * Picker for the `operation` label. Opens with every known operation listed so + * a value can be chosen without typing, and accepts a new name via freeform entry. + * The search text starts empty — seeding it with the current value would filter + * the list down to nothing. + */ +function OperationPicker({ + currentValue, + options, + isLoading, + loadFailed, + onSelect, + onSearchChange, + onDismiss, + inputRef, + className, + listboxClassName, + noteClassName, + noteErrorClassName, +}: OperationPickerProps) { + const [search, setSearch] = useState('') + + // Each labels bar fetches its own list, and the popover and ribbon mount + // separately, so a name created a moment ago may not be in `options` here. + // List it anyway, or the picker offers to create the value already in use. + // It goes first, whether or not the request returned it, so the cap below + // can never be what drops it. + // The placeholder is not a real operation, so it stays off the list. + const listed = useMemo(() => { + const inUse = currentValue && currentValue !== DUMMY_VALUES.operation + return inUse ? [currentValue, ...options.filter(option => option !== currentValue)] : options + }, [options, currentValue]) + + const matches = search ? listed.filter(option => option.toLowerCase().includes(search)) : listed + const isNewName = search.length > 0 && !listed.some(option => option.toLowerCase() === search) + // Say why a name can't be created while it is being typed, rather than + // rejecting it after the fact next to a bar that clips the message. + const searchError = isNewName ? validateValue(search) : null + const canCreate = isNewName && !searchError + + // A name typed in full has to survive the cap too. Without this, typing an + // operation whose name is also a substring of two hundred others would leave + // it off the list, and Enter would commit whichever one happened to be first. + const shown = useMemo(() => { + const exact = matches.find(option => option.toLowerCase() === search) + const ordered = exact ? [exact, ...matches.filter(option => option !== exact)] : matches + return ordered.slice(0, MAX_LISTED) + }, [matches, search]) + + // Deferred so focus lands on whatever the user moved to before this unmounts. + const dismissAfterFocusMoves = () => { setTimeout(onDismiss, 0) } + + return ( + { setSearch(e.target.value.toLowerCase()); onSearchChange() }} + onOptionSelect={(_, data) => { if (data.optionValue) onSelect(data.optionValue) }} + onKeyDownCapture={e => { + // Fluent commits the active option on Tab. Block that, but let the key + // through so focus still moves; onBlur then ends the edit. + if (e.key === 'Tab') e.stopPropagation() + }} + onKeyDown={e => { if (e.key === 'Escape') onDismiss() }} + onBlur={dismissAfterFocusMoves} + // Fluent sizes the dropdown to the input, which cuts off longer + // operation names, and stretches it to fill the space it has. Size to + // content instead, and leave the height to the listbox class. + positioning={{ matchTargetSize: undefined, autoSize: 'width' }} + listbox={{ className: listboxClassName }} + aria-label="Operation" + data-testid="edit-label-operation" + > + {isLoading && ( + + )} + {!isLoading && loadFailed && ( + + )} + {!isLoading && !loadFailed && listed.length === 0 && !canCreate && !searchError && ( + + )} + {shown.map(option => ( + + ))} + {matches.length > MAX_LISTED && ( + + )} + {canCreate && ( + + )} + {searchError && ( + + )} + + ) +} + export default function LabelsBar({ labels, onLabelsChange }: LabelsBarProps) { const styles = useLabelsBarStyles() const [isPopoverOpen, setIsPopoverOpen] = useState(false) @@ -37,13 +177,21 @@ export default function LabelsBar({ labels, onLabelsChange }: LabelsBarProps) { const [editValue, setEditValue] = useState('') const [error, setError] = useState('') const [existingLabels, setExistingLabels] = useState>({}) + const [labelsLoading, setLabelsLoading] = useState(true) + const [labelsFailed, setLabelsFailed] = useState(false) const editInputRef = useRef(null) // Fetch existing label keys/values for suggestions useEffect(() => { labelsApi.getLabels() - .then(resp => setExistingLabels(resp.labels)) - .catch(() => { /* ignore */ }) + // A name created while this was in flight is not in the response yet, + // so keep anything already collected rather than replacing outright. + .then(resp => setExistingLabels(prev => ({ + ...resp.labels, + operation: [...new Set([...(resp.labels.operation || []), ...(prev.operation || [])])], + }))) + .catch(() => setLabelsFailed(true)) + .finally(() => setLabelsLoading(false)) }, []) const isDummyValue = useCallback((key: string, value: string): boolean => { @@ -60,13 +208,6 @@ export default function LabelsBar({ labels, onLabelsChange }: LabelsBarProps) { return null } - const validateValue = (value: string): string | null => { - if (!value) return 'Value is required' - if (value !== value.toLowerCase()) return 'Values must be lowercase' - if (!/^[a-z0-9_]+$/.test(value)) return 'Only lowercase letters, numbers, underscores' - return null - } - const handleAddLabel = () => { const keyError = validateKey(newKey) if (keyError) { setError(keyError); return } @@ -95,6 +236,15 @@ export default function LabelsBar({ labels, onLabelsChange }: LabelsBarProps) { setTimeout(() => editInputRef.current?.focus(), 50) } + const handleStartEditKeyDown = (e: React.KeyboardEvent, key: string) => { + // Let focusable children (the remove button) handle their own keys. + if (e.target !== e.currentTarget) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleStartEdit(key) + } + } + const handleSaveEdit = () => { if (!editingLabel) return const valueError = validateValue(editValue) @@ -110,6 +260,34 @@ export default function LabelsBar({ labels, onLabelsChange }: LabelsBarProps) { if (e.key === 'Escape') { setEditingLabel(null); setError('') } } + const handleCancelEdit = () => { + setEditingLabel(null) + setEditValue('') + setError('') + } + + const handleSelectOperation = (operation: string) => { + const known = existingLabels.operation || [] + // Values already in memory predate the current rules, and so may the one + // already in use, so both are always selectable; only a newly typed name + // has to satisfy them. + const inUse = operation === labels.operation + if (!known.includes(operation) && !inUse) { + const valueError = validateValue(operation) + if (valueError) { setError(valueError); return } + // A name only reaches the labels API once an attack has been stored under + // it, so keep it listed here or the picker forgets what it just created. + setExistingLabels(prev => ({ + ...prev, + operation: [...(prev.operation || []), operation], + })) + } + onLabelsChange({ ...labels, operation }) + setEditingLabel(null) + setEditValue('') + setError('') + } + const handleAddKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') handleAddLabel() if (e.key === 'Escape') setIsPopoverOpen(false) @@ -189,42 +367,87 @@ export default function LabelsBar({ labels, onLabelsChange }: LabelsBarProps) { return () => observer.disconnect() }, [labelEntries]) + const renderValueEditor = (key: string, value: string) => { + if (key === 'operation') { + return ( + <> + {key}: + setError('')} + onDismiss={handleCancelEdit} + inputRef={editInputRef} + /> + {error && {error}} + + ) + } + + const filteredSuggestions = suggestedValues + .filter(v => v !== value && v.includes(editValue)) + .slice(0, 8) + return ( + <> + {key}: + { setEditValue(d.value.toLowerCase()); setError('') }} + onKeyDown={handleEditKeyDown} + onBlur={() => { setTimeout(handleSaveEdit, 150) }} + style={{ width: '120px' }} + data-testid={`edit-label-${key}`} + /> + {error && {error}} + {filteredSuggestions.length > 0 && ( +
+ {filteredSuggestions.map(v => ( + { onLabelsChange({ ...labels, [key]: v }); setEditingLabel(null); setEditValue('') }} + >{v} + ))} +
+ )} + + ) + } + const renderLabelBadge = (key: string, value: string, idx: number) => { const isDummy = isDummyValue(key, value) const isRequired = key === 'operator' || key === 'operation' - const isEditing = editingLabel === key + // The popover renders its own editor, so only one is mounted at a time. + const isEditing = editingLabel === key && !isPopoverOpen if (isEditing) { - const filteredSuggestions = suggestedValues - .filter(v => v !== value && v.includes(editValue)) - .slice(0, 8) + // The picker is wider than a plain input, so let its row give way rather + // than push the control past the edge the bar clips at. + const canShrink = key === 'operation' return ( -
- {key}: - { setEditValue(d.value.toLowerCase()); setError('') }} - onKeyDown={handleEditKeyDown} - onBlur={() => { setTimeout(handleSaveEdit, 150) }} - style={{ width: '120px' }} - data-testid={`edit-label-${key}`} - /> - {error && {error}} - {filteredSuggestions.length > 0 && ( -
- {filteredSuggestions.map(v => ( - { onLabelsChange({ ...labels, [key]: v }); setEditingLabel(null); setEditValue('') }} - >{v} - ))} -
- )} +
+ {renderValueEditor(key, value)}
) } @@ -239,6 +462,10 @@ export default function LabelsBar({ labels, onLabelsChange }: LabelsBarProps) { data-label-idx={idx} className={`${styles.labelBadge} ${isDummy ? styles.labelDummy : styles.labelNormal}`} onClick={() => handleStartEdit(key)} + onKeyDown={e => handleStartEditKeyDown(e, key)} + role="button" + tabIndex={0} + aria-label={`Edit ${key} label, currently ${value}`} data-testid={`label-${key}`} style={{ flexShrink: 0 }} > @@ -264,11 +491,22 @@ export default function LabelsBar({ labels, onLabelsChange }: LabelsBarProps) { {labelEntries.map(([key, value]) => { const isDummy = isDummyValue(key, value) const isRequired = key === 'operator' || key === 'operation' + if (editingLabel === key) { + return ( +
+ {renderValueEditor(key, value)} +
+ ) + } return (
handleStartEdit(key)} + onKeyDown={e => handleStartEditKeyDown(e, key)} + role="button" + tabIndex={0} + aria-label={`Edit ${key} label, currently ${value}`} data-testid={`popover-label-${key}`} style={{ flexShrink: 0 }} > @@ -352,7 +590,7 @@ export default function LabelsBar({ labels, onLabelsChange }: LabelsBarProps) {
)} - {error && {error}} + {error && !editingLabel && {error}} ) @@ -394,7 +632,7 @@ export default function LabelsBar({ labels, onLabelsChange }: LabelsBarProps) { — so even when every chip fits, this is still the canonical entry point for editing/adding labels. */} - { setIsPopoverOpen(d.open); setError('') }}> + { setIsPopoverOpen(d.open); setError(''); if (!d.open) setEditingLabel(null) }}> { + beforeEach(() => { + window.localStorage.clear() + jest.restoreAllMocks() + }) + + it('should round-trip the labels it was given', () => { + persistGlobalLabels({ operator: 'alice', operation: 'op_2026_08_probe' }) + + expect(readStoredGlobalLabels()).toEqual({ operator: 'alice', operation: 'op_2026_08_probe' }) + }) + + it('should read nothing when nothing has been stored', () => { + expect(readStoredGlobalLabels()).toEqual({}) + }) + + it('should ignore stored values that are not text', () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ operation: 'op_good', bad: 12, worse: null, worst: { a: 1 } }), + ) + + expect(readStoredGlobalLabels()).toEqual({ operation: 'op_good' }) + }) + + it.each([ + ['unparseable text', 'not json at all'], + ['a list', '["op_a"]'], + ['a bare string', '"op_a"'], + ['null', 'null'], + ])('should read nothing when storage holds %s', (_label, stored) => { + window.localStorage.setItem(STORAGE_KEY, stored) + + expect(readStoredGlobalLabels()).toEqual({}) + }) + + it('should read nothing when storage cannot be read', () => { + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('denied') + }) + + expect(readStoredGlobalLabels()).toEqual({}) + }) + + it('should not throw when storage cannot be written', () => { + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota exceeded') + }) + + expect(() => persistGlobalLabels({ operation: 'op_a' })).not.toThrow() + }) +}) diff --git a/frontend/src/components/Labels/labelStorage.ts b/frontend/src/components/Labels/labelStorage.ts new file mode 100644 index 0000000000..6801799fec --- /dev/null +++ b/frontend/src/components/Labels/labelStorage.ts @@ -0,0 +1,32 @@ +const STORAGE_KEY = 'pyrit.globalLabels' + +/** + * The labels the user last chose, so a refresh does not quietly put the next + * run back on the placeholder operation. Only string values are kept, so a + * hand-edited or half-written entry cannot reach the rest of the app. + */ +export function readStoredGlobalLabels(): Record { + if (typeof window === 'undefined') return {} + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return {} + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} + return Object.fromEntries( + Object.entries(parsed as Record).filter( + ([, value]) => typeof value === 'string', + ), + ) as Record + } catch { + return {} + } +} + +export function persistGlobalLabels(labels: Record): void { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(labels)) + } catch { + /* localStorage may be unavailable (private mode, quota, sandboxed iframe). */ + } +}