diff --git a/frontend/tests/unit/components/workspace/input-box-empty-skills.dom.test.tsx b/frontend/tests/unit/components/workspace/input-box-empty-skills.dom.test.tsx new file mode 100644 index 000000000..8378f6004 --- /dev/null +++ b/frontend/tests/unit/components/workspace/input-box-empty-skills.dom.test.tsx @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; + +import { PromptInputProvider } from "@/components/ai-elements/prompt-input"; +import { InputBox } from "@/components/workspace/input-box"; +import { ThreadContext } from "@/components/workspace/messages/context"; +import { AuthProvider } from "@/core/auth/AuthProvider"; +import { DEFAULT_LOCALE } from "@/core/i18n"; +import { I18nProvider } from "@/core/i18n/context"; + +rs.mock("next/navigation", () => ({ + useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }), + usePathname: () => "/workspace", + useSearchParams: () => new URLSearchParams(), +})); + +// The composer's model selector is irrelevant to skill-suggestion alignment; +// keep the react-query + network machinery out of the way entirely. +rs.mock("@/core/models/hooks", () => ({ + useModels: () => ({ + models: [], + tokenUsageEnabled: false, + isLoading: false, + isFetching: false, + error: null, + refetch: rs.fn(), + }), +})); + +// The server-side resource-level filter (GET /api/skills filtered by +// filter_resources(principal, "skill", ...)) is what produces an empty +// catalog for a denied role. Pin the composer wiring against that shape: +// useSkills returning [] must degrade to a builtin-only dropdown, never a +// broken or fully-vanishing catalog. +rs.mock("@/core/skills/hooks", () => ({ + useSkills: () => ({ + skills: [], + isLoading: false, + error: null, + }), +})); + +function renderComposer() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const tree: ReactNode = ( + + + + + + + + + + + + ); + return render(tree); +} + +function getComposerTextarea(container: HTMLElement): HTMLTextAreaElement { + const textarea = container.querySelector("textarea"); + if (!(textarea instanceof HTMLTextAreaElement)) { + throw new Error("composer textarea not rendered"); + } + return textarea; +} + +function typeSlashQuery( + container: HTMLElement, + value: string, +): HTMLTextAreaElement { + const textarea = getComposerTextarea(container); + fireEvent.focus(textarea); + fireEvent.change(textarea, { target: { value } }); + return textarea; +} + +afterEach(() => { + rs.restoreAllMocks(); + cleanup(); +}); + +describe("InputBox skill suggestions with an empty (denied) catalog", () => { + it("offers the builtin commands when '/' is typed with zero visible skills", () => { + const { container } = renderComposer(); + + typeSlashQuery(container, "/"); + + // The dropdown survives the empty catalog and offers exactly the builtin + // commands (/goal, /compact) — no phantom skill rows, no crash. + const listbox = screen.getByRole("listbox", { + name: "Skill suggestions", + }); + const options = Array.from(listbox.querySelectorAll('[role="option"]')); + const names = options.map((option) => option.textContent ?? ""); + expect(names.some((name) => name.includes("/goal"))).toBe(true); + expect(names.some((name) => name.includes("/compact"))).toBe(true); + expect(names.every((name) => !name.includes("/data-analysis"))).toBe(true); + }); + + it("hides the dropdown when an empty catalog matches nothing more specifically", () => { + const { container } = renderComposer(); + + // No builtin matches "nosuch" and the catalog is empty, so the dropdown + // must be absent — same silent behavior as an unmatched query today. + typeSlashQuery(container, "/nosuch"); + + expect( + screen.queryByRole("listbox", { name: "Skill suggestions" }), + ).toBeNull(); + }); +}); diff --git a/frontend/tests/unit/components/workspace/input-box-helpers.test.ts b/frontend/tests/unit/components/workspace/input-box-helpers.test.ts index 3529cc7d8..893e1ffac 100644 --- a/frontend/tests/unit/components/workspace/input-box-helpers.test.ts +++ b/frontend/tests/unit/components/workspace/input-box-helpers.test.ts @@ -322,6 +322,22 @@ describe("filterSkillsForAgent", () => { }); describe("getMatchingSkillSuggestions", () => { + it("offers only builtin commands when the catalog is empty (denied role)", () => { + // A role whose `skills` policy allows nothing gets an empty catalog from + // GET /api/skills (resource-level listing filter); the composer must + // still offer the builtin commands rather than lose the whole dropdown. + const result = getMatchingSkillSuggestions([], "", builtins); + + expect(result.map((s) => `${s.kind}:${s.name}`)).toEqual([ + "builtin:goal", + "builtin:new", + ]); + }); + + it("returns an empty list when an empty catalog matches nothing", () => { + expect(getMatchingSkillSuggestions([], "deep", builtins)).toEqual([]); + }); + it("excludes disabled skills and ranks prefix matches first", () => { const skills = [ makeSkill("deep-research"),