Merge develop into main
All checks were successful
All checks were successful
Consolidate all feature and fix branches into main: - feat: orchestrator observability + mosaic rails integration (#422) - fix: post-422 CI and compose env follow-up (#423) - fix: orchestrator startup provider-key requirements (#425) - fix: BetterAuth OAuth2 flow and compose wiring (#426) - fix: BetterAuth UUID ID generation (#427) - test: web vitest localStorage/file warnings (#428) - fix: auth frontend remediation + review hardening (#421) - Plus numerous Docker, deploy, and auth fixes from develop Lockfile conflict resolved by regenerating from merged package.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,15 @@ const WIDGET_REGISTRY = {
|
||||
minWidth: 1,
|
||||
minHeight: 1,
|
||||
},
|
||||
OrchestratorEventsWidget: {
|
||||
name: "orchestrator-events",
|
||||
displayName: "Orchestrator Events",
|
||||
description: "Recent events and stream health for orchestration",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 1,
|
||||
minHeight: 1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type WidgetRegistryKey = keyof typeof WIDGET_REGISTRY;
|
||||
@@ -73,7 +82,7 @@ export function HUD({ className = "" }: HUDProps): React.JSX.Element {
|
||||
|
||||
const handleAddWidget = (widgetType: WidgetRegistryKey): void => {
|
||||
const widgetConfig = WIDGET_REGISTRY[widgetType];
|
||||
const widgetId = `${widgetType.toLowerCase()}-${String(Date.now())}`;
|
||||
const widgetId = `${widgetConfig.name}-${String(Date.now())}`;
|
||||
|
||||
// Find the next available position
|
||||
const maxY = currentLayout?.layout.reduce((max, w): number => Math.max(max, w.y + w.h), 0) ?? 0;
|
||||
|
||||
47
apps/web/src/components/hud/WidgetRenderer.test.tsx
Normal file
47
apps/web/src/components/hud/WidgetRenderer.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { WidgetRenderer } from "./WidgetRenderer";
|
||||
import type { WidgetPlacement } from "@mosaic/shared";
|
||||
|
||||
vi.mock("@/components/widgets", () => ({
|
||||
TasksWidget: ({ id }: { id: string }): React.JSX.Element => <div>Tasks Widget {id}</div>,
|
||||
CalendarWidget: ({ id }: { id: string }): React.JSX.Element => <div>Calendar Widget {id}</div>,
|
||||
QuickCaptureWidget: ({ id }: { id: string }): React.JSX.Element => (
|
||||
<div>Quick Capture Widget {id}</div>
|
||||
),
|
||||
AgentStatusWidget: ({ id }: { id: string }): React.JSX.Element => (
|
||||
<div>Agent Status Widget {id}</div>
|
||||
),
|
||||
OrchestratorEventsWidget: ({ id }: { id: string }): React.JSX.Element => (
|
||||
<div>Orchestrator Events Widget {id}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function createWidgetPlacement(id: string): WidgetPlacement {
|
||||
return {
|
||||
i: id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 2,
|
||||
h: 2,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WidgetRenderer", () => {
|
||||
it("renders hyphenated quick-capture widget IDs correctly", () => {
|
||||
render(<WidgetRenderer widget={createWidgetPlacement("quick-capture-123")} />);
|
||||
expect(screen.getByText("Quick Capture Widget quick-capture-123")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders hyphenated agent-status widget IDs correctly", () => {
|
||||
render(<WidgetRenderer widget={createWidgetPlacement("agent-status-123")} />);
|
||||
expect(screen.getByText("Agent Status Widget agent-status-123")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders hyphenated orchestrator-events widget IDs correctly", () => {
|
||||
render(<WidgetRenderer widget={createWidgetPlacement("orchestrator-events-123")} />);
|
||||
expect(
|
||||
screen.getByText("Orchestrator Events Widget orchestrator-events-123")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CalendarWidget,
|
||||
QuickCaptureWidget,
|
||||
AgentStatusWidget,
|
||||
OrchestratorEventsWidget,
|
||||
} from "@/components/widgets";
|
||||
import type { WidgetPlacement } from "@mosaic/shared";
|
||||
|
||||
@@ -24,6 +25,7 @@ const WIDGET_COMPONENTS = {
|
||||
calendar: CalendarWidget,
|
||||
"quick-capture": QuickCaptureWidget,
|
||||
"agent-status": AgentStatusWidget,
|
||||
"orchestrator-events": OrchestratorEventsWidget,
|
||||
};
|
||||
|
||||
const WIDGET_CONFIG = {
|
||||
@@ -43,6 +45,10 @@ const WIDGET_CONFIG = {
|
||||
displayName: "Agent Status",
|
||||
description: "View running agent sessions",
|
||||
},
|
||||
"orchestrator-events": {
|
||||
displayName: "Orchestrator Events",
|
||||
description: "Recent orchestration events and stream health",
|
||||
},
|
||||
};
|
||||
|
||||
export function WidgetRenderer({
|
||||
@@ -50,8 +56,12 @@ export function WidgetRenderer({
|
||||
isEditing = false,
|
||||
onRemove,
|
||||
}: WidgetRendererProps): React.JSX.Element {
|
||||
// Extract widget type from ID (e.g., "tasks-123" -> "tasks")
|
||||
const widgetType = widget.i.split("-")[0] as keyof typeof WIDGET_COMPONENTS;
|
||||
// Extract widget type from ID by removing the trailing unique suffix
|
||||
// (e.g., "agent-status-123" -> "agent-status").
|
||||
const separatorIndex = widget.i.lastIndexOf("-");
|
||||
const widgetType = (
|
||||
separatorIndex > 0 ? widget.i.substring(0, separatorIndex) : widget.i
|
||||
) as keyof typeof WIDGET_COMPONENTS;
|
||||
const WidgetComponent = WIDGET_COMPONENTS[widgetType];
|
||||
const config = WIDGET_CONFIG[widgetType] || { displayName: "Widget", description: "" };
|
||||
|
||||
|
||||
@@ -56,6 +56,15 @@ export function LinkAutocomplete({
|
||||
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
||||
const cursorSpanRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
// Refs for event handler to avoid stale closures when effects re-attach listeners
|
||||
const stateRef = useRef(state);
|
||||
const resultsRef = useRef(results);
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
const insertLinkRef = useRef<((result: SearchResult) => void) | null>(null);
|
||||
stateRef.current = state;
|
||||
resultsRef.current = results;
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
/**
|
||||
* Search for knowledge entries matching the query.
|
||||
* Accepts an AbortSignal to allow cancellation of in-flight requests,
|
||||
@@ -254,47 +263,48 @@ export function LinkAutocomplete({
|
||||
}, [textareaRef, state.isOpen, calculateDropdownPosition, debouncedSearch]);
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation in the dropdown
|
||||
* Handle keyboard navigation in the dropdown.
|
||||
* Reads from refs to avoid stale closures when the effect
|
||||
* that attaches this listener hasn't re-run yet.
|
||||
*/
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent): void => {
|
||||
if (!state.isOpen) return;
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent): void => {
|
||||
if (!stateRef.current.isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % results.length);
|
||||
break;
|
||||
const currentResults = resultsRef.current;
|
||||
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + results.length) % results.length);
|
||||
break;
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % currentResults.length);
|
||||
break;
|
||||
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (results.length > 0 && selectedIndex >= 0) {
|
||||
const selected = results[selectedIndex];
|
||||
if (selected) {
|
||||
insertLink(selected);
|
||||
}
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + currentResults.length) % currentResults.length);
|
||||
break;
|
||||
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (currentResults.length > 0 && selectedIndexRef.current >= 0) {
|
||||
const selected = currentResults[selectedIndexRef.current];
|
||||
if (selected) {
|
||||
insertLinkRef.current?.(selected);
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setState({
|
||||
isOpen: false,
|
||||
query: "",
|
||||
position: { top: 0, left: 0 },
|
||||
triggerIndex: -1,
|
||||
});
|
||||
setResults([]);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[state.isOpen, results, selectedIndex]
|
||||
);
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setState({
|
||||
isOpen: false,
|
||||
query: "",
|
||||
position: { top: 0, left: 0 },
|
||||
triggerIndex: -1,
|
||||
});
|
||||
setResults([]);
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Insert the selected link into the textarea
|
||||
@@ -330,6 +340,7 @@ export function LinkAutocomplete({
|
||||
},
|
||||
[textareaRef, state.triggerIndex, onInsert]
|
||||
);
|
||||
insertLinkRef.current = insertLink;
|
||||
|
||||
/**
|
||||
* Handle click on a result
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
import React from "react";
|
||||
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
|
||||
@@ -352,10 +351,7 @@ describe("LinkAutocomplete", (): void => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should perform debounced search when typing query", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should perform debounced search when typing query", async (): Promise<void> => {
|
||||
const mockResults = {
|
||||
data: [
|
||||
{
|
||||
@@ -395,11 +391,6 @@ describe("LinkAutocomplete", (): void => {
|
||||
// Should not call API immediately
|
||||
expect(mockApiRequest).not.toHaveBeenCalled();
|
||||
|
||||
// Fast-forward 300ms and let promises resolve
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
"/api/knowledge/search?q=test&limit=10",
|
||||
@@ -411,14 +402,9 @@ describe("LinkAutocomplete", (): void => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Entry")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should navigate results with arrow keys", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should navigate results with arrow keys", async (): Promise<void> => {
|
||||
const mockResults = {
|
||||
data: [
|
||||
{
|
||||
@@ -471,10 +457,6 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Entry One")).toBeInTheDocument();
|
||||
});
|
||||
@@ -484,7 +466,9 @@ describe("LinkAutocomplete", (): void => {
|
||||
expect(firstItem).toHaveClass("bg-blue-50");
|
||||
|
||||
// Press ArrowDown
|
||||
fireEvent.keyDown(textarea, { key: "ArrowDown" });
|
||||
act(() => {
|
||||
fireEvent.keyDown(textarea, { key: "ArrowDown" });
|
||||
});
|
||||
|
||||
// Second item should now be selected
|
||||
await waitFor(() => {
|
||||
@@ -493,21 +477,18 @@ describe("LinkAutocomplete", (): void => {
|
||||
});
|
||||
|
||||
// Press ArrowUp
|
||||
fireEvent.keyDown(textarea, { key: "ArrowUp" });
|
||||
act(() => {
|
||||
fireEvent.keyDown(textarea, { key: "ArrowUp" });
|
||||
});
|
||||
|
||||
// First item should be selected again
|
||||
await waitFor(() => {
|
||||
const firstItem = screen.getByText("Entry One").closest("li");
|
||||
expect(firstItem).toHaveClass("bg-blue-50");
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should insert link on Enter key", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should insert link on Enter key", async (): Promise<void> => {
|
||||
const mockResults = {
|
||||
data: [
|
||||
{
|
||||
@@ -544,10 +525,6 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Entry")).toBeInTheDocument();
|
||||
});
|
||||
@@ -558,14 +535,9 @@ describe("LinkAutocomplete", (): void => {
|
||||
await waitFor(() => {
|
||||
expect(onInsertMock).toHaveBeenCalledWith("[[test-entry|Test Entry]]");
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should insert link on click", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should insert link on click", async (): Promise<void> => {
|
||||
const mockResults = {
|
||||
data: [
|
||||
{
|
||||
@@ -602,10 +574,6 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Entry")).toBeInTheDocument();
|
||||
});
|
||||
@@ -616,14 +584,9 @@ describe("LinkAutocomplete", (): void => {
|
||||
await waitFor(() => {
|
||||
expect(onInsertMock).toHaveBeenCalledWith("[[test-entry|Test Entry]]");
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should close dropdown on Escape key", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should close dropdown on Escape key", async (): Promise<void> => {
|
||||
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
@@ -636,28 +599,19 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Start typing to search/)).toBeInTheDocument();
|
||||
expect(screen.getByText("↑↓ Navigate • Enter Select • Esc Cancel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Press Escape
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Start typing to search/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("↑↓ Navigate • Enter Select • Esc Cancel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should close dropdown when closing brackets are typed", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should close dropdown when closing brackets are typed", async (): Promise<void> => {
|
||||
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
@@ -670,12 +624,8 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Start typing to search/)).toBeInTheDocument();
|
||||
expect(screen.getByText("↑↓ Navigate • Enter Select • Esc Cancel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Type closing brackets
|
||||
@@ -686,16 +636,11 @@ describe("LinkAutocomplete", (): void => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Start typing to search/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("↑↓ Navigate • Enter Select • Esc Cancel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should show 'No entries found' when search returns no results", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should show 'No entries found' when search returns no results", async (): Promise<void> => {
|
||||
mockApiRequest.mockResolvedValue({
|
||||
data: [],
|
||||
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
|
||||
@@ -713,32 +658,24 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No entries found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should show loading state while searching", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should show loading state while searching", async (): Promise<void> => {
|
||||
// Mock a slow API response
|
||||
let resolveSearch: (value: unknown) => void;
|
||||
const searchPromise = new Promise((resolve) => {
|
||||
let resolveSearch: (value: {
|
||||
data: unknown[];
|
||||
meta: { total: number; page: number; limit: number; totalPages: number };
|
||||
}) => void = () => undefined;
|
||||
const searchPromise = new Promise<{
|
||||
data: unknown[];
|
||||
meta: { total: number; page: number; limit: number; totalPages: number };
|
||||
}>((resolve) => {
|
||||
resolveSearch = resolve;
|
||||
});
|
||||
mockApiRequest.mockReturnValue(
|
||||
searchPromise as Promise<{
|
||||
data: unknown[];
|
||||
meta: { total: number; page: number; limit: number; totalPages: number };
|
||||
}>
|
||||
);
|
||||
mockApiRequest.mockReturnValue(searchPromise);
|
||||
|
||||
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||
|
||||
@@ -752,16 +689,12 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Searching...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Resolve the search
|
||||
resolveSearch!({
|
||||
resolveSearch({
|
||||
data: [],
|
||||
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
|
||||
});
|
||||
@@ -769,14 +702,9 @@ describe("LinkAutocomplete", (): void => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Searching...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should display summary preview for entries", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should display summary preview for entries", async (): Promise<void> => {
|
||||
const mockResults = {
|
||||
data: [
|
||||
{
|
||||
@@ -813,14 +741,8 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("This is a helpful summary")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
* Agent Status Widget - shows running agents
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Bot, Activity, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
import { ORCHESTRATOR_URL } from "@/lib/config";
|
||||
|
||||
interface Agent {
|
||||
agentId: string;
|
||||
@@ -22,46 +21,57 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchAgents = useCallback(async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/orchestrator/agents", {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch agents: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Agent[];
|
||||
setAgents(data);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("Failed to fetch agents:", errorMessage);
|
||||
setError(errorMessage);
|
||||
setAgents([]); // Clear agents on error
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
// Fetch agents from orchestrator API
|
||||
useEffect(() => {
|
||||
const fetchAgents = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ORCHESTRATOR_URL}/agents`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch agents: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Agent[];
|
||||
setAgents(data);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("Failed to fetch agents:", errorMessage);
|
||||
setError(errorMessage);
|
||||
setAgents([]); // Clear agents on error
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchAgents();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(() => {
|
||||
void fetchAgents();
|
||||
}, 30000);
|
||||
}, 20000);
|
||||
|
||||
const eventSource =
|
||||
typeof EventSource !== "undefined" ? new EventSource("/api/orchestrator/events") : null;
|
||||
if (eventSource) {
|
||||
eventSource.onmessage = (): void => {
|
||||
void fetchAgents();
|
||||
};
|
||||
eventSource.onerror = (): void => {
|
||||
// polling remains fallback
|
||||
};
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
eventSource?.close();
|
||||
};
|
||||
}, []);
|
||||
}, [fetchAgents]);
|
||||
|
||||
const getStatusIcon = (status: string): React.JSX.Element => {
|
||||
const statusLower = status.toLowerCase();
|
||||
|
||||
190
apps/web/src/components/widgets/OrchestratorEventsWidget.tsx
Normal file
190
apps/web/src/components/widgets/OrchestratorEventsWidget.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Activity, DatabaseZap, Loader2, Wifi, WifiOff } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
|
||||
interface OrchestratorEvent {
|
||||
type: string;
|
||||
timestamp: string;
|
||||
agentId?: string;
|
||||
taskId?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface RecentEventsResponse {
|
||||
events: OrchestratorEvent[];
|
||||
}
|
||||
|
||||
function isMatrixSignal(event: OrchestratorEvent): boolean {
|
||||
const text = JSON.stringify(event).toLowerCase();
|
||||
return (
|
||||
text.includes("matrix") ||
|
||||
text.includes("room") ||
|
||||
text.includes("channel") ||
|
||||
text.includes("thread")
|
||||
);
|
||||
}
|
||||
|
||||
export function OrchestratorEventsWidget({
|
||||
id: _id,
|
||||
config: _config,
|
||||
}: WidgetProps): React.JSX.Element {
|
||||
const [events, setEvents] = useState<OrchestratorEvent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [streamConnected, setStreamConnected] = useState(false);
|
||||
const [backendReady, setBackendReady] = useState<boolean | null>(null);
|
||||
|
||||
const loadRecentEvents = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch("/api/orchestrator/events/recent?limit=25");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load events: HTTP ${String(response.status)}`);
|
||||
}
|
||||
const payload = (await response.json()) as unknown;
|
||||
const events =
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"events" in payload &&
|
||||
Array.isArray(payload.events)
|
||||
? (payload.events as RecentEventsResponse["events"])
|
||||
: [];
|
||||
setEvents(events);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unable to load events.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadHealth = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch("/api/orchestrator/health");
|
||||
setBackendReady(response.ok);
|
||||
} catch {
|
||||
setBackendReady(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRecentEvents();
|
||||
void loadHealth();
|
||||
|
||||
const eventSource =
|
||||
typeof EventSource !== "undefined" ? new EventSource("/api/orchestrator/events") : null;
|
||||
if (eventSource) {
|
||||
eventSource.onopen = (): void => {
|
||||
setStreamConnected(true);
|
||||
};
|
||||
eventSource.onmessage = (): void => {
|
||||
void loadRecentEvents();
|
||||
void loadHealth();
|
||||
};
|
||||
eventSource.onerror = (): void => {
|
||||
setStreamConnected(false);
|
||||
};
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
void loadRecentEvents();
|
||||
void loadHealth();
|
||||
}, 15000);
|
||||
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
eventSource?.close();
|
||||
};
|
||||
}, [loadHealth, loadRecentEvents]);
|
||||
|
||||
const matrixSignals = useMemo(
|
||||
() => events.filter((event) => isMatrixSignal(event)).length,
|
||||
[events]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-5 h-5 text-gray-400 animate-spin" />
|
||||
<span className="ml-2 text-gray-500 text-sm">Loading orchestrator events...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<WifiOff className="w-5 h-5 text-amber-500 mb-2" />
|
||||
<span className="text-sm text-amber-600">{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-3">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
|
||||
{streamConnected ? (
|
||||
<Wifi className="w-3 h-3 text-green-500" />
|
||||
) : (
|
||||
<WifiOff className="w-3 h-3 text-gray-400" />
|
||||
)}
|
||||
<span>{streamConnected ? "Live stream connected" : "Polling mode"}</span>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 ${
|
||||
backendReady === true
|
||||
? "bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300"
|
||||
: backendReady === false
|
||||
? "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300"
|
||||
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{backendReady === true ? "ready" : backendReady === false ? "degraded" : "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded bg-blue-50 dark:bg-blue-950 px-2 py-1 text-blue-700 dark:text-blue-300">
|
||||
<DatabaseZap className="w-3 h-3" />
|
||||
<span>Matrix signals: {matrixSignals}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto space-y-2">
|
||||
{events.length === 0 ? (
|
||||
<div className="text-center text-sm text-gray-500 py-4">
|
||||
No recent orchestration events.
|
||||
</div>
|
||||
) : (
|
||||
events
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((event, index) => (
|
||||
<div
|
||||
key={`${event.timestamp}-${event.type}-${String(index)}`}
|
||||
className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-2 py-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Activity className="w-3 h-3 text-blue-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{event.type}
|
||||
</span>
|
||||
{isMatrixSignal(event) && (
|
||||
<span className="text-[10px] rounded bg-indigo-100 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 px-1.5 py-0.5">
|
||||
matrix
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-500">
|
||||
{new Date(event.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-gray-600 dark:text-gray-300">
|
||||
{event.taskId ? `Task ${event.taskId}` : "Task n/a"}
|
||||
{event.agentId ? ` · Agent ${event.agentId.slice(0, 8)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,9 @@
|
||||
* including status, elapsed time, and work item details.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Activity, CheckCircle, XCircle, Clock, Loader2 } from "lucide-react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Activity, CheckCircle, XCircle, Clock, Loader2, Pause, Play } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
import { ORCHESTRATOR_URL } from "@/lib/config";
|
||||
|
||||
interface AgentTask {
|
||||
agentId: string;
|
||||
@@ -20,6 +19,21 @@ interface AgentTask {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface QueueStats {
|
||||
pending: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
}
|
||||
|
||||
interface RecentOrchestratorEvent {
|
||||
type: string;
|
||||
timestamp: string;
|
||||
taskId?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
function getElapsedTime(spawnedAt: string, completedAt?: string): string {
|
||||
const start = new Date(spawnedAt).getTime();
|
||||
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
||||
@@ -95,34 +109,108 @@ function getAgentTypeLabel(agentType: string): string {
|
||||
|
||||
export function TaskProgressWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
||||
const [queueStats, setQueueStats] = useState<QueueStats | null>(null);
|
||||
const [recentEvents, setRecentEvents] = useState<RecentOrchestratorEvent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isQueuePaused, setIsQueuePaused] = useState(false);
|
||||
const [isActionPending, setIsActionPending] = useState(false);
|
||||
|
||||
const fetchTasks = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const res = await fetch("/api/orchestrator/agents");
|
||||
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
||||
const data = (await res.json()) as AgentTask[];
|
||||
setTasks(data);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
} catch {
|
||||
setError("Unable to reach orchestrator");
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchQueueStats = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const res = await fetch("/api/orchestrator/queue/stats");
|
||||
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
||||
const data = (await res.json()) as QueueStats;
|
||||
setQueueStats(data);
|
||||
// Heuristic: active=0 with pending>0 for sustained windows usually means paused.
|
||||
setIsQueuePaused(data.active === 0 && data.pending > 0);
|
||||
} catch {
|
||||
// Keep widget functional even if queue controls are temporarily unavailable.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchRecentEvents = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const res = await fetch("/api/orchestrator/events/recent?limit=5");
|
||||
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
||||
const payload = (await res.json()) as unknown;
|
||||
const events =
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"events" in payload &&
|
||||
Array.isArray(payload.events)
|
||||
? (payload.events as RecentOrchestratorEvent[])
|
||||
: [];
|
||||
setRecentEvents(events);
|
||||
} catch {
|
||||
// Optional enhancement path; do not fail widget if recent-events endpoint is unavailable.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setQueueState = useCallback(
|
||||
async (action: "pause" | "resume"): Promise<void> => {
|
||||
setIsActionPending(true);
|
||||
try {
|
||||
const res = await fetch(`/api/orchestrator/queue/${action}`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
||||
setIsQueuePaused(action === "pause");
|
||||
await fetchQueueStats();
|
||||
} catch {
|
||||
setError("Unable to control queue state");
|
||||
} finally {
|
||||
setIsActionPending(false);
|
||||
}
|
||||
},
|
||||
[fetchQueueStats]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTasks = (): void => {
|
||||
fetch(`${ORCHESTRATOR_URL}/agents`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
|
||||
return res.json() as Promise<AgentTask[]>;
|
||||
})
|
||||
.then((data) => {
|
||||
setTasks(data);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Unable to reach orchestrator");
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
void fetchTasks();
|
||||
void fetchQueueStats();
|
||||
void fetchRecentEvents();
|
||||
|
||||
fetchTasks();
|
||||
const interval = setInterval(fetchTasks, 15000);
|
||||
const interval = setInterval(() => {
|
||||
void fetchTasks();
|
||||
void fetchQueueStats();
|
||||
void fetchRecentEvents();
|
||||
}, 15000);
|
||||
|
||||
const eventSource =
|
||||
typeof EventSource !== "undefined" ? new EventSource("/api/orchestrator/events") : null;
|
||||
if (eventSource) {
|
||||
eventSource.onmessage = (): void => {
|
||||
void fetchTasks();
|
||||
void fetchQueueStats();
|
||||
void fetchRecentEvents();
|
||||
};
|
||||
eventSource.onerror = (): void => {
|
||||
// Polling remains the resilience path.
|
||||
};
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
eventSource?.close();
|
||||
};
|
||||
}, []);
|
||||
}, [fetchTasks, fetchQueueStats, fetchRecentEvents]);
|
||||
|
||||
const latestEvent = recentEvents.length > 0 ? recentEvents[recentEvents.length - 1] : null;
|
||||
|
||||
const stats = {
|
||||
total: tasks.length,
|
||||
@@ -152,6 +240,30 @@ export function TaskProgressWidget({ id: _id, config: _config }: WidgetProps): R
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Queue: {isQueuePaused ? "Paused" : "Running"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(): void => {
|
||||
void setQueueState(isQueuePaused ? "resume" : "pause");
|
||||
}}
|
||||
disabled={isActionPending}
|
||||
className="inline-flex items-center gap-1 rounded border border-gray-300 dark:border-gray-700 px-2 py-1 text-xs hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{isQueuePaused ? <Play className="w-3 h-3" /> : <Pause className="w-3 h-3" />}
|
||||
{isQueuePaused ? "Resume" : "Pause"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{latestEvent && (
|
||||
<div className="rounded bg-gray-50 dark:bg-gray-800 px-2 py-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
Latest: {latestEvent.type}
|
||||
{latestEvent.taskId ? ` · ${latestEvent.taskId}` : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-4 gap-1 text-center text-xs">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded p-2">
|
||||
@@ -174,6 +286,29 @@ export function TaskProgressWidget({ id: _id, config: _config }: WidgetProps): R
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{queueStats && (
|
||||
<div className="grid grid-cols-3 gap-1 text-center text-xs">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded p-1">
|
||||
<div className="font-semibold text-gray-700 dark:text-gray-200">
|
||||
{queueStats.pending}
|
||||
</div>
|
||||
<div className="text-gray-500">Queued</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded p-1">
|
||||
<div className="font-semibold text-gray-700 dark:text-gray-200">
|
||||
{queueStats.active}
|
||||
</div>
|
||||
<div className="text-gray-500">Workers</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded p-1">
|
||||
<div className="font-semibold text-gray-700 dark:text-gray-200">
|
||||
{queueStats.failed}
|
||||
</div>
|
||||
<div className="text-gray-500">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task list */}
|
||||
<div className="flex-1 overflow-auto space-y-2">
|
||||
{tasks.length === 0 ? (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { QuickCaptureWidget } from "./QuickCaptureWidget";
|
||||
import { AgentStatusWidget } from "./AgentStatusWidget";
|
||||
import { ActiveProjectsWidget } from "./ActiveProjectsWidget";
|
||||
import { TaskProgressWidget } from "./TaskProgressWidget";
|
||||
import { OrchestratorEventsWidget } from "./OrchestratorEventsWidget";
|
||||
|
||||
export interface WidgetDefinition {
|
||||
name: string;
|
||||
@@ -95,6 +96,17 @@ export const widgetRegistry: Record<string, WidgetDefinition> = {
|
||||
minHeight: 2,
|
||||
maxWidth: 3,
|
||||
},
|
||||
OrchestratorEventsWidget: {
|
||||
name: "OrchestratorEventsWidget",
|
||||
displayName: "Orchestrator Events",
|
||||
description: "Recent orchestration events with stream/Matrix visibility",
|
||||
component: OrchestratorEventsWidget,
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 1,
|
||||
minHeight: 2,
|
||||
maxWidth: 4,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,126 +1,55 @@
|
||||
/**
|
||||
* CalendarWidget Component Tests
|
||||
* Following TDD principles
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { CalendarWidget } from "../CalendarWidget";
|
||||
|
||||
global.fetch = vi.fn() as typeof global.fetch;
|
||||
async function finishWidgetLoad(): Promise<void> {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
});
|
||||
}
|
||||
|
||||
describe("CalendarWidget", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-01T08:00:00Z"));
|
||||
});
|
||||
|
||||
it("should render loading state initially", (): void => {
|
||||
vi.mocked(global.fetch).mockImplementation(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
// Intentionally never resolves to keep loading state
|
||||
})
|
||||
);
|
||||
|
||||
render(<CalendarWidget id="calendar-1" />);
|
||||
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
afterEach((): void => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Re-enable when CalendarWidget uses fetch API instead of setTimeout mock data
|
||||
it.skip("should render upcoming events", async (): Promise<void> => {
|
||||
const mockEvents = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Team Meeting",
|
||||
startTime: new Date(Date.now() + 3600000).toISOString(),
|
||||
endTime: new Date(Date.now() + 7200000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Project Review",
|
||||
startTime: new Date(Date.now() + 86400000).toISOString(),
|
||||
endTime: new Date(Date.now() + 90000000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockEvents),
|
||||
} as unknown as Response);
|
||||
|
||||
it("renders loading state initially", (): void => {
|
||||
render(<CalendarWidget id="calendar-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Team Meeting")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project Review")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Loading events...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Re-enable when CalendarWidget uses fetch API instead of setTimeout mock data
|
||||
it.skip("should handle empty event list", async (): Promise<void> => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as unknown as Response);
|
||||
|
||||
it("renders upcoming events after loading", async (): Promise<void> => {
|
||||
render(<CalendarWidget id="calendar-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no upcoming events/i)).toBeInTheDocument();
|
||||
});
|
||||
await finishWidgetLoad();
|
||||
|
||||
expect(screen.getByText("Upcoming Events")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team Standup")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project Review")).toBeInTheDocument();
|
||||
expect(screen.getByText("Sprint Planning")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Re-enable when CalendarWidget uses fetch API instead of setTimeout mock data
|
||||
it.skip("should handle API errors gracefully", async (): Promise<void> => {
|
||||
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("API Error"));
|
||||
|
||||
it("shows relative day labels", async (): Promise<void> => {
|
||||
render(<CalendarWidget id="calendar-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/error/i)).toBeInTheDocument();
|
||||
});
|
||||
await finishWidgetLoad();
|
||||
|
||||
expect(screen.getAllByText("Today").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Tomorrow")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Re-enable when CalendarWidget uses fetch API instead of setTimeout mock data
|
||||
it.skip("should format event times correctly", async (): Promise<void> => {
|
||||
const now = new Date();
|
||||
const startTime = new Date(now.getTime() + 3600000); // 1 hour from now
|
||||
|
||||
const mockEvents = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Meeting",
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: new Date(startTime.getTime() + 3600000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockEvents),
|
||||
} as unknown as Response);
|
||||
|
||||
it("shows event locations when present", async (): Promise<void> => {
|
||||
render(<CalendarWidget id="calendar-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Meeting")).toBeInTheDocument();
|
||||
// Should show time in readable format
|
||||
});
|
||||
});
|
||||
await finishWidgetLoad();
|
||||
|
||||
// TODO: Re-enable when CalendarWidget uses fetch API and adds calendar-header test id
|
||||
it.skip("should display current date", async (): Promise<void> => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as unknown as Response);
|
||||
|
||||
render(<CalendarWidget id="calendar-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Widget should display current date or month
|
||||
expect(screen.getByTestId("calendar-header")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Zoom")).toBeInTheDocument();
|
||||
expect(screen.getByText("Conference Room A")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { OrchestratorEventsWidget } from "../OrchestratorEventsWidget";
|
||||
|
||||
describe("OrchestratorEventsWidget", () => {
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders loading state initially", () => {
|
||||
mockFetch.mockImplementation(
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
() => new Promise(() => {})
|
||||
);
|
||||
|
||||
render(<OrchestratorEventsWidget id="orchestrator-events-1" config={{}} />);
|
||||
expect(screen.getByText("Loading orchestrator events...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders events and matrix signal count", async () => {
|
||||
mockFetch.mockImplementation((input: RequestInfo | URL) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("/api/orchestrator/health")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ status: "ok" }),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
events: [
|
||||
{
|
||||
type: "task.completed",
|
||||
timestamp: "2026-02-17T16:40:00.000Z",
|
||||
taskId: "TASK-1",
|
||||
data: { channelId: "room-123" },
|
||||
},
|
||||
{
|
||||
type: "agent.running",
|
||||
timestamp: "2026-02-17T16:41:00.000Z",
|
||||
taskId: "TASK-2",
|
||||
agentId: "agent-abc12345",
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as Response);
|
||||
});
|
||||
|
||||
render(<OrchestratorEventsWidget id="orchestrator-events-1" config={{}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("task.completed")).toBeInTheDocument();
|
||||
expect(screen.getByText("agent.running")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Matrix signals: 1/)).toBeInTheDocument();
|
||||
expect(screen.getByText("ready")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders error state when API fails", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
});
|
||||
|
||||
render(<OrchestratorEventsWidget id="orchestrator-events-1" config={{}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Unable to load events: HTTP 503/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -242,4 +242,58 @@ describe("TaskProgressWidget", (): void => {
|
||||
expect(taskElements[1]?.textContent).toBe("COMPLETED-TASK");
|
||||
});
|
||||
});
|
||||
|
||||
it("should display latest orchestrator event when available", async (): Promise<void> => {
|
||||
mockFetch.mockImplementation((input: RequestInfo | URL) => {
|
||||
let url = "";
|
||||
if (typeof input === "string") {
|
||||
url = input;
|
||||
} else if (input instanceof URL) {
|
||||
url = input.toString();
|
||||
} else {
|
||||
url = input.url;
|
||||
}
|
||||
if (url.includes("/api/orchestrator/agents")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as unknown as Response);
|
||||
}
|
||||
if (url.includes("/api/orchestrator/queue/stats")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
pending: 0,
|
||||
active: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
delayed: 0,
|
||||
}),
|
||||
} as unknown as Response);
|
||||
}
|
||||
if (url.includes("/api/orchestrator/events/recent")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
events: [
|
||||
{
|
||||
type: "task.executing",
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId: "TASK-123",
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as Response);
|
||||
}
|
||||
return Promise.reject(new Error("Unknown endpoint"));
|
||||
});
|
||||
|
||||
render(<TaskProgressWidget id="task-progress-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Latest: task.executing · TASK-123/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,138 +1,54 @@
|
||||
/**
|
||||
* TasksWidget Component Tests
|
||||
* Following TDD principles
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { TasksWidget } from "../TasksWidget";
|
||||
|
||||
// Mock fetch for API calls
|
||||
global.fetch = vi.fn() as typeof global.fetch;
|
||||
async function finishWidgetLoad(): Promise<void> {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
});
|
||||
}
|
||||
|
||||
describe("TasksWidget", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it("should render loading state initially", (): void => {
|
||||
vi.mocked(global.fetch).mockImplementation(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
// Intentionally empty - creates a never-resolving promise for loading state
|
||||
})
|
||||
);
|
||||
|
||||
render(<TasksWidget id="tasks-1" />);
|
||||
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
afterEach((): void => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Re-enable when TasksWidget uses fetch API instead of setTimeout mock data
|
||||
it.skip("should render task statistics", async (): Promise<void> => {
|
||||
const mockTasks = [
|
||||
{ id: "1", title: "Task 1", status: "IN_PROGRESS", priority: "HIGH" },
|
||||
{ id: "2", title: "Task 2", status: "COMPLETED", priority: "MEDIUM" },
|
||||
{ id: "3", title: "Task 3", status: "NOT_STARTED", priority: "LOW" },
|
||||
];
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTasks),
|
||||
} as unknown as Response);
|
||||
|
||||
it("renders loading state initially", (): void => {
|
||||
render(<TasksWidget id="tasks-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("3")).toBeInTheDocument(); // Total
|
||||
expect(screen.getByText("1")).toBeInTheDocument(); // In Progress
|
||||
expect(screen.getByText("1")).toBeInTheDocument(); // Completed
|
||||
});
|
||||
expect(screen.getByText("Loading tasks...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Re-enable when TasksWidget uses fetch API instead of setTimeout mock data
|
||||
it.skip("should render task list", async (): Promise<void> => {
|
||||
const mockTasks = [
|
||||
{ id: "1", title: "Complete documentation", status: "IN_PROGRESS", priority: "HIGH" },
|
||||
{ id: "2", title: "Review PRs", status: "NOT_STARTED", priority: "MEDIUM" },
|
||||
];
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTasks),
|
||||
} as unknown as Response);
|
||||
|
||||
it("renders default summary stats", async (): Promise<void> => {
|
||||
render(<TasksWidget id="tasks-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Complete documentation")).toBeInTheDocument();
|
||||
expect(screen.getByText("Review PRs")).toBeInTheDocument();
|
||||
});
|
||||
await finishWidgetLoad();
|
||||
|
||||
expect(screen.getByText("Total")).toBeInTheDocument();
|
||||
expect(screen.getByText("In Progress")).toBeInTheDocument();
|
||||
expect(screen.getByText("Done")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Re-enable when TasksWidget uses fetch API instead of setTimeout mock data
|
||||
it.skip("should handle empty task list", async (): Promise<void> => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as unknown as Response);
|
||||
|
||||
it("renders default task rows", async (): Promise<void> => {
|
||||
render(<TasksWidget id="tasks-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no tasks/i)).toBeInTheDocument();
|
||||
});
|
||||
await finishWidgetLoad();
|
||||
|
||||
expect(screen.getByText("Complete project documentation")).toBeInTheDocument();
|
||||
expect(screen.getByText("Review pull requests")).toBeInTheDocument();
|
||||
expect(screen.getByText("Update dependencies")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Re-enable when TasksWidget uses fetch API instead of setTimeout mock data
|
||||
it.skip("should handle API errors gracefully", async (): Promise<void> => {
|
||||
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("API Error"));
|
||||
|
||||
it("shows due date labels for each task", async (): Promise<void> => {
|
||||
render(<TasksWidget id="tasks-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/error/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
await finishWidgetLoad();
|
||||
|
||||
// TODO: Re-enable when TasksWidget uses fetch API instead of setTimeout mock data
|
||||
it.skip("should display priority indicators", async (): Promise<void> => {
|
||||
const mockTasks = [
|
||||
{ id: "1", title: "High priority task", status: "IN_PROGRESS", priority: "HIGH" },
|
||||
];
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTasks),
|
||||
} as unknown as Response);
|
||||
|
||||
render(<TasksWidget id="tasks-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("High priority task")).toBeInTheDocument();
|
||||
// Priority icon should be rendered (high priority = red)
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Re-enable when TasksWidget uses fetch API instead of setTimeout mock data
|
||||
it.skip("should limit displayed tasks to 5", async (): Promise<void> => {
|
||||
const mockTasks = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: String(i + 1),
|
||||
title: `Task ${String(i + 1)}`,
|
||||
status: "NOT_STARTED",
|
||||
priority: "MEDIUM",
|
||||
}));
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTasks),
|
||||
} as unknown as Response);
|
||||
|
||||
render(<TasksWidget id="tasks-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
const taskElements = screen.getAllByText(/Task \d+/);
|
||||
expect(taskElements.length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
expect(screen.getAllByText(/Due:/).length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { widgetRegistry } from "../WidgetRegistry";
|
||||
import { TasksWidget } from "../TasksWidget";
|
||||
import { CalendarWidget } from "../CalendarWidget";
|
||||
import { QuickCaptureWidget } from "../QuickCaptureWidget";
|
||||
import { OrchestratorEventsWidget } from "../OrchestratorEventsWidget";
|
||||
|
||||
describe("WidgetRegistry", (): void => {
|
||||
it("should have a registry of widgets", (): void => {
|
||||
@@ -32,6 +33,11 @@ describe("WidgetRegistry", (): void => {
|
||||
expect(widgetRegistry.QuickCaptureWidget!.component).toBe(QuickCaptureWidget);
|
||||
});
|
||||
|
||||
it("should include OrchestratorEventsWidget in registry", (): void => {
|
||||
expect(widgetRegistry.OrchestratorEventsWidget).toBeDefined();
|
||||
expect(widgetRegistry.OrchestratorEventsWidget!.component).toBe(OrchestratorEventsWidget);
|
||||
});
|
||||
|
||||
it("should have correct metadata for TasksWidget", (): void => {
|
||||
const tasksWidget = widgetRegistry.TasksWidget!;
|
||||
expect(tasksWidget.name).toBe("TasksWidget");
|
||||
|
||||
@@ -6,3 +6,4 @@ export { TasksWidget } from "./TasksWidget";
|
||||
export { CalendarWidget } from "./CalendarWidget";
|
||||
export { QuickCaptureWidget } from "./QuickCaptureWidget";
|
||||
export { AgentStatusWidget } from "./AgentStatusWidget";
|
||||
export { OrchestratorEventsWidget } from "./OrchestratorEventsWidget";
|
||||
|
||||
Reference in New Issue
Block a user