Merge commit 'c04442f6ea8ead6b65330bc03dc434dc9278985c'

This commit is contained in:
gsxdsm
2026-05-21 16:09:54 -07:00
10 changed files with 407 additions and 3 deletions

View File

@@ -90,3 +90,5 @@ Dashboard Phase 1 resume instrumentation adds observation-only client/server tra
- Server ring (5000, in-memory): `GET /api/diagnostics/resume-events?limit=&since=&view=` returns `{ events, droppedSinceLastRead }` - Server ring (5000, in-memory): `GET /api/diagnostics/resume-events?limit=&since=&view=` returns `{ events, droppedSinceLastRead }`
- Client batching: POST `/api/diagnostics/resume-events` in idle batches (`<=25` per POST). - Client batching: POST `/api/diagnostics/resume-events` in idle batches (`<=25` per POST).
- Disable knob: `window.__fusionDebug.resumeInstrumentation.setEnabled(false)`. - Disable knob: `window.__fusionDebug.resumeInstrumentation.setEnabled(false)`.
FN-5415 extends this coverage across remaining board/data visibility hooks: `useNodes`, `useMeshState`, `useProjects`, and `useManagedDockerNodes`. Each now emits `trigger: "visibility"` with `reason: "debounced-refresh"` when refresh is taken and `reason: "debounce-skipped"` (including `detail.timeSinceLastRefreshMs`) when suppressed by debounce. This completes board/data-hook resume-correlation coverage needed for FN-5392 Phase 2 remediation analysis.

View File

@@ -0,0 +1,78 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../../api";
const { recordResumeEvent } = vi.hoisted(() => ({
recordResumeEvent: vi.fn(),
}));
vi.mock("../../api", () => ({
fetchManagedDockerNodes: vi.fn(),
fetchManagedDockerNodeContainerStatus: vi.fn(),
fetchDockerNodeLogs: vi.fn(),
createManagedDockerNode: vi.fn(),
}));
vi.mock("../../utils/resumeInstrumentation", () => ({
recordResumeEvent,
}));
const mockFetchManagedDockerNodes = vi.mocked(api.fetchManagedDockerNodes);
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("useManagedDockerNodes resume instrumentation", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchManagedDockerNodes.mockReset();
recordResumeEvent.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("emits debounced-refresh then debounce-skipped and refreshes once for visibility events", async () => {
mockFetchManagedDockerNodes.mockResolvedValue([]);
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
const { useManagedDockerNodes } = await import("../useManagedDockerNodes");
renderHook(() => useManagedDockerNodes());
await act(async () => {
await flushPromises();
});
// Reset to isolate visibility-driven refreshes from initial mount fetch.
mockFetchManagedDockerNodes.mockClear();
await act(async () => {
vi.advanceTimersByTime(1100);
document.dispatchEvent(new Event("visibilitychange"));
await flushPromises();
});
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
await flushPromises();
});
expect(recordResumeEvent).toHaveBeenNthCalledWith(1, expect.objectContaining({
view: "useManagedDockerNodes",
trigger: "visibility",
reason: "debounced-refresh",
}));
expect(recordResumeEvent).toHaveBeenNthCalledWith(2, expect.objectContaining({
view: "useManagedDockerNodes",
trigger: "visibility",
reason: "debounce-skipped",
detail: expect.objectContaining({
timeSinceLastRefreshMs: expect.any(Number),
}),
}));
expect(recordResumeEvent).toHaveBeenCalledTimes(2);
expect(mockFetchManagedDockerNodes).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,83 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../../api";
const { recordResumeEvent } = vi.hoisted(() => ({
recordResumeEvent: vi.fn(),
}));
vi.mock("../../api", () => ({
fetchMeshState: vi.fn(),
}));
vi.mock("../../utils/resumeInstrumentation", () => ({
recordResumeEvent,
}));
const mockFetchMeshState = vi.mocked(api.fetchMeshState);
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function meshPayload(nodeId: string) {
return {
collectedAt: "2026-01-01T00:00:00.000Z",
sourceNodeId: "local",
nodes: [{ nodeId, nodeName: nodeId, nodeUrl: undefined, nodeType: "local", status: "online", metrics: null, lastSeen: "2026-01-01T00:00:00.000Z", connectedAt: "2026-01-01T00:00:00.000Z", knownPeers: [] }],
};
}
describe("useMeshState resume instrumentation", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchMeshState.mockReset();
recordResumeEvent.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("emits debounced-refresh then debounce-skipped and refreshes once for visibility events", async () => {
mockFetchMeshState.mockResolvedValue(meshPayload("local"));
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
const { useMeshState } = await import("../useMeshState");
renderHook(() => useMeshState());
await act(async () => {
await flushPromises();
});
// Reset to isolate visibility-driven refreshes from initial mount fetch.
mockFetchMeshState.mockClear();
await act(async () => {
vi.advanceTimersByTime(1100);
document.dispatchEvent(new Event("visibilitychange"));
await flushPromises();
});
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
await flushPromises();
});
expect(recordResumeEvent).toHaveBeenNthCalledWith(1, expect.objectContaining({
view: "useMeshState",
trigger: "visibility",
reason: "debounced-refresh",
}));
expect(recordResumeEvent).toHaveBeenNthCalledWith(2, expect.objectContaining({
view: "useMeshState",
trigger: "visibility",
reason: "debounce-skipped",
detail: expect.objectContaining({
timeSinceLastRefreshMs: expect.any(Number),
}),
}));
expect(recordResumeEvent).toHaveBeenCalledTimes(2);
expect(mockFetchMeshState).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,87 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../../api";
const { recordResumeEvent } = vi.hoisted(() => ({
recordResumeEvent: vi.fn(),
}));
vi.mock("../../api", () => ({
fetchNodes: vi.fn(),
registerNode: vi.fn(),
updateNode: vi.fn(),
unregisterNode: vi.fn(),
checkNodeHealth: vi.fn(),
discoverRemoteNodeProjects: vi.fn(),
fetchDockerNodeConfig: vi.fn(),
updateDockerNodeConfig: vi.fn(),
fetchDockerConfigDiff: vi.fn(),
}));
vi.mock("../../api-node", () => ({
persistNodeProjectPathMappings: vi.fn(),
}));
vi.mock("../../utils/resumeInstrumentation", () => ({
recordResumeEvent,
}));
const mockFetchNodes = vi.mocked(api.fetchNodes);
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("useNodes resume instrumentation", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchNodes.mockReset();
recordResumeEvent.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("emits debounced-refresh then debounce-skipped and refreshes once for visibility events", async () => {
mockFetchNodes.mockResolvedValue([]);
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
const { useNodes } = await import("../useNodes");
renderHook(() => useNodes());
await act(async () => {
await flushPromises();
});
// Reset to isolate visibility-driven refreshes from initial mount fetch.
mockFetchNodes.mockClear();
await act(async () => {
vi.advanceTimersByTime(1100);
document.dispatchEvent(new Event("visibilitychange"));
await flushPromises();
});
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
await flushPromises();
});
expect(recordResumeEvent).toHaveBeenNthCalledWith(1, expect.objectContaining({
view: "useNodes",
trigger: "visibility",
reason: "debounced-refresh",
}));
expect(recordResumeEvent).toHaveBeenNthCalledWith(2, expect.objectContaining({
view: "useNodes",
trigger: "visibility",
reason: "debounce-skipped",
detail: expect.objectContaining({
timeSinceLastRefreshMs: expect.any(Number),
}),
}));
expect(recordResumeEvent).toHaveBeenCalledTimes(2);
expect(mockFetchNodes).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,86 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../../api";
import * as swrCache from "../../utils/swrCache";
const { recordResumeEvent } = vi.hoisted(() => ({
recordResumeEvent: vi.fn(),
}));
vi.mock("../../api", () => ({
fetchProjectsAcrossNodes: vi.fn(),
registerProject: vi.fn(),
updateProject: vi.fn(),
unregisterProject: vi.fn(),
hasNodeMappingsSupport: vi.fn(),
}));
vi.mock("../../utils/resumeInstrumentation", () => ({
recordResumeEvent,
}));
const mockFetchProjectsAcrossNodes = vi.mocked(api.fetchProjectsAcrossNodes);
const mockHasNodeMappingsSupport = vi.mocked(api.hasNodeMappingsSupport);
const mockReadCache = vi.spyOn(swrCache, "readCache");
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("useProjects resume instrumentation", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchProjectsAcrossNodes.mockReset();
mockHasNodeMappingsSupport.mockReset();
mockReadCache.mockReset();
recordResumeEvent.mockReset();
mockReadCache.mockReturnValue(null);
mockHasNodeMappingsSupport.mockReturnValue(false);
});
afterEach(() => {
vi.useRealTimers();
});
it("emits debounced-refresh then debounce-skipped and refreshes once for visibility events", async () => {
mockFetchProjectsAcrossNodes.mockResolvedValue([]);
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
const { useProjects } = await import("../useProjects");
renderHook(() => useProjects());
await act(async () => {
await flushPromises();
});
// Reset to isolate visibility-driven refreshes from initial mount fetch.
mockFetchProjectsAcrossNodes.mockClear();
await act(async () => {
vi.advanceTimersByTime(1100);
document.dispatchEvent(new Event("visibilitychange"));
await flushPromises();
});
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
await flushPromises();
});
expect(recordResumeEvent).toHaveBeenNthCalledWith(1, expect.objectContaining({
view: "useProjects",
trigger: "visibility",
reason: "debounced-refresh",
}));
expect(recordResumeEvent).toHaveBeenNthCalledWith(2, expect.objectContaining({
view: "useProjects",
trigger: "visibility",
reason: "debounce-skipped",
detail: expect.objectContaining({
timeSinceLastRefreshMs: expect.any(Number),
}),
}));
expect(recordResumeEvent).toHaveBeenCalledTimes(2);
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(1);
});
});

View File

@@ -7,6 +7,7 @@ import {
fetchManagedDockerNodeContainerStatus, fetchManagedDockerNodeContainerStatus,
fetchManagedDockerNodes, fetchManagedDockerNodes,
} from "../api"; } from "../api";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
export interface UseManagedDockerNodesResult { export interface UseManagedDockerNodesResult {
dockerNodes: ManagedDockerNodeInfo[]; dockerNodes: ManagedDockerNodeInfo[];
@@ -68,11 +69,27 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
} }
const now = Date.now(); const now = Date.now();
if (now - lastVisibilityRefreshRef.current < VISIBILITY_REFRESH_DEBOUNCE_MS) { const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) {
recordResumeEvent({
view: "useManagedDockerNodes",
trigger: "visibility",
projectId: undefined,
replayAttempted: false,
reason: "debounce-skipped",
detail: { timeSinceLastRefreshMs: timeSinceLastRefresh },
});
return; return;
} }
lastVisibilityRefreshRef.current = now; lastVisibilityRefreshRef.current = now;
recordResumeEvent({
view: "useManagedDockerNodes",
trigger: "visibility",
projectId: undefined,
replayAttempted: false,
reason: "debounced-refresh",
});
void refresh(); void refresh();
}; };

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { NodeMeshState } from "@fusion/core"; import type { NodeMeshState } from "@fusion/core";
import { fetchMeshState } from "../api"; import { fetchMeshState } from "../api";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
const POLL_INTERVAL_MS = 10000; const POLL_INTERVAL_MS = 10000;
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000; const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
@@ -56,8 +57,26 @@ export function useMeshState(): UseMeshStateResult {
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") return; if (document.visibilityState !== "visible") return;
const now = Date.now(); const now = Date.now();
if (now - lastVisibilityRefreshRef.current < VISIBILITY_REFRESH_DEBOUNCE_MS) return; const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) {
recordResumeEvent({
view: "useMeshState",
trigger: "visibility",
projectId: undefined,
replayAttempted: false,
reason: "debounce-skipped",
detail: { timeSinceLastRefreshMs: timeSinceLastRefresh },
});
return;
}
lastVisibilityRefreshRef.current = now; lastVisibilityRefreshRef.current = now;
recordResumeEvent({
view: "useMeshState",
trigger: "visibility",
projectId: undefined,
replayAttempted: false,
reason: "debounced-refresh",
});
void refresh(); void refresh();
}; };

View File

@@ -12,6 +12,7 @@ import {
discoverRemoteNodeProjects, discoverRemoteNodeProjects,
} from "../api"; } from "../api";
import { persistNodeProjectPathMappings } from "../api-node"; import { persistNodeProjectPathMappings } from "../api-node";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
export interface UseNodesResult { export interface UseNodesResult {
nodes: NodeInfo[]; nodes: NodeInfo[];
@@ -86,10 +87,25 @@ export function useNodes(): UseNodesResult {
const now = Date.now(); const now = Date.now();
const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current; const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) { if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) {
recordResumeEvent({
view: "useNodes",
trigger: "visibility",
projectId: undefined,
replayAttempted: false,
reason: "debounce-skipped",
detail: { timeSinceLastRefreshMs: timeSinceLastRefresh },
});
return; return;
} }
lastVisibilityRefreshRef.current = now; lastVisibilityRefreshRef.current = now;
recordResumeEvent({
view: "useNodes",
trigger: "visibility",
projectId: undefined,
replayAttempted: false,
reason: "debounced-refresh",
});
void refresh(); void refresh();
}; };

View File

@@ -11,6 +11,7 @@ import {
type ProjectNodeAvailability, type ProjectNodeAvailability,
} from "../api"; } from "../api";
import { SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, clearCache, readCache, writeCache } from "../utils/swrCache"; import { SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, clearCache, readCache, writeCache } from "../utils/swrCache";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
export interface UseProjectsResult { export interface UseProjectsResult {
/** List of all registered projects (local + remote) */ /** List of all registered projects (local + remote) */
@@ -147,10 +148,25 @@ export function useProjects(): UseProjectsResult {
const now = Date.now(); const now = Date.now();
const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current; const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) { if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) {
recordResumeEvent({
view: "useProjects",
trigger: "visibility",
projectId: undefined,
replayAttempted: false,
reason: "debounce-skipped",
detail: { timeSinceLastRefreshMs: timeSinceLastRefresh },
});
return; return;
} }
lastVisibilityRefreshRef.current = now; lastVisibilityRefreshRef.current = now;
recordResumeEvent({
view: "useProjects",
trigger: "visibility",
projectId: undefined,
replayAttempted: false,
reason: "debounced-refresh",
});
void refresh(); void refresh();
}; };

View File

@@ -15,7 +15,7 @@ const qualityAppTests = [
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx", "app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
// Hooks and utilities are fast, user-visible state/formatting behavior. // Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx", "app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}", "app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
"app/utils/**/*.test.{ts,tsx}", "app/utils/**/*.test.{ts,tsx}",
]; ];