test(FUX-039): add cross-instance toast dedupe regression test

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
ddonaldson130
2026-07-08 12:47:13 -04:00
parent 8a35b0bc4c
commit 2479081415
2 changed files with 98 additions and 9 deletions

View File

@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { RuntimeFallbackBadge } from "../RuntimeFallbackBadge";
import { ToastProvider, useToast } from "../../hooks/useToast";
import { __resetRuntimeFallbackToastDedupeStoreForTests } from "../../hooks/useRuntimeFallbackStatus";
import type { TaskRuntimeFallbackResponse } from "../../api/legacy";
const legacyMocks = vi.hoisted(() => ({
@@ -88,6 +89,10 @@ describe("RuntimeFallbackBadge", () => {
beforeEach(() => {
vi.useFakeTimers();
legacyMocks.fetchTaskRuntimeFallback.mockReset();
// The toast dedupe store is module-level/shared by design (that is the
// fix under test) — reset it between test cases so one test's "already
// toasted" state does not leak into the next.
__resetRuntimeFallbackToastDedupeStoreForTests();
});
afterEach(() => {
@@ -238,12 +243,46 @@ describe("RuntimeFallbackBadge", () => {
expect(legacyMocks.fetchTaskRuntimeFallback).toHaveBeenCalled();
expect(screen.getByTestId("runtime-fallback-badge")).toBeInTheDocument();
});
it("fires exactly one toast when the same task/event is observed by two simultaneously-mounted badge instances (e.g. ActiveAgentsPanel + AgentsView rendering the same task concurrently)", async () => {
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
render(
<ToastProvider>
<RuntimeFallbackBadge taskId="FN-100" isInViewport={true} projectId="proj-1" />
<RuntimeFallbackBadge taskId="FN-100" isInViewport={true} projectId="proj-1" />
<ToastPeek />
</ToastProvider>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
// Both instances polled and both observed the same new eventId on their
// first poll, but the shared module-level dedupe store means only one of
// them should have won the "claim" and fired a toast.
expect(screen.getAllByTestId("toast-entry")).toHaveLength(1);
expect(screen.getAllByTestId("toast-entry")[0].textContent).toContain("hermes");
// Both badges still render independently (dedupe only affects the toast,
// not the per-instance badge display).
expect(screen.getAllByTestId("runtime-fallback-badge")).toHaveLength(2);
// Further polls with the same event on both instances must not add a
// second toast either.
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
expect(screen.getAllByTestId("toast-entry")).toHaveLength(1);
});
});
describe("RuntimeFallbackBadge — mobile breakpoint", () => {
beforeEach(() => {
vi.useFakeTimers();
legacyMocks.fetchTaskRuntimeFallback.mockReset();
__resetRuntimeFallbackToastDedupeStoreForTests();
});
afterEach(() => {

View File

@@ -14,11 +14,65 @@
* This hook only polls while `enabled` is true (callers should pass
* `isInViewport` so off-screen cards do not generate background traffic).
*/
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { fetchTaskRuntimeFallback, type TaskRuntimeFallbackResponse } from "../api/legacy";
const POLL_INTERVAL_MS = 30_000;
// Toast dedupe must be shared across ALL hook instances in the process, not
// scoped per-instance: the same task/event can be observed simultaneously by
// multiple mounted badges (e.g. ActiveAgentsPanel + AgentsView board/list +
// TaskCard all rendering the same in-progress task at once), each running
// its own useRuntimeFallbackStatus() call. A per-instance ref only dedupes
// within one component instance's own poll history, so the same eventId
// would independently look "newly observed" to every instance and fire one
// toast each. Module-level state is shared across every call site because
// there is exactly one copy of this module per process/bundle.
//
// Keyed by `${taskId}:${eventId}` (not eventId alone) so ids are unambiguous
// even if two different tasks' audit logs ever produced colliding event ids.
// Bounded via a simple FIFO eviction (insertion order === Map iteration
// order) so a long-lived dashboard session touching many tasks over many
// hours cannot grow this unboundedly; runtime-fallback events are rare
// (at most one per agent session), so a few hundred entries comfortably
// covers realistic session lengths without needing TTL bookkeeping.
const MAX_TOASTED_EVENTS = 500;
const toastedEventKeys = new Map<string, true>();
function toastKey(taskId: string, eventId: string): string {
return `${taskId}:${eventId}`;
}
/**
* Returns true and records the key the first time it is seen; returns false
* on every subsequent call for the same key, regardless of which hook
* instance/component asks. This is the single shared gate all simultaneously
* mounted badge instances for the same task funnel through.
*/
function claimToastOnce(taskId: string, eventId: string): boolean {
const key = toastKey(taskId, eventId);
if (toastedEventKeys.has(key)) {
return false;
}
toastedEventKeys.set(key, true);
if (toastedEventKeys.size > MAX_TOASTED_EVENTS) {
const oldestKey = toastedEventKeys.keys().next().value;
if (oldestKey !== undefined) {
toastedEventKeys.delete(oldestKey);
}
}
return true;
}
/**
* Test-only escape hatch: clears the shared module-level dedupe store between
* test cases so one test's "already toasted" state cannot leak into the
* next. Not used by production code paths.
*/
export function __resetRuntimeFallbackToastDedupeStoreForTests(): void {
toastedEventKeys.clear();
}
export interface RuntimeFallbackStatus {
/** True only when the latest resolution has wasConfigured=false and a non-empty runtimeHint. */
showBadge: boolean;
@@ -55,10 +109,6 @@ export function useRuntimeFallbackStatus(
projectId?: string,
): RuntimeFallbackStatus {
const [status, setStatus] = useState<RuntimeFallbackStatus>(IDLE_STATUS);
// Dedupe key for toasts: last audit event ID we already toasted for. Persists across
// polls/re-renders for the lifetime of the component so the toast fires exactly once
// per newly-observed fallback session, not on every poll.
const lastToastedEventIdRef = useRef<string | null>(null);
useEffect(() => {
if (!enabled || !taskId) {
@@ -83,10 +133,10 @@ export function useRuntimeFallbackStatus(
return;
}
const isNewlyObserved = data.eventId !== null && data.eventId !== lastToastedEventIdRef.current;
if (isNewlyObserved && data.eventId) {
lastToastedEventIdRef.current = data.eventId;
}
// Dedupe against the shared module-level store (not a per-instance ref)
// so a fallback event toasts exactly once across every simultaneously
// mounted badge instance for this task, not once per instance.
const isNewlyObserved = data.eventId !== null && taskId !== undefined && claimToastOnce(taskId, data.eventId);
setStatus({
showBadge: true,