diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.github-signals.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.github-signals.test.tsx new file mode 100644 index 0000000000..2cfe828c3a --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.github-signals.test.tsx @@ -0,0 +1,406 @@ +/* +FNXC:CommandCenter 2026-06-25-00:00: +FN-7044 extracts the GitHub and Signals Command Center area suites from areas.test.tsx so each focused test file remains under the 2000-line hard cap while preserving every assertion. +*/ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; + +// Mock the api() helper so the areas fetch deterministic fixtures. Keep these Vitest hoisted mocks inline in each split suite to avoid TDZ failures. +const mocks = vi.hoisted(() => ({ + api: vi.fn(), + backfillGithubSourceIssueClosedAt: vi.fn(), + backfillCommitAssociationDiffStats: vi.fn(), + fetchOrgTree: vi.fn(), + fetchExecutorStats: vi.fn(), + fetchSettings: vi.fn(), + updateSettings: vi.fn(), + toggleEnginePause: vi.fn(), + appSettings: { globalPaused: false, enginePaused: false }, +})); +const apiMock = mocks.api; +const backfillGithubSourceIssueClosedAtMock = mocks.backfillGithubSourceIssueClosedAt; +const backfillCommitAssociationDiffStatsMock = mocks.backfillCommitAssociationDiffStats; +const fetchOrgTreeMock = mocks.fetchOrgTree; +const fetchExecutorStatsMock = mocks.fetchExecutorStats; +const toggleEnginePauseMock = mocks.toggleEnginePause; +const appSettingsMock = mocks.appSettings; +vi.mock("../../../../api/legacy", () => ({ + api: (path: string, opts?: RequestInit) => mocks.api(path, opts), + apiBackfillGithubSourceIssueClosedAt: (options?: { offset?: number; limit?: number }, projectId?: string) => + mocks.backfillGithubSourceIssueClosedAt(options, projectId), + backfillCommitAssociationDiffStats: (options?: { dryRun?: boolean }, projectId?: string) => + mocks.backfillCommitAssociationDiffStats(options, projectId), + fetchOrgTree: mocks.fetchOrgTree, + fetchExecutorStats: mocks.fetchExecutorStats, + fetchSettings: mocks.fetchSettings, + updateSettings: mocks.updateSettings, +})); + +vi.mock("../../../../hooks/useAppSettings", () => ({ + useAppSettings: () => ({ + globalPaused: mocks.appSettings.globalPaused, + enginePaused: mocks.appSettings.enginePaused, + toggleEnginePause: mocks.toggleEnginePause, + }), +})); + +import { GithubArea } from "../GithubArea"; +import { SignalsArea } from "../SignalsArea"; +import { customRange, githubFixture, range7d } from "./areas.test-harness"; + +beforeEach(() => { + apiMock.mockReset(); + backfillGithubSourceIssueClosedAtMock.mockReset(); + backfillCommitAssociationDiffStatsMock.mockReset(); + fetchOrgTreeMock.mockReset(); + fetchOrgTreeMock.mockResolvedValue([]); + fetchExecutorStatsMock.mockReset(); + fetchExecutorStatsMock.mockResolvedValue({ + globalPause: false, + enginePaused: false, + maxConcurrent: 2, + lastActivityAt: "2026-06-19T12:00:00.000Z", + }); + mocks.fetchSettings.mockReset(); + mocks.fetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 }); + mocks.updateSettings.mockReset(); + mocks.updateSettings.mockResolvedValue({}); + toggleEnginePauseMock.mockReset(); + appSettingsMock.globalPaused = false; + appSettingsMock.enginePaused = false; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("GithubArea", () => { + it("renders filed/fixed/net stats, daily trend, and by-repo bars", async () => { + apiMock.mockResolvedValue(githubFixture()); + render(); + + await screen.findByTestId("cc-area-github"); + expect(screen.getByTestId("cc-github-filed").textContent).toContain("5"); + expect(screen.getByTestId("cc-github-fixed").textContent).toContain("3"); + expect(screen.getByTestId("cc-github-net").textContent).toContain("2"); + expect(screen.getByTestId("cc-github-pie")).toBeTruthy(); + expect(screen.getByTestId("cc-github-line")).toBeTruthy(); + expect(screen.getByRole("img", { name: "Filed vs fixed share" })).toBeTruthy(); + expect(screen.getByRole("img", { name: "Filed vs fixed line" })).toBeTruthy(); + expect(screen.getByTestId("cc-github-daily-trend")).toBeTruthy(); + expect(screen.getByRole("img", { name: "Filed" })).toBeTruthy(); + expect(screen.getByRole("img", { name: "Fixed" })).toBeTruthy(); + const repoChart = screen.getByRole("list", { name: "By repository" }); + expect(within(repoChart).getByText("acme/alpha")).toBeTruthy(); + expect(within(repoChart).getByLabelText("acme/alpha: 4 filed / 1 fixed")).toBeTruthy(); + }); + + it("renders resolved issues with safe outbound links and approximation labels", async () => { + apiMock.mockResolvedValue({ + ...githubFixture(), + resolved: [ + { + taskId: "FN-100", + taskTitle: "Fix alpha crash", + repo: "acme/alpha", + issueNumber: 123, + url: "https://github.com/acme/alpha/issues/123", + resolvedAt: "2026-06-10T12:34:56.000Z", + resolvedAtExact: true, + }, + { + taskId: "FN-101", + taskTitle: "Patch unknown import", + repo: "(unknown)", + issueNumber: null, + url: null, + resolvedAt: "2026-06-09T08:00:00.000Z", + resolvedAtExact: false, + }, + ], + }); + + render(); + + const section = await screen.findByTestId("cc-github-resolved"); + expect(section.textContent).toContain("Resolved issues"); + expect(section.textContent).toContain("acme/alpha#123"); + expect(section.textContent).toContain("Fix alpha crash"); + expect(section.textContent).toContain("FN-100"); + expect(section.textContent).toContain("(unknown)"); + expect(section.textContent).toContain("Patch unknown import"); + expect(section.textContent).toContain("approx"); + expect(section.textContent).toContain("2026"); + + const linkedIssue = within(section).getByRole("link", { name: "Open GitHub issue acme/alpha#123" }); + expect(linkedIssue.getAttribute("href")).toBe("https://github.com/acme/alpha/issues/123"); + expect(linkedIssue.getAttribute("target")).toBe("_blank"); + expect(linkedIssue.getAttribute("rel")).toBe("noopener noreferrer"); + expect(within(section).queryByRole("link", { name: /unknown/i })).toBeNull(); + }); + + it("omits the resolved issues section for an empty resolved list", async () => { + apiMock.mockResolvedValue({ ...githubFixture(), resolved: [] }); + + render(); + + await screen.findByTestId("cc-area-github"); + expect(screen.queryByTestId("cc-github-resolved")).toBeNull(); + expect(screen.getByTestId("cc-area-github").textContent).not.toContain("NaN"); + }); + + it("renders the empty state without empty chart shells", async () => { + apiMock.mockResolvedValue({ ...githubFixture(), filed: 0, fixed: 0, net: 0, daily: [], byRepo: [] }); + render(); + + await screen.findByTestId("cc-area-github"); + expect(screen.getByTestId("cc-area-github").textContent).toContain("No GitHub issue activity"); + expect(screen.getByTestId("cc-github-backfill-button")).toBeTruthy(); + expect(screen.queryByTestId("cc-github-pie")).toBeNull(); + expect(screen.queryByTestId("cc-github-line")).toBeNull(); + expect(screen.queryByTestId("cc-github-daily-trend")).toBeNull(); + expect(screen.queryByTestId("cc-github-by-repo")).toBeNull(); + }); + + it("renders loading and error states", async () => { + apiMock.mockImplementationOnce(() => new Promise(() => undefined)); + const { unmount } = render(); + expect(screen.getByTestId("cc-area-github-loading")).toBeTruthy(); + expect(screen.queryByTestId("cc-github-pie")).toBeNull(); + expect(screen.queryByTestId("cc-github-line")).toBeNull(); + unmount(); + + apiMock.mockRejectedValueOnce(new Error("github failed")); + render(); + await screen.findByTestId("cc-area-github-error"); + expect(screen.getByTestId("cc-area-github-error").textContent).toContain("github failed"); + expect(screen.queryByTestId("cc-github-pie")).toBeNull(); + expect(screen.queryByTestId("cc-github-line")).toBeNull(); + }); + + it("handles undefined chart arrays and zero values without NaN output", async () => { + apiMock.mockResolvedValue({ ...githubFixture(), filed: 1, fixed: 0, net: 1, daily: undefined, byRepo: undefined }); + render(); + + await screen.findByTestId("cc-area-github"); + expect(screen.getByTestId("cc-github-pie")).toBeTruthy(); + expect(screen.queryByTestId("cc-github-line")).toBeNull(); + expect(screen.queryByTestId("cc-github-daily-trend")).toBeNull(); + expect(screen.queryByTestId("cc-github-by-repo")).toBeNull(); + expect(screen.getByTestId("cc-area-github").textContent).not.toContain("NaN"); + }); + + it("keeps GitHub recharts safe for single-item and non-finite daily data", async () => { + apiMock.mockResolvedValue({ + ...githubFixture(), + filed: 1, + fixed: 1, + net: 0, + daily: [{ date: "2026-06-08", filed: Number.NaN, fixed: Number.POSITIVE_INFINITY }], + byRepo: [{ repo: "acme/broken", filed: Number.NaN, fixed: -1 }], + }); + render(); + + await screen.findByTestId("cc-area-github"); + expect(screen.getByTestId("cc-github-pie")).toBeTruthy(); + expect(screen.getByTestId("cc-github-line")).toBeTruthy(); + expect(screen.getByTestId("cc-area-github").textContent).not.toContain("NaN"); + expect(screen.getByTestId("cc-area-github").textContent).not.toContain("Infinity"); + }); + + it("rejects an inverted custom range client-side without fetching", async () => { + render(); + await waitFor(() => expect(apiMock).not.toHaveBeenCalled()); + }); + + it("runs a single backfill batch and renders accumulated result counts", async () => { + apiMock.mockResolvedValue(githubFixture()); + backfillGithubSourceIssueClosedAtMock.mockResolvedValueOnce({ + scanned: 4, + filled: 2, + skipped: 1, + errors: 0, + hasMore: false, + }); + + render(); + await screen.findByTestId("cc-area-github"); + + fireEvent.click(screen.getByTestId("cc-github-backfill-button")); + + await screen.findByText(/Backfill complete/i); + expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenCalledWith({ offset: 0, limit: 100 }, undefined); + const result = screen.getByTestId("cc-github-backfill-result"); + expect(result.textContent).toContain("Scanned 4, filled 2, skipped 1, errors 0"); + }); + + it("paginates multi-batch backfills with advancing offsets and summed counts", async () => { + apiMock.mockResolvedValue(githubFixture()); + backfillGithubSourceIssueClosedAtMock + .mockResolvedValueOnce({ scanned: 100, filled: 4, skipped: 90, errors: 1, hasMore: true }) + .mockResolvedValueOnce({ scanned: 25, filled: 3, skipped: 22, errors: 0, hasMore: false }); + + render(); + await screen.findByTestId("cc-area-github"); + fireEvent.click(screen.getByTestId("cc-github-backfill-button")); + + await waitFor(() => expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenCalledTimes(2)); + expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenNthCalledWith(1, { offset: 0, limit: 100 }, undefined); + expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenNthCalledWith(2, { offset: 100, limit: 100 }, undefined); + const result = await screen.findByTestId("cc-github-backfill-result"); + expect(result.textContent).toContain("Scanned 125, filled 7, skipped 112, errors 1"); + expect(result.className).toContain("cc-github-backfill-status--error"); + }); + + it("shows the all-zero backfill as nothing to backfill instead of an error", async () => { + apiMock.mockResolvedValue(githubFixture()); + backfillGithubSourceIssueClosedAtMock.mockResolvedValueOnce({ + scanned: 0, + filled: 0, + skipped: 0, + errors: 0, + hasMore: false, + }); + + render(); + await screen.findByTestId("cc-area-github"); + fireEvent.click(screen.getByTestId("cc-github-backfill-button")); + + const result = await screen.findByTestId("cc-github-backfill-result"); + expect(result.textContent).toContain("Nothing to backfill"); + expect(result.className).not.toContain("cc-github-backfill-status--error"); + }); + + it("surfaces nonzero backfill error counts without throwing", async () => { + apiMock.mockResolvedValue(githubFixture()); + backfillGithubSourceIssueClosedAtMock.mockResolvedValueOnce({ + scanned: 8, + filled: 1, + skipped: 5, + errors: 2, + hasMore: false, + }); + + render(); + await screen.findByTestId("cc-area-github"); + fireEvent.click(screen.getByTestId("cc-github-backfill-button")); + + const result = await screen.findByTestId("cc-github-backfill-result"); + expect(result.textContent).toContain("errors 2"); + expect(result.className).toContain("cc-github-backfill-status--error"); + }); + + it("captures endpoint failures in local error UI", async () => { + apiMock.mockResolvedValue(githubFixture()); + backfillGithubSourceIssueClosedAtMock.mockRejectedValueOnce(new Error("endpoint failed")); + + render(); + await screen.findByTestId("cc-area-github"); + fireEvent.click(screen.getByTestId("cc-github-backfill-button")); + + const result = await screen.findByTestId("cc-github-backfill-result"); + expect(result.textContent).toContain("endpoint failed"); + expect(result.className).toContain("cc-github-backfill-status--error"); + }); + + it("disables and guards the button while a backfill is in flight", async () => { + apiMock.mockResolvedValue(githubFixture()); + let resolveBackfill: ((value: { scanned: number; filled: number; skipped: number; errors: number; hasMore: boolean }) => void) | null = null; + backfillGithubSourceIssueClosedAtMock.mockImplementationOnce( + () => new Promise((resolve) => { + resolveBackfill = resolve; + }), + ); + + render(); + await screen.findByTestId("cc-area-github"); + const button = screen.getByTestId("cc-github-backfill-button") as HTMLButtonElement; + fireEvent.click(button); + + await waitFor(() => expect(button.disabled).toBe(true)); + fireEvent.click(button); + expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenCalledTimes(1); + + resolveBackfill?.({ scanned: 1, filled: 1, skipped: 0, errors: 0, hasMore: false }); + await screen.findByText(/Backfill complete/i); + }); + + it("stops a pathological always-has-more response at the max iteration guard", async () => { + apiMock.mockResolvedValue(githubFixture()); + backfillGithubSourceIssueClosedAtMock.mockResolvedValue({ + scanned: 1, + filled: 0, + skipped: 1, + errors: 0, + hasMore: true, + }); + + render(); + await screen.findByTestId("cc-area-github"); + fireEvent.click(screen.getByTestId("cc-github-backfill-button")); + + const result = await screen.findByText(/safety limit/i); + expect(result.textContent).toContain("safety limit"); + expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenCalledTimes(1000); + expect(screen.getByTestId("cc-github-backfill-result").textContent).toContain("Scanned 1000"); + }); +}); + +// FN-6684 Mission Control decision: no extra pie/line test here because MissionControlPanel already renders the live SDLC Funnel for its only quantitative distribution; adding a pie would duplicate that affordance. +describe("SignalsArea", () => { + it("renders the empty state (not an error) when the signals endpoint is missing", async () => { + apiMock.mockRejectedValue(new Error("API returned HTML instead of JSON (404)")); + render(); + await screen.findByTestId("cc-area-signals-empty"); + // Must not surface the error UI. + expect(screen.queryByTestId("cc-area-signals-error")).toBeNull(); + expect(screen.queryByTestId("cc-signals-pie")).toBeNull(); + }); + + it("renders signal metrics and status pie when data is present", async () => { + apiMock.mockResolvedValue({ + totalSignals: 8, + open: 3, + resolved: 5, + mttr: { value: 42, unavailable: false }, + bySource: [{ source: "sentry", count: 8 }], + bySeverity: [{ severity: "error", count: 8 }], + }); + render(); + await screen.findByTestId("cc-area-signals"); + expect(screen.getByTestId("cc-signals-total").textContent).toContain("8"); + expect(screen.getByTestId("cc-signals-mttr").textContent).toContain("42"); + expect(screen.getByTestId("cc-signals-pie")).toBeTruthy(); + expect(screen.getByRole("img", { name: "Signal status share" })).toBeTruthy(); + }); + + it("keeps signals pie safe for single-item and non-finite source/severity data", async () => { + apiMock.mockResolvedValue({ + totalSignals: 1, + open: 1, + resolved: 0, + mttr: { value: null, unavailable: true }, + bySource: [{ source: "broken", count: Number.NaN }], + bySeverity: [{ severity: "broken", count: Number.POSITIVE_INFINITY }], + }); + render(); + await screen.findByTestId("cc-area-signals"); + expect(screen.getByTestId("cc-signals-pie")).toBeTruthy(); + expect(screen.getByTestId("cc-area-signals").textContent).not.toContain("NaN"); + expect(screen.getByTestId("cc-area-signals").textContent).not.toContain("Infinity"); + }); + + it("renders settled zero signals without a pie shell", async () => { + apiMock.mockResolvedValue({ + totalSignals: 0, + open: 0, + resolved: 0, + mttr: { value: null, unavailable: true }, + bySource: [], + bySeverity: [], + }); + render(); + await screen.findByTestId("cc-area-signals-empty"); + expect(screen.queryByTestId("cc-signals-pie")).toBeNull(); + }); +}); diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test-harness.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test-harness.tsx new file mode 100644 index 0000000000..28f8c015c0 --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test-harness.tsx @@ -0,0 +1,302 @@ +/* +FNXC:CommandCenter 2026-06-25-00:00: +FN-7044 extracted shared Command Center area test fixtures into this harness so focused sibling suites can stay under the 2000-line hard cap without duplicating fixture logic or moving Vitest hoisted mocks. +*/ +import { expect, vi } from "vitest"; +import { screen, within } from "@testing-library/react"; +import type { OrgTreeNode } from "@fusion/core"; +import type { DateRange } from "../../DateRangePicker"; + +export const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" }; +export const customRange = (from: string, to: string): DateRange => ({ from, to, preset: "custom" }); + +export function providerIconIn(element: HTMLElement, provider: string): Element | null { + return element.querySelector(`.provider-icon[data-provider="${provider}"]`); +} + +export function tokenFixture(totalTokens = 1500) { + return { + from: "2026-06-08", + to: null, + groupBy: "model", + totals: { + inputTokens: Math.round(totalTokens * 0.6), + outputTokens: Math.round(totalTokens * 0.3), + cachedTokens: Math.round(totalTokens * 0.1), + cacheWriteTokens: 0, + totalTokens, + nTasks: totalTokens > 0 ? 5 : 0, + }, + cost: { usd: 12.5, unavailable: false, stale: false }, + series: [ + { + bucket: "2026-06-08", + inputTokens: 400, + outputTokens: 200, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 600, + nTasks: 2, + cost: { usd: 4.5, unavailable: false, stale: false }, + }, + { + bucket: "2026-06-09", + inputTokens: 600, + outputTokens: 300, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 900, + nTasks: 3, + cost: { usd: 8, unavailable: false, stale: false }, + }, + ], + groups: [ + { + key: "gpt-4o", + inputTokens: 600, + outputTokens: 300, + cachedTokens: 100, + cacheWriteTokens: 0, + totalTokens: 900, + nTasks: 3, + cost: { usd: 9.0, unavailable: false, stale: false }, + }, + { + key: "claude-sonnet", + inputTokens: 400, + outputTokens: 200, + cachedTokens: 100, + cacheWriteTokens: 0, + totalTokens: 600, + nTasks: 2, + cost: { usd: 3.5, unavailable: false, stale: false }, + }, + ], + }; +} + +export function githubFixture() { + return { + from: "2026-06-08", + to: null, + filed: 5, + fixed: 3, + net: 2, + daily: [ + { date: "2026-06-08", filed: 2, fixed: 1 }, + { date: "2026-06-09", filed: 3, fixed: 2 }, + ], + byRepo: [ + { repo: "acme/alpha", filed: 4, fixed: 1 }, + { repo: "acme/beta", filed: 1, fixed: 2 }, + ], + resolved: [], + }; +} + +export function productivityFixture() { + return { + from: "2026-06-08", + to: null, + modifiedFiles: 7, + commits: 4, + pullRequests: 2, + byLanguage: [{ language: "TypeScript", count: 7 }], + loc: { value: null, unavailable: true }, + hoursSaved: { value: null, unavailable: true }, + taskDuration: { + completedTasks: 3, + averageMs: 90 * 60 * 1000, + medianMs: 60 * 60 * 1000, + p90Ms: 2 * 60 * 60 * 1000, + totalMs: 270 * 60 * 1000, + unavailable: false, + }, + }; +} + +export function installElementClientWidth(width: number) { + return vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(width); +} + +export function agentNode(id: string, name: string, children: OrgTreeNode[] = [], title = "Team Lead"): OrgTreeNode { + return { + agent: { + id, + name, + title, + role: "executor", + state: "idle", + createdAt: "2026-06-19T00:00:00.000Z", + updatedAt: "2026-06-19T00:00:00.000Z", + metadata: {}, + }, + children, + }; +} + +export function emptyTeamFixture() { + return { + from: null, + to: null, + totals: { + tokens: { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 }, + cost: { usd: null, unavailable: false, stale: false }, + filesChanged: 0, + tasksCompleted: 0, + tasksInProgress: 0, + tasksInReview: 0, + }, + agents: [], + }; +} + +export function populatedTeamFixture(totalTokens = 1500) { + return { + ...emptyTeamFixture(), + totals: { + tokens: { + inputTokens: Math.round(totalTokens * 0.6), + outputTokens: Math.round(totalTokens * 0.3), + cachedTokens: Math.round(totalTokens * 0.1), + cacheWriteTokens: 0, + totalTokens, + nTasks: 2, + }, + cost: { usd: 4.25, unavailable: false, stale: false }, + filesChanged: 7, + tasksCompleted: 3, + tasksInProgress: 1, + tasksInReview: 0, + }, + agents: [ + { + agentId: "agent-alpha", + agentName: "Alpha Agent", + role: "executor", + state: "running", + tokens: { + inputTokens: Math.round(totalTokens * 0.6), + outputTokens: Math.round(totalTokens * 0.3), + cachedTokens: Math.round(totalTokens * 0.1), + cacheWriteTokens: 0, + totalTokens, + nTasks: 2, + }, + cost: { usd: 4.25, unavailable: false, stale: false }, + filesChanged: 7, + tasksCompleted: 3, + tasksInProgress: 1, + tasksInReview: 0, + }, + ], + }; +} + +export function activityFixture() { + return { + from: "2026-06-08", + to: null, + sessions: 4, + messages: 12, + activeNodes: 3, + activeAgents: 2, + agentRuns: { total: 8, active: 1, completed: 6, failed: 1 }, + daily: [ + { day: "2026-06-08", messages: 2, activeNodes: 1, activeAgents: 1, agentRuns: 2 }, + { day: "2026-06-09", messages: 4, activeNodes: 2, activeAgents: 1, agentRuns: 3 }, + { day: "2026-06-10", messages: 6, activeNodes: 3, activeAgents: 2, agentRuns: 3 }, + ], + stickiness: 0.5, + mttr: { value: null, unavailable: true, sampleCount: 0 }, + monitor: { + mttr: { value: null, unavailable: true, sampleCount: 0 }, + incidentsOpened: 0, + incidentsResolved: 0, + openIncidents: 0, + deployments: 0, + }, + funnel: { + stages: [], + enteredInRange: 0, + doneInRange: 0, + completionRate: null, + throughputPerDay: 0, + rangeDays: 7, + }, + }; +} + +export function expectRechartsWrapperWithin(testId: string, label: string): void { + const section = screen.getByTestId(testId); + const chart = within(section).getByRole("img", { name: label }); + expect(chart.classList.contains("cc-recharts-chart") || chart.classList.contains("cc-recharts-empty")).toBe(true); + expect(chart.outerHTML).not.toMatch(/NaN|Infinity/); +} + +export function expectBarFillsFinite(testId: string): void { + const section = screen.getByTestId(testId); + for (const fill of Array.from(section.querySelectorAll(".cc-bar-fill"))) { + expect(fill.style.width).toMatch(/^\d+(?:\.\d+)?%$/); + expect(fill.style.width).not.toMatch(/NaN|Infinity/); + } +} + +export function expectSparklineHeightsFinite(testId: string): void { + const section = screen.getByTestId(testId); + for (const bar of Array.from(section.querySelectorAll(".cc-sparkline-bar"))) { + expect(bar.style.height).toMatch(/^\d+(?:\.\d+)?%$/); + expect(bar.style.height).not.toMatch(/NaN|Infinity/); + } +} + +export function viewBoxNumbers(chart: Element): [number, number, number, number] { + return (chart.getAttribute("viewBox") ?? "") + .split(/\s+/) + .map(Number) as [number, number, number, number]; +} + +export function expectSvgLinePointsInsideViewBox(testId: string, label: string): void { + const section = screen.getByTestId(testId); + const chart = within(section).getByRole("img", { name: label }); + const [, , viewBoxWidth, viewBoxHeight] = viewBoxNumbers(chart); + for (const point of Array.from(chart.querySelectorAll(".cc-line-chart-point"))) { + const cx = Number(point.getAttribute("cx")); + const cy = Number(point.getAttribute("cy")); + const r = Number(point.getAttribute("r")); + expect(cx).toBeGreaterThanOrEqual(r); + expect(cx).toBeLessThanOrEqual(viewBoxWidth - r); + expect(cy).toBeGreaterThanOrEqual(r); + expect(cy).toBeLessThanOrEqual(viewBoxHeight - r); + } +} + +export function expectSvgLineFillsBoxAndKeepsRoundMarkers(testId: string, label: string): void { + const section = screen.getByTestId(testId); + const chart = within(section).getByRole("img", { name: label }); + const [, , viewBoxWidth, viewBoxHeight] = viewBoxNumbers(chart); + const line = chart.querySelector(".cc-line-chart-path"); + const pointPairs = (line?.getAttribute("points") ?? "") + .trim() + .split(/\s+/) + .filter(Boolean) + .map((pair) => pair.split(",").map(Number) as [number, number]); + expect(viewBoxWidth).toBeGreaterThan(viewBoxHeight); + expect(chart.getAttribute("preserveAspectRatio")).toBe("none"); + expect(chart.getAttribute("viewBox")).not.toBe("0 0 100 100"); + expect(chart.querySelectorAll(".cc-line-chart-point").length).toBeGreaterThan(0); + expect(pointPairs[0]?.[0]).toBe(3); + expect(pointPairs.at(-1)?.[0]).toBe(viewBoxWidth - 3); +} + +export function pluginActivationFixture(overrides: Partial<{ activations: number; unavailable: boolean }> = {}) { + const activations = overrides.activations ?? 0; + const unavailable = overrides.unavailable ?? true; + return { + from: "2026-06-08", + to: null, + activations, + byPlugin: unavailable ? [] : [{ pluginId: "fusion-plugin-example", count: activations }], + unavailable, + }; +} diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx index a5c8fa5490..98188c5923 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx @@ -1,12 +1,14 @@ /* FNXC:CommandCenter 2026-06-16-09:42: Command Center area component tests (PR #1683). Pin loading/error/unavailable-vs-zero rendering for each analytics area against mocked fixtures so the "—" sentinel and cost-unavailable contracts can't regress. + +FNXC:CommandCenter 2026-06-25-00:00: +FN-7044 split shared fixtures into areas.test-harness.tsx so this suite can be partitioned into focused sibling files under the 2000-line hard cap without losing coverage. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, within, act, renderHook } from "@testing-library/react"; -import type { OrgTreeNode } from "@fusion/core"; // Mock the api() helper so the areas fetch deterministic fixtures. const mocks = vi.hoisted(() => ({ @@ -50,235 +52,31 @@ vi.mock("../../../../hooks/useAppSettings", () => ({ import { TokensArea } from "../TokensArea"; import { ToolsArea } from "../ToolsArea"; import { ProductivityArea } from "../ProductivityArea"; -import { GithubArea } from "../GithubArea"; -import { SignalsArea } from "../SignalsArea"; import { TeamArea } from "../TeamArea"; import { ActivityArea } from "../ActivityArea"; import { EcosystemArea } from "../EcosystemArea"; import { useAnalyticsArea } from "../useAnalyticsArea"; import { ConfirmDialogProvider } from "../../../../hooks/useConfirm"; import { rangeQuery } from "../areaShared"; -import { defaultPresets, rangeFromPreset, type DateRange } from "../../DateRangePicker"; - -const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" }; -const customRange = (from: string, to: string): DateRange => ({ from, to, preset: "custom" }); - -function providerIconIn(element: HTMLElement, provider: string): Element | null { - return element.querySelector(`.provider-icon[data-provider="${provider}"]`); -} - -function tokenFixture(totalTokens = 1500) { - return { - from: "2026-06-08", - to: null, - groupBy: "model", - totals: { - inputTokens: Math.round(totalTokens * 0.6), - outputTokens: Math.round(totalTokens * 0.3), - cachedTokens: Math.round(totalTokens * 0.1), - cacheWriteTokens: 0, - totalTokens, - nTasks: totalTokens > 0 ? 5 : 0, - }, - cost: { usd: 12.5, unavailable: false, stale: false }, - series: [ - { - bucket: "2026-06-08", - inputTokens: 400, - outputTokens: 200, - cachedTokens: 0, - cacheWriteTokens: 0, - totalTokens: 600, - nTasks: 2, - cost: { usd: 4.5, unavailable: false, stale: false }, - }, - { - bucket: "2026-06-09", - inputTokens: 600, - outputTokens: 300, - cachedTokens: 0, - cacheWriteTokens: 0, - totalTokens: 900, - nTasks: 3, - cost: { usd: 8, unavailable: false, stale: false }, - }, - ], - groups: [ - { - key: "gpt-4o", - inputTokens: 600, - outputTokens: 300, - cachedTokens: 100, - cacheWriteTokens: 0, - totalTokens: 900, - nTasks: 3, - cost: { usd: 9.0, unavailable: false, stale: false }, - }, - { - key: "claude-sonnet", - inputTokens: 400, - outputTokens: 200, - cachedTokens: 100, - cacheWriteTokens: 0, - totalTokens: 600, - nTasks: 2, - cost: { usd: 3.5, unavailable: false, stale: false }, - }, - ], - }; -} - -function githubFixture() { - return { - from: "2026-06-08", - to: null, - filed: 5, - fixed: 3, - net: 2, - daily: [ - { date: "2026-06-08", filed: 2, fixed: 1 }, - { date: "2026-06-09", filed: 3, fixed: 2 }, - ], - byRepo: [ - { repo: "acme/alpha", filed: 4, fixed: 1 }, - { repo: "acme/beta", filed: 1, fixed: 2 }, - ], - resolved: [], - }; -} - -function productivityFixture() { - return { - from: "2026-06-08", - to: null, - modifiedFiles: 7, - commits: 4, - pullRequests: 2, - byLanguage: [{ language: "TypeScript", count: 7 }], - loc: { value: null, unavailable: true }, - hoursSaved: { value: null, unavailable: true }, - taskDuration: { - completedTasks: 3, - averageMs: 90 * 60 * 1000, - medianMs: 60 * 60 * 1000, - p90Ms: 2 * 60 * 60 * 1000, - totalMs: 270 * 60 * 1000, - unavailable: false, - }, - }; -} - -function installElementClientWidth(width: number) { - return vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(width); -} - -function agentNode(id: string, name: string, children: OrgTreeNode[] = [], title = "Team Lead"): OrgTreeNode { - return { - agent: { - id, - name, - title, - role: "executor", - state: "idle", - createdAt: "2026-06-19T00:00:00.000Z", - updatedAt: "2026-06-19T00:00:00.000Z", - metadata: {}, - }, - children, - }; -} - -function emptyTeamFixture() { - return { - from: null, - to: null, - totals: { - tokens: { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 }, - cost: { usd: null, unavailable: false, stale: false }, - filesChanged: 0, - tasksCompleted: 0, - tasksInProgress: 0, - tasksInReview: 0, - }, - agents: [], - }; -} - -function populatedTeamFixture(totalTokens = 1500) { - return { - ...emptyTeamFixture(), - totals: { - tokens: { - inputTokens: Math.round(totalTokens * 0.6), - outputTokens: Math.round(totalTokens * 0.3), - cachedTokens: Math.round(totalTokens * 0.1), - cacheWriteTokens: 0, - totalTokens, - nTasks: 2, - }, - cost: { usd: 4.25, unavailable: false, stale: false }, - filesChanged: 7, - tasksCompleted: 3, - tasksInProgress: 1, - tasksInReview: 0, - }, - agents: [ - { - agentId: "agent-alpha", - agentName: "Alpha Agent", - role: "executor", - state: "running", - tokens: { - inputTokens: Math.round(totalTokens * 0.6), - outputTokens: Math.round(totalTokens * 0.3), - cachedTokens: Math.round(totalTokens * 0.1), - cacheWriteTokens: 0, - totalTokens, - nTasks: 2, - }, - cost: { usd: 4.25, unavailable: false, stale: false }, - filesChanged: 7, - tasksCompleted: 3, - tasksInProgress: 1, - tasksInReview: 0, - }, - ], - }; -} - -function activityFixture() { - return { - from: "2026-06-08", - to: null, - sessions: 4, - messages: 12, - activeNodes: 3, - activeAgents: 2, - agentRuns: { total: 8, active: 1, completed: 6, failed: 1 }, - daily: [ - { day: "2026-06-08", messages: 2, activeNodes: 1, activeAgents: 1, agentRuns: 2 }, - { day: "2026-06-09", messages: 4, activeNodes: 2, activeAgents: 1, agentRuns: 3 }, - { day: "2026-06-10", messages: 6, activeNodes: 3, activeAgents: 2, agentRuns: 3 }, - ], - stickiness: 0.5, - mttr: { value: null, unavailable: true, sampleCount: 0 }, - monitor: { - mttr: { value: null, unavailable: true, sampleCount: 0 }, - incidentsOpened: 0, - incidentsResolved: 0, - openIncidents: 0, - deployments: 0, - }, - funnel: { - stages: [], - enteredInRange: 0, - doneInRange: 0, - completionRate: null, - throughputPerDay: 0, - rangeDays: 7, - }, - }; -} +import { defaultPresets, rangeFromPreset } from "../../DateRangePicker"; +import { + activityFixture, + agentNode, + customRange, + emptyTeamFixture, + expectBarFillsFinite, + expectRechartsWrapperWithin, + expectSparklineHeightsFinite, + expectSvgLineFillsBoxAndKeepsRoundMarkers, + expectSvgLinePointsInsideViewBox, + installElementClientWidth, + pluginActivationFixture, + populatedTeamFixture, + productivityFixture, + providerIconIn, + range7d, + tokenFixture, +} from "./areas.test-harness"; beforeEach(() => { apiMock.mockReset(); @@ -306,67 +104,6 @@ afterEach(() => { vi.useRealTimers(); }); -function expectRechartsWrapperWithin(testId: string, label: string): void { - const section = screen.getByTestId(testId); - const chart = within(section).getByRole("img", { name: label }); - expect(chart.classList.contains("cc-recharts-chart") || chart.classList.contains("cc-recharts-empty")).toBe(true); - expect(chart.outerHTML).not.toMatch(/NaN|Infinity/); -} - -function expectBarFillsFinite(testId: string): void { - const section = screen.getByTestId(testId); - for (const fill of Array.from(section.querySelectorAll(".cc-bar-fill"))) { - expect(fill.style.width).toMatch(/^\d+(?:\.\d+)?%$/); - expect(fill.style.width).not.toMatch(/NaN|Infinity/); - } -} - -function expectSparklineHeightsFinite(testId: string): void { - const section = screen.getByTestId(testId); - for (const bar of Array.from(section.querySelectorAll(".cc-sparkline-bar"))) { - expect(bar.style.height).toMatch(/^\d+(?:\.\d+)?%$/); - expect(bar.style.height).not.toMatch(/NaN|Infinity/); - } -} - -function viewBoxNumbers(chart: Element): [number, number, number, number] { - return (chart.getAttribute("viewBox") ?? "") - .split(/\s+/) - .map(Number) as [number, number, number, number]; -} - -function expectSvgLinePointsInsideViewBox(testId: string, label: string): void { - const section = screen.getByTestId(testId); - const chart = within(section).getByRole("img", { name: label }); - const [, , viewBoxWidth, viewBoxHeight] = viewBoxNumbers(chart); - for (const point of Array.from(chart.querySelectorAll(".cc-line-chart-point"))) { - const cx = Number(point.getAttribute("cx")); - const cy = Number(point.getAttribute("cy")); - const r = Number(point.getAttribute("r")); - expect(cx).toBeGreaterThanOrEqual(r); - expect(cx).toBeLessThanOrEqual(viewBoxWidth - r); - expect(cy).toBeGreaterThanOrEqual(r); - expect(cy).toBeLessThanOrEqual(viewBoxHeight - r); - } -} - -function expectSvgLineFillsBoxAndKeepsRoundMarkers(testId: string, label: string): void { - const section = screen.getByTestId(testId); - const chart = within(section).getByRole("img", { name: label }); - const [, , viewBoxWidth, viewBoxHeight] = viewBoxNumbers(chart); - const line = chart.querySelector(".cc-line-chart-path"); - const pointPairs = (line?.getAttribute("points") ?? "") - .trim() - .split(/\s+/) - .filter(Boolean) - .map((pair) => pair.split(",").map(Number) as [number, number]); - expect(viewBoxWidth).toBeGreaterThan(viewBoxHeight); - expect(chart.getAttribute("preserveAspectRatio")).toBe("none"); - expect(chart.getAttribute("viewBox")).not.toBe("0 0 100 100"); - expect(chart.querySelectorAll(".cc-line-chart-point").length).toBeGreaterThan(0); - expect(pointPairs[0]?.[0]).toBe(3); - expect(pointPairs.at(-1)?.[0]).toBe(viewBoxWidth - 3); -} describe("rangeQuery / rangeFromPreset", () => { it("serializes every default preset into a distinct server-resolvable query", () => { @@ -1626,17 +1363,6 @@ describe("TeamArea", () => { }); }); -function pluginActivationFixture(overrides: Partial<{ activations: number; unavailable: boolean }> = {}) { - const activations = overrides.activations ?? 0; - const unavailable = overrides.unavailable ?? true; - return { - from: "2026-06-08", - to: null, - activations, - byPlugin: unavailable ? [] : [{ pluginId: "fusion-plugin-example", count: activations }], - unavailable, - }; -} function mockEcosystemResponses(tokens: unknown, activations: unknown): void { apiMock.mockImplementation((path: string) => { @@ -1735,333 +1461,3 @@ describe("EcosystemArea", () => { }); }); -describe("GithubArea", () => { - it("renders filed/fixed/net stats, daily trend, and by-repo bars", async () => { - apiMock.mockResolvedValue(githubFixture()); - render(); - - await screen.findByTestId("cc-area-github"); - expect(screen.getByTestId("cc-github-filed").textContent).toContain("5"); - expect(screen.getByTestId("cc-github-fixed").textContent).toContain("3"); - expect(screen.getByTestId("cc-github-net").textContent).toContain("2"); - expect(screen.getByTestId("cc-github-pie")).toBeTruthy(); - expect(screen.getByTestId("cc-github-line")).toBeTruthy(); - expect(screen.getByRole("img", { name: "Filed vs fixed share" })).toBeTruthy(); - expect(screen.getByRole("img", { name: "Filed vs fixed line" })).toBeTruthy(); - expect(screen.getByTestId("cc-github-daily-trend")).toBeTruthy(); - expect(screen.getByRole("img", { name: "Filed" })).toBeTruthy(); - expect(screen.getByRole("img", { name: "Fixed" })).toBeTruthy(); - const repoChart = screen.getByRole("list", { name: "By repository" }); - expect(within(repoChart).getByText("acme/alpha")).toBeTruthy(); - expect(within(repoChart).getByLabelText("acme/alpha: 4 filed / 1 fixed")).toBeTruthy(); - }); - - it("renders resolved issues with safe outbound links and approximation labels", async () => { - apiMock.mockResolvedValue({ - ...githubFixture(), - resolved: [ - { - taskId: "FN-100", - taskTitle: "Fix alpha crash", - repo: "acme/alpha", - issueNumber: 123, - url: "https://github.com/acme/alpha/issues/123", - resolvedAt: "2026-06-10T12:34:56.000Z", - resolvedAtExact: true, - }, - { - taskId: "FN-101", - taskTitle: "Patch unknown import", - repo: "(unknown)", - issueNumber: null, - url: null, - resolvedAt: "2026-06-09T08:00:00.000Z", - resolvedAtExact: false, - }, - ], - }); - - render(); - - const section = await screen.findByTestId("cc-github-resolved"); - expect(section.textContent).toContain("Resolved issues"); - expect(section.textContent).toContain("acme/alpha#123"); - expect(section.textContent).toContain("Fix alpha crash"); - expect(section.textContent).toContain("FN-100"); - expect(section.textContent).toContain("(unknown)"); - expect(section.textContent).toContain("Patch unknown import"); - expect(section.textContent).toContain("approx"); - expect(section.textContent).toContain("2026"); - - const linkedIssue = within(section).getByRole("link", { name: "Open GitHub issue acme/alpha#123" }); - expect(linkedIssue.getAttribute("href")).toBe("https://github.com/acme/alpha/issues/123"); - expect(linkedIssue.getAttribute("target")).toBe("_blank"); - expect(linkedIssue.getAttribute("rel")).toBe("noopener noreferrer"); - expect(within(section).queryByRole("link", { name: /unknown/i })).toBeNull(); - }); - - it("omits the resolved issues section for an empty resolved list", async () => { - apiMock.mockResolvedValue({ ...githubFixture(), resolved: [] }); - - render(); - - await screen.findByTestId("cc-area-github"); - expect(screen.queryByTestId("cc-github-resolved")).toBeNull(); - expect(screen.getByTestId("cc-area-github").textContent).not.toContain("NaN"); - }); - - it("renders the empty state without empty chart shells", async () => { - apiMock.mockResolvedValue({ ...githubFixture(), filed: 0, fixed: 0, net: 0, daily: [], byRepo: [] }); - render(); - - await screen.findByTestId("cc-area-github"); - expect(screen.getByTestId("cc-area-github").textContent).toContain("No GitHub issue activity"); - expect(screen.getByTestId("cc-github-backfill-button")).toBeTruthy(); - expect(screen.queryByTestId("cc-github-pie")).toBeNull(); - expect(screen.queryByTestId("cc-github-line")).toBeNull(); - expect(screen.queryByTestId("cc-github-daily-trend")).toBeNull(); - expect(screen.queryByTestId("cc-github-by-repo")).toBeNull(); - }); - - it("renders loading and error states", async () => { - apiMock.mockImplementationOnce(() => new Promise(() => undefined)); - const { unmount } = render(); - expect(screen.getByTestId("cc-area-github-loading")).toBeTruthy(); - expect(screen.queryByTestId("cc-github-pie")).toBeNull(); - expect(screen.queryByTestId("cc-github-line")).toBeNull(); - unmount(); - - apiMock.mockRejectedValueOnce(new Error("github failed")); - render(); - await screen.findByTestId("cc-area-github-error"); - expect(screen.getByTestId("cc-area-github-error").textContent).toContain("github failed"); - expect(screen.queryByTestId("cc-github-pie")).toBeNull(); - expect(screen.queryByTestId("cc-github-line")).toBeNull(); - }); - - it("handles undefined chart arrays and zero values without NaN output", async () => { - apiMock.mockResolvedValue({ ...githubFixture(), filed: 1, fixed: 0, net: 1, daily: undefined, byRepo: undefined }); - render(); - - await screen.findByTestId("cc-area-github"); - expect(screen.getByTestId("cc-github-pie")).toBeTruthy(); - expect(screen.queryByTestId("cc-github-line")).toBeNull(); - expect(screen.queryByTestId("cc-github-daily-trend")).toBeNull(); - expect(screen.queryByTestId("cc-github-by-repo")).toBeNull(); - expect(screen.getByTestId("cc-area-github").textContent).not.toContain("NaN"); - }); - - it("keeps GitHub recharts safe for single-item and non-finite daily data", async () => { - apiMock.mockResolvedValue({ - ...githubFixture(), - filed: 1, - fixed: 1, - net: 0, - daily: [{ date: "2026-06-08", filed: Number.NaN, fixed: Number.POSITIVE_INFINITY }], - byRepo: [{ repo: "acme/broken", filed: Number.NaN, fixed: -1 }], - }); - render(); - - await screen.findByTestId("cc-area-github"); - expect(screen.getByTestId("cc-github-pie")).toBeTruthy(); - expect(screen.getByTestId("cc-github-line")).toBeTruthy(); - expect(screen.getByTestId("cc-area-github").textContent).not.toContain("NaN"); - expect(screen.getByTestId("cc-area-github").textContent).not.toContain("Infinity"); - }); - - it("rejects an inverted custom range client-side without fetching", async () => { - render(); - await waitFor(() => expect(apiMock).not.toHaveBeenCalled()); - }); - - it("runs a single backfill batch and renders accumulated result counts", async () => { - apiMock.mockResolvedValue(githubFixture()); - backfillGithubSourceIssueClosedAtMock.mockResolvedValueOnce({ - scanned: 4, - filled: 2, - skipped: 1, - errors: 0, - hasMore: false, - }); - - render(); - await screen.findByTestId("cc-area-github"); - - fireEvent.click(screen.getByTestId("cc-github-backfill-button")); - - await screen.findByText(/Backfill complete/i); - expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenCalledWith({ offset: 0, limit: 100 }, undefined); - const result = screen.getByTestId("cc-github-backfill-result"); - expect(result.textContent).toContain("Scanned 4, filled 2, skipped 1, errors 0"); - }); - - it("paginates multi-batch backfills with advancing offsets and summed counts", async () => { - apiMock.mockResolvedValue(githubFixture()); - backfillGithubSourceIssueClosedAtMock - .mockResolvedValueOnce({ scanned: 100, filled: 4, skipped: 90, errors: 1, hasMore: true }) - .mockResolvedValueOnce({ scanned: 25, filled: 3, skipped: 22, errors: 0, hasMore: false }); - - render(); - await screen.findByTestId("cc-area-github"); - fireEvent.click(screen.getByTestId("cc-github-backfill-button")); - - await waitFor(() => expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenCalledTimes(2)); - expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenNthCalledWith(1, { offset: 0, limit: 100 }, undefined); - expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenNthCalledWith(2, { offset: 100, limit: 100 }, undefined); - const result = await screen.findByTestId("cc-github-backfill-result"); - expect(result.textContent).toContain("Scanned 125, filled 7, skipped 112, errors 1"); - expect(result.className).toContain("cc-github-backfill-status--error"); - }); - - it("shows the all-zero backfill as nothing to backfill instead of an error", async () => { - apiMock.mockResolvedValue(githubFixture()); - backfillGithubSourceIssueClosedAtMock.mockResolvedValueOnce({ - scanned: 0, - filled: 0, - skipped: 0, - errors: 0, - hasMore: false, - }); - - render(); - await screen.findByTestId("cc-area-github"); - fireEvent.click(screen.getByTestId("cc-github-backfill-button")); - - const result = await screen.findByTestId("cc-github-backfill-result"); - expect(result.textContent).toContain("Nothing to backfill"); - expect(result.className).not.toContain("cc-github-backfill-status--error"); - }); - - it("surfaces nonzero backfill error counts without throwing", async () => { - apiMock.mockResolvedValue(githubFixture()); - backfillGithubSourceIssueClosedAtMock.mockResolvedValueOnce({ - scanned: 8, - filled: 1, - skipped: 5, - errors: 2, - hasMore: false, - }); - - render(); - await screen.findByTestId("cc-area-github"); - fireEvent.click(screen.getByTestId("cc-github-backfill-button")); - - const result = await screen.findByTestId("cc-github-backfill-result"); - expect(result.textContent).toContain("errors 2"); - expect(result.className).toContain("cc-github-backfill-status--error"); - }); - - it("captures endpoint failures in local error UI", async () => { - apiMock.mockResolvedValue(githubFixture()); - backfillGithubSourceIssueClosedAtMock.mockRejectedValueOnce(new Error("endpoint failed")); - - render(); - await screen.findByTestId("cc-area-github"); - fireEvent.click(screen.getByTestId("cc-github-backfill-button")); - - const result = await screen.findByTestId("cc-github-backfill-result"); - expect(result.textContent).toContain("endpoint failed"); - expect(result.className).toContain("cc-github-backfill-status--error"); - }); - - it("disables and guards the button while a backfill is in flight", async () => { - apiMock.mockResolvedValue(githubFixture()); - let resolveBackfill: ((value: { scanned: number; filled: number; skipped: number; errors: number; hasMore: boolean }) => void) | null = null; - backfillGithubSourceIssueClosedAtMock.mockImplementationOnce( - () => new Promise((resolve) => { - resolveBackfill = resolve; - }), - ); - - render(); - await screen.findByTestId("cc-area-github"); - const button = screen.getByTestId("cc-github-backfill-button") as HTMLButtonElement; - fireEvent.click(button); - - await waitFor(() => expect(button.disabled).toBe(true)); - fireEvent.click(button); - expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenCalledTimes(1); - - resolveBackfill?.({ scanned: 1, filled: 1, skipped: 0, errors: 0, hasMore: false }); - await screen.findByText(/Backfill complete/i); - }); - - it("stops a pathological always-has-more response at the max iteration guard", async () => { - apiMock.mockResolvedValue(githubFixture()); - backfillGithubSourceIssueClosedAtMock.mockResolvedValue({ - scanned: 1, - filled: 0, - skipped: 1, - errors: 0, - hasMore: true, - }); - - render(); - await screen.findByTestId("cc-area-github"); - fireEvent.click(screen.getByTestId("cc-github-backfill-button")); - - const result = await screen.findByText(/safety limit/i); - expect(result.textContent).toContain("safety limit"); - expect(backfillGithubSourceIssueClosedAtMock).toHaveBeenCalledTimes(1000); - expect(screen.getByTestId("cc-github-backfill-result").textContent).toContain("Scanned 1000"); - }); -}); - -// FN-6684 Mission Control decision: no extra pie/line test here because MissionControlPanel already renders the live SDLC Funnel for its only quantitative distribution; adding a pie would duplicate that affordance. -describe("SignalsArea", () => { - it("renders the empty state (not an error) when the signals endpoint is missing", async () => { - apiMock.mockRejectedValue(new Error("API returned HTML instead of JSON (404)")); - render(); - await screen.findByTestId("cc-area-signals-empty"); - // Must not surface the error UI. - expect(screen.queryByTestId("cc-area-signals-error")).toBeNull(); - expect(screen.queryByTestId("cc-signals-pie")).toBeNull(); - }); - - it("renders signal metrics and status pie when data is present", async () => { - apiMock.mockResolvedValue({ - totalSignals: 8, - open: 3, - resolved: 5, - mttr: { value: 42, unavailable: false }, - bySource: [{ source: "sentry", count: 8 }], - bySeverity: [{ severity: "error", count: 8 }], - }); - render(); - await screen.findByTestId("cc-area-signals"); - expect(screen.getByTestId("cc-signals-total").textContent).toContain("8"); - expect(screen.getByTestId("cc-signals-mttr").textContent).toContain("42"); - expect(screen.getByTestId("cc-signals-pie")).toBeTruthy(); - expect(screen.getByRole("img", { name: "Signal status share" })).toBeTruthy(); - }); - - it("keeps signals pie safe for single-item and non-finite source/severity data", async () => { - apiMock.mockResolvedValue({ - totalSignals: 1, - open: 1, - resolved: 0, - mttr: { value: null, unavailable: true }, - bySource: [{ source: "broken", count: Number.NaN }], - bySeverity: [{ severity: "broken", count: Number.POSITIVE_INFINITY }], - }); - render(); - await screen.findByTestId("cc-area-signals"); - expect(screen.getByTestId("cc-signals-pie")).toBeTruthy(); - expect(screen.getByTestId("cc-area-signals").textContent).not.toContain("NaN"); - expect(screen.getByTestId("cc-area-signals").textContent).not.toContain("Infinity"); - }); - - it("renders settled zero signals without a pie shell", async () => { - apiMock.mockResolvedValue({ - totalSignals: 0, - open: 0, - resolved: 0, - mttr: { value: null, unavailable: true }, - bySource: [], - bySeverity: [], - }); - render(); - await screen.findByTestId("cc-area-signals-empty"); - expect(screen.queryByTestId("cc-signals-pie")).toBeNull(); - }); -});