admin: live ring capacity is injectable, eviction test no longer timing-bound
Gates / test-aarch64 (push) Failing after 3h1m47s
Gates / package (push) Successful in 4m17s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 4h45m14s
Gates / frontend (push) Successful in 1m21s
Gates / test (push) Successful in 1m42s
Gates / test-aarch64 (push) Failing after 3h1m47s
Gates / package (push) Successful in 4m17s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 4h45m14s
Gates / frontend (push) Successful in 1m21s
Gates / test (push) Successful in 1m42s
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { RouterContextProvider, RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
@@ -16,6 +16,17 @@ import type { Client } from "@/lib/types";
|
||||
import { provenance, queryRow } from "@/features/provenance/provenanceFixture";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import { FakeEventSource } from "./fakeEventSource";
|
||||
import LiveActivity from "./LiveActivity";
|
||||
import type { ActivitySearch } from "./search";
|
||||
|
||||
const LIVE_ORIGIN: ActivitySearch = {
|
||||
mode: "live",
|
||||
since: undefined,
|
||||
until: undefined,
|
||||
domain: undefined,
|
||||
client: undefined,
|
||||
blocked: undefined,
|
||||
};
|
||||
|
||||
function client(ip: string, name: string, learnedName: string): Client {
|
||||
return {
|
||||
@@ -327,23 +338,55 @@ test("opening a second row moves the expanded state and the focus with it", asyn
|
||||
expect(document.activeElement).toBe(second);
|
||||
});
|
||||
|
||||
test("an open streamed detail survives the row being evicted from the ring buffer", async () => {
|
||||
await openLive();
|
||||
/**
|
||||
* The one render that bypasses the route, because the ring capacity is a
|
||||
* parameter of the component and the route deliberately never passes it:
|
||||
* evicting a row at the real 500 means pushing 500 frames through React state,
|
||||
* which proves nothing the fifth frame does not. The real route tree still
|
||||
* backs the links inside the detail.
|
||||
*/
|
||||
function renderLiveWithCapacity(capacity: number) {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/activity?mode=live"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterContextProvider router={router}>
|
||||
<LiveActivity origin={LIVE_ORIGIN} capacity={capacity} />
|
||||
</RouterContextProvider>
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
test("an open streamed detail survives the row being evicted from the ring buffer", () => {
|
||||
renderLiveWithCapacity(5);
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
|
||||
// 500 more queries: the ring keeps the newest 500, so the selected row is
|
||||
// gone from the table. The detail is a snapshot, not a lookup into the ring.
|
||||
// One ringful more: the ring keeps the newest 5, so the selected row is gone
|
||||
// from the table. The detail is a snapshot, not a lookup into the ring.
|
||||
act(() => {
|
||||
for (let index = 0; index < 500; index += 1) {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
|
||||
}
|
||||
});
|
||||
expect(screen.getAllByRole("row")).toHaveLength(6);
|
||||
expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull();
|
||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the route renders the live ring at its production capacity", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "pinned.example")));
|
||||
|
||||
expect(screen.getByText(/last 500 kept/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Freeze" }));
|
||||
expect(screen.getByText(/newest 500 kept/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an open streamed detail survives Freeze and Resume", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "held.example")));
|
||||
|
||||
@@ -276,8 +276,19 @@ function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: Activi
|
||||
);
|
||||
}
|
||||
|
||||
export default function LiveActivity({ origin }: { origin: ActivitySearch }) {
|
||||
const live = useLiveQueries();
|
||||
/**
|
||||
* `capacity` is the ring size, a parameter only so a test can provoke an
|
||||
* eviction with a handful of rows rather than 500 frames through React state.
|
||||
* The route renders this without it, so the app is always the 500-row ring.
|
||||
*/
|
||||
export default function LiveActivity({
|
||||
origin,
|
||||
capacity = RING_CAPACITY,
|
||||
}: {
|
||||
origin: ActivitySearch;
|
||||
capacity?: number;
|
||||
}) {
|
||||
const live = useLiveQueries({ capacity });
|
||||
const clientNames = useClientNames();
|
||||
const [selected, setSelected] = useState<StreamedRow | null>(null);
|
||||
const trigger = useRef<HTMLButtonElement | null>(null);
|
||||
@@ -312,8 +323,7 @@ export default function LiveActivity({ origin }: { origin: ActivitySearch }) {
|
||||
|
||||
{live.frozen && (
|
||||
<p {...stylex.props(styles.note)} role="status">
|
||||
Display frozen — new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "}
|
||||
kept).
|
||||
Display frozen — new queries keep buffering ({live.liveCount} in buffer, newest {capacity} kept).
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -413,7 +423,7 @@ export default function LiveActivity({ origin }: { origin: ActivitySearch }) {
|
||||
</div>
|
||||
<p {...stylex.props(styles.footnote)}>
|
||||
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
|
||||
{RING_CAPACITY} kept).
|
||||
{capacity} kept).
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,12 @@ export interface LiveQueriesOptions {
|
||||
fetchSince?: (since: number) => Promise<QueriesPage>;
|
||||
/** Cheap session-gated GET fired once on entering capped, to distinguish an expired session from a real cap. */
|
||||
probeSession?: () => Promise<unknown>;
|
||||
/**
|
||||
* Ring size. Injectable so a test can provoke an eviction with a handful of
|
||||
* rows instead of pushing 500 frames through React state; the app never
|
||||
* passes it, and the operator never sees it.
|
||||
*/
|
||||
capacity?: number;
|
||||
}
|
||||
|
||||
// A transient drop is invisible to EventSource beyond a bare `error` event;
|
||||
@@ -34,7 +40,10 @@ export interface LiveQueriesOptions {
|
||||
export const CAP_ERROR_THRESHOLD = 3;
|
||||
|
||||
const defaultEventSource: EventSourceFactory = (url) => new EventSource(url);
|
||||
const defaultFetchSince = (since: number): Promise<QueriesPage> => api.getQueries({ since, limit: RING_CAPACITY });
|
||||
const defaultFetchSince =
|
||||
(capacity: number) =>
|
||||
(since: number): Promise<QueriesPage> =>
|
||||
api.getQueries({ since, limit: capacity });
|
||||
const defaultProbeSession = (): Promise<unknown> => api.getPause();
|
||||
|
||||
function isUnauthorized(error: unknown): boolean {
|
||||
@@ -79,7 +88,8 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
|
||||
errorsRef.current = 0;
|
||||
setStatus("connecting");
|
||||
const opts = optionsRef.current;
|
||||
const fetchSince = opts?.fetchSince ?? defaultFetchSince;
|
||||
const capacity = opts?.capacity ?? RING_CAPACITY;
|
||||
const fetchSince = opts?.fetchSince ?? defaultFetchSince(capacity);
|
||||
const probeSession = opts?.probeSession ?? defaultProbeSession;
|
||||
const es = (opts?.createEventSource ?? defaultEventSource)(opts?.url ?? api.liveQueriesUrl);
|
||||
esRef.current = es;
|
||||
@@ -94,7 +104,7 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
|
||||
fetchSince(since).then(
|
||||
(page) => {
|
||||
if (esRef.current !== es) return;
|
||||
const merged = mergeGap(bufferRef.current, page.queries, () => ++keyRef.current);
|
||||
const merged = mergeGap(bufferRef.current, page.queries, () => ++keyRef.current, capacity);
|
||||
bufferRef.current = merged.rows;
|
||||
setRows(merged.rows);
|
||||
setMissed(merged.missed);
|
||||
@@ -122,11 +132,11 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
|
||||
return;
|
||||
}
|
||||
lastSeenTsRef.current = payload.request.time;
|
||||
bufferRef.current = pushRow(bufferRef.current, {
|
||||
kind: "streamed",
|
||||
event: payload,
|
||||
key: ++keyRef.current,
|
||||
});
|
||||
bufferRef.current = pushRow(
|
||||
bufferRef.current,
|
||||
{ kind: "streamed", event: payload, key: ++keyRef.current },
|
||||
capacity,
|
||||
);
|
||||
setRows(bufferRef.current);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user