import {render, screen, fireEvent, act, waitFor} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; import type {Cell} from "../../../src/utils/cellStore.ts"; import {cell} from "../../../src/utils/cellStore.ts"; import type {LogFilterPredicate, LogRecord} from "../../../src/components/Logging/useLogs.ts"; const mockFiltersRender = jest.fn(); const loggingStoreRef: { current: null | { setState: (state: Partial) => void } } = { current: null }; type LoggingSettingsState = { showRelativeTime: boolean; setShowRelativeTime: (show: boolean) => void; scrollToBottom: boolean; setScrollToBottom: (scroll: boolean) => void; }; jest.mock("zustand", () => { const actual = jest.requireActual("zustand"); const actualCreate = actual.create; return { __esModule: true, ...actual, create: (...args: any[]) => { const store = actualCreate(...args); const state = store.getState(); if ("setShowRelativeTime" in state && "setScrollToBottom" in state) { loggingStoreRef.current = store; } return store; }, }; }); jest.mock("../../../src/components/Logging/Filters.tsx", () => { const React = jest.requireActual("react"); return { __esModule: true, default: (props: any) => { mockFiltersRender(props); return React.createElement("div", {"data-testid": "filters-mock"}, "filters"); }, }; }); jest.mock("../../../src/components/Logging/useLogs.ts", () => { const actual = jest.requireActual("../../../src/components/Logging/useLogs.ts"); return { __esModule: true, ...actual, useLogs: jest.fn(), }; }); import {useLogs} from "../../../src/components/Logging/useLogs.ts"; const mockUseLogs = useLogs as jest.MockedFunction; type LoggingComponent = typeof import("../../../src/components/Logging/Logging.tsx").default; let Logging: LoggingComponent; beforeAll(async () => { if (!Element.prototype.scrollIntoView) { Object.defineProperty(Element.prototype, "scrollIntoView", { configurable: true, writable: true, value: function () {}, }); } ({default: Logging} = await import("../../../src/components/Logging/Logging.tsx")); }); beforeEach(() => { mockUseLogs.mockReset(); mockFiltersRender.mockReset(); mockUseLogs.mockReturnValue({filteredLogs: [], distinctNames: new Set()}); resetLoggingStore(); }); afterEach(() => { jest.restoreAllMocks(); }); function resetLoggingStore() { loggingStoreRef.current?.setState({ showRelativeTime: false, scrollToBottom: true, }); } function makeRecord(overrides: Partial = {}): LogRecord { return { name: "pepper.logger", message: "default", levelname: "INFO", levelno: 20, created: 1, relativeCreated: 1, firstCreated: 1, firstRelativeCreated: 1, ...overrides, }; } function makeCell(overrides: Partial = {}): Cell { return cell(makeRecord(overrides)); } describe("Logging component", () => { it("renders log messages and toggles the timestamp between absolute and relative view", async () => { const logCell = makeCell({ name: "pepper.trace.logging", message: "Ping", levelname: "WARNING", levelno: 30, created: 1_700_000_000, relativeCreated: 12_345, firstCreated: 1_700_000_000, firstRelativeCreated: 12_345, }); const names = new Set(["pepper.trace.logging"]); mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: names}); jest.spyOn(Date.prototype, "toLocaleTimeString").mockReturnValue("ABS TIME"); const user = userEvent.setup(); render(); expect(screen.getByText("Logs")).toBeInTheDocument(); expect(screen.getByText("WARNING")).toBeInTheDocument(); expect(screen.getByText("logging")).toBeInTheDocument(); expect(screen.getByText("Ping")).toBeInTheDocument(); let timestamp = screen.queryByText("ABS TIME"); if (!timestamp) { // if previous test left the store toggled, click once to show absolute time timestamp = screen.getByText("00:00:12.345"); await user.click(timestamp); timestamp = screen.getByText("ABS TIME"); } await user.click(timestamp); expect(screen.getByText("00:00:12.345")).toBeInTheDocument(); }); it("shows the scroll-to-bottom button after a manual scroll and scrolls when clicked", async () => { const logs = [ makeCell({message: "first", firstRelativeCreated: 1}), makeCell({message: "second", firstRelativeCreated: 2}), ]; mockUseLogs.mockReturnValue({filteredLogs: logs, distinctNames: new Set()}); const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {}); const user = userEvent.setup(); const view = render(); expect(screen.queryByRole("button", {name: "Scroll to bottom"})).toBeNull(); const scrollable = view.container.querySelector(".scroll-y"); expect(scrollable).toBeTruthy(); fireEvent.wheel(scrollable!); const button = await screen.findByRole("button", {name: "Scroll to bottom"}); await user.click(button); expect(scrollSpy).toHaveBeenCalled(); await waitFor(() => { expect(screen.queryByRole("button", {name: "Scroll to bottom"})).toBeNull(); }); }); it("scrolls the last element into view when a log cell updates", async () => { const logCell = makeCell({message: "Initial", firstRelativeCreated: 42}); mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: new Set()}); const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {}); render(); await waitFor(() => { expect(scrollSpy).toHaveBeenCalledTimes(1); }); scrollSpy.mockClear(); act(() => { const current = logCell.get(); logCell.set({...current, message: "Updated"}); }); expect(screen.getByText("Updated")).toBeInTheDocument(); await waitFor(() => { expect(scrollSpy).toHaveBeenCalledTimes(1); }); }); it("passes filter state to Filters and re-invokes useLogs when predicates change", async () => { const distinct = new Set(["pepper.core"]); mockUseLogs.mockImplementation((_filters: Map) => ({ filteredLogs: [], distinctNames: distinct, })); render(); expect(mockFiltersRender).toHaveBeenCalledTimes(1); const firstProps = mockFiltersRender.mock.calls[0][0]; expect(firstProps.agentNames).toBe(distinct); const initialMap = firstProps.filterPredicates; expect(initialMap).toBeInstanceOf(Map); expect(initialMap.size).toBe(0); expect(mockUseLogs).toHaveBeenCalledWith(initialMap); const updatedPredicate: LogFilterPredicate = { value: "custom", priority: 0, predicate: () => true, }; act(() => { firstProps.setFilterPredicates((prev: Map) => { const next = new Map(prev); next.set("custom", updatedPredicate); return next; }); }); await waitFor(() => { expect(mockUseLogs).toHaveBeenCalledTimes(2); }); const nextFilters = mockUseLogs.mock.calls[1][0]; expect(nextFilters.get("custom")).toBe(updatedPredicate); const secondProps = mockFiltersRender.mock.calls[mockFiltersRender.mock.calls.length - 1][0]; expect(secondProps.filterPredicates).toBe(nextFilters); }); });