diff --git a/.gitignore b/.gitignore index 2ac7e1e3a..f3831f29c 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ venv/ pytest-report.xml .coverage .python-version +CLAUDE.md +.claude/ diff --git a/llama_stack/ui/.gitignore b/llama_stack/ui/.gitignore index 5ef6a5207..e169988b4 100644 --- a/llama_stack/ui/.gitignore +++ b/llama_stack/ui/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# playwright +.last-run.json diff --git a/llama_stack/ui/app/logs/chat-completions/page.tsx b/llama_stack/ui/app/logs/chat-completions/page.tsx index 5bbfcce94..475a330b5 100644 --- a/llama_stack/ui/app/logs/chat-completions/page.tsx +++ b/llama_stack/ui/app/logs/chat-completions/page.tsx @@ -1,51 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; -import { ChatCompletion } from "@/lib/types"; import { ChatCompletionsTable } from "@/components/chat-completions/chat-completions-table"; -import { client } from "@/lib/client"; export default function ChatCompletionsPage() { - const [completions, setCompletions] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchCompletions = async () => { - setIsLoading(true); - setError(null); - try { - const response = await client.chat.completions.list(); - const data = Array.isArray(response) - ? response - : (response as { data: ChatCompletion[] }).data; - - if (Array.isArray(data)) { - setCompletions(data); - } else { - console.error("Unexpected response structure:", response); - setError(new Error("Unexpected response structure")); - setCompletions([]); - } - } catch (err) { - console.error("Error fetching chat completions:", err); - setError( - err instanceof Error ? err : new Error("Failed to fetch completions"), - ); - setCompletions([]); - } finally { - setIsLoading(false); - } - }; - - fetchCompletions(); - }, []); - - return ( - - ); + return ; } diff --git a/llama_stack/ui/app/logs/responses/page.tsx b/llama_stack/ui/app/logs/responses/page.tsx index dab0c735f..d7a2bb27f 100644 --- a/llama_stack/ui/app/logs/responses/page.tsx +++ b/llama_stack/ui/app/logs/responses/page.tsx @@ -1,66 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; -import type { ResponseListResponse } from "llama-stack-client/resources/responses/responses"; -import { OpenAIResponse } from "@/lib/types"; import { ResponsesTable } from "@/components/responses/responses-table"; -import { client } from "@/lib/client"; export default function ResponsesPage() { - const [responses, setResponses] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - // Helper function to convert ResponseListResponse.Data to OpenAIResponse - const convertResponseListData = ( - responseData: ResponseListResponse.Data, - ): OpenAIResponse => { - return { - id: responseData.id, - created_at: responseData.created_at, - model: responseData.model, - object: responseData.object, - status: responseData.status, - output: responseData.output as OpenAIResponse["output"], - input: responseData.input as OpenAIResponse["input"], - error: responseData.error, - parallel_tool_calls: responseData.parallel_tool_calls, - previous_response_id: responseData.previous_response_id, - temperature: responseData.temperature, - top_p: responseData.top_p, - truncation: responseData.truncation, - user: responseData.user, - }; - }; - - useEffect(() => { - const fetchResponses = async () => { - setIsLoading(true); - setError(null); - try { - const response = await client.responses.list(); - const responseListData = response as ResponseListResponse; - - const convertedResponses: OpenAIResponse[] = responseListData.data.map( - convertResponseListData, - ); - - setResponses(convertedResponses); - } catch (err) { - console.error("Error fetching responses:", err); - setError( - err instanceof Error ? err : new Error("Failed to fetch responses"), - ); - setResponses([]); - } finally { - setIsLoading(false); - } - }; - - fetchResponses(); - }, []); - - return ( - - ); + return ; } diff --git a/llama_stack/ui/components/chat-completions/chat-completion-table.test.tsx b/llama_stack/ui/components/chat-completions/chat-completion-table.test.tsx index c8a55b100..a32e795bc 100644 --- a/llama_stack/ui/components/chat-completions/chat-completion-table.test.tsx +++ b/llama_stack/ui/components/chat-completions/chat-completion-table.test.tsx @@ -16,6 +16,29 @@ jest.mock("next/navigation", () => ({ jest.mock("@/lib/truncate-text"); jest.mock("@/lib/format-message-content"); +// Mock the client +jest.mock("@/lib/client", () => ({ + client: { + chat: { + completions: { + list: jest.fn(), + }, + }, + }, +})); + +// Mock the usePagination hook +const mockLoadMore = jest.fn(); +jest.mock("@/hooks/usePagination", () => ({ + usePagination: jest.fn(() => ({ + data: [], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + })), +})); + // Import the mocked functions to set up default or specific implementations import { truncateText as originalTruncateText } from "@/lib/truncate-text"; import { @@ -23,6 +46,12 @@ import { extractDisplayableText as originalExtractDisplayableText, } from "@/lib/format-message-content"; +// Import the mocked hook +import { usePagination } from "@/hooks/usePagination"; +const mockedUsePagination = usePagination as jest.MockedFunction< + typeof usePagination +>; + // Cast to jest.Mock for typings const truncateText = originalTruncateText as jest.Mock; const extractTextFromContentPart = @@ -30,11 +59,7 @@ const extractTextFromContentPart = const extractDisplayableText = originalExtractDisplayableText as jest.Mock; describe("ChatCompletionsTable", () => { - const defaultProps = { - data: [] as ChatCompletion[], - isLoading: false, - error: null, - }; + const defaultProps = {}; beforeEach(() => { // Reset all mocks before each test @@ -42,16 +67,27 @@ describe("ChatCompletionsTable", () => { truncateText.mockClear(); extractTextFromContentPart.mockClear(); extractDisplayableText.mockClear(); + mockLoadMore.mockClear(); + jest.clearAllMocks(); // Default pass-through implementations truncateText.mockImplementation((text: string | undefined) => text); extractTextFromContentPart.mockImplementation((content: unknown) => typeof content === "string" ? content : "extracted text", ); - extractDisplayableText.mockImplementation( - (message: unknown) => - (message as { content?: string })?.content || "extracted output", - ); + extractDisplayableText.mockImplementation((message: unknown) => { + const msg = message as { content?: string }; + return msg?.content || "extracted output"; + }); + + // Default hook return value + mockedUsePagination.mockReturnValue({ + data: [], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); }); test("renders without crashing with default props", () => { @@ -60,41 +96,56 @@ describe("ChatCompletionsTable", () => { }); test("click on a row navigates to the correct URL", () => { - const mockCompletion: ChatCompletion = { - id: "comp_123", - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: "llama-test-model", - choices: [ - { - index: 0, - message: { role: "assistant", content: "Test output" }, - finish_reason: "stop", - }, - ], - input_messages: [{ role: "user", content: "Test input" }], - }; + const mockData: ChatCompletion[] = [ + { + id: "completion_123", + choices: [ + { + message: { role: "assistant", content: "Test response" }, + finish_reason: "stop", + index: 0, + }, + ], + object: "chat.completion", + created: 1234567890, + model: "test-model", + input_messages: [{ role: "user", content: "Test prompt" }], + }, + ]; - // Set up mocks to return expected values - extractTextFromContentPart.mockReturnValue("Test input"); - extractDisplayableText.mockReturnValue("Test output"); + // Configure the mock to return our test data + mockedUsePagination.mockReturnValue({ + data: mockData, + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); - render(); + render(); - const row = screen.getByText("Test input").closest("tr"); + const row = screen.getByText("Test prompt").closest("tr"); if (row) { fireEvent.click(row); - expect(mockPush).toHaveBeenCalledWith("/logs/chat-completions/comp_123"); + expect(mockPush).toHaveBeenCalledWith( + "/logs/chat-completions/completion_123", + ); } else { - throw new Error('Row with "Test input" not found for router mock test.'); + throw new Error('Row with "Test prompt" not found for router mock test.'); } }); describe("Loading State", () => { test("renders skeleton UI when isLoading is true", () => { - const { container } = render( - , - ); + mockedUsePagination.mockReturnValue({ + data: [], + status: "loading", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + const { container } = render(); // Check for skeleton in the table caption const tableCaption = container.querySelector("caption"); @@ -121,40 +172,48 @@ describe("ChatCompletionsTable", () => { describe("Error State", () => { test("renders error message when error prop is provided", () => { const errorMessage = "Network Error"; - render( - , - ); + mockedUsePagination.mockReturnValue({ + data: [], + status: "error", + hasMore: false, + error: { name: "Error", message: errorMessage } as Error, + loadMore: mockLoadMore, + }); + + render(); expect( - screen.getByText(`Error fetching data: ${errorMessage}`), + screen.getByText("Unable to load chat completions"), ).toBeInTheDocument(); + expect(screen.getByText(errorMessage)).toBeInTheDocument(); }); - test("renders default error message when error.message is not available", () => { - render( - , - ); - expect( - screen.getByText("Error fetching data: An unknown error occurred"), - ).toBeInTheDocument(); - }); + test.each([{ name: "Error", message: "" }, {}])( + "renders default error message when error has no message", + (errorObject) => { + mockedUsePagination.mockReturnValue({ + data: [], + status: "error", + hasMore: false, + error: errorObject as Error, + loadMore: mockLoadMore, + }); - test("renders default error message when error prop is an object without message", () => { - render(); - expect( - screen.getByText("Error fetching data: An unknown error occurred"), - ).toBeInTheDocument(); - }); + render(); + expect( + screen.getByText("Unable to load chat completions"), + ).toBeInTheDocument(); + expect( + screen.getByText( + "An unexpected error occurred while loading the data.", + ), + ).toBeInTheDocument(); + }, + ); }); describe("Empty State", () => { test('renders "No chat completions found." and no table when data array is empty', () => { - render(); + render(); expect( screen.getByText("No chat completions found."), ).toBeInTheDocument(); @@ -167,7 +226,7 @@ describe("ChatCompletionsTable", () => { describe("Data Rendering", () => { test("renders table caption, headers, and completion data correctly", () => { - const mockCompletions = [ + const mockCompletions: ChatCompletion[] = [ { id: "comp_1", object: "chat.completion", @@ -211,13 +270,15 @@ describe("ChatCompletionsTable", () => { return "extracted output"; }); - render( - , - ); + mockedUsePagination.mockReturnValue({ + data: mockCompletions, + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + render(); // Table caption expect( @@ -268,7 +329,7 @@ describe("ChatCompletionsTable", () => { extractTextFromContentPart.mockReturnValue(longInput); extractDisplayableText.mockReturnValue(longOutput); - const mockCompletions = [ + const mockCompletions: ChatCompletion[] = [ { id: "comp_trunc", object: "chat.completion", @@ -285,63 +346,72 @@ describe("ChatCompletionsTable", () => { }, ]; - render( - , - ); + mockedUsePagination.mockReturnValue({ + data: mockCompletions, + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + render(); // The truncated text should be present for both input and output const truncatedTexts = screen.getAllByText( longInput.slice(0, 10) + "...", ); expect(truncatedTexts.length).toBe(2); // one for input, one for output - truncatedTexts.forEach((textElement) => - expect(textElement).toBeInTheDocument(), - ); }); test("uses content extraction functions correctly", () => { - const mockCompletion = { - id: "comp_extract", - object: "chat.completion", - created: 1710003000, - model: "llama-extract-model", - choices: [ - { - index: 0, - message: { role: "assistant", content: "Extracted output" }, - finish_reason: "stop", - }, - ], - input_messages: [{ role: "user", content: "Extracted input" }], + const complexMessage = [ + { type: "text", text: "Extracted input" }, + { type: "image", url: "http://example.com/image.png" }, + ]; + const assistantMessage = { + role: "assistant", + content: "Extracted output from assistant", }; + const mockCompletions: ChatCompletion[] = [ + { + id: "comp_extract", + object: "chat.completion", + created: 1710003000, + model: "llama-extract-model", + choices: [ + { + index: 0, + message: assistantMessage, + finish_reason: "stop", + }, + ], + input_messages: [{ role: "user", content: complexMessage }], + }, + ]; + extractTextFromContentPart.mockReturnValue("Extracted input"); - extractDisplayableText.mockReturnValue("Extracted output"); + extractDisplayableText.mockReturnValue("Extracted output from assistant"); - render( - , - ); - - // Verify the extraction functions were called - expect(extractTextFromContentPart).toHaveBeenCalledWith( - "Extracted input", - ); - expect(extractDisplayableText).toHaveBeenCalledWith({ - role: "assistant", - content: "Extracted output", + mockedUsePagination.mockReturnValue({ + data: mockCompletions, + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, }); - // Verify the extracted content is displayed + render(); + + // Verify the extraction functions were called + expect(extractTextFromContentPart).toHaveBeenCalledWith(complexMessage); + expect(extractDisplayableText).toHaveBeenCalledWith(assistantMessage); + + // Verify the extracted text appears in the table expect(screen.getByText("Extracted input")).toBeInTheDocument(); - expect(screen.getByText("Extracted output")).toBeInTheDocument(); + expect( + screen.getByText("Extracted output from assistant"), + ).toBeInTheDocument(); }); }); }); diff --git a/llama_stack/ui/components/chat-completions/chat-completions-table.tsx b/llama_stack/ui/components/chat-completions/chat-completions-table.tsx index 5f1d2f03d..d5838d877 100644 --- a/llama_stack/ui/components/chat-completions/chat-completions-table.tsx +++ b/llama_stack/ui/components/chat-completions/chat-completions-table.tsx @@ -1,16 +1,21 @@ "use client"; -import { ChatCompletion } from "@/lib/types"; +import { + ChatCompletion, + UsePaginationOptions, + ListChatCompletionsResponse, +} from "@/lib/types"; import { LogsTable, LogTableRow } from "@/components/logs/logs-table"; import { extractTextFromContentPart, extractDisplayableText, } from "@/lib/format-message-content"; +import { usePagination } from "@/hooks/usePagination"; +import { client } from "@/lib/client"; interface ChatCompletionsTableProps { - data: ChatCompletion[]; - isLoading: boolean; - error: Error | null; + /** Optional pagination configuration */ + paginationOptions?: UsePaginationOptions; } function formatChatCompletionToRow(completion: ChatCompletion): LogTableRow { @@ -25,17 +30,39 @@ function formatChatCompletionToRow(completion: ChatCompletion): LogTableRow { } export function ChatCompletionsTable({ - data, - isLoading, - error, + paginationOptions, }: ChatCompletionsTableProps) { + const fetchFunction = async (params: { + after?: string; + limit: number; + model?: string; + order?: string; + }) => { + const response = await client.chat.completions.list({ + after: params.after, + limit: params.limit, + ...(params.model && { model: params.model }), + ...(params.order && { order: params.order }), + } as any); + + return response as ListChatCompletionsResponse; + }; + + const { data, status, hasMore, error, loadMore } = usePagination({ + ...paginationOptions, + fetchFunction, + errorMessagePrefix: "chat completions", + }); + const formattedData = data.map(formatChatCompletionToRow); return ( diff --git a/llama_stack/ui/components/layout/logs-layout.tsx b/llama_stack/ui/components/layout/logs-layout.tsx index 468ad6e9a..f52e7eebc 100644 --- a/llama_stack/ui/components/layout/logs-layout.tsx +++ b/llama_stack/ui/components/layout/logs-layout.tsx @@ -37,13 +37,11 @@ export default function LogsLayout({ } return ( -
- <> - {segments.length > 0 && ( - - )} - {children} - +
+ {segments.length > 0 && ( + + )} +
{children}
); } diff --git a/llama_stack/ui/components/logs/logs-table-scroll.test.tsx b/llama_stack/ui/components/logs/logs-table-scroll.test.tsx new file mode 100644 index 000000000..0a143cc16 --- /dev/null +++ b/llama_stack/ui/components/logs/logs-table-scroll.test.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import { render, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { LogsTable, LogTableRow } from "./logs-table"; +import { PaginationStatus } from "@/lib/types"; + +// Mock next/navigation +jest.mock("next/navigation", () => ({ + useRouter: () => ({ + push: jest.fn(), + }), +})); + +// Mock the useInfiniteScroll hook +jest.mock("@/hooks/useInfiniteScroll", () => ({ + useInfiniteScroll: jest.fn((onLoadMore, options) => { + const ref = React.useRef(null); + + React.useEffect(() => { + // Simulate the observer behavior + if (options?.enabled && onLoadMore) { + // Trigger load after a delay to simulate intersection + const timeout = setTimeout(() => { + onLoadMore(); + }, 100); + + return () => clearTimeout(timeout); + } + }, [options?.enabled, onLoadMore]); + + return ref; + }), +})); + +// IntersectionObserver mock is already in jest.setup.ts + +describe("LogsTable Viewport Loading", () => { + const mockData: LogTableRow[] = Array.from({ length: 10 }, (_, i) => ({ + id: `row_${i}`, + input: `Input ${i}`, + output: `Output ${i}`, + model: "test-model", + createdTime: new Date().toISOString(), + detailPath: `/logs/test/${i}`, + })); + + const defaultProps = { + data: mockData, + status: "idle" as PaginationStatus, + hasMore: true, + error: null, + caption: "Test table", + emptyMessage: "No data", + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should trigger loadMore when sentinel is visible", async () => { + const mockLoadMore = jest.fn(); + + render(); + + // Wait for the intersection observer to trigger + await waitFor( + () => { + expect(mockLoadMore).toHaveBeenCalled(); + }, + { timeout: 300 }, + ); + + expect(mockLoadMore).toHaveBeenCalledTimes(1); + }); + + test("should not trigger loadMore when already loading", async () => { + const mockLoadMore = jest.fn(); + + render( + , + ); + + // Wait for possible triggers + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(mockLoadMore).not.toHaveBeenCalled(); + }); + + test("should not trigger loadMore when status is loading", async () => { + const mockLoadMore = jest.fn(); + + render( + , + ); + + // Wait for possible triggers + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(mockLoadMore).not.toHaveBeenCalled(); + }); + + test("should not trigger loadMore when hasMore is false", async () => { + const mockLoadMore = jest.fn(); + + render( + , + ); + + // Wait for possible triggers + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(mockLoadMore).not.toHaveBeenCalled(); + }); + + test("sentinel element should not be rendered when loading", () => { + const { container } = render( + , + ); + + // Check that no sentinel row with height: 1 exists + const sentinelRow = container.querySelector('tr[style*="height: 1"]'); + expect(sentinelRow).not.toBeInTheDocument(); + }); + + test("sentinel element should be rendered when not loading and hasMore", () => { + const { container } = render( + , + ); + + // Check that sentinel row exists + const sentinelRow = container.querySelector('tr[style*="height: 1"]'); + expect(sentinelRow).toBeInTheDocument(); + }); +}); diff --git a/llama_stack/ui/components/logs/logs-table.test.tsx b/llama_stack/ui/components/logs/logs-table.test.tsx index 88263b2fc..9d129879b 100644 --- a/llama_stack/ui/components/logs/logs-table.test.tsx +++ b/llama_stack/ui/components/logs/logs-table.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom"; import { LogsTable, LogTableRow } from "./logs-table"; +import { PaginationStatus } from "@/lib/types"; // Mock next/navigation const mockPush = jest.fn(); @@ -23,7 +24,7 @@ const truncateText = originalTruncateText as jest.Mock; describe("LogsTable", () => { const defaultProps = { data: [] as LogTableRow[], - isLoading: false, + status: "idle" as PaginationStatus, error: null, caption: "Test table caption", emptyMessage: "No data found", @@ -69,7 +70,7 @@ describe("LogsTable", () => { describe("Loading State", () => { test("renders skeleton UI when isLoading is true", () => { const { container } = render( - , + , ); // Check for skeleton in the table caption @@ -101,7 +102,7 @@ describe("LogsTable", () => { test("renders correct number of skeleton rows", () => { const { container } = render( - , + , ); const skeletonRows = container.querySelectorAll("tbody tr"); @@ -115,27 +116,45 @@ describe("LogsTable", () => { render( , ); expect( - screen.getByText(`Error fetching data: ${errorMessage}`), + screen.getByText("Unable to load chat completions"), ).toBeInTheDocument(); + expect(screen.getByText(errorMessage)).toBeInTheDocument(); }); test("renders default error message when error.message is not available", () => { render( - , + , ); expect( - screen.getByText("Error fetching data: An unknown error occurred"), + screen.getByText("Unable to load chat completions"), + ).toBeInTheDocument(); + expect( + screen.getByText( + "An unexpected error occurred while loading the data.", + ), ).toBeInTheDocument(); }); test("renders default error message when error prop is an object without message", () => { - render(); + render( + , + ); expect( - screen.getByText("Error fetching data: An unknown error occurred"), + screen.getByText("Unable to load chat completions"), + ).toBeInTheDocument(); + expect( + screen.getByText( + "An unexpected error occurred while loading the data.", + ), ).toBeInTheDocument(); }); @@ -143,7 +162,8 @@ describe("LogsTable", () => { render( , ); const table = screen.queryByRole("table"); @@ -337,14 +357,19 @@ describe("LogsTable", () => { render(); - const table = screen.getByRole("table"); - expect(table).toBeInTheDocument(); + const tables = screen.getAllByRole("table"); + expect(tables).toHaveLength(2); // Fixed header table + body table const columnHeaders = screen.getAllByRole("columnheader"); expect(columnHeaders).toHaveLength(4); const rows = screen.getAllByRole("row"); - expect(rows).toHaveLength(2); // 1 header row + 1 data row + expect(rows).toHaveLength(3); // 1 header row + 1 data row + 1 "no more items" row + + expect(screen.getByText("Input")).toBeInTheDocument(); + expect(screen.getByText("Output")).toBeInTheDocument(); + expect(screen.getByText("Model")).toBeInTheDocument(); + expect(screen.getByText("Created")).toBeInTheDocument(); }); }); }); diff --git a/llama_stack/ui/components/logs/logs-table.tsx b/llama_stack/ui/components/logs/logs-table.tsx index 33afea61b..72924cea8 100644 --- a/llama_stack/ui/components/logs/logs-table.tsx +++ b/llama_stack/ui/components/logs/logs-table.tsx @@ -1,7 +1,10 @@ "use client"; import { useRouter } from "next/navigation"; +import { useRef } from "react"; import { truncateText } from "@/lib/truncate-text"; +import { PaginationStatus } from "@/lib/types"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; import { Table, TableBody, @@ -24,65 +27,107 @@ export interface LogTableRow { } interface LogsTableProps { + /** Array of log table row data to display */ data: LogTableRow[]; - isLoading: boolean; + /** Current loading/error status */ + status: PaginationStatus; + /** Whether more data is available to load */ + hasMore?: boolean; + /** Error state, null if no error */ error: Error | null; + /** Table caption for accessibility */ caption: string; + /** Message to show when no data is available */ emptyMessage: string; + /** Callback function to load more data */ + onLoadMore?: () => void; } export function LogsTable({ data, - isLoading, + status, + hasMore = false, error, caption, emptyMessage, + onLoadMore, }: LogsTableProps) { const router = useRouter(); + const tableContainerRef = useRef(null); - const tableHeader = ( - - - Input - Output - Model - Created - - + // Use Intersection Observer for infinite scroll + const sentinelRef = useInfiniteScroll(onLoadMore, { + enabled: hasMore && status === "idle", + rootMargin: "100px", + threshold: 0.1, + }); + + // Fixed header component + const FixedHeader = () => ( +
+ + + + Input + Output + Model + Created + + +
+
); - if (isLoading) { + if (status === "loading") { return ( - - - - - {tableHeader} - - {[...Array(3)].map((_, i) => ( - - - - - - - - - - - - - - - ))} - -
+
+ +
+ + + + + + {[...Array(3)].map((_, i) => ( + + + + + + + + + + + + + + + ))} + +
+
+
); } - if (error) { + if (status === "error") { return ( -

Error fetching data: {error.message || "An unknown error occurred"}

+
+
+ Unable to load chat completions +
+
+ {error?.message || + "An unexpected error occurred while loading the data."} +
+ +
); } @@ -91,23 +136,60 @@ export function LogsTable({ } return ( - - {caption} - {tableHeader} - - {data.map((row) => ( - router.push(row.detailPath)} - className="cursor-pointer hover:bg-muted/50" - > - {truncateText(row.input)} - {truncateText(row.output)} - {row.model} - {row.createdTime} - - ))} - -
+
+ +
+ + {caption} + + {data.map((row) => ( + router.push(row.detailPath)} + className="cursor-pointer hover:bg-muted/50" + > + + {truncateText(row.input)} + + + {truncateText(row.output)} + + {row.model} + + {row.createdTime} + + + ))} + {/* Sentinel element for infinite scroll */} + {hasMore && status === "idle" && ( + + + + )} + {status === "loading-more" && ( + + +
+ + + Loading more... + +
+
+
+ )} + {!hasMore && data.length > 0 && ( + + + + No more items to load + + + + )} +
+
+
+
); } diff --git a/llama_stack/ui/components/responses/responses-table.test.tsx b/llama_stack/ui/components/responses/responses-table.test.tsx index 7c45c57d3..2286f10d5 100644 --- a/llama_stack/ui/components/responses/responses-table.test.tsx +++ b/llama_stack/ui/components/responses/responses-table.test.tsx @@ -15,26 +15,60 @@ jest.mock("next/navigation", () => ({ // Mock helper functions jest.mock("@/lib/truncate-text"); +// Mock the client +jest.mock("@/lib/client", () => ({ + client: { + responses: { + list: jest.fn(), + }, + }, +})); + +// Mock the usePagination hook +const mockLoadMore = jest.fn(); +jest.mock("@/hooks/usePagination", () => ({ + usePagination: jest.fn(() => ({ + data: [], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + })), +})); + // Import the mocked functions import { truncateText as originalTruncateText } from "@/lib/truncate-text"; +// Import the mocked hook +import { usePagination } from "@/hooks/usePagination"; +const mockedUsePagination = usePagination as jest.MockedFunction< + typeof usePagination +>; + // Cast to jest.Mock for typings const truncateText = originalTruncateText as jest.Mock; describe("ResponsesTable", () => { - const defaultProps = { - data: [] as OpenAIResponse[], - isLoading: false, - error: null, - }; + const defaultProps = {}; beforeEach(() => { // Reset all mocks before each test mockPush.mockClear(); truncateText.mockClear(); + mockLoadMore.mockClear(); + jest.clearAllMocks(); // Default pass-through implementation truncateText.mockImplementation((text: string | undefined) => text); + + // Default hook return value + mockedUsePagination.mockReturnValue({ + data: [], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); }); test("renders without crashing with default props", () => { @@ -65,7 +99,16 @@ describe("ResponsesTable", () => { ], }; - render(); + // Configure the mock to return our test data + mockedUsePagination.mockReturnValue({ + data: [mockResponse], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + render(); const row = screen.getByText("Test input").closest("tr"); if (row) { @@ -77,10 +120,16 @@ describe("ResponsesTable", () => { }); describe("Loading State", () => { - test("renders skeleton UI when isLoading is true", () => { - const { container } = render( - , - ); + test("renders skeleton UI when status is loading", () => { + mockedUsePagination.mockReturnValue({ + data: [], + status: "loading", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + const { container } = render(); // Check for skeleton in the table caption const tableCaption = container.querySelector("caption"); @@ -105,42 +154,50 @@ describe("ResponsesTable", () => { }); describe("Error State", () => { - test("renders error message when error prop is provided", () => { + test("renders error message when error is provided", () => { const errorMessage = "Network Error"; - render( - , - ); + mockedUsePagination.mockReturnValue({ + data: [], + status: "error", + hasMore: false, + error: { name: "Error", message: errorMessage } as Error, + loadMore: mockLoadMore, + }); + + render(); expect( - screen.getByText(`Error fetching data: ${errorMessage}`), + screen.getByText("Unable to load chat completions"), ).toBeInTheDocument(); + expect(screen.getByText(errorMessage)).toBeInTheDocument(); }); - test("renders default error message when error.message is not available", () => { - render( - , - ); - expect( - screen.getByText("Error fetching data: An unknown error occurred"), - ).toBeInTheDocument(); - }); + test.each([{ name: "Error", message: "" }, {}])( + "renders default error message when error has no message", + (errorObject) => { + mockedUsePagination.mockReturnValue({ + data: [], + status: "error", + hasMore: false, + error: errorObject as Error, + loadMore: mockLoadMore, + }); - test("renders default error message when error prop is an object without message", () => { - render(); - expect( - screen.getByText("Error fetching data: An unknown error occurred"), - ).toBeInTheDocument(); - }); + render(); + expect( + screen.getByText("Unable to load chat completions"), + ).toBeInTheDocument(); + expect( + screen.getByText( + "An unexpected error occurred while loading the data.", + ), + ).toBeInTheDocument(); + }, + ); }); describe("Empty State", () => { test('renders "No responses found." and no table when data array is empty', () => { - render(); + render(); expect(screen.getByText("No responses found.")).toBeInTheDocument(); // Ensure that the table structure is NOT rendered in the empty state @@ -151,7 +208,7 @@ describe("ResponsesTable", () => { describe("Data Rendering", () => { test("renders table caption, headers, and response data correctly", () => { - const mockResponses = [ + const mockResponses: OpenAIResponse[] = [ { id: "resp_1", object: "response" as const, @@ -196,9 +253,15 @@ describe("ResponsesTable", () => { }, ]; - render( - , - ); + mockedUsePagination.mockReturnValue({ + data: mockResponses, + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + render(); // Table caption expect( @@ -246,9 +309,15 @@ describe("ResponsesTable", () => { ], }; - render( - , - ); + mockedUsePagination.mockReturnValue({ + data: [mockResponse], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + render(); expect(screen.getByText("Simple string input")).toBeInTheDocument(); }); @@ -272,9 +341,15 @@ describe("ResponsesTable", () => { ], }; - render( - , - ); + mockedUsePagination.mockReturnValue({ + data: [mockResponse], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + render(); expect(screen.getByText("Array input text")).toBeInTheDocument(); }); @@ -294,9 +369,15 @@ describe("ResponsesTable", () => { ], }; - const { container } = render( - , - ); + mockedUsePagination.mockReturnValue({ + data: [mockResponse], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + const { container } = render(); // Find the input cell (first cell in the data row) and verify it's empty const inputCell = container.querySelector("tbody tr td:first-child"); @@ -323,9 +404,15 @@ describe("ResponsesTable", () => { input: [{ type: "message", content: "input" }], }; - render( - , - ); + mockedUsePagination.mockReturnValue({ + data: [mockResponse], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + render(); expect(screen.getByText("Simple string output")).toBeInTheDocument(); }); @@ -349,9 +436,15 @@ describe("ResponsesTable", () => { input: [{ type: "message", content: "input" }], }; - render( - , - ); + mockedUsePagination.mockReturnValue({ + data: [mockResponse], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + render(); expect(screen.getByText("Array output text")).toBeInTheDocument(); }); @@ -374,9 +467,15 @@ describe("ResponsesTable", () => { input: [{ type: "message", content: "input" }], }; - render( - , - ); + mockedUsePagination.mockReturnValue({ + data: [mockResponse], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + render(); expect( screen.getByText('search_function({"query": "test"})'), ).toBeInTheDocument(); @@ -400,9 +499,15 @@ describe("ResponsesTable", () => { input: [{ type: "message", content: "input" }], }; - render( - , - ); + mockedUsePagination.mockReturnValue({ + data: [mockResponse], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + render(); expect(screen.getByText("simple_function({})")).toBeInTheDocument(); }); @@ -423,9 +528,15 @@ describe("ResponsesTable", () => { input: [{ type: "message", content: "input" }], }; - render( - , - ); + mockedUsePagination.mockReturnValue({ + data: [mockResponse], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + render(); expect( screen.getByText("web_search_call(status: completed)"), ).toBeInTheDocument(); @@ -449,9 +560,15 @@ describe("ResponsesTable", () => { input: [{ type: "message", content: "input" }], }; - render( - , - ); + mockedUsePagination.mockReturnValue({ + data: [mockResponse], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + render(); // Should contain the JSON stringified version expect(screen.getByText(/unknown_call/)).toBeInTheDocument(); }); @@ -472,9 +589,15 @@ describe("ResponsesTable", () => { input: [{ type: "message", content: "input" }], }; - render( - , - ); + mockedUsePagination.mockReturnValue({ + data: [mockResponse], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + render(); // Should contain the JSON stringified version of the output array expect(screen.getByText(/unknown_type/)).toBeInTheDocument(); }); @@ -520,18 +643,21 @@ describe("ResponsesTable", () => { ], }; - render( - , - ); + mockedUsePagination.mockReturnValue({ + data: [mockResponse], + status: "idle", + hasMore: false, + error: null, + loadMore: mockLoadMore, + }); + + render(); // The truncated text should be present for both input and output const truncatedTexts = screen.getAllByText( longInput.slice(0, 10) + "...", ); expect(truncatedTexts.length).toBe(2); // one for input, one for output - truncatedTexts.forEach((textElement) => - expect(textElement).toBeInTheDocument(), - ); }); }); }); diff --git a/llama_stack/ui/components/responses/responses-table.tsx b/llama_stack/ui/components/responses/responses-table.tsx index 352450d18..116a2adbe 100644 --- a/llama_stack/ui/components/responses/responses-table.tsx +++ b/llama_stack/ui/components/responses/responses-table.tsx @@ -2,10 +2,13 @@ import { OpenAIResponse, - ResponseInput, ResponseInputMessageContent, + UsePaginationOptions, } from "@/lib/types"; import { LogsTable, LogTableRow } from "@/components/logs/logs-table"; +import { usePagination } from "@/hooks/usePagination"; +import { client } from "@/lib/client"; +import type { ResponseListResponse } from "llama-stack-client/resources/responses/responses"; import { isMessageInput, isMessageItem, @@ -17,11 +20,34 @@ import { } from "./utils/item-types"; interface ResponsesTableProps { - data: OpenAIResponse[]; - isLoading: boolean; - error: Error | null; + /** Optional pagination configuration */ + paginationOptions?: UsePaginationOptions; } +/** + * Helper function to convert ResponseListResponse.Data to OpenAIResponse + */ +const convertResponseListData = ( + responseData: ResponseListResponse.Data, +): OpenAIResponse => { + return { + id: responseData.id, + created_at: responseData.created_at, + model: responseData.model, + object: responseData.object, + status: responseData.status, + output: responseData.output as OpenAIResponse["output"], + input: responseData.input as OpenAIResponse["input"], + error: responseData.error, + parallel_tool_calls: responseData.parallel_tool_calls, + previous_response_id: responseData.previous_response_id, + temperature: responseData.temperature, + top_p: responseData.top_p, + truncation: responseData.truncation, + user: responseData.user, + }; +}; + function getInputText(response: OpenAIResponse): string { const firstInput = response.input.find(isMessageInput); if (firstInput) { @@ -98,18 +124,43 @@ function formatResponseToRow(response: OpenAIResponse): LogTableRow { }; } -export function ResponsesTable({ - data, - isLoading, - error, -}: ResponsesTableProps) { +export function ResponsesTable({ paginationOptions }: ResponsesTableProps) { + const fetchFunction = async (params: { + after?: string; + limit: number; + model?: string; + order?: string; + }) => { + const response = await client.responses.list({ + after: params.after, + limit: params.limit, + ...(params.model && { model: params.model }), + ...(params.order && { order: params.order }), + } as any); + + const listResponse = response as ResponseListResponse; + + return { + ...listResponse, + data: listResponse.data.map(convertResponseListData), + }; + }; + + const { data, status, hasMore, error, loadMore } = usePagination({ + ...paginationOptions, + fetchFunction, + errorMessagePrefix: "responses", + }); + const formattedData = data.map(formatResponseToRow); return ( diff --git a/llama_stack/ui/e2e/logs-table-scroll.spec.ts b/llama_stack/ui/e2e/logs-table-scroll.spec.ts new file mode 100644 index 000000000..081e6d426 --- /dev/null +++ b/llama_stack/ui/e2e/logs-table-scroll.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "@playwright/test"; + +test.describe("LogsTable Scroll and Progressive Loading", () => { + test.beforeEach(async ({ page }) => { + // Navigate to the chat completions page + await page.goto("/logs/chat-completions"); + + // Wait for initial data to load + await page.waitForSelector("table tbody tr", { timeout: 10000 }); + }); + + test("should progressively load more data to fill tall viewports", async ({ + page, + }) => { + // Set a tall viewport (1400px height) + await page.setViewportSize({ width: 1200, height: 1400 }); + + // Wait for the table to be visible + await page.waitForSelector("table"); + + // Wait a bit for progressive loading to potentially trigger + await page.waitForTimeout(3000); + + // Count the number of rows loaded + const rowCount = await page.locator("table tbody tr").count(); + + // With a 1400px viewport, we should have more than the default 20 rows + // Assuming each row is ~50px, we should fit at least 25-30 rows + expect(rowCount).toBeGreaterThan(20); + }); + + test("should trigger infinite scroll when scrolling near bottom", async ({ + page, + }) => { + // Set a medium viewport + await page.setViewportSize({ width: 1200, height: 800 }); + + // Wait for initial load + await page.waitForSelector("table tbody tr"); + + // Get initial row count + const initialRowCount = await page.locator("table tbody tr").count(); + + // Find the scrollable container + const scrollContainer = page.locator("div.overflow-auto").first(); + + // Scroll to near the bottom + await scrollContainer.evaluate((element) => { + element.scrollTop = element.scrollHeight - element.clientHeight - 100; + }); + + // Wait for loading indicator or new data + await page.waitForTimeout(2000); + + // Check if more rows were loaded + const newRowCount = await page.locator("table tbody tr").count(); + + // We should have more rows after scrolling + expect(newRowCount).toBeGreaterThan(initialRowCount); + }); +}); diff --git a/llama_stack/ui/hooks/useInfiniteScroll.ts b/llama_stack/ui/hooks/useInfiniteScroll.ts new file mode 100644 index 000000000..08a64a899 --- /dev/null +++ b/llama_stack/ui/hooks/useInfiniteScroll.ts @@ -0,0 +1,55 @@ +"use client"; + +import { useRef, useEffect } from "react"; + +interface UseInfiniteScrollOptions { + /** Whether the feature is enabled (e.g., hasMore data) */ + enabled?: boolean; + /** Threshold for intersection (0-1, how much of sentinel must be visible) */ + threshold?: number; + /** Margin around root to trigger earlier (e.g., "100px" to load 100px before visible) */ + rootMargin?: string; +} + +/** + * Custom hook for infinite scroll using Intersection Observer + * + * @param onLoadMore - Callback to load more data + * @param options - Configuration options + * @returns ref to attach to sentinel element + */ +export function useInfiniteScroll( + onLoadMore: (() => void) | undefined, + options: UseInfiniteScrollOptions = {}, +) { + const { enabled = true, threshold = 0.1, rootMargin = "100px" } = options; + const sentinelRef = useRef(null); + + useEffect(() => { + if (!onLoadMore || !enabled) return; + + const observer = new IntersectionObserver( + (entries) => { + const [entry] = entries; + if (entry.isIntersecting) { + onLoadMore(); + } + }, + { + threshold, + rootMargin, + }, + ); + + const sentinel = sentinelRef.current; + if (sentinel) { + observer.observe(sentinel); + } + + return () => { + observer.disconnect(); + }; + }, [onLoadMore, enabled, threshold, rootMargin]); + + return sentinelRef; +} diff --git a/llama_stack/ui/hooks/usePagination.ts b/llama_stack/ui/hooks/usePagination.ts new file mode 100644 index 000000000..135754a77 --- /dev/null +++ b/llama_stack/ui/hooks/usePagination.ts @@ -0,0 +1,132 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef } from "react"; +import { PaginationStatus, UsePaginationOptions } from "@/lib/types"; + +interface PaginationState { + data: T[]; + status: PaginationStatus; + hasMore: boolean; + error: Error | null; + lastId: string | null; +} + +interface PaginationResponse { + data: T[]; + has_more: boolean; + last_id: string; + first_id: string; + object: "list"; +} + +export interface PaginationReturn { + data: T[]; + status: PaginationStatus; + hasMore: boolean; + error: Error | null; + loadMore: () => void; +} + +interface UsePaginationParams extends UsePaginationOptions { + fetchFunction: (params: { + after?: string; + limit: number; + model?: string; + order?: string; + }) => Promise>; + errorMessagePrefix: string; +} + +export function usePagination({ + limit = 20, + model, + order = "desc", + fetchFunction, + errorMessagePrefix, +}: UsePaginationParams): PaginationReturn { + const [state, setState] = useState>({ + data: [], + status: "loading", + hasMore: true, + error: null, + lastId: null, + }); + + // Use refs to avoid stale closures + const stateRef = useRef(state); + stateRef.current = state; + + /** + * Fetches data from the API with cursor-based pagination + */ + const fetchData = useCallback( + async (after?: string, targetRows?: number) => { + const isInitialLoad = !after; + const fetchLimit = targetRows || limit; + + try { + setState((prev) => ({ + ...prev, + status: isInitialLoad ? "loading" : "loading-more", + error: null, + })); + + const response = await fetchFunction({ + after: after || undefined, + limit: fetchLimit, + ...(model && { model }), + ...(order && { order }), + }); + + setState((prev) => ({ + ...prev, + data: isInitialLoad + ? response.data + : [...prev.data, ...response.data], + hasMore: response.has_more, + lastId: response.last_id || null, + status: "idle", + })); + } catch (err) { + const errorMessage = isInitialLoad + ? `Failed to load ${errorMessagePrefix}. Please try refreshing the page.` + : `Failed to load more ${errorMessagePrefix}. Please try again.`; + + const error = + err instanceof Error + ? new Error(`${errorMessage} ${err.message}`) + : new Error(errorMessage); + + setState((prev) => ({ + ...prev, + error, + status: "error", + })); + } + }, + [limit, model, order, fetchFunction, errorMessagePrefix], + ); + + /** + * Loads more data for infinite scroll + */ + const loadMore = useCallback(() => { + const currentState = stateRef.current; + if (currentState.hasMore && currentState.status === "idle") { + fetchData(currentState.lastId || undefined); + } + }, [fetchData]); + + // Auto-load initial data on mount + useEffect(() => { + fetchData(); + }, []); + + return { + data: state.data, + status: state.status, + hasMore: state.hasMore, + error: state.error, + loadMore, + }; +} diff --git a/llama_stack/ui/jest.config.ts b/llama_stack/ui/jest.config.ts index b94f03450..65adb2d41 100644 --- a/llama_stack/ui/jest.config.ts +++ b/llama_stack/ui/jest.config.ts @@ -100,6 +100,7 @@ const config: Config = { // However, for mocks, sometimes explicit mapping is needed. "^@/lib/(.*)$": "/lib/$1", "^@/components/(.*)$": "/components/$1", + "^@/hooks/(.*)$": "/hooks/$1", // Add other aliases here if needed }, @@ -148,7 +149,7 @@ const config: Config = { // setupFiles: [], // A list of paths to modules that run some code to configure or set up the testing framework before each test - // setupFilesAfterEnv: [], + setupFilesAfterEnv: ["/jest.setup.ts"], // The number of seconds after which a test is considered as slow and reported as such in the results. // slowTestThreshold: 5, @@ -172,9 +173,7 @@ const config: Config = { // ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - // testPathIgnorePatterns: [ - // "/node_modules/" - // ], + testPathIgnorePatterns: ["/e2e/"], // The regexp pattern or array of patterns that Jest uses to detect test files // testRegex: [], diff --git a/llama_stack/ui/jest.setup.ts b/llama_stack/ui/jest.setup.ts new file mode 100644 index 000000000..2448728a8 --- /dev/null +++ b/llama_stack/ui/jest.setup.ts @@ -0,0 +1,23 @@ +// Import llama-stack-client shims for Node environment +import "llama-stack-client/shims/node"; + +// Add any other global test setup here +import "@testing-library/jest-dom"; + +// Mock ResizeObserver globally +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +// Mock IntersectionObserver globally +global.IntersectionObserver = class IntersectionObserver { + constructor(callback: IntersectionObserverCallback) {} + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } +} as any; diff --git a/llama_stack/ui/lib/types.ts b/llama_stack/ui/lib/types.ts index e08fb8d82..cfd9190af 100644 --- a/llama_stack/ui/lib/types.ts +++ b/llama_stack/ui/lib/types.ts @@ -43,6 +43,33 @@ export interface ChatCompletion { input_messages: ChatMessage[]; } +export interface ListChatCompletionsResponse { + data: ChatCompletion[]; + has_more: boolean; + first_id: string; + last_id: string; + object: "list"; +} + +export type PaginationStatus = "idle" | "loading" | "loading-more" | "error"; + +export interface PaginationState { + data: ChatCompletion[]; + status: PaginationStatus; + hasMore: boolean; + error: Error | null; + lastId: string | null; +} + +export interface UsePaginationOptions { + /** Number of items to load per page (default: 20) */ + limit?: number; + /** Filter by specific model */ + model?: string; + /** Sort order for results (default: "desc") */ + order?: "asc" | "desc"; +} + // Response types for OpenAI Responses API export interface ResponseInputMessageContent { text?: string; diff --git a/llama_stack/ui/package.json b/llama_stack/ui/package.json index d6b37520f..e9814663a 100644 --- a/llama_stack/ui/package.json +++ b/llama_stack/ui/package.json @@ -9,7 +9,8 @@ "lint": "next lint", "format": "prettier --write \"./**/*.{ts,tsx}\"", "format:check": "prettier --check \"./**/*.{ts,tsx}\"", - "test": "jest" + "test": "jest", + "test:e2e": "playwright test" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.13", diff --git a/llama_stack/ui/playwright.config.ts b/llama_stack/ui/playwright.config.ts new file mode 100644 index 000000000..dcaf13a52 --- /dev/null +++ b/llama_stack/ui/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./e2e", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "line", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: "http://localhost:8322", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: "npm run dev", + url: "http://localhost:8322", + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +});