Files
Felis/panel/src/components/LogConsole.test.tsx
T

155 lines
6.6 KiB
TypeScript

// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import i18next from "i18next";
import { LogConsole } from "./LogConsole";
const tier = vi.hoisted(() => ({ revalidate: () => Promise.resolve() }));
vi.mock("@/lib/tier", () => ({ useTier: () => tier }));
// sameChunk is wrapped so a test can see which chunks React skipped.
const spies = vi.hoisted(() => ({ sameChunk: null as unknown as ReturnType<typeof vi.fn> }));
vi.mock("@/lib/logchunks", async (importActual) => {
const actual = await importActual<typeof import("@/lib/logchunks")>();
spies.sameChunk = vi.fn(actual.sameChunk);
return { ...actual, sameChunk: spies.sameChunk };
});
// FakeEventSource replaces the browser one: jsdom has none.
class FakeEventSource {
static last: FakeEventSource | null = null;
onopen: ((ev: unknown) => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onerror: ((ev: unknown) => void) | null = null;
readyState = 0;
constructor() {
FakeEventSource.last = this;
}
close() {
this.readyState = 2;
}
addEventListener() {}
}
// send delivers lines and waits past the frame that commits them.
async function send(...lines: string[]) {
await act(async () => {
for (const line of lines) FakeEventSource.last!.onmessage?.({ data: line });
await new Promise((resolve) => setTimeout(resolve, 40));
});
}
const range = (from: number, to: number) => Array.from({ length: to - from }, (_, i) => `line ${from + i}`);
const lineRows = (container: HTMLElement) => container.querySelectorAll(".whitespace-pre-wrap");
beforeEach(() => {
vi.stubGlobal("EventSource", FakeEventSource);
spies.sameChunk.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("LogConsole", () => {
it("renders colour codes as styled text and never as markup", async () => {
const { container } = render(<LogConsole url="/c" />);
await send("§c<b>boom</b> §lloud§r plain");
const red = screen.getByText("<b>boom</b>", { exact: false });
expect(red.tagName).toBe("SPAN");
expect(red.style.color).toBe("rgb(255, 85, 85)");
expect(screen.getByText("loud").style.fontWeight).toBe("700");
expect(container.querySelector("b")).toBeNull();
expect(container.textContent).not.toContain("§");
});
it("hides only RCON connection lifecycle notices and can reveal them", async () => {
render(<LogConsole url="/c" />);
const started = "[15:42:03 INFO]: Thread RCON Client /10.42.0.80 started";
const stopped = "[15:42:03] [RCON Client /10.42.0.80/INFO]: Thread RCON Client /10.42.0.80 shutting down";
const warning = "[15:42:04 WARN]: Thread RCON Client /10.42.0.80 shutting down";
const error = "[15:42:04 ERROR]: RCON authentication failed";
const chat = "[15:42:04 INFO]: <Steve> Thread RCON Client /10.42.0.80 started";
await send(started, stopped, warning, error, chat);
expect(screen.queryByText(started)).toBeNull();
expect(screen.queryByText(stopped)).toBeNull();
expect(screen.getByText(warning)).toBeTruthy();
expect(screen.getByText(error).className).toContain("text-red-400");
expect(screen.getByText(chat)).toBeTruthy();
const toggle = screen.getByRole("button", { name: /Show RCON connection logs/ });
expect(toggle.getAttribute("aria-pressed")).toBe("false");
expect(toggle.textContent).toContain("(2)");
fireEvent.click(toggle);
expect(screen.getByText(started)).toBeTruthy();
expect(screen.getByText(stopped)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Hide RCON connection logs" }));
expect(screen.queryByText(started)).toBeNull();
});
it("explains when the buffer contains only filtered connection notices", async () => {
render(<LogConsole url="/c" />);
await send("[15:42:03 INFO]: Thread RCON Client /10.42.0.80 started");
expect(screen.getByText(i18next.t("servers:log_rcon_only"))).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Show RCON connection logs/ }));
expect(screen.queryByText(i18next.t("servers:log_rcon_only"))).toBeNull();
});
it("keeps showing new lines in a chunk that was already on screen, and after a clear", async () => {
const { container } = render(<LogConsole url="/c" />);
await send(...range(0, 150));
expect(screen.getByText("line 149")).toBeTruthy();
await send(...range(150, 160));
expect(screen.getByText("line 159")).toBeTruthy();
expect(lineRows(container)).toHaveLength(160);
fireEvent.click(screen.getByRole("button", { name: i18next.t("servers:log_clear_btn") }));
await send("after clear");
expect(screen.getByText("after clear")).toBeTruthy();
expect(screen.queryByText("line 159")).toBeNull();
expect(lineRows(container)).toHaveLength(1);
});
it("drops the oldest lines from the first chunk as the buffer trims", async () => {
const { container } = render(<LogConsole url="/c" />);
await send(...range(0, 2000));
expect(screen.getByText("line 0")).toBeTruthy();
await send(...range(2000, 2050));
expect(screen.queryByText("line 0")).toBeNull();
expect(screen.queryByText("line 49")).toBeNull();
expect(screen.getByText("line 50")).toBeTruthy();
expect(lineRows(container)).toHaveLength(2000);
});
it("re-renders only the chunk a new line lands in", async () => {
render(<LogConsole url="/c" />);
await send(...range(0, 250));
spies.sameChunk.mockClear();
await send("line 250");
expect(screen.getByText("line 250")).toBeTruthy();
const verdicts = spies.sameChunk.mock.results.map((r) => r.value);
expect(verdicts.filter((same) => same === true)).toHaveLength(2);
expect(verdicts.filter((same) => same === false)).toHaveLength(1);
});
});
describe("startup log retries", () => {
it("recovers from a fatal early attach without requiring a manual reconnect", async () => {
vi.useFakeTimers();
const view = render(<LogConsole url="/startup" starting />);
try {
const original = FakeEventSource.last!;
act(() => { original.readyState = 2; original.onerror?.({}); });
expect(screen.getByText(i18next.t("servers:log_reconnecting"))).toBeTruthy();
expect(screen.getByText(i18next.t("servers:log_starting_wait"))).toBeTruthy();
await act(async () => vi.advanceTimersByTime(5000));
expect(FakeEventSource.last).not.toBe(original);
act(() => FakeEventSource.last!.onopen?.({}));
expect(screen.getByText(i18next.t("servers:log_live"))).toBeTruthy();
} finally { view.unmount(); vi.useRealTimers(); }
});
});