Files
fusion/packages/dashboard/app/components/__tests__/AgentLogViewer.layout.test.tsx
gsxdsm e48f80b764 FN-7028: split AgentLogViewer tests by concern
Split the large AgentLogViewer suite into focused, line-count-friendly test files.

- Move header, layout, markdown, and rendering coverage into separate test modules.
- Add a shared AgentLogViewer test helper for reusable setup.
- Remove the oversized combined AgentLogViewer test and update the line-count baseline.

Files changed:
 .../__tests__/AgentLogViewer.header.test.tsx       |  490 +++++
 .../__tests__/AgentLogViewer.layout.test.tsx       |  367 ++++
 .../__tests__/AgentLogViewer.markdown.test.tsx     |  797 ++++++++
 .../__tests__/AgentLogViewer.rendering.test.tsx    |  410 ++++
 .../__tests__/AgentLogViewer.test-helpers.ts       |   16 +
 .../components/__tests__/AgentLogViewer.test.tsx   | 2010 --------------------
 scripts/line-count-baseline.json                   |    1 -
 7 files changed, 2080 insertions(+), 2011 deletions(-)

Fusion-Task-Id: FN-7028

Fusion-Task-Lineage: 5ef134c7-0969-4b9b-908c-9713daa9d0d2
2026-06-25 19:51:21 -07:00

368 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { AgentLogViewer } from "../AgentLogViewer";
import { makeEntry, getScrollContainer } from "./AgentLogViewer.test-helpers";
import "../../styles.css";
import "../TaskDetailModal.css";
// Mock lucide-react icons used by AgentLogViewer and ProviderIcon
vi.mock("lucide-react", () => ({
Maximize2: () => null,
Minimize2: () => null,
Loader2: () => null,
Cpu: () => null,
ChevronDown: () => null,
ChevronRight: () => null,
}));
describe("AgentLogViewer", () => {
beforeEach(() => {
window.localStorage.clear();
});
describe("horizontal overflow prevention", () => {
it("uses the scroll container class for overflow-x handling", () => {
const longString = "A".repeat(300);
const entries = [makeEntry({ text: longString })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const scrollContainer = getScrollContainer(container);
expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true);
expect(scrollContainer.style.overflowX).toBe("");
});
it("uses the scroll container class for overflow-wrap handling", () => {
const entries = [makeEntry({ text: "x".repeat(250) })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const scrollContainer = getScrollContainer(container);
expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true);
expect(scrollContainer.style.overflowWrap).toBe("");
});
it("renders pre elements with overflow-x auto for internal scrolling", () => {
const longLine = "const x = " + "'a'.repeat(500)";
const entries = [makeEntry({ text: "```\n" + longLine + "\n```" })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const pre = container.querySelector("pre") as HTMLElement;
expect(pre).toBeTruthy();
expect(pre.style.overflowX).toBe("auto");
expect(pre.style.maxWidth).toBe("100%");
});
it("applies model-header wrapping via class", () => {
const entries = [makeEntry()];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const header = container.querySelector("[data-testid='agent-log-model-header']") as HTMLElement;
expect(header.classList.contains("agent-log-model-header")).toBe(true);
expect(header.style.flexWrap).toBe("");
});
});
describe("full-height layout", () => {
it("does not have a fixed maxHeight constraint", () => {
const entries = [makeEntry()];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement;
// The viewer should NOT have a maxHeight of 500px (the old fixed constraint)
expect(viewer.style.maxHeight).not.toBe("500px");
// maxHeight should be empty (unset) so the viewer can grow to fill available space
expect(viewer.style.maxHeight).toBe("");
});
it("uses class-based overflow-y scrolling on the entries container", () => {
const entries = [makeEntry()];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const scrollContainer = getScrollContainer(container);
// Scrolling behavior is now defined in CSS.
expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true);
expect(scrollContainer.style.overflowY).toBe("");
});
it("uses agent-log-viewer--streaming class when entries are present", () => {
const entries = [makeEntry()];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement;
expect(viewer.classList.contains("agent-log-viewer")).toBe(true);
expect(viewer.classList.contains("agent-log-viewer--streaming")).toBe(true);
});
it("does not use streaming class on loading state", () => {
const { container } = render(<AgentLogViewer entries={[]} loading={true} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement;
expect(viewer.classList.contains("agent-log-viewer")).toBe(true);
expect(viewer.classList.contains("agent-log-viewer--streaming")).toBe(false);
});
});
describe("sticky header layout", () => {
it("renders the model header as a sibling of the scroll container", () => {
const entries = [makeEntry()];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement;
const header = screen.getByTestId("agent-log-model-header");
const scrollContainer = getScrollContainer(container);
expect(header.parentElement).toBe(viewer);
expect(scrollContainer.parentElement).toBe(viewer);
expect(scrollContainer.contains(header)).toBe(false);
});
it("renders log entry rows inside the scroll container", () => {
const entries = [
makeEntry({ type: "text", text: "hello" }),
makeEntry({ type: "tool", text: "Bash" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const scrollContainer = getScrollContainer(container);
expect(scrollContainer.querySelector(".agent-log-text")).toBeTruthy();
expect(scrollContainer.querySelector(".agent-log-tool")).toBeTruthy();
});
it("renders pagination summary and load-more controls inside the scroll container", () => {
const entries = [makeEntry({ text: "hello" })];
const { container } = render(
<AgentLogViewer
entries={entries}
loading={false}
totalCount={42}
hasMore={true}
onLoadMore={() => {}}
/>,
);
const scrollContainer = getScrollContainer(container);
expect(scrollContainer.querySelector("[data-testid='agent-log-summary']")).toBeTruthy();
expect(scrollContainer.querySelector("[data-testid='agent-log-load-more']")).toBeTruthy();
});
it("renders the return-to-live button inside the scroll container", () => {
const entries = [
makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }),
makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const scrollContainer = getScrollContainer(container);
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 200 });
scrollContainer.scrollTop = 300;
fireEvent.scroll(scrollContainer);
const returnToLive = screen.getByTestId("agent-log-return-to-live");
expect(returnToLive.parentElement).toBe(scrollContainer);
});
});
describe("auto-scroll behavior", () => {
it("scrolls to bottom when streaming updates arrive and user is near the bottom", () => {
const initialEntries = [
makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }),
];
const streamedEntries = [
...initialEntries,
makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }),
];
const { rerender, container } = render(<AgentLogViewer entries={initialEntries} loading={false} />);
const viewer = getScrollContainer(container);
let scrollHeight = 600;
Object.defineProperty(viewer, "scrollHeight", {
configurable: true,
get: () => scrollHeight,
});
viewer.scrollTop = 560;
rerender(<AgentLogViewer entries={[...initialEntries]} loading={false} />);
scrollHeight = 720;
rerender(<AgentLogViewer entries={streamedEntries} loading={false} />);
expect(viewer.scrollTop).toBe(720);
});
it("does not auto-scroll when streaming updates arrive and user is reading older output", () => {
const initialEntries = [
makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }),
];
const streamedEntries = [
...initialEntries,
makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }),
];
const { rerender, container } = render(<AgentLogViewer entries={initialEntries} loading={false} />);
const viewer = getScrollContainer(container);
let scrollHeight = 1000;
Object.defineProperty(viewer, "scrollHeight", {
configurable: true,
get: () => scrollHeight,
});
viewer.scrollTop = 220;
rerender(<AgentLogViewer entries={[...initialEntries]} loading={false} />);
scrollHeight = 1120;
rerender(<AgentLogViewer entries={streamedEntries} loading={false} />);
expect(viewer.scrollTop).toBe(220);
});
it("keeps viewport anchored when older history is prepended", () => {
const initialEntries = [
makeEntry({ text: "recent", timestamp: "2026-01-01T00:00:00Z" }),
];
const olderLoadedEntries = [
makeEntry({ text: "older", timestamp: "2025-12-31T23:59:00Z" }),
...initialEntries,
];
const { rerender, container } = render(<AgentLogViewer entries={initialEntries} loading={false} />);
const viewer = getScrollContainer(container);
let scrollHeight = 900;
Object.defineProperty(viewer, "scrollHeight", {
configurable: true,
get: () => scrollHeight,
});
viewer.scrollTop = 260;
rerender(<AgentLogViewer entries={[...initialEntries]} loading={false} />);
scrollHeight = 1030;
rerender(<AgentLogViewer entries={olderLoadedEntries} loading={false} />);
// Anchored by delta (1030 - 900): 260 + 130
expect(viewer.scrollTop).toBe(390);
});
it("shows return-to-live button when user scrolls away from bottom", () => {
const entries = [
makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }),
makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const viewer = getScrollContainer(container);
Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 });
viewer.scrollTop = 300;
fireEvent.scroll(viewer);
expect(screen.getByTestId("agent-log-return-to-live")).toBeTruthy();
});
it("hides return-to-live button when user is following live output", () => {
const entries = [
makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }),
makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const viewer = getScrollContainer(container);
Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 });
viewer.scrollTop = 760;
fireEvent.scroll(viewer);
expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull();
});
it("returns to bottom and resumes following when return-to-live is clicked", () => {
const entries = [
makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }),
makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const viewer = getScrollContainer(container);
Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 });
viewer.scrollTop = 280;
fireEvent.scroll(viewer);
const returnButton = screen.getByTestId("agent-log-return-to-live");
fireEvent.click(returnButton);
expect(viewer.scrollTop).toBe(1000);
expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull();
});
it("re-pins to bottom on resize while following", () => {
const resizeCallbacks: Array<() => void> = [];
const originalResizeObserver = globalThis.ResizeObserver;
class ResizeObserverMock {
constructor(callback: ResizeObserverCallback) {
resizeCallbacks.push(() => callback([], this as unknown as ResizeObserver));
}
observe() {}
unobserve() {}
disconnect() {}
}
Object.defineProperty(globalThis, "ResizeObserver", {
configurable: true,
value: ResizeObserverMock,
});
try {
const entries = [
makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }),
makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const viewer = getScrollContainer(container);
Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1200 });
Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 });
viewer.scrollTop = 980;
fireEvent.scroll(viewer);
viewer.scrollTop = 640;
resizeCallbacks.forEach((callback) => callback());
expect(viewer.scrollTop).toBe(1200);
} finally {
if (originalResizeObserver) {
Object.defineProperty(globalThis, "ResizeObserver", {
configurable: true,
value: originalResizeObserver,
});
} else {
delete (globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
}
}
});
});
describe("pagination placement", () => {
it("renders the load-more control above the first log entry", () => {
const entries = [
makeEntry({ text: "oldest", timestamp: "2026-01-01T00:00:00Z" }),
makeEntry({ text: "newest", timestamp: "2026-01-01T00:00:01Z" }),
];
const { container } = render(
<AgentLogViewer
entries={entries}
loading={false}
hasMore={true}
onLoadMore={() => {}}
/>,
);
const loadMore = screen.getByTestId("agent-log-load-more");
const firstRow = container.querySelector(".agent-log-text") as HTMLElement;
expect(firstRow).toBeTruthy();
expect(loadMore.compareDocumentPosition(firstRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});
});