From 0f685e1a9246ab0e4b74a8f95f1636e425a8010b Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Wed, 25 Mar 2026 14:16:41 -0700 Subject: [PATCH] chore: Artifact Visualization Tests --- .../ArtifactVisualizer.test.tsx | 296 ++++++++++++++++++ .../ArtifactVisualizer/CsvVisualizer.test.tsx | 161 ++++++++++ .../ImageVisualizer.test.tsx | 22 ++ .../JsonVisualizer.test.tsx | 105 +++++++ .../ParquetVisualizer.test.tsx | 153 +++++++++ .../TableVisualizer.test.tsx | 87 +++++ .../TextVisualizer.test.tsx | 103 ++++++ .../IOSection/IOCell/IOCell.test.tsx | 215 +++++++++++++ 8 files changed, 1142 insertions(+) create mode 100644 src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ArtifactVisualizer.test.tsx create mode 100644 src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/CsvVisualizer.test.tsx create mode 100644 src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ImageVisualizer.test.tsx create mode 100644 src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/JsonVisualizer.test.tsx create mode 100644 src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ParquetVisualizer.test.tsx create mode 100644 src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/TableVisualizer.test.tsx create mode 100644 src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/TextVisualizer.test.tsx create mode 100644 src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/IOCell.test.tsx diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ArtifactVisualizer.test.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ArtifactVisualizer.test.tsx new file mode 100644 index 000000000..591cf9307 --- /dev/null +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ArtifactVisualizer.test.tsx @@ -0,0 +1,296 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ArtifactNodeResponse } from "@/api/types.gen"; + +import ArtifactVisualizer from "./ArtifactVisualizer"; + +vi.mock("@/providers/BackendProvider", () => ({ + useBackend: () => ({ backendUrl: "http://localhost:8000" }), +})); + +vi.mock("@/services/executionService", () => ({ + getArtifactSignedUrl: vi.fn().mockResolvedValue({ + signed_url: "https://storage.example.com/signed", + }), +})); + +vi.mock("./TextVisualizer", () => ({ + TextVisualizerValue: ({ value }: { value: string }) => ( +
+ ), + TextVisualizerRemote: ({ signedUrl }: { signedUrl: string }) => ( +
+ ), +})); + +vi.mock("./ImageVisualizer", () => ({ + default: ({ src, name }: { src: string; name: string }) => ( +
+ ), +})); + +vi.mock("./CsvVisualizer", () => ({ + CsvVisualizerValue: ({ value }: { value: string }) => ( +
+ ), + CsvVisualizerRemote: ({ signedUrl }: { signedUrl: string }) => ( +
+ ), +})); + +vi.mock("./JsonVisualizer", () => ({ + JsonVisualizerValue: ({ value, name }: { value: string; name: string }) => ( +
+ ), + JsonVisualizerRemote: ({ + signedUrl, + name, + }: { + signedUrl: string; + name: string; + }) => ( +
+ ), +})); + +vi.mock("./ParquetVisualizer", () => ({ + default: ({ signedUrl }: { signedUrl: string }) => ( +
+ ), +})); + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +const renderWithQuery = (ui: React.ReactElement) => + render({ui}); + +const makeArtifact = ( + overrides?: Partial, +): ArtifactNodeResponse => ({ + id: "artifact-1", + artifact_data: { total_size: 1024, is_dir: false }, + ...overrides, +}); + +beforeEach(() => { + queryClient.clear(); +}); + +describe("ArtifactVisualizer", () => { + describe("trigger button", () => { + it("renders Eye + Preview button when no value is provided", () => { + renderWithQuery( + , + ); + + expect(screen.getByText("Preview")).toBeInTheDocument(); + }); + + it("renders Maximize2 button when value is provided", () => { + renderWithQuery( + , + ); + + expect(screen.queryByText("Preview")).not.toBeInTheDocument(); + // The maximize button should be present (ghost button without text) + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + }); + + it("returns null for non-visualizable types", () => { + const { container } = renderWithQuery( + , + ); + + expect(container.innerHTML).toBe(""); + }); + + describe("inline value rendering", () => { + it("renders TextVisualizerValue for text type", async () => { + renderWithQuery( + , + ); + + await userEvent.click(screen.getByRole("button")); + + await waitFor(() => { + const viz = screen.getByTestId("text-visualizer"); + expect(viz).toHaveAttribute("data-value", "hello"); + }); + }); + + it("renders CsvVisualizerValue for csv type", async () => { + renderWithQuery( + , + ); + + await userEvent.click(screen.getByRole("button")); + + await waitFor(() => { + const viz = screen.getByTestId("csv-visualizer"); + expect(viz).toHaveAttribute("data-value", "a,b\n1,2"); + }); + }); + + it("renders CsvVisualizerValue for tsv type", async () => { + renderWithQuery( + , + ); + + await userEvent.click(screen.getByRole("button")); + + await waitFor(() => { + const viz = screen.getByTestId("csv-visualizer"); + expect(viz).toHaveAttribute("data-value", "a\tb\n1\t2"); + }); + }); + + it("renders JsonVisualizerValue for jsonobject type", async () => { + renderWithQuery( + , + ); + + await userEvent.click(screen.getByRole("button")); + + await waitFor(() => { + const viz = screen.getByTestId("json-visualizer"); + expect(viz).toHaveAttribute("data-value", '{"key":"val"}'); + }); + }); + }); + + describe("signed URL rendering", () => { + it("renders TextVisualizerRemote with signedUrl for text type", async () => { + renderWithQuery( + , + ); + + await userEvent.click(screen.getByText("Preview")); + + await waitFor(() => { + const viz = screen.getByTestId("text-visualizer"); + expect(viz).toHaveAttribute( + "data-signed-url", + "https://storage.example.com/signed", + ); + }); + }); + + it("renders ImageVisualizer for image type", async () => { + renderWithQuery( + , + ); + + await userEvent.click(screen.getByText("Preview")); + + await waitFor(() => { + expect(screen.getByTestId("image-visualizer")).toBeInTheDocument(); + }); + }); + + it("renders ParquetVisualizer for apacheparquet type", async () => { + renderWithQuery( + , + ); + + await userEvent.click(screen.getByText("Preview")); + + await waitFor(() => { + expect(screen.getByTestId("parquet-visualizer")).toBeInTheDocument(); + }); + }); + }); + + describe("dialog header", () => { + it("shows artifact name and type in dialog", async () => { + renderWithQuery( + , + ); + + await userEvent.click(screen.getByRole("button")); + + await waitFor(() => { + expect(screen.getByText("my-output")).toBeInTheDocument(); + expect(screen.getByText("CSV")).toBeInTheDocument(); + }); + }); + + it("shows artifact URI when available", async () => { + renderWithQuery( + , + ); + + await userEvent.click(screen.getByRole("button")); + + await waitFor(() => { + expect(screen.getByText("Copy URI")).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/CsvVisualizer.test.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/CsvVisualizer.test.tsx new file mode 100644 index 000000000..4f2f0b0c5 --- /dev/null +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/CsvVisualizer.test.tsx @@ -0,0 +1,161 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { type ReactElement, Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { CsvVisualizerRemote, CsvVisualizerValue } from "./CsvVisualizer"; + +vi.mock("./TableVisualizer", () => ({ + default: ({ + data, + isFullscreen, + }: { + data: { headers: string[]; rows: string[][] }; + isFullscreen: boolean; + }) => ( +
+ ), +})); + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +const renderWithQuery = (ui: ReactElement) => + render({ui}); + +const renderWithSuspense = (ui: ReactElement) => + render( + + ( +
+ {error instanceof Error ? error.message : "Unknown error"} +
+ )} + > + Loading
}> + {ui} + + + , + ); + +beforeEach(() => { + queryClient.clear(); +}); + +describe("CsvVisualizerValue", () => { + it("parses CSV and renders TableVisualizer", () => { + renderWithQuery( + , + ); + + const table = screen.getByTestId("table-visualizer"); + expect(table).toHaveAttribute("data-headers", "Name,Age"); + expect(table).toHaveAttribute("data-row-count", "2"); + }); + + it("parses TSV with tab delimiter", () => { + renderWithQuery( + , + ); + + const table = screen.getByTestId("table-visualizer"); + expect(table).toHaveAttribute("data-headers", "Name,Age"); + }); + + it("shows 'No data' for empty CSV", () => { + renderWithQuery(); + expect(screen.getByText("No data")).toBeInTheDocument(); + }); + + it("does not fetch", () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + renderWithQuery( + , + ); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); + +describe("CsvVisualizerRemote", () => { + it("fetches and renders CSV data", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + text: () => Promise.resolve("X,Y\n1,2\n3,4"), + } as Response); + + renderWithSuspense( + , + ); + + await waitFor(() => { + const table = screen.getByTestId("table-visualizer"); + expect(table).toHaveAttribute("data-headers", "X,Y"); + expect(table).toHaveAttribute("data-row-count", "2"); + }); + + vi.restoreAllMocks(); + }); + + it("renders error boundary on fetch failure", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status: 403, + } as Response); + + renderWithSuspense( + , + ); + + await waitFor(() => { + expect(screen.getByTestId("error")).toBeInTheDocument(); + expect(screen.getByText(/Failed to fetch artifact/)).toBeInTheDocument(); + }); + + vi.restoreAllMocks(); + }); + + it("passes isFullscreen to TableVisualizer", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + text: () => Promise.resolve("A,B\n1,2"), + } as Response); + + renderWithSuspense( + , + ); + + await waitFor(() => { + expect(screen.getByTestId("table-visualizer")).toHaveAttribute( + "data-fullscreen", + "true", + ); + }); + + vi.restoreAllMocks(); + }); +}); diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ImageVisualizer.test.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ImageVisualizer.test.tsx new file mode 100644 index 000000000..472cd78dc --- /dev/null +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ImageVisualizer.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import ImageVisualizer from "./ImageVisualizer"; + +describe("ImageVisualizer", () => { + it("renders an image with the correct src and alt", () => { + render( + , + ); + const img = screen.getByRole("img", { name: "test-image" }); + expect(img).toHaveAttribute("src", "https://example.com/image.png"); + }); + + it("applies object-contain styling", () => { + render( + , + ); + const img = screen.getByRole("img"); + expect(img).toHaveClass("object-contain"); + }); +}); diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/JsonVisualizer.test.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/JsonVisualizer.test.tsx new file mode 100644 index 000000000..09e9a2606 --- /dev/null +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/JsonVisualizer.test.tsx @@ -0,0 +1,105 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { type ReactElement, Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { JsonVisualizerRemote, JsonVisualizerValue } from "./JsonVisualizer"; + +vi.mock("../IOCodeViewer", () => ({ + default: ({ title, value }: { title: string; value: string }) => ( +
+ {value} +
+ ), +})); + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +const renderWithQuery = (ui: ReactElement) => + render({ui}); + +const renderWithSuspense = (ui: ReactElement) => + render( + + ( +
+ {error instanceof Error ? error.message : "Unknown error"} +
+ )} + > + Loading
}> + {ui} + + + , + ); + +beforeEach(() => { + queryClient.clear(); +}); + +describe("JsonVisualizerValue", () => { + it("renders the JSON value via IOCodeViewer", () => { + const json = '{"key": "value"}'; + renderWithQuery(); + + const viewer = screen.getByTestId("io-code-viewer"); + expect(viewer).toHaveAttribute("data-title", "output.json"); + expect(viewer).toHaveTextContent(json); + }); + + it("does not fetch", () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + renderWithQuery(); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); + +describe("JsonVisualizerRemote", () => { + it("renders fetched JSON content", async () => { + const json = '{"fetched": true}'; + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + text: () => Promise.resolve(json), + } as Response); + + renderWithSuspense( + , + ); + + await waitFor(() => { + expect(screen.getByTestId("io-code-viewer")).toHaveTextContent(json); + }); + + vi.restoreAllMocks(); + }); + + it("renders error boundary on fetch failure", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status: 500, + } as Response); + + renderWithSuspense( + , + ); + + await waitFor(() => { + expect(screen.getByTestId("error")).toBeInTheDocument(); + expect(screen.getByText(/Failed to fetch artifact/)).toBeInTheDocument(); + }); + + vi.restoreAllMocks(); + }); +}); diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ParquetVisualizer.test.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ParquetVisualizer.test.tsx new file mode 100644 index 000000000..36a29c89d --- /dev/null +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ParquetVisualizer.test.tsx @@ -0,0 +1,153 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { type ReactElement, Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import ParquetVisualizer from "./ParquetVisualizer"; + +vi.mock("hyparquet", () => ({ + parquetReadObjects: vi.fn(), +})); + +vi.mock("./TableVisualizer", () => ({ + default: ({ + data, + isFullscreen, + }: { + data: { headers: string[]; rows: string[][] }; + isFullscreen: boolean; + }) => ( +
+ ), +})); + +const { parquetReadObjects } = await import("hyparquet"); + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +const renderWithSuspense = (ui: ReactElement) => + render( + + ( +
+ {error instanceof Error ? error.message : "Unknown error"} +
+ )} + > + Loading
}> + {ui} + + + , + ); + +beforeEach(() => { + queryClient.clear(); +}); + +describe("ParquetVisualizer", () => { + it("fetches, parses parquet, and renders TableVisualizer", async () => { + const buffer = new ArrayBuffer(8); + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(buffer), + } as Response); + + vi.mocked(parquetReadObjects).mockResolvedValue([ + { name: "Alice", score: 100 }, + { name: "Bob", score: 90 }, + ]); + + renderWithSuspense( + , + ); + + await waitFor(() => { + const table = screen.getByTestId("table-visualizer"); + expect(table).toHaveAttribute("data-headers", "name,score"); + expect(table).toHaveAttribute("data-row-count", "2"); + }); + + vi.restoreAllMocks(); + }); + + it("renders error boundary on fetch failure", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status: 500, + } as Response); + + renderWithSuspense( + , + ); + + await waitFor(() => { + expect(screen.getByTestId("error")).toBeInTheDocument(); + expect(screen.getByText(/Failed to fetch artifact/)).toBeInTheDocument(); + }); + + vi.restoreAllMocks(); + }); + + it("shows 'No data' for empty parquet files", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + } as Response); + + vi.mocked(parquetReadObjects).mockResolvedValue([]); + + renderWithSuspense( + , + ); + + await waitFor(() => { + expect(screen.getByText("No data")).toBeInTheDocument(); + }); + + vi.restoreAllMocks(); + }); + + it("passes isFullscreen to TableVisualizer", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + } as Response); + + vi.mocked(parquetReadObjects).mockResolvedValue([{ col: "val" }]); + + renderWithSuspense( + , + ); + + await waitFor(() => { + expect(screen.getByTestId("table-visualizer")).toHaveAttribute( + "data-fullscreen", + "true", + ); + }); + + vi.restoreAllMocks(); + }); +}); diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/TableVisualizer.test.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/TableVisualizer.test.tsx new file mode 100644 index 000000000..b24bca2b7 --- /dev/null +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/TableVisualizer.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import TableVisualizer from "./TableVisualizer"; +import type { ArtifactTableData } from "./utils"; + +const makeData = (rowCount: number): ArtifactTableData => ({ + headers: ["Name", "Score"], + rows: Array.from({ length: rowCount }, (_, i) => [ + `row-${i}`, + String(i * 10), + ]), +}); + +describe("TableVisualizer", () => { + it("renders headers and rows", () => { + const data = makeData(3); + render(); + + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Score")).toBeInTheDocument(); + expect(screen.getByText("row-0")).toBeInTheDocument(); + expect(screen.getByText("row-2")).toBeInTheDocument(); + }); + + it("limits rows to DEFAULT_PREVIEW_ROWS (10) when not fullscreen", () => { + const data = makeData(20); + render(); + + expect(screen.getByText("row-9")).toBeInTheDocument(); + expect(screen.queryByText("row-10")).not.toBeInTheDocument(); + expect(screen.getByText("Showing first 10 rows")).toBeInTheDocument(); + }); + + it("shows up to MAX_PREVIEW_ROWS (30) when fullscreen", () => { + const data = makeData(35); + render(); + + expect(screen.getByText("row-29")).toBeInTheDocument(); + expect(screen.queryByText("row-30")).not.toBeInTheDocument(); + expect(screen.getByText("Showing first 30 rows")).toBeInTheDocument(); + }); + + it("shows 'Showing all N rows' when all rows fit", () => { + const data = makeData(5); + render(); + + expect(screen.getByText("Showing all 5 rows")).toBeInTheDocument(); + }); + + it("renders 'See all' link when remoteLink is provided and rows are truncated", () => { + const data = makeData(20); + render( + , + ); + + const link = screen.getByRole("link", { name: "See all" }); + expect(link).toHaveAttribute( + "href", + "https://storage.example.com/file.csv", + ); + }); + + it("does not render 'See all' link when remoteLink is not provided", () => { + const data = makeData(20); + render(); + + expect(screen.queryByText("See all")).not.toBeInTheDocument(); + }); + + it("does not render 'See all' link when all rows are shown", () => { + const data = makeData(3); + render( + , + ); + + expect(screen.queryByText("See all")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/TextVisualizer.test.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/TextVisualizer.test.tsx new file mode 100644 index 000000000..e003109a2 --- /dev/null +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/TextVisualizer.test.tsx @@ -0,0 +1,103 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { type ReactElement, Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { TextVisualizerRemote, TextVisualizerValue } from "./TextVisualizer"; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +const renderWithQuery = (ui: ReactElement) => + render({ui}); + +const renderWithSuspense = (ui: ReactElement) => + render( + + ( +
+ {error instanceof Error ? error.message : "Unknown error"} +
+ )} + > + Loading
}> + {ui} + + + , + ); + +beforeEach(() => { + queryClient.clear(); +}); + +describe("TextVisualizerValue", () => { + it("renders the value directly", () => { + renderWithQuery(); + expect(screen.getByText("Hello world")).toBeInTheDocument(); + }); + + it("does not fetch", () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + renderWithQuery(); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); + +describe("TextVisualizerRemote", () => { + it("renders fetched text content", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + text: () => Promise.resolve("Remote content"), + } as Response); + + renderWithSuspense( + , + ); + + await waitFor(() => { + expect(screen.getByText("Remote content")).toBeInTheDocument(); + }); + + vi.restoreAllMocks(); + }); + + it("renders error boundary on fetch failure", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status: 404, + } as Response); + + renderWithSuspense( + , + ); + + await waitFor(() => { + expect(screen.getByTestId("error")).toBeInTheDocument(); + expect(screen.getByText(/Failed to fetch artifact/)).toBeInTheDocument(); + }); + + vi.restoreAllMocks(); + }); + + it("shows 'No data' when fetched text is empty", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + text: () => Promise.resolve(""), + } as Response); + + renderWithSuspense( + , + ); + + await waitFor(() => { + expect(screen.getByText("No data")).toBeInTheDocument(); + }); + + vi.restoreAllMocks(); + }); +}); diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/IOCell.test.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/IOCell.test.tsx new file mode 100644 index 000000000..fe203a753 --- /dev/null +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/IOCell.test.tsx @@ -0,0 +1,215 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ArtifactNodeResponse } from "@/api/types.gen"; + +import IOCell from "./IOCell"; + +vi.mock("@/providers/BackendProvider", () => ({ + useBackend: () => ({ backendUrl: "http://localhost:8000" }), +})); + +vi.mock("./ArtifactVisualizer/ArtifactVisualizer", () => ({ + default: ({ + name, + type, + value, + }: { + name: string; + type: string; + value?: string; + }) => ( +
+ ), +})); + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +const renderWithQuery = (ui: React.ReactElement) => + render({ui}); + +const makeArtifact = ( + overrides?: Partial, +): ArtifactNodeResponse => ({ + id: "artifact-1", + artifact_data: { total_size: 0, is_dir: false }, + ...overrides, +}); + +describe("IOCell", () => { + it("renders the artifact name", () => { + renderWithQuery(); + expect(screen.getByText("my_input")).toBeInTheDocument(); + }); + + it("shows the explicit type when provided", () => { + renderWithQuery( + , + ); + expect(screen.getByText("CSV")).toBeInTheDocument(); + }); + + it("falls back to type_name from artifact", () => { + renderWithQuery( + , + ); + expect(screen.getByText("JsonObject")).toBeInTheDocument(); + }); + + it("shows 'Directory' when artifact is a directory", () => { + renderWithQuery( + , + ); + expect(screen.getByText("Directory")).toBeInTheDocument(); + }); + + it("shows 'Any' as default type", () => { + renderWithQuery(); + expect(screen.getByText("Any")).toBeInTheDocument(); + }); + + it("displays formatted file size", () => { + renderWithQuery( + , + ); + expect(screen.getByText(/2/)).toBeInTheDocument(); + }); + + it("renders ArtifactVisualizer with value for inline values", () => { + renderWithQuery( + , + ); + + const viz = screen.getByTestId("artifact-visualizer"); + expect(viz).toHaveAttribute("data-value", "hello world"); + expect(viz).toHaveAttribute("data-type", "text"); + }); + + it("renders ArtifactVisualizer without value for remote artifacts", () => { + renderWithQuery( + , + ); + + const viz = screen.getByTestId("artifact-visualizer"); + expect(viz).not.toHaveAttribute("data-value"); + expect(viz).toHaveAttribute("data-type", "CSV"); + }); + + it("renders inline value text with line-clamp", () => { + renderWithQuery( + , + ); + + expect(screen.getByText("some inline value")).toBeInTheDocument(); + }); + + it("does not render inline value for whitespace-only strings", () => { + renderWithQuery( + , + ); + + expect(screen.queryByTestId("artifact-visualizer")).not.toBeInTheDocument(); + }); + + it("renders artifact URI when available", () => { + renderWithQuery( + , + ); + + expect(screen.getByText("Copy URI")).toBeInTheDocument(); + }); + + it("does not render ArtifactVisualizer when artifact is null", () => { + renderWithQuery(); + expect(screen.queryByTestId("artifact-visualizer")).not.toBeInTheDocument(); + }); + + it("defaults type to 'text' for inline values without explicit type", () => { + renderWithQuery( + , + ); + + const viz = screen.getByTestId("artifact-visualizer"); + expect(viz).toHaveAttribute("data-type", "text"); + }); +});