feat(FN-4760): complete Step 3 — add PR checks polling hook
Fusion-Task-Id: FN-4760 Fusion-Task-Lineage: 71db4a53-ee59-49f0-a1f7-232954b86660
This commit is contained in:
committed by
gsxdsm
parent
49daa3046d
commit
76c5142f4f
120
packages/dashboard/app/hooks/__tests__/usePrChecksStream.test.ts
Normal file
120
packages/dashboard/app/hooks/__tests__/usePrChecksStream.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { usePrChecksStream } from "../usePrChecksStream";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchPrChecks: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchPrChecks = vi.mocked(api.fetchPrChecks);
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("usePrChecksStream", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(document, "hidden", { configurable: true, value: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("polls at the default interval", async () => {
|
||||
mockFetchPrChecks.mockResolvedValue({ checks: [], rollup: "unknown", lastCheckedAt: "2026-01-01T00:00:00Z" });
|
||||
|
||||
renderHook(() => usePrChecksStream({ taskId: "KB-1", prNumber: 1, enabled: true }));
|
||||
await flush();
|
||||
expect(mockFetchPrChecks).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15_000);
|
||||
});
|
||||
await flush();
|
||||
expect(mockFetchPrChecks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("backs off after 3 identical payloads", async () => {
|
||||
mockFetchPrChecks.mockResolvedValue({
|
||||
checks: [{ name: "ci", required: true, state: "pending" }],
|
||||
rollup: "pending",
|
||||
lastCheckedAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
|
||||
renderHook(() => usePrChecksStream({ taskId: "KB-1", prNumber: 1, enabled: true }));
|
||||
|
||||
await flush();
|
||||
expect(mockFetchPrChecks).toHaveBeenCalledTimes(1);
|
||||
await act(async () => vi.advanceTimersByTime(15_000));
|
||||
await flush();
|
||||
expect(mockFetchPrChecks).toHaveBeenCalledTimes(2);
|
||||
await act(async () => vi.advanceTimersByTime(15_000));
|
||||
await flush();
|
||||
expect(mockFetchPrChecks).toHaveBeenCalledTimes(3);
|
||||
await act(async () => vi.advanceTimersByTime(15_000));
|
||||
await flush();
|
||||
expect(mockFetchPrChecks).toHaveBeenCalledTimes(4);
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(59_000));
|
||||
await flush();
|
||||
expect(mockFetchPrChecks).toHaveBeenCalledTimes(4);
|
||||
await act(async () => vi.advanceTimersByTime(1_000));
|
||||
await flush();
|
||||
expect(mockFetchPrChecks).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("pauses when hidden and resumes on visibilitychange", async () => {
|
||||
mockFetchPrChecks.mockResolvedValue({ checks: [], rollup: "unknown", lastCheckedAt: "2026-01-01T00:00:00Z" });
|
||||
|
||||
renderHook(() => usePrChecksStream({ taskId: "KB-1", prNumber: 1, enabled: true }));
|
||||
await flush();
|
||||
expect(mockFetchPrChecks).toHaveBeenCalledTimes(1);
|
||||
|
||||
Object.defineProperty(document, "hidden", { configurable: true, value: true });
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
expect(mockFetchPrChecks).toHaveBeenCalledTimes(1);
|
||||
|
||||
Object.defineProperty(document, "hidden", { configurable: true, value: false });
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await flush();
|
||||
expect(mockFetchPrChecks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("refresh triggers off-cycle fetch", async () => {
|
||||
mockFetchPrChecks.mockResolvedValue({ checks: [], rollup: "unknown", lastCheckedAt: "2026-01-01T00:00:00Z" });
|
||||
|
||||
const { result } = renderHook(() => usePrChecksStream({ taskId: "KB-1", prNumber: 1, enabled: true }));
|
||||
await flush();
|
||||
expect(mockFetchPrChecks).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(mockFetchPrChecks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stops polling after unmount", async () => {
|
||||
mockFetchPrChecks.mockResolvedValue({ checks: [], rollup: "unknown", lastCheckedAt: "2026-01-01T00:00:00Z" });
|
||||
|
||||
const { unmount } = renderHook(() => usePrChecksStream({ taskId: "KB-1", prNumber: 1, enabled: true }));
|
||||
await flush();
|
||||
expect(mockFetchPrChecks).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
await act(async () => vi.advanceTimersByTime(60_000));
|
||||
|
||||
expect(mockFetchPrChecks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
172
packages/dashboard/app/hooks/usePrChecksStream.ts
Normal file
172
packages/dashboard/app/hooks/usePrChecksStream.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { fetchPrChecks, type PrCheckStatus, type PrChecksResponse } from "../api";
|
||||
|
||||
type RollupState = PrChecksResponse["rollup"];
|
||||
|
||||
interface UsePrChecksStreamOptions {
|
||||
taskId: string;
|
||||
projectId?: string;
|
||||
prNumber?: number;
|
||||
enabled?: boolean;
|
||||
initialChecks?: PrCheckStatus[];
|
||||
initialRollup?: RollupState;
|
||||
initialLastCheckedAt?: string;
|
||||
}
|
||||
|
||||
interface UsePrChecksStreamResult {
|
||||
checks: PrCheckStatus[];
|
||||
rollup: RollupState;
|
||||
lastCheckedAt?: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ACTIVE_INTERVAL_MS = 15_000;
|
||||
const BACKOFF_INTERVAL_MS = 60_000;
|
||||
|
||||
export function usePrChecksStream({
|
||||
taskId,
|
||||
projectId,
|
||||
prNumber,
|
||||
enabled = true,
|
||||
initialChecks = [],
|
||||
initialRollup = "unknown",
|
||||
initialLastCheckedAt,
|
||||
}: UsePrChecksStreamOptions): UsePrChecksStreamResult {
|
||||
const [checks, setChecks] = useState<PrCheckStatus[]>(initialChecks);
|
||||
const [rollup, setRollup] = useState<RollupState>(initialRollup);
|
||||
const [lastCheckedAt, setLastCheckedAt] = useState<string | undefined>(initialLastCheckedAt);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const stableCountRef = useRef(0);
|
||||
const previousSignatureRef = useRef("");
|
||||
const pendingClearAtRef = useRef<number | null>(null);
|
||||
|
||||
const shouldPoll = enabled && Boolean(taskId) && Boolean(prNumber);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const computeSignature = useCallback((items: PrCheckStatus[]) => items.map((check) => `${check.name}:${check.state}`).join("|"), []);
|
||||
|
||||
const scheduleNext = useCallback((delayMs: number, run: () => void) => {
|
||||
clearTimer();
|
||||
timerRef.current = setTimeout(run, delayMs);
|
||||
}, [clearTimer]);
|
||||
|
||||
const doFetch = useCallback(async () => {
|
||||
if (!shouldPoll || document.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchPrChecks(taskId, projectId);
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const signature = computeSignature(response.checks);
|
||||
if (signature === previousSignatureRef.current) {
|
||||
stableCountRef.current += 1;
|
||||
} else {
|
||||
previousSignatureRef.current = signature;
|
||||
stableCountRef.current = 0;
|
||||
}
|
||||
|
||||
setChecks(response.checks);
|
||||
setRollup(response.rollup);
|
||||
setLastCheckedAt(response.lastCheckedAt);
|
||||
|
||||
const hasPending = response.checks.some((check) => check.state === "pending");
|
||||
if (response.rollup === "success" && !hasPending) {
|
||||
if (!pendingClearAtRef.current) {
|
||||
pendingClearAtRef.current = Date.now();
|
||||
}
|
||||
} else {
|
||||
pendingClearAtRef.current = null;
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [computeSignature, projectId, shouldPoll, taskId]);
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
await doFetch();
|
||||
if (!shouldPoll || document.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingClearAtRef.current && Date.now() - pendingClearAtRef.current >= BACKOFF_INTERVAL_MS) {
|
||||
clearTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = stableCountRef.current >= 3 ? BACKOFF_INTERVAL_MS : ACTIVE_INTERVAL_MS;
|
||||
scheduleNext(delay, () => {
|
||||
void poll();
|
||||
});
|
||||
}, [clearTimer, doFetch, scheduleNext, shouldPoll]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
pendingClearAtRef.current = null;
|
||||
stableCountRef.current = 0;
|
||||
await doFetch();
|
||||
}, [doFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldPoll) {
|
||||
clearTimer();
|
||||
abortRef.current?.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
void poll();
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
clearTimer();
|
||||
abortRef.current?.abort();
|
||||
return;
|
||||
}
|
||||
void poll();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
clearTimer();
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [clearTimer, poll, shouldPoll]);
|
||||
|
||||
return useMemo(() => ({
|
||||
checks,
|
||||
rollup,
|
||||
lastCheckedAt,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
}), [checks, error, lastCheckedAt, loading, refresh, rollup]);
|
||||
}
|
||||
Reference in New Issue
Block a user