test(web): cover queue polling and stale-dream detection
- pickQueueRefetchInterval picks 2.5s when work is active, 10s otherwise - useStaleQueueDetection only flags after 30 min without forward progress - stall anchor resets when completed advances or all work finishes
This commit is contained in:
176
packages/web/src/test/dream-progress.test.tsx
Normal file
176
packages/web/src/test/dream-progress.test.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
pickQueueRefetchInterval,
|
||||||
|
QUEUE_REFETCH_ACTIVE_MS,
|
||||||
|
QUEUE_REFETCH_IDLE_MS,
|
||||||
|
} from "@/api/queries";
|
||||||
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
import { STALE_QUEUE_THRESHOLD_MS, useStaleQueueDetection } from "@/hooks/useStaleQueueDetection";
|
||||||
|
|
||||||
|
type QueueStatus = components["schemas"]["QueueStatus"];
|
||||||
|
|
||||||
|
function buildStatus(partial: Partial<QueueStatus>): QueueStatus {
|
||||||
|
return {
|
||||||
|
total_work_units: 0,
|
||||||
|
completed_work_units: 0,
|
||||||
|
in_progress_work_units: 0,
|
||||||
|
pending_work_units: 0,
|
||||||
|
sessions: null,
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pickQueueRefetchInterval", () => {
|
||||||
|
it("backs off to idle interval when no data has loaded yet", () => {
|
||||||
|
expect(pickQueueRefetchInterval(undefined)).toBe(QUEUE_REFETCH_IDLE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses idle interval when no work is queued or in-flight", () => {
|
||||||
|
const data = buildStatus({
|
||||||
|
total_work_units: 5,
|
||||||
|
completed_work_units: 5,
|
||||||
|
});
|
||||||
|
expect(pickQueueRefetchInterval(data)).toBe(QUEUE_REFETCH_IDLE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses active interval when work is in progress", () => {
|
||||||
|
const data = buildStatus({
|
||||||
|
total_work_units: 5,
|
||||||
|
completed_work_units: 2,
|
||||||
|
in_progress_work_units: 1,
|
||||||
|
});
|
||||||
|
expect(pickQueueRefetchInterval(data)).toBe(QUEUE_REFETCH_ACTIVE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses active interval when work is pending even without in-progress", () => {
|
||||||
|
const data = buildStatus({
|
||||||
|
total_work_units: 5,
|
||||||
|
completed_work_units: 0,
|
||||||
|
pending_work_units: 3,
|
||||||
|
});
|
||||||
|
expect(pickQueueRefetchInterval(data)).toBe(QUEUE_REFETCH_ACTIVE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active interval is faster than idle interval", () => {
|
||||||
|
// Sanity check on the constants so a future tweak doesn't invert them.
|
||||||
|
expect(QUEUE_REFETCH_ACTIVE_MS).toBeLessThan(QUEUE_REFETCH_IDLE_MS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useStaleQueueDetection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-05-24T12:00:00Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no stall when there's no active work", () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useStaleQueueDetection(buildStatus({ total_work_units: 3, completed_work_units: 3 })),
|
||||||
|
);
|
||||||
|
expect(result.current.stalledSince).toBeNull();
|
||||||
|
expect(result.current.isStale).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag a freshly-started run as stale", () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useStaleQueueDetection(
|
||||||
|
buildStatus({
|
||||||
|
total_work_units: 5,
|
||||||
|
completed_work_units: 0,
|
||||||
|
in_progress_work_units: 2,
|
||||||
|
pending_work_units: 3,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(result.current.stalledSince).not.toBeNull();
|
||||||
|
expect(result.current.isStale).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a warning after 30 minutes of in-progress work without forward progress", () => {
|
||||||
|
const stuck = buildStatus({
|
||||||
|
total_work_units: 5,
|
||||||
|
completed_work_units: 0,
|
||||||
|
in_progress_work_units: 2,
|
||||||
|
pending_work_units: 3,
|
||||||
|
});
|
||||||
|
const { result } = renderHook(({ data }) => useStaleQueueDetection(data), {
|
||||||
|
initialProps: { data: stuck },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isStale).toBe(false);
|
||||||
|
|
||||||
|
// Advance past the threshold; the hook's internal tick interval triggers
|
||||||
|
// a re-render so the new elapsed time is reflected.
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(STALE_QUEUE_THRESHOLD_MS + 30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isStale).toBe(true);
|
||||||
|
expect(result.current.elapsedMs).toBeGreaterThan(STALE_QUEUE_THRESHOLD_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the stall anchor when completed_work_units advances", () => {
|
||||||
|
const stuck = buildStatus({
|
||||||
|
total_work_units: 5,
|
||||||
|
completed_work_units: 0,
|
||||||
|
in_progress_work_units: 2,
|
||||||
|
pending_work_units: 3,
|
||||||
|
});
|
||||||
|
const progressed = buildStatus({
|
||||||
|
total_work_units: 5,
|
||||||
|
completed_work_units: 2,
|
||||||
|
in_progress_work_units: 1,
|
||||||
|
pending_work_units: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(({ data }) => useStaleQueueDetection(data), {
|
||||||
|
initialProps: { data: stuck },
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(STALE_QUEUE_THRESHOLD_MS + 60_000);
|
||||||
|
});
|
||||||
|
expect(result.current.isStale).toBe(true);
|
||||||
|
|
||||||
|
// Forward progress arrives — the stall clock should reset.
|
||||||
|
rerender({ data: progressed });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(0);
|
||||||
|
});
|
||||||
|
expect(result.current.isStale).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the stall anchor once all work finishes", () => {
|
||||||
|
const stuck = buildStatus({
|
||||||
|
total_work_units: 5,
|
||||||
|
completed_work_units: 0,
|
||||||
|
in_progress_work_units: 2,
|
||||||
|
pending_work_units: 3,
|
||||||
|
});
|
||||||
|
const idle = buildStatus({
|
||||||
|
total_work_units: 5,
|
||||||
|
completed_work_units: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(({ data }) => useStaleQueueDetection(data), {
|
||||||
|
initialProps: { data: stuck },
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(STALE_QUEUE_THRESHOLD_MS + 60_000);
|
||||||
|
});
|
||||||
|
expect(result.current.isStale).toBe(true);
|
||||||
|
|
||||||
|
rerender({ data: idle });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(0);
|
||||||
|
});
|
||||||
|
expect(result.current.stalledSince).toBeNull();
|
||||||
|
expect(result.current.isStale).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user