Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@/components/ui/tooltip";
import { axe } from "@/utils/a11y-test";
import OutputModal from "../index";

// Lighthouse flagged two real violations on this modal:
// 1. button-name — the copy button was icon-only with no aria-label.
// 2. aria-valid-attr-value — the active TabsTrigger's aria-controls pointed
// at a TabsContent id that never existed, because this modal drove its
// panel content from a sibling <SwitchOutputView> instead of real
// TabsContent elements. Fixed by pairing each TabsTrigger with a real
// TabsContent (Radix's expected trigger/content contract), matching the
// pattern in components/ui/__tests__/tabs.a11y.test.tsx.
//
// SwitchOutputView is mocked here — its internals (data grids, JSON views,
// text views) are unrelated to the two ARIA fixes under test and drag in
// heavy dependencies; it has no bearing on the modal's own tab/button ARIA.
jest.mock("../components/switchOutputView", () => ({
__esModule: true,
default: ({ type }: { type: string }) => (
<div data-testid={`switch-output-view-${type}`}>{type} content</div>
),
}));

// The global jest.setup.js mock for genericIconComponent only stubs the
// default export, not the named ForwardedIconComponent OutputModal uses.
jest.mock("@/components/common/genericIconComponent", () => ({
__esModule: true,
default: () => null,
ForwardedIconComponent: ({ name }: { name?: string }) => (
<span data-testid={`icon-${name}`} aria-hidden="true" />
),
}));

const mockFlowPool = {
"node-1": [
{
data: {
outputs: { output_name: { message: "output content" } },
logs: { output_name: { message: "log content" } },
},
},
],
};

jest.mock("@/stores/flowStore", () => ({
__esModule: true,
default: (selector: (state: { flowPool: typeof mockFlowPool }) => unknown) =>
selector({ flowPool: mockFlowPool }),
}));

const mockSetSuccessData = jest.fn();
jest.mock("@/stores/alertStore", () => ({
__esModule: true,
default: (
selector: (state: { setSuccessData: typeof mockSetSuccessData }) => unknown,
) => selector({ setSuccessData: mockSetSuccessData }),
}));

// userEvent.setup() installs its own navigator.clipboard stub, replacing
// any predefined one — so the writeText spy must be created after setup().
const spyOnWriteText = () =>
jest.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined);

const renderModal = () =>
render(
<TooltipProvider>
<OutputModal
nodeId="node-1"
outputName="output_name"
disabled={false}
open={true}
setOpen={jest.fn()}
>
<button type="button">Open</button>
</OutputModal>
</TooltipProvider>,
);

describe("OutputModal accessibility", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("should_have_no_axe_violations", async () => {
const { container } = renderModal();

expect(await axe(container)).toHaveNoViolations();
});

it("names the icon-only copy button after the active tab's content", async () => {
const user = userEvent.setup();
renderModal();

expect(screen.getByTestId("copy-output-button")).toHaveAccessibleName(
"Copy output",
);

await user.click(screen.getByRole("tab", { name: "Logs" }));

expect(screen.getByTestId("copy-output-button")).toHaveAccessibleName(
"Copy logs",
);
});

it("gives the active tab trigger a valid aria-controls reference", () => {
renderModal();

const outputsTab = screen.getByRole("tab", { name: "Outputs" });
const controlsId = outputsTab.getAttribute("aria-controls");
expect(controlsId).toBeTruthy();
expect(document.getElementById(controlsId as string)).toBeInTheDocument();
});

// Regression guard: the pill-shaped tab switcher must stay visually
// isolated (absolute/overflow-hidden) to the trigger buttons only. Content
// was briefly nested inside that same constrained box while wiring up
// TabsContent, which clipped the output panel instead of letting it fill
// the modal.
it("does not trap the tab content panel inside the switcher's constrained box", () => {
renderModal();

const panel = screen.getByRole("tabpanel");
expect(panel.className).not.toMatch(/\boverflow-hidden\b/);
expect(panel.className).not.toMatch(/\babsolute\b/);
});

// Regression guard: TabsList's own base class includes w-full. That was
// previously inert (only the outer pill div's shrink-to-fit width
// mattered), but once TabsList itself became the absolutely positioned
// element, an unoverridden w-full stretches the switcher to the full
// modal width instead of staying pill-sized.
it("keeps the tab switcher pill-sized instead of stretching full width", () => {
renderModal();

const tablist = screen.getByRole("tablist");
expect(tablist.className).not.toMatch(/\bw-full\b/);
});

// Regression guard: TabsList clips overflow, so a default outset browser
// focus outline on TabsTrigger gets cut off and keyboard focus becomes
// invisible. Each trigger needs its own inset focus-visible ring, which
// renders within the trigger's box and can't be clipped by the ancestor.
it("gives each tab trigger a visible, non-clippable focus-visible ring", () => {
renderModal();

for (const tab of screen.getAllByRole("tab")) {
expect(tab.className).toMatch(/\bfocus-visible:ring-inset\b/);
expect(tab.className).toMatch(/\bfocus-visible:ring-2\b/);
}
});
});

describe("OutputModal behavior parity", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("defaults to the Outputs tab content", () => {
renderModal();

expect(
screen.getByTestId("switch-output-view-outputs"),
).toBeInTheDocument();
expect(
screen.queryByTestId("switch-output-view-logs"),
).not.toBeInTheDocument();
});

it("switches panel content when the Logs tab is selected", async () => {
const user = userEvent.setup();
renderModal();

await user.click(screen.getByRole("tab", { name: "Logs" }));

expect(screen.getByTestId("switch-output-view-logs")).toBeInTheDocument();
expect(
screen.queryByTestId("switch-output-view-outputs"),
).not.toBeInTheDocument();
});

it("copies the active tab's content, defaulting to outputs", async () => {
const user = userEvent.setup();
const writeText = spyOnWriteText();
renderModal();

await user.click(screen.getByTestId("copy-output-button"));

expect(writeText).toHaveBeenCalledWith("output content");
});

it("copies the logs content after switching to the Logs tab", async () => {
const user = userEvent.setup();
const writeText = spyOnWriteText();
renderModal();

await user.click(screen.getByRole("tab", { name: "Logs" }));
await user.click(screen.getByTestId("copy-output-button"));

expect(writeText).toHaveBeenCalledWith("log content");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import {
import { Case } from "../../../../../../shared/components/caseComponent";
import TextOutputView from "../../../../../../shared/components/textOutputView";
import useFlowStore from "../../../../../../stores/flowStore";
import type { OutputModalTab } from "../../index";
import ErrorOutput from "./components";

// Define the props type
interface SwitchOutputViewProps {
nodeId: string;
outputName: string;
type: "Outputs" | "Logs";
type: OutputModalTab;
}

const SwitchOutputView: React.FC<SwitchOutputViewProps> = ({
Expand Down Expand Up @@ -48,7 +49,7 @@ const SwitchOutputView: React.FC<SwitchOutputViewProps> = ({
(outputConfig.types && outputConfig.types.includes("Tool")));

const results: OutputLogType | LogsLogType[] =
(type === "Outputs"
(type === "outputs"
? flowPoolNode?.data?.outputs?.[outputName]
: flowPoolNode?.data?.logs?.[outputName]) ?? {};
const resultType = Array.isArray(results) ? undefined : results?.type;
Expand Down Expand Up @@ -132,7 +133,7 @@ const SwitchOutputView: React.FC<SwitchOutputViewProps> = ({
);
};

return type === "Outputs" ? (
return type === "outputs" ? (
<>
<Case condition={isToolOutput && resultMessageMemoized}>
<ToolOutputDisplay
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ForwardedIconComponent } from "@/components/common/genericIconComponent";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import useAlertStore from "@/stores/alertStore";
import useFlowStore from "@/stores/flowStore";
import BaseModal from "../../../../modals/baseModal";
import SwitchOutputView from "./components/switchOutputView";

export type OutputModalTab = "outputs" | "logs";

export default function OutputModal({
nodeId,
outputName,
Expand All @@ -17,7 +19,7 @@ export default function OutputModal({
setOpen,
}): JSX.Element {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"Outputs" | "Logs">("Outputs");
const [activeTab, setActiveTab] = useState<OutputModalTab>("outputs");
const [isCopied, setIsCopied] = useState(false);
const flowPool = useFlowStore((state) => state.flowPool);
const setSuccessData = useAlertStore((state) => state.setSuccessData);
Expand All @@ -28,13 +30,14 @@ export default function OutputModal({
];

const results =
activeTab === "Outputs"
activeTab === "outputs"
? flowPoolNode?.data?.outputs?.[outputName]
: flowPoolNode?.data?.logs?.[outputName];

if (!results) return "";

let content = results.message ?? results;
let content =
!Array.isArray(results) && results.message ? results.message : results;
content = content?.raw ?? content;

return typeof content === "string"
Expand Down Expand Up @@ -77,6 +80,11 @@ export default function OutputModal({
className="absolute right-12 top-2 p-2"
onClick={handleCopy}
data-testid="copy-output-button"
aria-label={
activeTab === "outputs"
? t("output.copyOutputAria")
: t("output.copyLogsAria")
}
>
<ForwardedIconComponent
name={isCopied ? "Check" : "Copy"}
Expand All @@ -87,24 +95,38 @@ export default function OutputModal({
<BaseModal.Content>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as "Outputs" | "Logs")}
className={
"absolute top-6 flex flex-col self-center overflow-hidden rounded-md border bg-muted text-center"
}
onValueChange={(value) => setActiveTab(value as OutputModalTab)}
className="flex h-full w-full flex-1 flex-col"
>
<TabsList>
<TabsTrigger value="Outputs">
<TabsList className="absolute top-6 flex w-fit self-center overflow-hidden rounded-md border bg-muted text-center">
<TabsTrigger
value="outputs"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
{t("misc.outputsModalTitle")}
</TabsTrigger>
<TabsTrigger value="Logs">{t("modal.logs")}</TabsTrigger>
<TabsTrigger
value="logs"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
{t("modal.logs")}
</TabsTrigger>
</TabsList>
<TabsContent value="outputs" className="mt-0 flex-1 overflow-auto">
<SwitchOutputView
nodeId={nodeId}
outputName={outputName}
type="outputs"
/>
</TabsContent>
<TabsContent value="logs" className="mt-0 flex-1 overflow-auto">
<SwitchOutputView
nodeId={nodeId}
outputName={outputName}
type="logs"
/>
</TabsContent>
</Tabs>

<SwitchOutputView
nodeId={nodeId}
outputName={outputName}
type={activeTab}
/>
</BaseModal.Content>
<BaseModal.Footer close></BaseModal.Footer>
<BaseModal.Trigger asChild>{children}</BaseModal.Trigger>
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1681,6 +1681,8 @@
"noteNode.emptyPlaceholder": "Doppelklicken Sie, um mit der Eingabe zu beginnen, oder geben Sie Markdown ein...",
"noteNode.pickColor": "Farbe auswählen",
"output.componentOutput": "Komponentenausgang",
"output.copyOutputAria": "Ausgabe kopieren",
"output.copyLogsAria": "Protokolle kopieren",
"output.csvError": "Fehler beim Laden von CSV",
"output.csvNoData": "Keine Daten verfügbar",
"output.csvTitle": "CSV Ausgabe",
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1696,6 +1696,8 @@
"output.noToolsAvailable": "No tools available",
"output.noOutput": "NO OUTPUT",
"output.componentOutput": "Component Output",
"output.copyOutputAria": "Copy output",
"output.copyLogsAria": "Copy logs",
"modal.sessionLogs": "Session logs",
"modal.flowDetails": "Flow Details",
"modal.viewText": "View Text",
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1681,6 +1681,8 @@
"noteNode.emptyPlaceholder": "Haz doble clic para empezar a escribir o introduce código Markdown...",
"noteNode.pickColor": "Elige un color",
"output.componentOutput": "Salida de componentes",
"output.copyOutputAria": "Copiar salida",
"output.copyLogsAria": "Copiar registros",
"output.csvError": "Error al cargar CSV",
"output.csvNoData": "No hay datos disponibles",
"output.csvTitle": "CSV resultado",
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1681,6 +1681,8 @@
"noteNode.emptyPlaceholder": "Double-cliquez pour commencer à taper ou entrez du code Markdown...",
"noteNode.pickColor": "Choisissez une couleur",
"output.componentOutput": "Sortie composante",
"output.copyOutputAria": "Copier la sortie",
"output.copyLogsAria": "Copier les journaux",
"output.csvError": "Erreur lors du chargement de CSV",
"output.csvNoData": "Aucune donnée disponible",
"output.csvTitle": "CSV résultat",
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/src/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -1681,6 +1681,8 @@
"noteNode.emptyPlaceholder": "ダブルクリックして入力を開始するか、Markdownを入力してください...",
"noteNode.pickColor": "色を選ぶ",
"output.componentOutput": "コンポーネント出力",
"output.copyOutputAria": "出力をコピー",
"output.copyLogsAria": "ログをコピー",
"output.csvError": "CSV の読み込みに失敗しました",
"output.csvNoData": "使用可能なデータがありません",
"output.csvTitle": "CSV 出力",
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/src/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -1681,6 +1681,8 @@
"noteNode.emptyPlaceholder": "Clique duas vezes para começar a digitar ou insira Markdown...",
"noteNode.pickColor": "Escolha a cor",
"output.componentOutput": "Saída do componente",
"output.copyOutputAria": "Copiar saída",
"output.copyLogsAria": "Copiar logs",
"output.csvError": "Erro ao carregar CSV",
"output.csvNoData": "Nenhum dado disponível",
"output.csvTitle": "CSV saída",
Expand Down
Loading
Loading