diff --git a/.env.example b/.env.example index 43db7e8c..2a9560c1 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,8 @@ CONFIGS_DIR= # Path to workspace-configs-templates/ (auto-disc PLUGINS_DIR= # Path to plugins/ directory (default: /plugins in container) # PLATFORM_URL=http://host.docker.internal:8080 # URL agent containers use to reach the platform; injected into workspace env. Default derives from PORT. # MOLECULE_URL=http://localhost:8080 # Canonical MCP-client URL (mirrors PLATFORM_URL inside containers). Read by the MCP server (mcp-server/) and Molecule MCP tooling. +# MOLECULE_MCP_ALLOW_SEND_MESSAGE= # Set to "true" to include send_message_to_user in the MCP bridge tool list (issue #810). Excluded by default to prevent unintended WebSocket pushes from CLI sessions. +# MOLECULE_MCP_URL=http://localhost:8080 # Platform URL for opencode MCP config (opencode.json). Same as PLATFORM_URL; separate var so opencode configs can reference it without ambiguity. # WORKSPACE_DIR= # Optional global host path bind-mounted to /workspace in every container. Per-workspace workspace_dir column overrides this; if neither is set each workspace gets an isolated Docker named volume. # MOLECULE_ENV=development # Environment label (development/staging/production). Used for log tagging and conditional behaviour. # MOLECULE_ENABLE_TEST_TOKENS= # Set to 1 to expose GET /admin/workspaces/:id/test-token (mints a fresh bearer token for E2E scripts). The route is auto-enabled when MOLECULE_ENV != production; this flag is the explicit override. Leave unset/0 in prod — the route 404s unless enabled. @@ -148,3 +150,11 @@ GADS_MCC_ID= # Google Ads MCC (manager) account ID, format 123 GADS_CUSTOMER_ID= # Google Ads child customer ID, format 987-654-3210 GCP_PROJECT_ID= # Google Cloud project ID (e.g. my-website-123456) GSC_SERVICE_ACCOUNT= # Search Console reporter service account email + +# ---- opencode / remote MCP client auth (see docs/integrations/opencode.md) ---- +# MOLECULE_MCP_URL is the base URL of the Molecule platform's /mcp endpoint. +# MOLECULE_MCP_TOKEN is a workspace-scoped bearer token issued via +# POST /workspaces/:id/tokens (scopes: mcp:read, mcp:delegate). +# Token goes in Authorization: Bearer header — never embed in the URL. +MOLECULE_MCP_URL= # e.g. https://api.molecule.ai or http://localhost:8080 +MOLECULE_MCP_TOKEN= # workspace-scoped bearer token — NEVER COMMIT diff --git a/.gitignore b/.gitignore index 2ebb565c..f665de99 100644 --- a/.gitignore +++ b/.gitignore @@ -133,7 +133,5 @@ org-templates/**/.auth-token !/org-templates/molecule-dev /org-templates/molecule-dev/* !/org-templates/molecule-dev/system-prompt.md -/plugins/* -# Exception: molecule-medo lives here until it gets its own standalone repo. -!/plugins/molecule-medo/ +/plugins/ /workspace-configs-templates/ diff --git a/.mcp-eval/mcpeval.yaml b/.mcp-eval/mcpeval.yaml new file mode 100644 index 00000000..30fd6ddc --- /dev/null +++ b/.mcp-eval/mcpeval.yaml @@ -0,0 +1,23 @@ +# mcp-eval configuration for @molecule-ai/mcp-server +# Run: mcp-eval run .mcp-eval/tests/ --json mcp-eval-results.json +# Docs: https://github.com/lastmile-ai/mcp-eval + +provider: anthropic +model: claude-opus-4-7 + +mcp: + servers: + molecule_mcp: + command: "npx" + args: ["-y", "@molecule-ai/mcp-server"] + env: + MOLECULE_URL: "${MOLECULE_URL:-http://localhost:8080}" + +thresholds: + success_rate_min: 0.98 # ≥ 98% tool calls must succeed + latency_p95_max_ms: 1000 # P95 latency < 1 s + latency_p50_max_ms: 300 # P50 latency < 300 ms + +execution: + timeout_seconds: 60 + max_concurrency: 3 diff --git a/.mcp-eval/tests/test_a2a_tools.yaml b/.mcp-eval/tests/test_a2a_tools.yaml new file mode 100644 index 00000000..2a9aafa0 --- /dev/null +++ b/.mcp-eval/tests/test_a2a_tools.yaml @@ -0,0 +1,48 @@ +# Gate: A2A delegation and peer-discovery tools +# list_peers must return a list structure; async_delegate must return a task_id. + +name: a2a_tools +description: > + Verifies the core A2A communication tools: peer discovery (list_peers), + async delegation (async_delegate → task_id), delegation status check + (check_delegations), and access-check enforcement (check_access). + +steps: + - name: list_peers_returns_list + tool: list_peers + input: {} + assertions: + - type: no_error + - type: response_type + expected: list_or_empty + - type: latency_ms + max: 500 + + - name: async_delegate_returns_task_id + tool: async_delegate + input: + task: "mcp-eval smoke test — no-op" + assertions: + - type: no_error + - type: contains_key + key: "task_id" + - type: latency_ms + max: 1000 + + - name: check_delegations_reachable + tool: check_delegations + input: {} + assertions: + - type: no_error + - type: latency_ms + max: 500 + + - name: check_access_reachable + tool: check_access + input: + source_workspace_id: "test:mcp-eval" + target_workspace_id: "test:mcp-eval" + assertions: + - type: no_error + - type: latency_ms + max: 500 diff --git a/.mcp-eval/tests/test_approval_tool.yaml b/.mcp-eval/tests/test_approval_tool.yaml new file mode 100644 index 00000000..ccf9572a --- /dev/null +++ b/.mcp-eval/tests/test_approval_tool.yaml @@ -0,0 +1,39 @@ +# Gate: approval workflow tools are reachable and return correct schema +# Verifies create_approval, list_pending_approvals, get_workspace_approvals. + +name: approval_tool +description: > + Verifies the approval-gate tools expose the correct schema and respond + within latency budget. Does NOT create real approvals — uses a dry-run + input that exercises the schema-validation path. + +steps: + - name: list_pending_approvals_reachable + tool: list_pending_approvals + input: {} + assertions: + - type: no_error + - type: latency_ms + max: 500 + + - name: get_workspace_approvals_schema + tool: get_workspace_approvals + input: {} + assertions: + - type: no_error + - type: response_type + expected: list_or_empty + - type: latency_ms + max: 500 + + - name: create_approval_returns_id + tool: create_approval + input: + reason: "mcp-eval smoke test approval — safe to auto-reject" + context: "Triggered by mcp-eval CI quality gate" + assertions: + - type: no_error + - type: contains_key + key: "id" + - type: latency_ms + max: 1000 diff --git a/.mcp-eval/tests/test_list_tools.yaml b/.mcp-eval/tests/test_list_tools.yaml new file mode 100644 index 00000000..5f260171 --- /dev/null +++ b/.mcp-eval/tests/test_list_tools.yaml @@ -0,0 +1,32 @@ +# Gate: all expected @molecule-ai/mcp-server tools are present and reachable +# Threshold: list_workspaces latency < 500ms + +name: list_tools +description: > + Verifies that the MCP server exposes its full tool inventory and that the + core workspace-management tool responds within latency budget. + +steps: + - name: list_workspaces_smoke + tool: list_workspaces + input: {} + assertions: + - type: no_error + - type: latency_ms + max: 500 + + - name: list_peers_reachable + tool: list_peers + input: {} + assertions: + - type: no_error + - type: latency_ms + max: 500 + + - name: get_workspace_approvals_reachable + tool: get_workspace_approvals + input: {} + assertions: + - type: no_error + - type: latency_ms + max: 500 diff --git a/.mcp-eval/tests/test_memory_tools.yaml b/.mcp-eval/tests/test_memory_tools.yaml new file mode 100644 index 00000000..1507cacb --- /dev/null +++ b/.mcp-eval/tests/test_memory_tools.yaml @@ -0,0 +1,51 @@ +# Gate: commit + recall round-trip integrity +# Verifies memory_set → memory_get returns the exact value that was stored. + +name: memory_tools +description: > + Commits a unique sentinel value via memory_set, then retrieves it with + memory_get and asserts the value matches. Also exercises search_memory to + confirm full-text indexing is operational. + +steps: + - name: memory_set_sentinel + tool: memory_set + input: + key: "mcp_eval_sentinel" + value: "mcp-eval-round-trip-ok-{{ timestamp }}" + assertions: + - type: no_error + - type: latency_ms + max: 500 + + - name: memory_get_sentinel + tool: memory_get + input: + key: "mcp_eval_sentinel" + assertions: + - type: no_error + - type: contains + value: "mcp-eval-round-trip-ok" + - type: latency_ms + max: 500 + + - name: commit_memory_hma + tool: commit_memory + input: + content: "mcp-eval HMA commit smoke test" + scope: "LOCAL" + assertions: + - type: no_error + - type: latency_ms + max: 1000 + + - name: search_memory_finds_committed + tool: search_memory + input: + query: "mcp-eval HMA commit smoke test" + assertions: + - type: no_error + - type: contains + value: "mcp-eval" + - type: latency_ms + max: 1000 diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index add2ffa4..714f7e6d 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -32,7 +32,7 @@ import { Toolbar } from "./Toolbar"; import { ConfirmDialog } from "./ConfirmDialog"; // Phase 20 components import { SettingsPanel, DeleteConfirmDialog } from "./settings"; -// import { ProvisioningTimeout } from "./ProvisioningTimeout"; +import { ProvisioningTimeout } from "./ProvisioningTimeout"; const nodeTypes = { workspaceNode: WorkspaceNode, @@ -334,7 +334,7 @@ function CanvasInner() { - {/* */} + {!selectedNodeId && } {/* Confirmation dialog for structure changes */} diff --git a/canvas/src/components/ConversationTraceModal.tsx b/canvas/src/components/ConversationTraceModal.tsx index 9b8851bc..a603b553 100644 --- a/canvas/src/components/ConversationTraceModal.tsx +++ b/canvas/src/components/ConversationTraceModal.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; import { api } from "@/lib/api"; import { useCanvasStore } from "@/store/canvas"; import { type ActivityEntry } from "@/types/activity"; @@ -46,7 +47,7 @@ function extractMessageText(body: Record | null): string { return ""; } -export function ConversationTraceModal({ open, workspaceId, onClose }: Props) { +export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClose }: Props) { const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const nodes = useCanvasStore((s) => s.nodes); @@ -83,205 +84,215 @@ export function ConversationTraceModal({ open, workspaceId, onClose }: Props) { }); }, [open, nodes]); - if (!open) return null; - const isA2A = (e: ActivityEntry) => e.activity_type === "a2a_receive" || e.activity_type === "a2a_send"; return ( -
- {/* Backdrop */} -
+ { if (!o) onClose(); }}> + + {/* Overlay replaces the old manual backdrop div */} + - {/* Modal */} -
- {/* Header */} -
-
-

- Conversation Trace -

-

- {entries.length} events across all workspaces -

-
- -
- - {/* Timeline */} -
- {loading && ( -
- Loading trace from all workspaces... + {/* Content wraps the entire centred modal panel */} + + {/* Modal panel */} +
+ {/* Header */} +
+
+ + Conversation Trace + +

+ {entries.length} events across all workspaces +

+
+ + +
- )} - {!loading && entries.length === 0 && ( -
- No activity found -
- )} + {/* Timeline */} +
+ {loading && ( +
+ Loading trace from all workspaces... +
+ )} -
- {entries.map((entry) => { - const time = new Date(entry.created_at).toLocaleTimeString(); - const wsName = resolveName(entry.workspace_id); - const sourceName = resolveName(entry.source_id); - const targetName = resolveName(entry.target_id); - const requestText = extractMessageText(entry.request_body); - const responseText = extractMessageText(entry.response_body); - const isError = entry.status === "error"; - const isSend = entry.activity_type === "a2a_send"; - const isReceive = entry.activity_type === "a2a_receive"; + {!loading && entries.length === 0 && ( +
+ No activity found +
+ )} - return ( -
- {/* Event header */} -
- {/* Timeline dot + line */} -
-
-
-
+
+ {entries.map((entry) => { + const time = new Date(entry.created_at).toLocaleTimeString(); + const wsName = resolveName(entry.workspace_id); + const sourceName = resolveName(entry.source_id); + const targetName = resolveName(entry.target_id); + const requestText = extractMessageText(entry.request_body); + const responseText = extractMessageText(entry.response_body); + const isError = entry.status === "error"; + const isSend = entry.activity_type === "a2a_send"; + const isReceive = entry.activity_type === "a2a_receive"; - {/* Content */} -
-
- - {time} - - - {isSend - ? "SEND" - : isReceive - ? "RECEIVE" - : entry.activity_type.toUpperCase()} - - {entry.duration_ms != null && entry.duration_ms > 0 && ( - - {entry.duration_ms > 1000 - ? `${Math.round(entry.duration_ms / 1000)}s` - : `${entry.duration_ms}ms`} - - )} -
+ return ( +
+ {/* Event header */} +
+ {/* Timeline dot + line */} +
+
+
+
- {/* Flow */} - {isA2A(entry) && ( -
- {isSend ? ( - - - {sourceName || wsName} - - - - {targetName} - + {/* Content */} +
+
+ + {time} - ) : ( - - - {targetName || wsName} + + {isSend + ? "SEND" + : isReceive + ? "RECEIVE" + : entry.activity_type.toUpperCase()} + + {entry.duration_ms != null && entry.duration_ms > 0 && ( + + {entry.duration_ms > 1000 + ? `${Math.round(entry.duration_ms / 1000)}s` + : `${entry.duration_ms}ms`} - {sourceName && ( - <> - - {" "}← {" "} - + )} +
+ + {/* Flow */} + {isA2A(entry) && ( +
+ {isSend ? ( + - {sourceName} + {sourceName || wsName} - + + + {targetName} + + + ) : ( + + + {targetName || wsName} + + {sourceName && ( + <> + + {" "}← {" "} + + + {sourceName} + + + )} + )} - +
+ )} + + {/* Summary */} + {entry.summary && !isA2A(entry) && ( +
+ {wsName}:{" "} + {entry.summary} +
+ )} + + {/* Error */} + {isError && entry.error_detail && ( +
+ {entry.error_detail.slice(0, 200)} +
+ )} + + {/* Message content — show request and/or response */} + {requestText && ( +
+
+ {isSend ? "Task" : "Request"} +
+
+ {requestText.slice(0, 2000)} + {requestText.length > 2000 && ( + ...({requestText.length} chars) + )} +
+
+ )} + {responseText && ( +
+
Response
+
+ {responseText.slice(0, 2000)} + {responseText.length > 2000 && ( + ...({responseText.length} chars) + )} +
+
)}
- )} - - {/* Summary */} - {entry.summary && !isA2A(entry) && ( -
- {wsName}:{" "} - {entry.summary} -
- )} - - {/* Error */} - {isError && entry.error_detail && ( -
- {entry.error_detail.slice(0, 200)} -
- )} - - {/* Message content — show request and/or response */} - {requestText && ( -
-
- {isSend ? "Task" : "Request"} -
-
- {requestText.slice(0, 2000)} - {requestText.length > 2000 && ( - ...({requestText.length} chars) - )} -
-
- )} - {responseText && ( -
-
Response
-
- {responseText.slice(0, 2000)} - {responseText.length > 2000 && ( - ...({responseText.length} chars) - )} -
-
- )} +
-
-
- ); - })} -
-
+ ); + })} +
+
- {/* Footer */} -
- -
-
-
+ {/* Footer */} +
+ + + +
+
+ + + ); } diff --git a/canvas/src/components/EmptyState.tsx b/canvas/src/components/EmptyState.tsx index 52cab350..3b793495 100644 --- a/canvas/src/components/EmptyState.tsx +++ b/canvas/src/components/EmptyState.tsx @@ -153,7 +153,7 @@ export function EmptyState() {
{error && ( -
+
{error}
)} diff --git a/canvas/src/components/MemoryInspectorPanel.tsx b/canvas/src/components/MemoryInspectorPanel.tsx index ed54d8b5..eac67c65 100644 --- a/canvas/src/components/MemoryInspectorPanel.tsx +++ b/canvas/src/components/MemoryInspectorPanel.tsx @@ -33,6 +33,19 @@ interface Props { // ── Helpers ─────────────────────────────────────────────────────────────────── +/** + * Sanitise a memory key for use in an HTML id attribute. + * HTML IDs must not contain whitespace; many non-alphanumeric characters also + * cause selector or ARIA failures. Replace every non-alphanumeric character + * with a hyphen, collapse consecutive hyphens, then strip leading/trailing ones. + */ +function sanitizeId(key: string): string { + return key + .replace(/[^a-zA-Z0-9]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); if (diff < 60_000) return `${Math.floor(diff / 1000)}s`; @@ -291,7 +304,11 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { {/* Error banner */} {error && ( -
+
{error}
)} @@ -410,6 +427,7 @@ function MemoryEntryRow({ onCancelEdit, onDelete, }: MemoryEntryRowProps) { + const bodyId = `mem-body-${sanitizeId(entry.key)}`; return (
{/* Header row — click to expand/collapse */} @@ -417,6 +435,7 @@ function MemoryEntryRow({ className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-zinc-800/30 transition-colors" onClick={onToggle} aria-expanded={isExpanded} + aria-controls={bodyId} > {entry.key} @@ -427,11 +446,18 @@ function MemoryEntryRow({ {/* Similarity score badge — only rendered when backend provides a score */} {entry.similarity_score != null && ( = 0.8 + ? "text-blue-500" + : entry.similarity_score >= 0.5 + ? "text-zinc-400" + : "text-zinc-400 italic", + ].join(" ")} title={`Similarity: ${(entry.similarity_score * 100).toFixed(1)}%`} data-testid="similarity-badge" > - {Math.round(entry.similarity_score * 100)}% + {entry.similarity_score < 0.5 ? "~" : ""}{Math.round(entry.similarity_score * 100)}% )} @@ -444,7 +470,12 @@ function MemoryEntryRow({ {/* Expanded body */} {isExpanded && ( -
+
{entry.expires_at && (

Expires: {new Date(entry.expires_at).toLocaleString()} @@ -462,7 +493,9 @@ function MemoryEntryRow({ className="w-full bg-zinc-950 border border-zinc-700 focus:border-blue-500 rounded px-2 py-1.5 text-[11px] font-mono text-zinc-100 focus:outline-none resize-none transition-colors" /> {editError && ( -

{editError}

+

+ {editError} +

)}
diff --git a/canvas/src/components/Toolbar.tsx b/canvas/src/components/Toolbar.tsx index a4273a05..63684204 100644 --- a/canvas/src/components/Toolbar.tsx +++ b/canvas/src/components/Toolbar.tsx @@ -157,6 +157,7 @@ export function Toolbar() { disabled={stopping} className="flex items-center gap-1.5 px-2.5 py-1 bg-red-950/50 hover:bg-red-900/60 border border-red-800/40 rounded-lg transition-colors disabled:opacity-50" title={`Stop all running tasks (${counts.activeTasks} active)`} + aria-label={stopping ? "Stopping all running tasks" : `Stop all running tasks (${counts.activeTasks} active)`} > @@ -174,6 +175,7 @@ export function Toolbar() { disabled={restartingAll} className="flex items-center gap-1.5 px-2.5 py-1 bg-amber-950/40 hover:bg-amber-900/50 border border-amber-800/40 rounded-lg transition-colors disabled:opacity-50" title={`Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"} that need to pick up config or secret changes`} + aria-label={restartingAll ? "Restarting workspaces" : `Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"} pending config or secret changes`} > @@ -315,9 +317,9 @@ export function Toolbar() { function StatusPill({ color, count, label }: { color: string; count: number; label: string }) { return ( -
-
- {count} +
+ ); } @@ -325,24 +327,24 @@ function StatusPill({ color, count, label }: { color: string; count: number; lab function WsStatusPill({ status }: { status: "connected" | "connecting" | "disconnected" }) { if (status === "connected") { return ( -
-
- Live +
+ ); } if (status === "connecting") { return ( -
-
- Reconnecting +
+ ); } return ( -
-
- Offline +
+ ); } diff --git a/canvas/src/components/__tests__/AuthGate.test.tsx b/canvas/src/components/__tests__/AuthGate.test.tsx index 065dc8b3..656a7701 100644 --- a/canvas/src/components/__tests__/AuthGate.test.tsx +++ b/canvas/src/components/__tests__/AuthGate.test.tsx @@ -23,12 +23,14 @@ beforeEach(() => { }); vi.mock("@/lib/auth", () => ({ - fetchSession: (...args: unknown[]) => mockFetchSession(...args), - redirectToLogin: (...args: unknown[]) => mockRedirectToLogin(...args), + // Cast required: vi.fn() returns Mock which TypeScript + // won't call directly inside a factory closure (TS2348). Cast to Function resolves it. + fetchSession: (...args: unknown[]) => (mockFetchSession as unknown as (...a: unknown[]) => unknown)(...args), + redirectToLogin: (...args: unknown[]) => (mockRedirectToLogin as unknown as (...a: unknown[]) => unknown)(...args), })); vi.mock("@/lib/tenant", () => ({ - getTenantSlug: (...args: unknown[]) => mockGetTenantSlug(...args), + getTenantSlug: (...args: unknown[]) => (mockGetTenantSlug as unknown as (...a: unknown[]) => unknown)(...args), })); // Import after mocks are set up diff --git a/canvas/src/components/__tests__/Canvas.a11y.test.tsx b/canvas/src/components/__tests__/Canvas.a11y.test.tsx index a03b5e23..9e50f8fd 100644 --- a/canvas/src/components/__tests__/Canvas.a11y.test.tsx +++ b/canvas/src/components/__tests__/Canvas.a11y.test.tsx @@ -104,6 +104,11 @@ vi.mock("../settings", () => ({ })); vi.mock("../Toaster", () => ({ Toaster: () => null })); vi.mock("../WorkspaceNode", () => ({ WorkspaceNode: () => null })); +vi.mock("../ProvisioningTimeout", () => ({ + ProvisioningTimeout: () => ( +
+ ), +})); // ── Import the component under test AFTER mocks ─────────────────────────────── import { Canvas } from "../Canvas"; @@ -143,3 +148,15 @@ describe("Canvas — accessibility landmarks", () => { expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); }); + +// ── Fix #833: ProvisioningTimeout is mounted in the Canvas tree ─────────────── +describe("Canvas — ProvisioningTimeout integration (issue #833)", () => { + it("renders ProvisioningTimeout in the component tree", () => { + render(); + expect( + document.querySelector( + '[data-testid="provisioning-timeout-sentinel"]' + ) + ).toBeTruthy(); + }); +}); diff --git a/canvas/src/components/__tests__/ConversationTraceModal.a11y.test.tsx b/canvas/src/components/__tests__/ConversationTraceModal.a11y.test.tsx new file mode 100644 index 00000000..7983b2fe --- /dev/null +++ b/canvas/src/components/__tests__/ConversationTraceModal.a11y.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment jsdom +/** + * WCAG 2.1 / Issue M — ConversationTraceModal accessibility + * + * Migrated from custom
to Radix Dialog, which provides: + * - role="dialog" + aria-modal="true" automatically (WCAG 4.1.2) + * - aria-labelledby pointing to Dialog.Title (WCAG 1.3.1) + * - Focus trap (WCAG 2.1.2 / 2.4.3) + * - Escape key closes the dialog (WCAG 2.1.1) + * - ✕ close button has aria-label="Close conversation trace" + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +// ── Mocks must be declared before importing the component ──────────────────── + +vi.mock("@/lib/api", () => ({ + api: { + get: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: (selector: (s: { nodes: unknown[] }) => unknown) => + selector({ nodes: [] }), +})); + +vi.mock("@/hooks/useWorkspaceName", () => ({ + useWorkspaceName: () => () => "Test WS", +})); + +import { ConversationTraceModal } from "../ConversationTraceModal"; + +// Helper: renders the modal in open state with a spy for onClose +function renderOpen() { + const onClose = vi.fn(); + render( + + ); + return { onClose }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Presence / absence +// ──────────────────────────────────────────────────────────────────────────── + +describe("ConversationTraceModal — dialog presence (Issue M)", () => { + it("dialog is absent when open=false", () => { + render( + + ); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("dialog is present when open=true", () => { + renderOpen(); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// ARIA attributes provided by Radix Dialog +// ──────────────────────────────────────────────────────────────────────────── + +describe("ConversationTraceModal — ARIA attributes (Issue M)", () => { + it("dialog element is accessible via role='dialog' with a non-empty accessible name", () => { + renderOpen(); + // Radix Dialog.Content renders role="dialog" with aria-labelledby pointing + // to Dialog.Title. Verify the role is present and the name is non-empty + // (testing-library computes the accessible name from aria-labelledby). + const dialog = screen.getByRole("dialog", { name: /conversation trace/i }); + expect(dialog).toBeTruthy(); + }); + + it("dialog has aria-labelledby pointing to 'Conversation Trace' title", () => { + renderOpen(); + const dialog = screen.getByRole("dialog"); + const labelledBy = dialog.getAttribute("aria-labelledby"); + expect(labelledBy).toBeTruthy(); + const titleEl = document.getElementById(labelledBy!); + expect(titleEl?.textContent?.trim()).toBe("Conversation Trace"); + }); + + it("dialog has data-state='open' (Radix state attribute)", () => { + renderOpen(); + const dialog = screen.getByRole("dialog"); + expect(dialog.getAttribute("data-state")).toBe("open"); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Close button accessible name +// ──────────────────────────────────────────────────────────────────────────── + +describe("ConversationTraceModal — close button (Issue M)", () => { + it("✕ close button has aria-label='Close conversation trace'", () => { + renderOpen(); + const closeBtn = screen.getByRole("button", { + name: /close conversation trace/i, + }); + expect(closeBtn).toBeTruthy(); + }); + + it("clicking ✕ button calls onClose", async () => { + const { onClose } = renderOpen(); + const closeBtn = screen.getByRole("button", { + name: /close conversation trace/i, + }); + fireEvent.click(closeBtn); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); + + it("footer 'Close' button also closes the dialog", async () => { + const { onClose } = renderOpen(); + const closeBtn = screen.getByRole("button", { name: /^Close$/i }); + fireEvent.click(closeBtn); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Escape key closes the dialog (WCAG 2.1.1 — Keyboard) +// ──────────────────────────────────────────────────────────────────────────── + +describe("ConversationTraceModal — Escape key (Issue M)", () => { + it("Escape key triggers onClose via Radix onOpenChange", async () => { + const { onClose } = renderOpen(); + // Radix Dialog automatically closes on Escape and fires onOpenChange(false) + // which our handler converts to onClose(). Dispatch on the document so + // Radix's own keydown listener picks it up. + fireEvent.keyDown(document, { key: "Escape", code: "Escape" }); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Empty state +// ──────────────────────────────────────────────────────────────────────────── + +describe("ConversationTraceModal — loading state (Issue M)", () => { + it("shows loading indicator when dialog opens and fetch is in progress", () => { + renderOpen(); + // After render + effects (flushed by act inside render), loading=true + // because useEffect fired setLoading(true). The loading text should + // be visible at this synchronous point. + expect(screen.getByText(/loading trace from all workspaces/i)).toBeTruthy(); + }); +}); diff --git a/canvas/src/components/__tests__/MemoryInspectorPanel.test.tsx b/canvas/src/components/__tests__/MemoryInspectorPanel.test.tsx index 1cb709ac..25f308f0 100644 --- a/canvas/src/components/__tests__/MemoryInspectorPanel.test.tsx +++ b/canvas/src/components/__tests__/MemoryInspectorPanel.test.tsx @@ -401,6 +401,45 @@ describe("MemoryInspectorPanel — Refresh button", () => { }); }); +// ── role=alert a11y (issue #830) ───────────────────────────────────────────── + +describe("MemoryInspectorPanel — error elements have role=alert (issue #830)", () => { + it("fetch error banner has role='alert'", async () => { + mockGet.mockRejectedValue(new Error("Network error")); + render(); + await waitFor(() => screen.getByText("Network error")); + const alert = screen.getByRole("alert"); + expect(alert).toBeTruthy(); + expect(alert.textContent).toContain("Network error"); + }); + + it("editError paragraph has role='alert' on invalid JSON submission", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValue(TWO_ENTRIES as any); + render(); + await waitFor(() => screen.getByText("task-queue")); + + // Expand and open edit mode + fireEvent.click(screen.getByText("task-queue").closest("button")!); + await waitFor(() => + screen.getByRole("button", { name: "Edit task-queue" }) + ); + fireEvent.click(screen.getByRole("button", { name: "Edit task-queue" })); + + // Submit invalid JSON to trigger editError + fireEvent.change( + screen.getByRole("textbox", { name: "Edit memory value" }), + { target: { value: "{{bad json" } } + ); + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + + await waitFor(() => screen.getByText(/invalid json/i)); + const alert = screen.getByRole("alert"); + expect(alert).toBeTruthy(); + expect(alert.textContent).toMatch(/invalid json/i); + }); +}); + // ── Semantic search (issue #783) ────────────────────────────────────────────── describe("MemoryInspectorPanel — semantic search", () => { @@ -475,6 +514,47 @@ describe("MemoryInspectorPanel — semantic search", () => { ).toBeNull(); }); + it("colors similarity-badge blue-500 when score >= 0.8", async () => { + mockGet.mockResolvedValue([ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { ...ENTRY_A, similarity_score: 0.92 }, + ] as any); + render(); + await waitFor(() => screen.getByText("task-queue")); + const badge = document.querySelector('[data-testid="similarity-badge"]'); + expect(badge?.className).toContain("text-blue-500"); + expect(badge?.className).not.toContain("text-zinc-400"); + expect(badge?.className).not.toContain("text-zinc-600"); + }); + + it("colors similarity-badge zinc-400 when score is between 0.5 and 0.8", async () => { + mockGet.mockResolvedValue([ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { ...ENTRY_A, similarity_score: 0.65 }, + ] as any); + render(); + await waitFor(() => screen.getByText("task-queue")); + const badge = document.querySelector('[data-testid="similarity-badge"]'); + expect(badge?.className).toContain("text-zinc-400"); + expect(badge?.className).not.toContain("text-blue-500"); + expect(badge?.className).not.toContain("text-zinc-600"); + }); + + it("colors similarity-badge zinc-400 italic with tilde prefix when score is below 0.5", async () => { + mockGet.mockResolvedValue([ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { ...ENTRY_A, similarity_score: 0.31 }, + ] as any); + render(); + await waitFor(() => screen.getByText("task-queue")); + const badge = document.querySelector('[data-testid="similarity-badge"]'); + expect(badge?.className).toContain("text-zinc-400"); + expect(badge?.className).toContain("italic"); + expect(badge?.className).not.toContain("text-blue-500"); + expect(badge?.className).not.toContain("text-zinc-600"); + expect(badge?.textContent).toBe("~31%"); + }); + it("clear button resets debouncedQuery immediately and re-fetches without ?q=", async () => { vi.useFakeTimers(); // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/canvas/src/components/__tests__/SidePanel.tabs.test.tsx b/canvas/src/components/__tests__/SidePanel.tabs.test.tsx index 4bd9e75b..ae16e094 100644 --- a/canvas/src/components/__tests__/SidePanel.tabs.test.tsx +++ b/canvas/src/components/__tests__/SidePanel.tabs.test.tsx @@ -217,3 +217,14 @@ describe("SidePanel — localStorage width persistence (issue #425)", () => { expect(parseInt(saved!, 10)).toBeGreaterThanOrEqual(320); }); }); + +// ── Fix #832: close button accessibility ───────────────────────────────────── +describe("SidePanel — close button a11y (issue #832)", () => { + it("close button has aria-label='Close workspace panel'", () => { + render(); + const closeBtn = screen.getByRole("button", { + name: "Close workspace panel", + }); + expect(closeBtn).toBeTruthy(); + }); +}); diff --git a/canvas/src/components/__tests__/WorkspaceNode.a11y.test.tsx b/canvas/src/components/__tests__/WorkspaceNode.a11y.test.tsx new file mode 100644 index 00000000..1a463842 --- /dev/null +++ b/canvas/src/components/__tests__/WorkspaceNode.a11y.test.tsx @@ -0,0 +1,200 @@ +// @vitest-environment jsdom +/** + * WorkspaceNode a11y tests — issue #831 + * + * Covers the TeamMemberChip sub-component (rendered inside a parent workspace + * node when that node has children): + * - role="button" is present + * - aria-label="Select " is present + * - pressing Enter triggers onSelect with the child's id + * - pressing Space triggers onSelect with the child's id + * - the eject button has aria-label="Extract from team" + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); +}); + +// ── Mock @xyflow/react (Handles) ────────────────────────────────────────────── +vi.mock("@xyflow/react", () => ({ + Handle: () => null, + Position: { Top: "top", Bottom: "bottom" }, +})); + +// ── Mock Tooltip (passthrough) ──────────────────────────────────────────────── +vi.mock("@/components/Tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// ── Mock Toaster ────────────────────────────────────────────────────────────── +vi.mock("@/components/Toaster", () => ({ + showToast: vi.fn(), +})); + +// ── Mock design tokens ──────────────────────────────────────────────────────── +vi.mock("@/lib/design-tokens", () => ({ + STATUS_CONFIG: { + online: { + dot: "bg-emerald-400", + glow: "", + bar: "from-emerald-950/30", + label: "Online", + }, + offline: { + dot: "bg-zinc-500", + glow: "", + bar: "from-zinc-900", + label: "Offline", + }, + degraded: { + dot: "bg-amber-400", + glow: "", + bar: "from-amber-950/30", + label: "Degraded", + }, + provisioning: { + dot: "bg-sky-400", + glow: "", + bar: "from-sky-950/30", + label: "Provisioning", + }, + failed: { + dot: "bg-red-400", + glow: "", + bar: "from-red-950/30", + label: "Failed", + }, + }, + TIER_CONFIG: { + 1: { label: "T1", color: "text-zinc-400 bg-zinc-800" }, + 2: { label: "T2", color: "text-zinc-400 bg-zinc-800" }, + 3: { label: "T3", color: "text-zinc-400 bg-zinc-800" }, + }, +})); + +// ── Store state with a parent + one child ──────────────────────────────────── + +const mockSelectNode = vi.fn(); +const mockOpenContextMenu = vi.fn(); +const mockNestNode = vi.fn(); + +const PARENT_ID = "ws-parent"; +const CHILD_ID = "ws-child"; + +const PARENT_DATA = { + name: "Parent Workspace", + status: "online", + tier: 1 as const, + role: "Manager", + parentId: null, + needsRestart: false, + currentTask: null, + activeTasks: 0, + agentCard: null, + runtime: "langgraph", + lastSampleError: null, +}; + +const CHILD_DATA = { + name: "Child Workspace", + status: "online", + tier: 1 as const, + role: "Worker", + parentId: PARENT_ID, + needsRestart: false, + currentTask: null, + activeTasks: 0, + agentCard: null, + runtime: "langgraph", + lastSampleError: null, +}; + +const ALL_NODES = [ + { id: PARENT_ID, position: { x: 0, y: 0 }, data: PARENT_DATA }, + { id: CHILD_ID, position: { x: 0, y: 0 }, data: CHILD_DATA }, +]; + +const mockStoreState = { + nodes: ALL_NODES, + selectedNodeId: null, + dragOverNodeId: null, + selectNode: mockSelectNode, + openContextMenu: mockOpenContextMenu, + nestNode: mockNestNode, + restartWorkspace: vi.fn(() => Promise.resolve()), + setPanelTab: vi.fn(), +}; + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + vi.fn((selector: (s: typeof mockStoreState) => unknown) => + selector(mockStoreState) + ), + { getState: () => mockStoreState } + ), +})); + +// ── Import component AFTER mocks ────────────────────────────────────────────── +import { WorkspaceNode } from "../WorkspaceNode"; + +// ── Helper ──────────────────────────────────────────────────────────────────── + +function renderParentNode() { + // WorkspaceNode's full NodeProps has many optional fields; we only need id+data + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return render(); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("WorkspaceNode — TeamMemberChip a11y (issue #831)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("TeamMemberChip renders with role='button'", () => { + renderParentNode(); + // The parent WorkspaceNode div is role=button (aria-label contains the name), + // and the chip is a separate role=button with aria-label starting with "Select" + const chip = screen.getByRole("button", { + name: "Select Child Workspace", + }); + expect(chip).toBeTruthy(); + }); + + it("TeamMemberChip has aria-label='Select '", () => { + renderParentNode(); + const chip = screen.getByRole("button", { + name: "Select Child Workspace", + }); + expect(chip.getAttribute("aria-label")).toBe("Select Child Workspace"); + }); + + it("pressing Enter on TeamMemberChip calls selectNode with the child's id", () => { + renderParentNode(); + const chip = screen.getByRole("button", { + name: "Select Child Workspace", + }); + fireEvent.keyDown(chip, { key: "Enter" }); + expect(mockSelectNode).toHaveBeenCalledWith(CHILD_ID); + }); + + it("pressing Space on TeamMemberChip calls selectNode with the child's id", () => { + renderParentNode(); + const chip = screen.getByRole("button", { + name: "Select Child Workspace", + }); + fireEvent.keyDown(chip, { key: " " }); + expect(mockSelectNode).toHaveBeenCalledWith(CHILD_ID); + }); + + it("eject button has aria-label='Extract from team'", () => { + renderParentNode(); + const ejectBtn = screen.getByRole("button", { + name: "Extract from team", + }); + expect(ejectBtn).toBeTruthy(); + }); +}); diff --git a/canvas/src/components/__tests__/tabs.a11y.test.tsx b/canvas/src/components/__tests__/tabs.a11y.test.tsx new file mode 100644 index 00000000..712555e0 --- /dev/null +++ b/canvas/src/components/__tests__/tabs.a11y.test.tsx @@ -0,0 +1,289 @@ +// @vitest-environment jsdom +/** + * WCAG 1.3.1 — label↔input association tests for SkillsTab, FilesTab, + * ChannelsTab, and ScheduleTab. + * + * Each test verifies that every form control has an accessible name either via: + * - `aria-label` (bare inputs without a visible label element) + * - `htmlFor` + matching `id` wired through `useId()` (label↔control pairs) + * + * `getByLabelText` is the definitive assertion for the htmlFor/id pattern — + * if it resolves, the association is valid per the AT accessibility tree. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; + +// ── Global mocks (hoisted before imports) ──────────────────────────────────── + +const mockApiGet = vi.fn(); +vi.mock("@/lib/api", () => ({ + api: { + get: (...args: unknown[]) => mockApiGet(...args), + post: vi.fn().mockResolvedValue({}), + put: vi.fn().mockResolvedValue({}), + del: vi.fn().mockResolvedValue({}), + patch: vi.fn().mockResolvedValue({}), + }, +})); + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: vi.fn((selector: (s: Record) => unknown) => + selector({ setPanelTab: vi.fn() }) + ), + summarizeWorkspaceCapabilities: vi.fn(() => ({ skills: [], tools: [] })), +})); + +vi.mock("../Toaster", () => ({ showToast: vi.fn() })); + +// FilesTab sub-module stubs — stub them so we control the onNewFile callback +vi.mock("../tabs/FilesTab/FilesToolbar", () => ({ + FilesToolbar: ({ onNewFile }: { onNewFile: () => void }) => ( + + ), +})); +vi.mock("../tabs/FilesTab/FileTree", () => ({ + FileTree: () =>
, +})); +vi.mock("../tabs/FilesTab/FileEditor", () => ({ + FileEditor: () =>
, +})); +vi.mock("../tabs/FilesTab/useFilesApi", () => ({ + useFilesApi: () => ({ + files: [], + loading: false, + loadFiles: vi.fn(), + expandedDirs: new Set(), + loadingDir: null, + toggleDir: vi.fn(), + readFile: vi.fn().mockResolvedValue({ content: "" }), + writeFile: vi.fn().mockResolvedValue({}), + deleteFile: vi.fn().mockResolvedValue({}), + downloadAllFiles: vi.fn(), + uploadFiles: vi.fn(), + deleteAllFiles: vi.fn(), + }), +})); +vi.mock("../tabs/FilesTab/tree", () => ({ + buildTree: vi.fn(() => []), +})); + +vi.mock("../ConfirmDialog", () => ({ + ConfirmDialog: () => null, +})); + +// ── Static imports (after mocks) ───────────────────────────────────────────── + +import { SkillsTab } from "../tabs/SkillsTab"; +import { FilesTab } from "../tabs/FilesTab"; +import { ChannelsTab } from "../tabs/ChannelsTab"; +import { ScheduleTab } from "../tabs/ScheduleTab"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeSkillsData() { + return { + id: "ws-1", + name: "Test WS", + status: "online", + tier: 1, + agentCard: null, + activeTasks: 0, + collapsed: false, + role: "agent", + lastErrorRate: 0, + lastSampleError: "", + url: "http://localhost:9000", + parentId: null, + currentTask: "", + runtime: "langgraph", + needsRestart: false, + budgetLimit: null, + }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 1. SkillsTab — aria-label on the "Install from source" bare input +// ──────────────────────────────────────────────────────────────────────────── + +describe("SkillsTab — aria-label on bare source input (WCAG 1.3.1)", () => { + beforeEach(() => { + mockApiGet.mockResolvedValue([]); + }); + + it('install source input has aria-label="Install from source URL"', async () => { + render(); + + // The source input is inside the registry section (showRegistry=false initially). + // Click the "+ Install Plugin" button to reveal it. + const installBtn = screen.getByRole("button", { name: /install plugin/i }); + fireEvent.click(installBtn); + + const input = screen.getByRole("textbox", { + name: /install from source url/i, + }); + expect(input).toBeDefined(); + expect(input.getAttribute("aria-label")).toBe("Install from source URL"); + }); + + it("install source input is a text input (not hidden)", async () => { + render(); + + const installBtn = screen.getByRole("button", { name: /install plugin/i }); + fireEvent.click(installBtn); + + const input = screen.getByRole("textbox", { + name: /install from source url/i, + }); + expect(input.tagName.toLowerCase()).toBe("input"); + expect((input as HTMLInputElement).type).toBe("text"); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 2. FilesTab — aria-label on the new file path bare input +// ──────────────────────────────────────────────────────────────────────────── + +describe("FilesTab — aria-label on new file path input (WCAG 1.3.1)", () => { + it('new file input has aria-label="New file path"', () => { + render(); + + // Trigger showNewFile via the FilesToolbar stub + const btn = screen.getByTestId("new-file-btn"); + fireEvent.click(btn); + + const input = screen.getByRole("textbox", { name: /new file path/i }); + expect(input).toBeDefined(); + expect(input.getAttribute("aria-label")).toBe("New file path"); + }); + + it("new file input is not shown before clicking the new file button", () => { + render(); + + expect(screen.queryByRole("textbox", { name: /new file path/i })).toBeNull(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 3. ChannelsTab — htmlFor/id label associations via useId() +// ──────────────────────────────────────────────────────────────────────────── + +describe("ChannelsTab — htmlFor/id label associations (WCAG 1.3.1)", () => { + beforeEach(() => { + mockApiGet.mockImplementation((url: string) => { + if (url.includes("/channels/adapters")) { + return Promise.resolve([{ type: "telegram", display_name: "Telegram" }]); + } + return Promise.resolve([]); + }); + }); + + async function renderAndOpenForm() { + render(); + await waitFor(() => screen.getByRole("button", { name: /\+ connect/i })); + fireEvent.click(screen.getByRole("button", { name: /\+ connect/i })); + } + + it("Platform label is associated with the select via htmlFor/id", async () => { + await renderAndOpenForm(); + const platformSelect = screen.getByLabelText("Platform"); + expect(platformSelect.tagName.toLowerCase()).toBe("select"); + }); + + it("Bot Token label is associated with the password input via htmlFor/id", async () => { + await renderAndOpenForm(); + const botTokenInput = screen.getByLabelText("Bot Token"); + expect(botTokenInput.tagName.toLowerCase()).toBe("input"); + expect((botTokenInput as HTMLInputElement).type).toBe("password"); + }); + + it("Chat IDs label is associated with the input via htmlFor/id", async () => { + await renderAndOpenForm(); + const chatIdInput = screen.getByLabelText("Chat IDs"); + expect(chatIdInput.tagName.toLowerCase()).toBe("input"); + }); + + it("Allowed Users label is associated with the input via htmlFor/id", async () => { + await renderAndOpenForm(); + // Label contains "(optional, comma-separated)" in a nested span — use regex + const allowedUsersInput = screen.getByLabelText(/allowed users/i); + expect(allowedUsersInput.tagName.toLowerCase()).toBe("input"); + }); + + it("all form control ids are unique and non-empty", async () => { + await renderAndOpenForm(); + + const platformSelect = screen.getByLabelText("Platform"); + const botTokenInput = screen.getByLabelText("Bot Token"); + const chatIdInput = screen.getByLabelText("Chat IDs"); + const allowedUsersInput = screen.getByLabelText(/allowed users/i); + + const ids = [ + platformSelect.id, + botTokenInput.id, + chatIdInput.id, + allowedUsersInput.id, + ]; + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(4); + ids.forEach((id) => expect(id).toBeTruthy()); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 4. ScheduleTab — aria-label on name + htmlFor/id associations via useId() +// ──────────────────────────────────────────────────────────────────────────── + +describe("ScheduleTab — aria-label + htmlFor/id label associations (WCAG 1.3.1)", () => { + beforeEach(() => { + mockApiGet.mockResolvedValue([]); + }); + + async function renderAndOpenForm() { + render(); + await waitFor(() => screen.getByRole("button", { name: /\+ add schedule/i })); + fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i })); + } + + it('Schedule name input has aria-label="Schedule name"', async () => { + await renderAndOpenForm(); + const nameInput = screen.getByRole("textbox", { name: /^schedule name$/i }); + expect(nameInput.getAttribute("aria-label")).toBe("Schedule name"); + }); + + it("Cron Expression label is associated with the input via htmlFor/id", async () => { + await renderAndOpenForm(); + const cronInput = screen.getByLabelText("Cron Expression"); + expect(cronInput.tagName.toLowerCase()).toBe("input"); + expect((cronInput as HTMLInputElement).type).toBe("text"); + }); + + it("Timezone label is associated with the select via htmlFor/id", async () => { + await renderAndOpenForm(); + const timezoneSelect = screen.getByLabelText("Timezone"); + expect(timezoneSelect.tagName.toLowerCase()).toBe("select"); + }); + + it("Prompt / Task label is associated with the textarea via htmlFor/id", async () => { + await renderAndOpenForm(); + const promptTextarea = screen.getByLabelText(/prompt \/ task/i); + expect(promptTextarea.tagName.toLowerCase()).toBe("textarea"); + }); + + it("all form control ids are unique and non-empty", async () => { + await renderAndOpenForm(); + + const cronInput = screen.getByLabelText("Cron Expression"); + const timezoneSelect = screen.getByLabelText("Timezone"); + const promptTextarea = screen.getByLabelText(/prompt \/ task/i); + + const ids = [cronInput.id, timezoneSelect.id, promptTextarea.id]; + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(3); + ids.forEach((id) => expect(id).toBeTruthy()); + }); +}); diff --git a/canvas/src/components/tabs/ActivityTab.tsx b/canvas/src/components/tabs/ActivityTab.tsx index 68d942a6..74f0d781 100644 --- a/canvas/src/components/tabs/ActivityTab.tsx +++ b/canvas/src/components/tabs/ActivityTab.tsx @@ -80,6 +80,7 @@ export function ActivityTab({ workspaceId }: Props) {
-