From 1c0e92ceb4611e8cf89f34db60051710e6708d46 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Wed, 10 Jun 2026 13:08:15 +0200 Subject: [PATCH 01/12] feat: comprehensive unit test coverage expansion (89.8% lines, 83.4% branches) - Expand test suite from ~2700 to 3368 tests across 229 files - Add 500+ tests for pages, components, hooks, and APIs - Add data-testid attributes to ~20 source components for reliable testing - All tests use userEvent.setup(), MSW server.use(), and data-testid queries - Branches coverage target >80%% achieved at 83.37%% - Lines coverage at 89.8%%, 85 statements from 90%% target --- deploy-to-local-eddi-repo.ps1 | 7 +- .../agents/__tests__/agent-card.test.tsx | 168 +++ .../__tests__/create-agent-dialog.test.tsx | 102 ++ .../__tests__/export-agent-dialog.test.tsx | 117 ++ .../__tests__/sync-config-panel.test.tsx | 120 ++ src/components/agents/export-agent-dialog.tsx | 1 + .../channels/__tests__/channel-card.test.tsx | 180 +++ .../__tests__/create-channel-dialog.test.tsx | 163 +++ .../chat/__tests__/chat-activity.test.tsx | 176 +++ .../chat/__tests__/chat-drawer.test.tsx | 251 ++++ .../chat/__tests__/chat-history.test.tsx | 126 ++ .../chat/__tests__/chat-input.test.tsx | 126 ++ .../chat/__tests__/chat-message.test.tsx | 135 +++ .../chat/__tests__/chat-panel.test.tsx | 346 ++++++ .../chat/__tests__/streaming-toggle.test.tsx | 44 + .../__tests__/live-log-viewer.test.tsx | 220 ++++ .../__tests__/memory-inspector.test.tsx | 180 +++ .../__tests__/pipeline-trace.test.tsx | 267 ++++- .../debugger/__tests__/prompt-viewer.test.tsx | 442 +++++-- .../__tests__/add-extension-dialog.test.tsx | 201 ++++ .../__tests__/apicalls-editor.test.tsx | 908 +++++++++++++++ .../editors/__tests__/content-editor.test.tsx | 196 +--- .../__tests__/dictionary-editor.test.tsx | 173 +++ .../__tests__/editor-registry.test.tsx | 93 ++ .../editors/__tests__/editor-section.test.tsx | 112 ++ .../__tests__/import-openapi-dialog.test.tsx | 402 +++++++ .../editors/__tests__/json-editor.test.tsx | 73 ++ .../editors/__tests__/llm-editor.test.tsx | 1035 +++++++++++++++++ .../__tests__/mcpcalls-editor.test.tsx | 180 +++ .../editors/__tests__/output-editor.test.tsx | 149 +++ .../__tests__/pipeline-builder.test.tsx | 130 +++ .../editors/__tests__/prompt-preview.test.tsx | 202 ++++ .../__tests__/propertysetter-editor.test.tsx | 385 ++++++ .../editors/__tests__/rag-editor.test.tsx | 657 +++++++++++ .../editors/__tests__/rules-editor.test.tsx | 202 ++++ .../editors/__tests__/snippet-editor.test.tsx | 112 ++ .../__tests__/update-usage-dialog.test.tsx | 127 ++ .../__tests__/version-diff-dialog.test.tsx | 123 ++ .../editors/__tests__/version-picker.test.tsx | 110 ++ .../__tests__/task-memory-section.test.tsx | 130 +++ .../llm/__tests__/task-rag-section.test.tsx | 152 +++ src/components/editors/rag-editor.tsx | 1 + .../__tests__/agent-response-card.test.tsx | 164 +++ .../__tests__/create-group-dialog.test.tsx | 243 ++++ .../__tests__/discussion-input.test.tsx | 117 ++ .../__tests__/discussion-transcript.test.tsx | 220 ++++ .../groups/__tests__/group-card.test.tsx | 134 +++ .../__tests__/group-config-panel.test.tsx | 151 +++ .../groups/__tests__/group-utils.test.ts | 159 +++ .../groups/__tests__/phase-header.test.tsx | 153 +++ .../layout/__tests__/app-layout.test.tsx | 88 ++ .../__tests__/mock-data-banner.test.tsx | 60 + .../layout/__tests__/page-loader.test.tsx | 21 + .../layout/__tests__/platform-status.test.tsx | 237 ++++ .../layout/__tests__/sidebar.test.tsx | 427 ++++++- .../layout/__tests__/theme-provider.test.tsx | 122 ++ .../layout/__tests__/top-bar.test.tsx | 341 +++++- .../__tests__/create-resource-dialog.test.tsx | 220 ++++ .../__tests__/resource-card.test.tsx | 200 ++++ .../shared/__tests__/action-badge.test.tsx | 72 ++ .../shared/__tests__/agent-picker.test.tsx | 138 +++ .../shared/__tests__/back-link.test.tsx | 38 + .../shared/__tests__/command-palette.test.tsx | 117 ++ .../create-or-wizard-dialog.test.tsx | 134 +++ .../shared/__tests__/empty-state.test.tsx | 101 ++ .../shared/__tests__/error-state.test.tsx | 58 + .../infinite-scroll-sentinel.test.tsx | 100 ++ .../__tests__/resource-type-badge.test.tsx | 104 ++ .../__tests__/secret-key-picker.test.tsx | 236 ++++ .../__tests__/studio-editor-panel.test.tsx | 156 +++ .../__tests__/create-workflow-dialog.test.tsx | 84 ++ .../__tests__/workflow-card.test.tsx | 176 +++ src/hooks/__tests__/session-log-store.test.ts | 138 +++ src/hooks/__tests__/use-admin-hooks.test.tsx | 311 +++++ .../__tests__/use-agent-context.test.tsx | 63 + src/hooks/__tests__/use-agent-setup.test.tsx | 63 + src/hooks/__tests__/use-agents.test.tsx | 427 +++++++ src/hooks/__tests__/use-audit.test.tsx | 102 ++ src/hooks/__tests__/use-auth.test.tsx | 98 ++ src/hooks/__tests__/use-backup.test.tsx | 323 +++++ src/hooks/__tests__/use-capabilities.test.tsx | 85 ++ src/hooks/__tests__/use-channels.test.tsx | 203 ++++ src/hooks/__tests__/use-chat-drawer.test.ts | 90 ++ src/hooks/__tests__/use-chat-drawer.test.tsx | 76 ++ src/hooks/__tests__/use-chat.test.tsx | 637 ++++++++++ .../__tests__/use-command-palette.test.tsx | 87 ++ .../__tests__/use-conversations.test.tsx | 127 ++ src/hooks/__tests__/use-coordinator.test.tsx | 214 ++++ .../use-dashboard-and-schemas.test.tsx | 217 ++++ src/hooks/__tests__/use-dashboard.test.tsx | 76 ++ src/hooks/__tests__/use-debounce.test.ts | 98 ++ src/hooks/__tests__/use-debounce.test.tsx | 92 ++ src/hooks/__tests__/use-debug-events.test.tsx | 180 +++ .../__tests__/use-document-title.test.tsx | 79 ++ src/hooks/__tests__/use-domain-hooks.test.tsx | 392 +++++++ .../__tests__/use-extensions-store.test.tsx | 116 ++ src/hooks/__tests__/use-gdpr.test.tsx | 101 ++ .../use-group-discussion-stream.test.ts | 201 ++++ src/hooks/__tests__/use-groups.test.tsx | 274 +++++ src/hooks/__tests__/use-json-schema.test.tsx | 69 ++ .../__tests__/use-latest-versions.test.tsx | 103 ++ src/hooks/__tests__/use-logs.test.tsx | 251 ++++ src/hooks/__tests__/use-misc-hooks.test.tsx | 324 ++++++ src/hooks/__tests__/use-onboarding.test.tsx | 253 ++++ src/hooks/__tests__/use-orphans.test.tsx | 63 + .../__tests__/use-platform-status.test.tsx | 66 ++ src/hooks/__tests__/use-properties.test.tsx | 62 + src/hooks/__tests__/use-quotas.test.tsx | 110 ++ src/hooks/__tests__/use-resources.test.tsx | 160 +++ .../__tests__/use-save-and-deploy.test.tsx | 101 ++ src/hooks/__tests__/use-schedules.test.tsx | 185 +++ src/hooks/__tests__/use-secrets.test.tsx | 119 ++ src/hooks/__tests__/use-target-rect.test.ts | 117 ++ .../__tests__/use-template-preview.test.tsx | 77 ++ src/hooks/__tests__/use-tool-metrics.test.tsx | 129 ++ src/hooks/__tests__/use-triggers.test.tsx | 86 ++ .../use-unsaved-changes-guard.test.ts | 54 + .../use-unsaved-changes-guard.test.tsx | 68 ++ .../__tests__/use-user-conversations.test.tsx | 97 ++ src/hooks/__tests__/use-user-memory.test.tsx | 129 ++ src/hooks/__tests__/use-variables.test.tsx | 72 ++ src/hooks/__tests__/use-workflows.test.tsx | 183 +++ src/hooks/session-log-store.ts | 11 + src/hooks/use-coordinator.ts | 13 +- src/hooks/use-logs.ts | 13 +- src/lib/__tests__/bearer-event-source.test.ts | 395 +++++++ src/lib/__tests__/utils.test.ts | 239 ++++ src/lib/api/__tests__/agent-setup.test.ts | 90 ++ src/lib/api/__tests__/attachments.test.ts | 42 + src/lib/api/__tests__/audit.test.ts | 71 ++ src/lib/api/__tests__/capabilities.test.ts | 48 + src/lib/api/__tests__/cascade-save.test.ts | 219 +++- src/lib/api/__tests__/chat.test.ts | 350 ++++++ src/lib/api/__tests__/conversations.test.ts | 425 +++++++ src/lib/api/__tests__/coordinator.test.ts | 57 + src/lib/api/__tests__/dashboard.test.ts | 68 ++ src/lib/api/__tests__/descriptors.test.ts | 51 + src/lib/api/__tests__/gdpr.test.ts | 65 ++ src/lib/api/__tests__/logs.test.ts | 104 ++ src/lib/api/__tests__/mcp-discover.test.ts | 35 + .../api/__tests__/openapi-discover.test.ts | 50 + src/lib/api/__tests__/orphans.test.ts | 39 + src/lib/api/__tests__/properties.test.ts | 48 + src/lib/api/__tests__/quotas.test.ts | 64 + src/lib/api/__tests__/schedules.test.ts | 151 +++ src/lib/api/__tests__/schemas.test.ts | 75 ++ src/lib/api/__tests__/secrets.test.ts | 239 +++- src/lib/api/__tests__/system.test.ts | 45 + .../api/__tests__/template-preview.test.ts | 39 + src/lib/api/__tests__/tool-metrics.test.ts | 86 ++ src/lib/api/__tests__/triggers.test.ts | 83 ++ .../api/__tests__/user-conversations.test.ts | 45 + src/lib/api/__tests__/user-memory.test.ts | 121 ++ src/lib/api/__tests__/variables.test.ts | 124 ++ src/lib/api/__tests__/workflows.test.ts | 196 ++++ src/lib/bearer-event-source.ts | 5 +- .../agent-detail-capabilities.test.tsx | 183 ++- .../__tests__/agent-detail-sections.test.tsx | 108 +- .../__tests__/agent-detail-security.test.tsx | 141 ++- src/pages/__tests__/agent-detail.test.tsx | 527 ++++++++- src/pages/__tests__/agent-studio.test.tsx | 93 +- src/pages/__tests__/agent-wizard.test.tsx | 551 ++++++++- src/pages/__tests__/agents.test.tsx | 449 ++++++- src/pages/__tests__/audit.test.tsx | 25 +- src/pages/__tests__/backup.test.tsx | 26 +- src/pages/__tests__/channel-detail.test.tsx | 188 ++- src/pages/__tests__/channels.test.tsx | 397 ++++++- .../__tests__/conversation-detail.test.tsx | 380 ++++++ src/pages/__tests__/conversations.test.tsx | 339 +++++- src/pages/__tests__/coordinator.test.tsx | 399 ++++++- src/pages/__tests__/dashboard.test.tsx | 601 +++++++++- src/pages/__tests__/export-dialog.test.tsx | 32 +- src/pages/__tests__/gdpr.test.tsx | 364 +++++- src/pages/__tests__/group-detail.test.tsx | 305 +++++ src/pages/__tests__/group-wizard.test.tsx | 384 ++++++ src/pages/__tests__/groups.test.tsx | 453 +++++++- src/pages/__tests__/onboarding.test.tsx | 13 +- src/pages/__tests__/orphans.test.tsx | 304 +++++ src/pages/__tests__/prompt-preview.test.tsx | 174 --- src/pages/__tests__/properties.test.tsx | 35 +- .../resource-detail-dictionary.test.tsx | 48 + .../resource-detail-httpcalls.test.tsx | 121 +- .../__tests__/resource-detail-llm.test.tsx | 623 ++++++++-- .../resource-detail-mcpcalls.test.tsx | 684 +++++++++-- .../__tests__/resource-detail-output.test.tsx | 48 + .../resource-detail-propertysetter.test.tsx | 353 +++++- .../__tests__/resource-detail-rag.test.tsx | 212 ++-- .../__tests__/resource-detail-rules.test.tsx | 163 ++- .../resource-detail-snippets.test.tsx | 34 +- src/pages/__tests__/resource-list.test.tsx | 230 ++++ src/pages/__tests__/schedules.test.tsx | 438 ++++++- src/pages/__tests__/secrets.test.tsx | 637 +++++++++- src/pages/__tests__/studio-save-flow.test.tsx | 61 +- src/pages/__tests__/sync-page.test.tsx | 375 +++++- src/pages/__tests__/triggers.test.tsx | 200 +++- .../__tests__/user-conversations.test.tsx | 254 ++++ src/pages/__tests__/user-data.test.tsx | 26 +- src/pages/__tests__/user-memory.test.tsx | 581 ++++++++- src/pages/__tests__/variables.test.tsx | 45 +- src/pages/__tests__/workflow-detail.test.tsx | 353 +++++- src/pages/__tests__/workflows.test.tsx | 409 ++++++- src/pages/agent-detail.tsx | 3 +- src/pages/agents.tsx | 2 +- src/pages/channel-detail.tsx | 2 +- src/pages/conversation-detail.tsx | 6 +- src/pages/coordinator.tsx | 2 +- src/pages/group-wizard.tsx | 2 + src/pages/schedules.tsx | 2 +- src/pages/secrets.tsx | 2 + src/pages/user-memory.tsx | 3 +- src/pages/workflow-detail.tsx | 4 +- vitest.config.ts | 11 +- 212 files changed, 35983 insertions(+), 1471 deletions(-) create mode 100644 src/components/agents/__tests__/agent-card.test.tsx create mode 100644 src/components/agents/__tests__/create-agent-dialog.test.tsx create mode 100644 src/components/agents/__tests__/export-agent-dialog.test.tsx create mode 100644 src/components/agents/__tests__/sync-config-panel.test.tsx create mode 100644 src/components/channels/__tests__/channel-card.test.tsx create mode 100644 src/components/channels/__tests__/create-channel-dialog.test.tsx create mode 100644 src/components/chat/__tests__/chat-activity.test.tsx create mode 100644 src/components/chat/__tests__/chat-drawer.test.tsx create mode 100644 src/components/chat/__tests__/chat-history.test.tsx create mode 100644 src/components/chat/__tests__/chat-input.test.tsx create mode 100644 src/components/chat/__tests__/chat-message.test.tsx create mode 100644 src/components/chat/__tests__/chat-panel.test.tsx create mode 100644 src/components/chat/__tests__/streaming-toggle.test.tsx create mode 100644 src/components/debugger/__tests__/live-log-viewer.test.tsx create mode 100644 src/components/debugger/__tests__/memory-inspector.test.tsx create mode 100644 src/components/editors/__tests__/add-extension-dialog.test.tsx create mode 100644 src/components/editors/__tests__/apicalls-editor.test.tsx create mode 100644 src/components/editors/__tests__/dictionary-editor.test.tsx create mode 100644 src/components/editors/__tests__/editor-registry.test.tsx create mode 100644 src/components/editors/__tests__/editor-section.test.tsx create mode 100644 src/components/editors/__tests__/import-openapi-dialog.test.tsx create mode 100644 src/components/editors/__tests__/json-editor.test.tsx create mode 100644 src/components/editors/__tests__/llm-editor.test.tsx create mode 100644 src/components/editors/__tests__/mcpcalls-editor.test.tsx create mode 100644 src/components/editors/__tests__/output-editor.test.tsx create mode 100644 src/components/editors/__tests__/pipeline-builder.test.tsx create mode 100644 src/components/editors/__tests__/prompt-preview.test.tsx create mode 100644 src/components/editors/__tests__/propertysetter-editor.test.tsx create mode 100644 src/components/editors/__tests__/rag-editor.test.tsx create mode 100644 src/components/editors/__tests__/rules-editor.test.tsx create mode 100644 src/components/editors/__tests__/snippet-editor.test.tsx create mode 100644 src/components/editors/__tests__/update-usage-dialog.test.tsx create mode 100644 src/components/editors/__tests__/version-diff-dialog.test.tsx create mode 100644 src/components/editors/__tests__/version-picker.test.tsx create mode 100644 src/components/editors/llm/__tests__/task-memory-section.test.tsx create mode 100644 src/components/editors/llm/__tests__/task-rag-section.test.tsx create mode 100644 src/components/groups/__tests__/agent-response-card.test.tsx create mode 100644 src/components/groups/__tests__/create-group-dialog.test.tsx create mode 100644 src/components/groups/__tests__/discussion-input.test.tsx create mode 100644 src/components/groups/__tests__/discussion-transcript.test.tsx create mode 100644 src/components/groups/__tests__/group-card.test.tsx create mode 100644 src/components/groups/__tests__/group-config-panel.test.tsx create mode 100644 src/components/groups/__tests__/group-utils.test.ts create mode 100644 src/components/groups/__tests__/phase-header.test.tsx create mode 100644 src/components/layout/__tests__/app-layout.test.tsx create mode 100644 src/components/layout/__tests__/mock-data-banner.test.tsx create mode 100644 src/components/layout/__tests__/page-loader.test.tsx create mode 100644 src/components/layout/__tests__/platform-status.test.tsx create mode 100644 src/components/layout/__tests__/theme-provider.test.tsx create mode 100644 src/components/resources/__tests__/create-resource-dialog.test.tsx create mode 100644 src/components/resources/__tests__/resource-card.test.tsx create mode 100644 src/components/shared/__tests__/action-badge.test.tsx create mode 100644 src/components/shared/__tests__/agent-picker.test.tsx create mode 100644 src/components/shared/__tests__/back-link.test.tsx create mode 100644 src/components/shared/__tests__/command-palette.test.tsx create mode 100644 src/components/shared/__tests__/create-or-wizard-dialog.test.tsx create mode 100644 src/components/shared/__tests__/empty-state.test.tsx create mode 100644 src/components/shared/__tests__/error-state.test.tsx create mode 100644 src/components/shared/__tests__/infinite-scroll-sentinel.test.tsx create mode 100644 src/components/shared/__tests__/resource-type-badge.test.tsx create mode 100644 src/components/shared/__tests__/secret-key-picker.test.tsx create mode 100644 src/components/studio/__tests__/studio-editor-panel.test.tsx create mode 100644 src/components/workflows/__tests__/create-workflow-dialog.test.tsx create mode 100644 src/components/workflows/__tests__/workflow-card.test.tsx create mode 100644 src/hooks/__tests__/session-log-store.test.ts create mode 100644 src/hooks/__tests__/use-admin-hooks.test.tsx create mode 100644 src/hooks/__tests__/use-agent-context.test.tsx create mode 100644 src/hooks/__tests__/use-agent-setup.test.tsx create mode 100644 src/hooks/__tests__/use-agents.test.tsx create mode 100644 src/hooks/__tests__/use-audit.test.tsx create mode 100644 src/hooks/__tests__/use-auth.test.tsx create mode 100644 src/hooks/__tests__/use-backup.test.tsx create mode 100644 src/hooks/__tests__/use-capabilities.test.tsx create mode 100644 src/hooks/__tests__/use-channels.test.tsx create mode 100644 src/hooks/__tests__/use-chat-drawer.test.ts create mode 100644 src/hooks/__tests__/use-chat-drawer.test.tsx create mode 100644 src/hooks/__tests__/use-chat.test.tsx create mode 100644 src/hooks/__tests__/use-command-palette.test.tsx create mode 100644 src/hooks/__tests__/use-conversations.test.tsx create mode 100644 src/hooks/__tests__/use-coordinator.test.tsx create mode 100644 src/hooks/__tests__/use-dashboard-and-schemas.test.tsx create mode 100644 src/hooks/__tests__/use-dashboard.test.tsx create mode 100644 src/hooks/__tests__/use-debounce.test.ts create mode 100644 src/hooks/__tests__/use-debounce.test.tsx create mode 100644 src/hooks/__tests__/use-debug-events.test.tsx create mode 100644 src/hooks/__tests__/use-document-title.test.tsx create mode 100644 src/hooks/__tests__/use-domain-hooks.test.tsx create mode 100644 src/hooks/__tests__/use-extensions-store.test.tsx create mode 100644 src/hooks/__tests__/use-gdpr.test.tsx create mode 100644 src/hooks/__tests__/use-group-discussion-stream.test.ts create mode 100644 src/hooks/__tests__/use-groups.test.tsx create mode 100644 src/hooks/__tests__/use-json-schema.test.tsx create mode 100644 src/hooks/__tests__/use-latest-versions.test.tsx create mode 100644 src/hooks/__tests__/use-logs.test.tsx create mode 100644 src/hooks/__tests__/use-misc-hooks.test.tsx create mode 100644 src/hooks/__tests__/use-onboarding.test.tsx create mode 100644 src/hooks/__tests__/use-orphans.test.tsx create mode 100644 src/hooks/__tests__/use-platform-status.test.tsx create mode 100644 src/hooks/__tests__/use-properties.test.tsx create mode 100644 src/hooks/__tests__/use-quotas.test.tsx create mode 100644 src/hooks/__tests__/use-resources.test.tsx create mode 100644 src/hooks/__tests__/use-save-and-deploy.test.tsx create mode 100644 src/hooks/__tests__/use-schedules.test.tsx create mode 100644 src/hooks/__tests__/use-secrets.test.tsx create mode 100644 src/hooks/__tests__/use-target-rect.test.ts create mode 100644 src/hooks/__tests__/use-template-preview.test.tsx create mode 100644 src/hooks/__tests__/use-tool-metrics.test.tsx create mode 100644 src/hooks/__tests__/use-triggers.test.tsx create mode 100644 src/hooks/__tests__/use-unsaved-changes-guard.test.ts create mode 100644 src/hooks/__tests__/use-unsaved-changes-guard.test.tsx create mode 100644 src/hooks/__tests__/use-user-conversations.test.tsx create mode 100644 src/hooks/__tests__/use-user-memory.test.tsx create mode 100644 src/hooks/__tests__/use-variables.test.tsx create mode 100644 src/hooks/__tests__/use-workflows.test.tsx create mode 100644 src/lib/__tests__/bearer-event-source.test.ts create mode 100644 src/lib/__tests__/utils.test.ts create mode 100644 src/lib/api/__tests__/agent-setup.test.ts create mode 100644 src/lib/api/__tests__/attachments.test.ts create mode 100644 src/lib/api/__tests__/audit.test.ts create mode 100644 src/lib/api/__tests__/capabilities.test.ts create mode 100644 src/lib/api/__tests__/chat.test.ts create mode 100644 src/lib/api/__tests__/conversations.test.ts create mode 100644 src/lib/api/__tests__/coordinator.test.ts create mode 100644 src/lib/api/__tests__/dashboard.test.ts create mode 100644 src/lib/api/__tests__/descriptors.test.ts create mode 100644 src/lib/api/__tests__/gdpr.test.ts create mode 100644 src/lib/api/__tests__/logs.test.ts create mode 100644 src/lib/api/__tests__/mcp-discover.test.ts create mode 100644 src/lib/api/__tests__/openapi-discover.test.ts create mode 100644 src/lib/api/__tests__/orphans.test.ts create mode 100644 src/lib/api/__tests__/properties.test.ts create mode 100644 src/lib/api/__tests__/quotas.test.ts create mode 100644 src/lib/api/__tests__/schedules.test.ts create mode 100644 src/lib/api/__tests__/schemas.test.ts create mode 100644 src/lib/api/__tests__/system.test.ts create mode 100644 src/lib/api/__tests__/template-preview.test.ts create mode 100644 src/lib/api/__tests__/tool-metrics.test.ts create mode 100644 src/lib/api/__tests__/triggers.test.ts create mode 100644 src/lib/api/__tests__/user-conversations.test.ts create mode 100644 src/lib/api/__tests__/user-memory.test.ts create mode 100644 src/lib/api/__tests__/variables.test.ts create mode 100644 src/lib/api/__tests__/workflows.test.ts create mode 100644 src/pages/__tests__/conversation-detail.test.tsx create mode 100644 src/pages/__tests__/group-detail.test.tsx create mode 100644 src/pages/__tests__/orphans.test.tsx delete mode 100644 src/pages/__tests__/prompt-preview.test.tsx create mode 100644 src/pages/__tests__/resource-list.test.tsx create mode 100644 src/pages/__tests__/user-conversations.test.tsx diff --git a/deploy-to-local-eddi-repo.ps1 b/deploy-to-local-eddi-repo.ps1 index 38c58aed..6bcaee67 100644 --- a/deploy-to-local-eddi-repo.ps1 +++ b/deploy-to-local-eddi-repo.ps1 @@ -7,6 +7,7 @@ 2. Cleans up old hashed assets from previous builds. 3. Copies the entire new assets folder into EDDI's assets/ directory. 4. Updates manage.html with the new hashed filenames + Note: index.html is a simple redirect to /manage and does not need updating. .PARAMETER EddiPath Path to the EDDI repository root. Default: ..\EDDI @@ -113,14 +114,10 @@ Write-Host " Copied all $($distFiles.Count) files into assets/" # Update manage.html references $html = Get-Content $ManageHtml -Raw - -# Replace the HTML references to either /scripts/js or /assets/ logic $html = $html -replace 'src="/(scripts/js|assets)/index-[^"]+\.js"', "src=`"/assets/$($newJs.Name)`"" $html = $html -replace 'href="/(scripts/css|assets)/index-[^"]+\.css"', "href=`"/assets/$($newCss.Name)`"" - Set-Content $ManageHtml -Value $html -NoNewline - -Write-Host "`n Updated manage.html" -ForegroundColor Green +Write-Host " Updated manage.html" -ForegroundColor Green Write-Host "`n[DONE] EDDI Manager deployed successfully!" -ForegroundColor Green Write-Host " JS: /assets/$($newJs.Name)" Write-Host " CSS: /assets/$($newCss.Name)`n" diff --git a/src/components/agents/__tests__/agent-card.test.tsx b/src/components/agents/__tests__/agent-card.test.tsx new file mode 100644 index 00000000..d5ee5383 --- /dev/null +++ b/src/components/agents/__tests__/agent-card.test.tsx @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { AgentCard } from "@/components/agents/agent-card"; +import { server } from "@/test/mocks/server"; +import { http, HttpResponse } from "msw"; + +const mockAgent = { + id: "agent-test-1", + version: 1, + resource: "eddi://ai.labs.agent/agentstore/agents/agent-test-1?version=1", + name: "Test Agent", + description: "A test agent for unit testing", + lastModifiedOn: new Date().toISOString(), + createdOn: new Date().toISOString(), +}; + +describe("AgentCard", () => { + const defaultProps = { + agent: mockAgent, + onDuplicate: vi.fn(), + onDelete: vi.fn(), + onExport: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders agent name and description", () => { + renderWithProviders(); + expect(screen.getByText("Test Agent")).toBeInTheDocument(); + expect( + screen.getByText("A test agent for unit testing") + ).toBeInTheDocument(); + }); + + it("renders agent ID", () => { + renderWithProviders(); + expect(screen.getByText("agent-test-1")).toBeInTheDocument(); + }); + + it("renders data-testid with agent id", () => { + renderWithProviders(); + expect( + screen.getByTestId("agent-card-agent-test-1") + ).toBeInTheDocument(); + }); + + it("renders 'Unnamed Agent' when name is empty", () => { + renderWithProviders( + + ); + expect(screen.getByText("Unnamed Agent")).toBeInTheDocument(); + }); + + it("renders 'No description' when description is empty", () => { + renderWithProviders( + + ); + expect(screen.getByText("No description")).toBeInTheDocument(); + }); + + it("shows deploy button when status is NOT_FOUND", async () => { + server.use( + http.get("*/administration/:env/deploymentstatus/:agentId", () => { + return HttpResponse.json({}); + }) + ); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText("Deploy")).toBeInTheDocument(); + }); + }); + + it("shows undeploy button when status is READY", async () => { + server.use( + http.get("*/administration/:env/deploymentstatus/:agentId", () => { + return HttpResponse.json({ status: "READY" }); + }) + ); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText("Undeploy")).toBeInTheDocument(); + }); + }); + + it("shows chat buttons when deployed", async () => { + server.use( + http.get("*/administration/:env/deploymentstatus/:agentId", () => { + return HttpResponse.json({ status: "READY" }); + }) + ); + renderWithProviders(); + await waitFor(() => { + expect( + screen.getByTestId("agent-chat-agent-test-1") + ).toBeInTheDocument(); + }); + }); + + it("opens context menu when more actions clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + const menuBtn = screen.getByTestId("agent-menu-agent-test-1"); + await user.click(menuBtn); + expect(screen.getByText("Duplicate")).toBeInTheDocument(); + expect(screen.getByText("Export")).toBeInTheDocument(); + expect(screen.getByText("Delete")).toBeInTheDocument(); + }); + + it("calls onDuplicate when duplicate is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByTestId("agent-menu-agent-test-1")); + await user.click(screen.getByText("Duplicate")); + expect(defaultProps.onDuplicate).toHaveBeenCalledWith( + "agent-test-1", + 1 + ); + }); + + it("calls onDelete when delete is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByTestId("agent-menu-agent-test-1")); + await user.click(screen.getByText("Delete")); + expect(defaultProps.onDelete).toHaveBeenCalledWith("agent-test-1", 1); + }); + + it("calls onExport when export is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByTestId("agent-menu-agent-test-1")); + await user.click(screen.getByText("Export")); + expect(defaultProps.onExport).toHaveBeenCalledWith("agent-test-1", 1); + }); + + it("has a link to agent detail page", () => { + renderWithProviders(); + const link = screen.getByRole("link", { name: /Test Agent/ }); + expect(link).toHaveAttribute("href", "/manage/agentview/agent-test-1"); + }); + + it("shows external chat link when deployed", async () => { + server.use( + http.get("*/administration/:env/deploymentstatus/:agentId", () => { + return HttpResponse.json({ status: "READY" }); + }) + ); + renderWithProviders(); + await waitFor(() => { + const extLink = screen.getByTestId( + "agent-external-chat-agent-test-1" + ); + expect(extLink).toHaveAttribute( + "href", + "/chat/production/agent-test-1" + ); + }); + }); +}); diff --git a/src/components/agents/__tests__/create-agent-dialog.test.tsx b/src/components/agents/__tests__/create-agent-dialog.test.tsx new file mode 100644 index 00000000..b8dc4bd7 --- /dev/null +++ b/src/components/agents/__tests__/create-agent-dialog.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { CreateAgentDialog } from "@/components/agents/create-agent-dialog"; + +describe("CreateAgentDialog", () => { + const defaultProps = { + open: true, + onClose: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders dialog title", () => { + renderWithProviders(); + expect(screen.getByText("Create New Agent")).toBeInTheDocument(); + }); + + it("renders name input", () => { + renderWithProviders(); + expect(screen.getByLabelText("Name")).toBeInTheDocument(); + }); + + it("renders description textarea", () => { + renderWithProviders(); + expect(screen.getByLabelText("Description")).toBeInTheDocument(); + }); + + it("renders cancel button", () => { + renderWithProviders(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); + + it("renders create button", () => { + renderWithProviders(); + expect(screen.getByText("Create")).toBeInTheDocument(); + }); + + it("shows name placeholder", () => { + renderWithProviders(); + expect( + screen.getByPlaceholderText("My Agent") + ).toBeInTheDocument(); + }); + + it("shows description placeholder", () => { + renderWithProviders(); + expect( + screen.getByPlaceholderText("Describe what this agent does...") + ).toBeInTheDocument(); + }); + + it("calls onClose when cancel is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByText("Cancel")); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it("allows typing in name field", async () => { + const user = userEvent.setup(); + renderWithProviders(); + const nameInput = screen.getByLabelText("Name"); + await user.click(nameInput); + await user.type(nameInput, "My Test Agent"); + expect(nameInput).toHaveValue("My Test Agent"); + }); + + it("allows typing in description field", async () => { + const user = userEvent.setup(); + renderWithProviders(); + const descInput = screen.getByLabelText("Description"); + await user.click(descInput); + await user.type(descInput, "A helpful assistant"); + expect(descInput).toHaveValue("A helpful assistant"); + }); + + it("has data-testid create-agent-dialog", () => { + renderWithProviders(); + expect( + screen.getByTestId("create-agent-dialog") + ).toBeInTheDocument(); + }); + + it("submits form and creates agent", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const nameInput = screen.getByLabelText("Name"); + await user.click(nameInput); + await user.type(nameInput, "New Agent"); + + const createBtn = screen.getByText("Create"); + await user.click(createBtn); + + await waitFor(() => { + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/agents/__tests__/export-agent-dialog.test.tsx b/src/components/agents/__tests__/export-agent-dialog.test.tsx new file mode 100644 index 00000000..a8c703b4 --- /dev/null +++ b/src/components/agents/__tests__/export-agent-dialog.test.tsx @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { ExportAgentDialog } from "@/components/agents/export-agent-dialog"; +import { server } from "@/test/mocks/server"; +import { http, HttpResponse } from "msw"; + +describe("ExportAgentDialog", () => { + const defaultProps = { + open: true, + onClose: vi.fn(), + agentId: "agent1", + agentVersion: 3, + }; + + it("renders nothing when open is false", () => { + const { container } = renderWithProviders( + + ); + expect(container.innerHTML).toBe(""); + }); + + it("renders dialog with title when open", () => { + renderWithProviders(); + expect(screen.getByText("Export Agent")).toBeInTheDocument(); + }); + + it("shows loading state initially", () => { + renderWithProviders(); + expect( + screen.getByText("Loading resource tree...") + ).toBeInTheDocument(); + }); + + it("shows resource tree when loaded", async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText("Select resources to export")).toBeInTheDocument(); + }); + // Agent root + expect(screen.getByText("Support Agent")).toBeInTheDocument(); + // Workflow + expect(screen.getByText("Support Ticket Pipeline")).toBeInTheDocument(); + }); + + it("shows export button", async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId("export-confirm-btn")).toBeInTheDocument(); + }); + expect(screen.getByText("Export Selected")).toBeInTheDocument(); + }); + + it("shows all resources selected by default", async () => { + renderWithProviders(); + await waitFor(() => { + expect( + screen.getByText("All resources selected") + ).toBeInTheDocument(); + }); + }); + + it("shows select all checkbox", async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText("All resources selected")).toBeInTheDocument(); + }); + const checkboxes = screen.getAllByRole("checkbox"); + // At least the select all checkbox plus resource checkboxes + expect(checkboxes.length).toBeGreaterThan(1); + }); + + it("has data-testid export-agent-dialog", async () => { + renderWithProviders(); + await waitFor(() => { + expect( + screen.getByTestId("export-agent-dialog") + ).toBeInTheDocument(); + }); + }); + + it("shows error state when API fails", async () => { + server.use( + http.post("*/backup/export/:agentId/preview", () => { + return new HttpResponse(null, { status: 500 }); + }) + ); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText(/error/i)).toBeInTheDocument(); + }); + }); + + it("shows snippets group", async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText("Snippets")).toBeInTheDocument(); + }); + expect(screen.getByText("System Prompt Base")).toBeInTheDocument(); + }); + + it("can toggle a non-required resource", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText("Support Rules")).toBeInTheDocument(); + }); + + // Find the checkbox for Support Rules + const label = screen.getByText("Support Rules"); + const checkbox = label.closest("label")!.querySelector("input[type='checkbox']") as HTMLInputElement; + expect(checkbox.checked).toBe(true); + + await user.click(checkbox); + expect(checkbox.checked).toBe(false); + }); +}); diff --git a/src/components/agents/__tests__/sync-config-panel.test.tsx b/src/components/agents/__tests__/sync-config-panel.test.tsx new file mode 100644 index 00000000..d6f2d09f --- /dev/null +++ b/src/components/agents/__tests__/sync-config-panel.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { SyncConfigPanel } from "@/components/agents/sync-config-panel"; + +describe("SyncConfigPanel", () => { + const defaultProps = { + url: "", + auth: "", + onUrlChange: vi.fn(), + onAuthChange: vi.fn(), + onConnected: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with data-testid sync-config-panel", () => { + renderWithProviders(); + expect(screen.getByTestId("sync-config-panel")).toBeInTheDocument(); + }); + + it("renders URL input", () => { + renderWithProviders(); + expect(screen.getByTestId("sync-url-input")).toBeInTheDocument(); + }); + + it("renders auth input", () => { + renderWithProviders(); + expect(screen.getByTestId("sync-auth-input")).toBeInTheDocument(); + }); + + it("renders connect button", () => { + renderWithProviders(); + expect(screen.getByTestId("sync-connect-btn")).toBeInTheDocument(); + expect(screen.getByText("Connect")).toBeInTheDocument(); + }); + + it("shows source URL label", () => { + renderWithProviders(); + expect(screen.getByText("Source EDDI Instance URL")).toBeInTheDocument(); + }); + + it("shows auth token label", () => { + renderWithProviders(); + expect(screen.getByText("Authorization Token")).toBeInTheDocument(); + }); + + it("shows auth hint text", () => { + renderWithProviders(); + expect( + screen.getByText("Optional. Sent as X-Source-Authorization header.") + ).toBeInTheDocument(); + }); + + it("disables connect button when URL is empty", () => { + renderWithProviders(); + expect(screen.getByTestId("sync-connect-btn")).toBeDisabled(); + }); + + it("enables connect button when URL has value", () => { + renderWithProviders( + + ); + expect(screen.getByTestId("sync-connect-btn")).not.toBeDisabled(); + }); + + it("calls onUrlChange when URL is typed", async () => { + const user = userEvent.setup(); + renderWithProviders(); + const input = screen.getByTestId("sync-url-input"); + await user.type(input, "h"); + expect(defaultProps.onUrlChange).toHaveBeenCalled(); + }); + + it("calls onAuthChange when auth is typed", async () => { + const user = userEvent.setup(); + renderWithProviders(); + const input = screen.getByTestId("sync-auth-input"); + await user.type(input, "B"); + expect(defaultProps.onAuthChange).toHaveBeenCalled(); + }); + + it("auth input defaults to password type", () => { + renderWithProviders(); + expect(screen.getByTestId("sync-auth-input")).toHaveAttribute("type", "password"); + }); + + it("toggles auth visibility", async () => { + const user = userEvent.setup(); + renderWithProviders(); + const authInput = screen.getByTestId("sync-auth-input"); + expect(authInput).toHaveAttribute("type", "password"); + + // Click eye toggle - find the button near the auth input + const toggleButtons = screen.getByTestId("sync-auth-input") + .closest(".relative")! + .querySelector("button") as HTMLButtonElement; + await user.click(toggleButtons); + expect(authInput).toHaveAttribute("type", "text"); + + await user.click(toggleButtons); + expect(authInput).toHaveAttribute("type", "password"); + }); + + it("shows URL placeholder", () => { + renderWithProviders(); + expect( + screen.getByPlaceholderText("https://staging.eddi.example.com") + ).toBeInTheDocument(); + }); + + it("shows auth placeholder", () => { + renderWithProviders(); + expect( + screen.getByPlaceholderText("Bearer eyJhb...") + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/agents/export-agent-dialog.tsx b/src/components/agents/export-agent-dialog.tsx index 6cc8dfbb..a9fd9957 100644 --- a/src/components/agents/export-agent-dialog.tsx +++ b/src/components/agents/export-agent-dialog.tsx @@ -248,6 +248,7 @@ export function ExportAgentDialog({ checked={selected.size === allResourceIds.length} onChange={toggleAll} className="accent-primary" + data-testid="select-all-checkbox" /> {selected.size === allResourceIds.length diff --git a/src/components/channels/__tests__/channel-card.test.tsx b/src/components/channels/__tests__/channel-card.test.tsx new file mode 100644 index 00000000..f4d26624 --- /dev/null +++ b/src/components/channels/__tests__/channel-card.test.tsx @@ -0,0 +1,180 @@ +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { ChannelCard } from "@/components/channels/channel-card"; +import type { EnrichedChannelDescriptor } from "@/lib/api/channels"; + +const baseChannel: EnrichedChannelDescriptor = { + id: "ch-001", + version: 1, + name: "Support Slack", + resource: "eddi://ai.labs.channel/channelstore/channels/ch-001?version=1", + lastModifiedOn: Date.now() - 86400000, + createdOn: Date.now() - 86400000 * 2, + channelType: "slack", + channelId: "C0123ABC", + targetCount: 3, +}; + +describe("ChannelCard", () => { + it("renders the channel name", () => { + renderWithProviders( + + ); + expect(screen.getByText("Support Slack")).toBeInTheDocument(); + }); + + it("renders the channel ID hash", () => { + renderWithProviders( + + ); + expect(screen.getByText("C0123ABC")).toBeInTheDocument(); + }); + + it("has correct data-testid", () => { + renderWithProviders( + + ); + expect(screen.getByTestId("channel-card-ch-001")).toBeInTheDocument(); + }); + + it("shows type badge with capitalized label", () => { + renderWithProviders( + + ); + expect(screen.getByText("Slack")).toBeInTheDocument(); + }); + + it("shows target count badge", () => { + renderWithProviders( + + ); + // The badge contains "3 targets" across multiple text nodes + expect(screen.getByText(/targets/)).toBeInTheDocument(); + }); + + it("shows singular 'target' for count of 1", () => { + const singleTarget = { ...baseChannel, targetCount: 1 }; + renderWithProviders( + + ); + // The badge contains "1 target" across text nodes + const badge = screen.getByText(/target$/); + expect(badge).toBeInTheDocument(); + }); + + it("renders version number", () => { + renderWithProviders( + + ); + expect(screen.getByText("v1")).toBeInTheDocument(); + }); + + it("shows 'Unnamed Channel' for empty name", () => { + const unnamed = { ...baseChannel, name: "" }; + renderWithProviders( + + ); + expect(screen.getByText("Unnamed Channel")).toBeInTheDocument(); + }); + + it("calls onDuplicate when duplicate button is clicked", async () => { + const onDuplicate = vi.fn(); + const user = userEvent.setup(); + renderWithProviders( + + ); + // Duplicate button has title="Duplicate" + const dupBtn = screen.getByTitle("Duplicate"); + await user.click(dupBtn); + expect(onDuplicate).toHaveBeenCalledWith("ch-001", 1); + }); + + it("calls onDelete when delete button is clicked", async () => { + const onDelete = vi.fn(); + const user = userEvent.setup(); + renderWithProviders( + + ); + const deleteBtn = screen.getByTitle("Delete"); + await user.click(deleteBtn); + expect(onDelete).toHaveBeenCalledWith("ch-001", 1); + }); + + it("shows 'Unknown' when channelType is empty", () => { + const unknownType = { ...baseChannel, channelType: "" }; + renderWithProviders( + + ); + expect(screen.getByText("Unknown")).toBeInTheDocument(); + }); + + it("does not show channelId hash if channelId is empty", () => { + const noChannelId = { ...baseChannel, channelId: "" }; + renderWithProviders( + + ); + expect(screen.queryByText("C0123ABC")).not.toBeInTheDocument(); + }); + + it("has role=button and tabIndex", () => { + renderWithProviders( + + ); + const card = screen.getByTestId("channel-card-ch-001"); + expect(card).toHaveAttribute("role", "button"); + expect(card).toHaveAttribute("tabindex", "0"); + }); +}); diff --git a/src/components/channels/__tests__/create-channel-dialog.test.tsx b/src/components/channels/__tests__/create-channel-dialog.test.tsx new file mode 100644 index 00000000..dad7f7c3 --- /dev/null +++ b/src/components/channels/__tests__/create-channel-dialog.test.tsx @@ -0,0 +1,163 @@ +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { CreateChannelDialog } from "@/components/channels/create-channel-dialog"; + +describe("CreateChannelDialog", () => { + it("renders nothing when open is false", () => { + const { container } = renderWithProviders( + + ); + // No dialog rendered + expect(container.querySelector("[role='dialog']")).toBeNull(); + }); + + it("renders dialog when open is true", () => { + const { container } = renderWithProviders( + + ); + expect(container.querySelector("[role='dialog']")).not.toBeNull(); + expect(screen.getByText("Create Channel Integration")).toBeInTheDocument(); + }); + + it("shows step 1 with name and channel type fields", () => { + renderWithProviders( + + ); + expect(screen.getByTestId("create-channel-name")).toBeInTheDocument(); + expect(screen.getByTestId("create-channel-type")).toBeInTheDocument(); + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Channel Type")).toBeInTheDocument(); + }); + + it("disables Next button when name is empty", () => { + renderWithProviders( + + ); + const nextBtn = screen.getByTestId("create-channel-next"); + expect(nextBtn).toBeDisabled(); + }); + + it("enables Next button when name is entered", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + await user.type(screen.getByTestId("create-channel-name"), "My Channel"); + const nextBtn = screen.getByTestId("create-channel-next"); + expect(nextBtn).not.toBeDisabled(); + }); + + it("navigates to step 2 (credentials) when Next is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + await user.type(screen.getByTestId("create-channel-name"), "My Channel"); + await user.click(screen.getByTestId("create-channel-next")); + + // Step 2 shows channel ID field + expect(screen.getByTestId("create-channel-id")).toBeInTheDocument(); + expect(screen.getByText("Slack Channel ID")).toBeInTheDocument(); + }); + + it("navigates to step 3 (target) from step 2", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + // Step 1: enter name + await user.type(screen.getByTestId("create-channel-name"), "My Channel"); + await user.click(screen.getByTestId("create-channel-next")); + + // Step 2: enter channel ID + await user.type(screen.getByTestId("create-channel-id"), "C0123ABC"); + await user.click(screen.getByTestId("create-channel-next")); + + // Step 3: target + expect(screen.getByTestId("create-target-agent")).toBeInTheDocument(); + expect(screen.getByText("Default Agent ID")).toBeInTheDocument(); + }); + + it("shows Back button on step 2", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + await user.type(screen.getByTestId("create-channel-name"), "My Channel"); + await user.click(screen.getByTestId("create-channel-next")); + + expect(screen.getByText("Back")).toBeInTheDocument(); + }); + + it("navigates back from step 2 to step 1", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + await user.type(screen.getByTestId("create-channel-name"), "My Channel"); + await user.click(screen.getByTestId("create-channel-next")); + + // Click back + await user.click(screen.getByText("Back")); + expect(screen.getByTestId("create-channel-name")).toBeInTheDocument(); + }); + + it("shows Create Channel button on step 3", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + await user.type(screen.getByTestId("create-channel-name"), "My Channel"); + await user.click(screen.getByTestId("create-channel-next")); + await user.type(screen.getByTestId("create-channel-id"), "C0123ABC"); + await user.click(screen.getByTestId("create-channel-next")); + + expect(screen.getByTestId("create-channel-submit")).toBeInTheDocument(); + expect(screen.getByText("Create Channel")).toBeInTheDocument(); + }); + + it("uses defaultAgentId when provided", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + await user.type(screen.getByTestId("create-channel-name"), "My Channel"); + await user.click(screen.getByTestId("create-channel-next")); + await user.type(screen.getByTestId("create-channel-id"), "C0123ABC"); + await user.click(screen.getByTestId("create-channel-next")); + + expect(screen.getByTestId("create-target-agent")).toHaveValue("agent-xyz"); + }); + + it("shows step progress dots", () => { + renderWithProviders( + + ); + // 3 step dots + const dots = document.querySelectorAll(".rounded-full.h-2.w-2"); + expect(dots.length).toBe(3); + }); + + it("shows hint text for channel ID field", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + await user.type(screen.getByTestId("create-channel-name"), "My Channel"); + await user.click(screen.getByTestId("create-channel-next")); + + expect( + screen.getByText( + "Right-click a Slack channel → View channel details → copy the Channel ID" + ) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/chat/__tests__/chat-activity.test.tsx b/src/components/chat/__tests__/chat-activity.test.tsx new file mode 100644 index 00000000..019cab14 --- /dev/null +++ b/src/components/chat/__tests__/chat-activity.test.tsx @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"; +import { screen, fireEvent } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { ChatActivity } from "../chat-activity"; +import type { PipelineEvent } from "@/hooks/use-debug-events"; + +describe("ChatActivity", () => { + const mockWriteText = vi.fn(); + + beforeAll(() => { + if (typeof navigator !== "undefined") { + Object.defineProperty(navigator, "clipboard", { + value: { writeText: mockWriteText }, + writable: true, + configurable: true, + }); + } + }); + + beforeEach(() => { + mockWriteText.mockReset(); + }); + + it("renders nothing when events are empty", () => { + const { container } = renderWithProviders( + + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders processing state when hasRunning is true and isLive is true", () => { + const events: PipelineEvent[] = [ + { + type: "task_start", + taskType: "ai.labs.rules", + taskId: "1", + index: 0, + }, + ]; + + renderWithProviders(); + expect(screen.getByText(/Processing…/)).toBeInTheDocument(); + expect(screen.getByText("0/5")).toBeInTheDocument(); + }); + + it("renders completed state with total steps, duration and tool calls count", () => { + const events: PipelineEvent[] = [ + { + type: "task_start", + taskType: "ai.labs.rules", + taskId: "1", + index: 0, + }, + { + type: "task_complete", + taskType: "ai.labs.rules", + taskId: "1", + index: 0, + durationMs: 150, + }, + { + type: "task_start", + taskType: "ai.labs.llm", + taskId: "2", + index: 1, + }, + { + type: "task_complete", + taskType: "ai.labs.llm", + taskId: "2", + index: 1, + durationMs: 850, + toolTrace: [ + { type: "tool_call", tool: "weather", arguments: '{"city":"Vienna"}' }, + { type: "tool_result", tool: "weather", result: '{"temp":20}' }, + ], + }, + ]; + + renderWithProviders(); + + expect(screen.getByText(/2 steps/)).toBeInTheDocument(); + expect(screen.getByText("1.0s")).toBeInTheDocument(); // 150ms + 850ms = 1000ms = 1.0s + expect(screen.getByText(/1 tool calls/)).toBeInTheDocument(); + }); + + it("toggles expanded state on click", async () => { + const user = userEvent.setup(); + const events: PipelineEvent[] = [ + { + type: "task_complete", + taskType: "ai.labs.rules", + taskId: "1", + index: 0, + durationMs: 5, + }, + ]; + + renderWithProviders(); + const toggle = screen.getByTestId("chat-activity-toggle"); + + // Initially collapsed since isLive is false + expect(toggle).toHaveAttribute("aria-expanded", "false"); + + await user.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + + await user.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + }); + + it("renders details and supports tool tracing, expansion, and copying details", async () => { + const user = userEvent.setup(); + const events: PipelineEvent[] = [ + { + type: "task_complete", + taskType: "ai.labs.llm", + taskId: "2", + index: 1, + durationMs: 50, + toolTrace: [ + { type: "tool_call", tool: "calculator", arguments: "2+2" }, + { type: "tool_result", tool: "calculator", result: "4" }, + ], + }, + { + // Unmatched/raw task event + type: "task_start", + taskType: "ai.labs.unknown", + taskId: "3", + index: 2, + }, + ]; + + renderWithProviders(); + + // Since isLive is true, it should start expanded + expect(screen.getByText("llm")).toBeInTheDocument(); + expect(screen.getByText("unknown")).toBeInTheDocument(); + + // Weather tool count badge + const toolBadge = screen.getByRole("button", { name: "1" }); + expect(toolBadge).toBeInTheDocument(); + + // Tool calls list is hidden until we click the badge + expect(screen.queryByTestId("tool-call-row")).not.toBeInTheDocument(); + await user.click(toolBadge); + + const toolRow = screen.getByTestId("tool-call-row"); + expect(toolRow).toBeInTheDocument(); + expect(screen.getByText("calculator")).toBeInTheDocument(); + expect(screen.getByText("(2+2)")).toBeInTheDocument(); + + // Click toolRow to expand details + await user.click(toolRow); + expect(screen.getByText("Args")).toBeInTheDocument(); + expect(screen.getByText("Result")).toBeInTheDocument(); + + // Copy buttons are rendered. Let's find copy buttons + const copyBtns = screen.getAllByTitle("Copy"); + expect(copyBtns).toHaveLength(2); + + // Define mock right before clicking to avoid userEvent.setup override + Object.defineProperty(navigator, "clipboard", { + value: { writeText: mockWriteText }, + writable: true, + configurable: true, + }); + + fireEvent.click(copyBtns[0]); + expect(mockWriteText).toHaveBeenCalledWith("2+2"); + + fireEvent.click(copyBtns[1]); + expect(mockWriteText).toHaveBeenCalledWith("4"); + }); +}); diff --git a/src/components/chat/__tests__/chat-drawer.test.tsx b/src/components/chat/__tests__/chat-drawer.test.tsx new file mode 100644 index 00000000..79cdc694 --- /dev/null +++ b/src/components/chat/__tests__/chat-drawer.test.tsx @@ -0,0 +1,251 @@ +import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { ChatDrawer } from "../chat-drawer"; +import { useChatDrawerStore } from "@/hooks/use-chat-drawer"; +import { useChatStore } from "@/hooks/use-chat"; +import { server } from "@/test/mocks/server"; +import { http, HttpResponse } from "msw"; + +describe("ChatDrawer", () => { + beforeAll(() => { + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + }); + + beforeEach(() => { + useChatDrawerStore.setState({ + isOpen: false, + agentId: null, + agentName: null, + step: "idle", + errorMessage: null, + }); + useChatStore.getState().reset(); + useChatStore.setState({ streamingEnabled: false }); + }); + + it("renders a closed container when isOpen is false", () => { + renderWithProviders(); + const container = screen.getByTestId("chat-drawer"); + expect(container).toHaveClass("w-0"); + expect(container).toHaveClass("opacity-0"); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders header and agent info when open", async () => { + const user = userEvent.setup(); + useChatDrawerStore.setState({ + isOpen: true, + agentId: "agent-1", + agentName: "Agent One", + step: "idle", + }); + + renderWithProviders(); + expect(screen.getByText("Agent One")).toBeInTheDocument(); + + // Close button triggers store close + const closeBtn = screen.getByTestId("drawer-close"); + await user.click(closeBtn); + expect(useChatDrawerStore.getState().isOpen).toBe(false); + }); + + it("displays progress steps during deploy pipeline", () => { + useChatDrawerStore.setState({ + isOpen: true, + agentId: "agent-1", + agentName: "Agent One", + step: "deploying", + }); + + renderWithProviders(); + expect(screen.getByText("Deploying agent…")).toBeInTheDocument(); + expect(screen.getByText("Saving changes…")).toBeInTheDocument(); + }); + + it("shows error step and supports retry", async () => { + const user = userEvent.setup(); + useChatDrawerStore.setState({ + isOpen: true, + agentId: "agent-1", + agentName: "Agent One", + step: "error", + errorMessage: "Deploy failed catastrophically", + }); + + renderWithProviders(); + expect(screen.getByText("Deploy failed catastrophically")).toBeInTheDocument(); + + const retryBtn = screen.getByTestId("drawer-retry"); + await user.click(retryBtn); + + expect(useChatDrawerStore.getState().step).toBe("idle"); + expect(useChatDrawerStore.getState().isOpen).toBe(false); + }); + + it("shows ready/idle empty state", () => { + useChatDrawerStore.setState({ + isOpen: true, + agentId: "agent-1", + agentName: "Agent One", + step: "ready", + }); + + renderWithProviders(); + expect(screen.getByText("Ready — type a message to test")).toBeInTheDocument(); + }); + + it("shows active conversation messages, thinking state, and quick replies", async () => { + const user = userEvent.setup(); + useChatDrawerStore.setState({ + isOpen: true, + agentId: "agent-1", + agentName: "Agent One", + step: "ready", + }); + + useChatStore.getState().setSelectedAgent("agent-1", "Agent One"); + useChatStore.getState().setConversationId("conv1"); + useChatStore.getState().addMessage({ + id: "m1", + role: "user", + content: "Hello agent", + timestamp: Date.now(), + }); + useChatStore.getState().addMessage({ + id: "m2", + role: "agent", + content: "Hello human", + timestamp: Date.now(), + }); + useChatStore.getState().setThinking(true); + useChatStore.getState().setQuickReplies(["Reply Option 1", "Reply Option 2"]); + + server.use( + http.post("*/agents/conv1", async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return HttpResponse.json({ + conversationOutputs: [ + { + output: [{ type: "text", text: "Mock response" }], + }, + ], + }); + }) + ); + + renderWithProviders(); + + expect(screen.getByText("Hello agent")).toBeInTheDocument(); + expect(screen.getByText("Hello human")).toBeInTheDocument(); + expect(screen.getByText("Thinking...")).toBeInTheDocument(); + + // Check quick replies + const qrBtns = screen.getAllByTestId("drawer-quick-reply"); + expect(qrBtns).toHaveLength(2); + expect(qrBtns[0]).toHaveTextContent("Reply Option 1"); + + // Click quick reply to trigger mutation + await user.click(qrBtns[0]); + await waitFor(() => { + expect(useChatStore.getState().isProcessing).toBe(true); + }); + await waitFor(() => { + expect(useChatStore.getState().isProcessing).toBe(false); + }); + }); + + it("allows typing and sending message", async () => { + const user = userEvent.setup(); + useChatDrawerStore.setState({ + isOpen: true, + agentId: "agent-1", + agentName: "Agent One", + step: "ready", + }); + + useChatStore.getState().setSelectedAgent("agent-1", "Agent One"); + useChatStore.getState().setConversationId("conv1"); + + server.use( + http.post("*/agents/conv1", async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return HttpResponse.json({ + conversationOutputs: [ + { + output: [{ type: "text", text: "I received your message" }], + }, + ], + }); + }) + ); + + renderWithProviders(); + + const input = screen.getByTestId("drawer-chat-input"); + const sendBtn = screen.getByTestId("drawer-chat-send"); + + expect(sendBtn).toBeDisabled(); + + await user.type(input, "Ping"); + expect(sendBtn).toBeEnabled(); + + await user.click(sendBtn); + + // Should clear input and start processing + expect(input).toHaveValue(""); + await waitFor(() => { + expect(useChatStore.getState().isProcessing).toBe(true); + }); + await waitFor(() => { + expect(useChatStore.getState().isProcessing).toBe(false); + }); + }); + + it("resets conversation and starts a new one on button click", async () => { + const user = userEvent.setup(); + useChatDrawerStore.setState({ + isOpen: true, + agentId: "agent-1", + agentName: "Agent One", + step: "ready", + }); + + useChatStore.getState().setSelectedAgent("agent-1", "Agent One"); + useChatStore.getState().setConversationId("conv1"); + useChatStore.getState().addMessage({ + id: "m1", + role: "user", + content: "Hello agent", + timestamp: Date.now(), + }); + + server.use( + http.post("*/agents/agent-1/start", () => { + return HttpResponse.json(null, { + status: 201, + headers: { + Location: "eddi://ai.labs.conversation/conversationstore/conversations/conv-new", + }, + }); + }), + http.get("*/agents/conv-new", () => { + return HttpResponse.json({ + conversationSteps: [], + conversationOutputs: [], + }); + }) + ); + + renderWithProviders(); + + const newBtn = screen.getByTestId("drawer-new-conversation"); + await user.click(newBtn); + + await waitFor(() => { + expect(useChatDrawerStore.getState().step).toBe("ready"); + expect(useChatStore.getState().conversationId).toBe("conv-new"); + expect(useChatStore.getState().messages).toHaveLength(0); + }); + }); +}); diff --git a/src/components/chat/__tests__/chat-history.test.tsx b/src/components/chat/__tests__/chat-history.test.tsx new file mode 100644 index 00000000..37940a80 --- /dev/null +++ b/src/components/chat/__tests__/chat-history.test.tsx @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { ChatHistory } from "../chat-history"; +import { useChatStore } from "@/hooks/use-chat"; +import { server } from "@/test/mocks/server"; +import { http, HttpResponse } from "msw"; + +describe("ChatHistory", () => { + beforeEach(() => { + useChatStore.getState().reset(); + }); + + it("renders nothing when open is false", () => { + const { container } = renderWithProviders( + {}} /> + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders the history header and controls when open is true", async () => { + useChatStore.getState().setSelectedAgent("agent-1", "Agent 1"); + renderWithProviders( {}} />); + + expect(screen.getByText("History")).toBeInTheDocument(); + expect(screen.getByTestId("new-conversation-btn")).toBeInTheDocument(); + }); + + it("calls onNewConversation when clicking new conversation button", async () => { + const user = userEvent.setup(); + const handleNew = vi.fn(); + useChatStore.getState().setSelectedAgent("agent-1", "Agent 1"); + + renderWithProviders(); + + const newBtn = screen.getByTestId("new-conversation-btn"); + await user.click(newBtn); + + expect(handleNew).toHaveBeenCalled(); + }); + + it("shows empty state when there are no conversations", async () => { + useChatStore.getState().setSelectedAgent("agent-empty", "Agent Empty"); + + server.use( + http.get("*/conversationstore/conversations", () => { + return HttpResponse.json([]); + }) + ); + + renderWithProviders( {}} />); + + await waitFor(() => { + expect(screen.getByText("No conversation history yet.")).toBeInTheDocument(); + }); + }); + + it("displays conversation list when loaded", async () => { + useChatStore.getState().setSelectedAgent("agent-1", "Agent 1"); + useChatStore.getState().setConversationId("conv2"); + + server.use( + http.get("*/conversationstore/conversations", () => { + return HttpResponse.json([ + { + resource: "eddi://ai.labs.conversation/conversationstore/conversations/conv1", + conversationState: "READY", + lastModifiedOn: new Date("2026-06-01").getTime(), + }, + { + resource: "eddi://ai.labs.conversation/conversationstore/conversations/conv2", + conversationState: "IN_PROGRESS", + lastModifiedOn: new Date("2026-06-02").getTime(), + }, + ]); + }) + ); + + renderWithProviders( {}} />); + + await waitFor(() => { + expect(screen.getByText(/conv1/)).toBeInTheDocument(); + expect(screen.getByText(/conv2/)).toBeInTheDocument(); + }); + + // Check states + expect(screen.getByText("READY")).toBeInTheDocument(); + expect(screen.getByText("IN_PROGRESS")).toBeInTheDocument(); + }); + + it("triggers loadConversation when clicking a conversation item", async () => { + const user = userEvent.setup(); + useChatStore.getState().setSelectedAgent("agent-1", "Agent 1"); + + server.use( + http.get("*/conversationstore/conversations", () => { + return HttpResponse.json([ + { + resource: "eddi://ai.labs.conversation/conversationstore/conversations/conv1", + conversationState: "READY", + lastModifiedOn: Date.now(), + }, + ]); + }), + http.get("*/agents/production/agent-1/conv1", () => { + return HttpResponse.json({ + agentId: "agent-1", + agentVersion: 1, + conversationId: "conv1", + conversationState: "READY", + environment: "production", + conversationSteps: [], + }); + }) + ); + + renderWithProviders( {}} />); + + const convBtn = await screen.findByText(/conv1/); + await user.click(convBtn); + + await waitFor(() => { + expect(useChatStore.getState().conversationId).toBe("conv1"); + }); + }); +}); diff --git a/src/components/chat/__tests__/chat-input.test.tsx b/src/components/chat/__tests__/chat-input.test.tsx new file mode 100644 index 00000000..69bcfa95 --- /dev/null +++ b/src/components/chat/__tests__/chat-input.test.tsx @@ -0,0 +1,126 @@ +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { ChatInput } from "@/components/chat/chat-input"; + +describe("ChatInput", () => { + it("renders textarea and send button", () => { + renderWithProviders(); + + expect(screen.getByTestId("chat-input")).toBeInTheDocument(); + expect(screen.getByTestId("chat-send")).toBeInTheDocument(); + }); + + it("send button is disabled when textarea is empty", () => { + renderWithProviders(); + + expect(screen.getByTestId("chat-send")).toBeDisabled(); + }); + + it("send button enables when text is typed", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByTestId("chat-input"), "Hello"); + expect(screen.getByTestId("chat-send")).not.toBeDisabled(); + }); + + it("calls onSend with trimmed text on button click", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByTestId("chat-input"), " Hello World "); + await user.click(screen.getByTestId("chat-send")); + + expect(onSend).toHaveBeenCalledWith("Hello World"); + }); + + it("clears input after sending", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByTestId("chat-input"); + await user.type(input, "Test message"); + await user.click(screen.getByTestId("chat-send")); + + expect(input).toHaveValue(""); + }); + + it("sends on Enter key press", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByTestId("chat-input"), "Enter test{Enter}"); + expect(onSend).toHaveBeenCalledWith("Enter test"); + }); + + it("does NOT send on Shift+Enter (allows newline)", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + await user.type( + screen.getByTestId("chat-input"), + "Line 1{Shift>}{Enter}{/Shift}Line 2" + ); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("does not send when disabled", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByTestId("chat-input"); + expect(input).toBeDisabled(); + }); + + it("does not send when only whitespace is entered", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByTestId("chat-input"), " "); + expect(screen.getByTestId("chat-send")).toBeDisabled(); + }); + + it("shows spinner icon when isProcessing is true", () => { + renderWithProviders( + + ); + + // When processing, should show animate-spin icon + const sendBtn = screen.getByTestId("chat-send"); + const spinner = sendBtn.querySelector(".animate-spin"); + expect(spinner).not.toBeNull(); + }); + + it("does not send when isProcessing", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + renderWithProviders( + + ); + + // Type text but isProcessing should prevent sending + await user.type(screen.getByTestId("chat-input"), "Hello"); + await user.click(screen.getByTestId("chat-send")); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("has proper placeholder text", () => { + renderWithProviders(); + + const input = screen.getByTestId("chat-input"); + expect(input).toHaveAttribute("placeholder"); + }); + + it("has aria-label on send button", () => { + renderWithProviders(); + + const sendBtn = screen.getByTestId("chat-send"); + expect(sendBtn).toHaveAttribute("aria-label"); + }); +}); diff --git a/src/components/chat/__tests__/chat-message.test.tsx b/src/components/chat/__tests__/chat-message.test.tsx new file mode 100644 index 00000000..5be65c76 --- /dev/null +++ b/src/components/chat/__tests__/chat-message.test.tsx @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "@/test/test-utils"; +import { ChatMessage } from "@/components/chat/chat-message"; +import type { ChatMessage as ChatMessageType } from "@/lib/api/chat"; + +const userMessage: ChatMessageType = { + role: "user", + content: "Hello, how are you?", + timestamp: new Date("2024-01-15T10:30:00Z").getTime(), + isStreaming: false, +}; + +const agentMessage: ChatMessageType = { + role: "agent", + content: "I'm doing well, thanks!", + timestamp: new Date("2024-01-15T10:30:05Z").getTime(), + isStreaming: false, +}; + +const streamingMessage: ChatMessageType = { + role: "agent", + content: "", + timestamp: new Date("2024-01-15T10:30:10Z").getTime(), + isStreaming: true, +}; + +const agentNoContent: ChatMessageType = { + role: "agent", + content: "", + timestamp: new Date("2024-01-15T10:30:15Z").getTime(), + isStreaming: false, +}; + +describe("ChatMessage", () => { + it("renders user message content", () => { + renderWithProviders(); + expect(screen.getByText("Hello, how are you?")).toBeInTheDocument(); + }); + + it("renders agent message content", () => { + renderWithProviders(); + expect(screen.getByText("I'm doing well, thanks!")).toBeInTheDocument(); + }); + + it("shows User icon for user messages", () => { + const { container } = renderWithProviders( + + ); + const userIcon = container.querySelector("svg.lucide-user"); + expect(userIcon).not.toBeNull(); + }); + + it("shows Bot icon for agent messages", () => { + const { container } = renderWithProviders( + + ); + const botIcon = container.querySelector("svg.lucide-bot"); + expect(botIcon).not.toBeNull(); + }); + + it("shows typing indicator for streaming message with no content", () => { + renderWithProviders( + + ); + // Typing indicator has aria-label + expect( + screen.getByLabelText("Agent is typing") + ).toBeInTheDocument(); + }); + + it("shows 'No response' for agent message with empty content and not streaming", () => { + renderWithProviders( + + ); + expect(screen.getByText("No response")).toBeInTheDocument(); + }); + + it("shows timestamp", () => { + renderWithProviders(); + // Should have a time displayed (format varies by locale) + const timeSpan = document.querySelector( + "span.text-\\[10px\\]" + ); + expect(timeSpan).not.toBeNull(); + expect(timeSpan?.textContent?.length).toBeGreaterThan(0); + }); + + it("applies animate-pulse class for streaming messages", () => { + const streamingWithContent: ChatMessageType = { + role: "agent", + content: "Typing...", + timestamp: Date.now(), + isStreaming: true, + }; + const { container } = renderWithProviders( + + ); + const pulseEl = container.querySelector(".animate-pulse"); + expect(pulseEl).not.toBeNull(); + }); + + it("user message uses primary background", () => { + const { container } = renderWithProviders( + + ); + const bubble = container.querySelector(".bg-primary"); + expect(bubble).not.toBeNull(); + }); + + it("agent message uses card background with border", () => { + const { container } = renderWithProviders( + + ); + const bubble = container.querySelector(".bg-card"); + expect(bubble).not.toBeNull(); + }); + + it("user message aligns right (flex-row-reverse)", () => { + const { container } = renderWithProviders( + + ); + const msgDiv = container.querySelector(".group.relative"); + expect(msgDiv?.className).toContain("flex-row-reverse"); + }); + + it("agent message aligns left (flex-row)", () => { + const { container } = renderWithProviders( + + ); + const msgDiv = container.querySelector(".group.relative"); + expect(msgDiv?.className).toContain("flex-row"); + expect(msgDiv?.className).not.toContain("flex-row-reverse"); + }); +}); diff --git a/src/components/chat/__tests__/chat-panel.test.tsx b/src/components/chat/__tests__/chat-panel.test.tsx new file mode 100644 index 00000000..4b6851d9 --- /dev/null +++ b/src/components/chat/__tests__/chat-panel.test.tsx @@ -0,0 +1,346 @@ +import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { ChatPanel } from "../chat-panel"; +import { useChatStore } from "@/hooks/use-chat"; +import { useDebugStore } from "@/hooks/use-debug-events"; +import { server } from "@/test/mocks/server"; +import { http, HttpResponse } from "msw"; + +describe("ChatPanel", () => { + const mockWriteText = vi.fn(); + + beforeAll(() => { + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + + if (typeof navigator !== "undefined") { + if (!navigator.clipboard) { + Object.defineProperty(navigator, "clipboard", { + value: { writeText: mockWriteText }, + writable: true, + configurable: true, + }); + } else { + vi.spyOn(navigator.clipboard, "writeText").mockImplementation(mockWriteText); + } + } + }); + + beforeEach(() => { + useChatStore.getState().reset(); + useChatStore.setState({ streamingEnabled: false }); + useDebugStore.getState().reset(); + mockWriteText.mockReset(); + }); + + it("renders empty state initially when no agent is selected", () => { + renderWithProviders(); + expect(screen.getByText("Select an agent and start chatting!")).toBeInTheDocument(); + }); + + it("loads and allows selecting deployed agents", async () => { + const user = userEvent.setup(); + + server.use( + http.get("*/agentstore/agents/descriptors", () => { + return HttpResponse.json([ + { + resource: "eddi://ai.labs.agent/agentstore/agents/agent1?version=1", + name: "Customer Support Bot", + description: "Answers user questions", + }, + ]); + }), + http.get("*/documentdescriptor/descriptors/agentstore/agents/agent1", () => { + return HttpResponse.json({ + resource: "eddi://ai.labs.agent/agentstore/agents/agent1?version=1", + name: "Customer Support Bot", + description: "Answers user questions", + }); + }), + http.get("*/deployment/production/agentstore/agents/agent1/version/1", () => { + return HttpResponse.json({ status: "READY" }); + }), + http.post("*/agents/agent1/start", () => { + return HttpResponse.json(null, { + status: 201, + headers: { + Location: "eddi://ai.labs.conversation/conversationstore/conversations/conv123", + }, + }); + }), + http.get("*/agents/conv123", () => { + return HttpResponse.json({ + conversationSteps: [], + conversationOutputs: [ + { + output: [{ type: "text", text: "Welcome to support!" }], + }, + ], + }); + }) + ); + + renderWithProviders(); + + const selector = screen.getByTestId("agent-selector"); + expect(selector).toBeInTheDocument(); + + await user.click(selector); + + const option = await screen.findByText("Customer Support Bot"); + await user.click(option); + + await waitFor(() => { + expect(useChatStore.getState().selectedAgentId).toBe("agent1"); + expect(useChatStore.getState().conversationId).toBe("conv123"); + expect(screen.getByText("Welcome to support!")).toBeInTheDocument(); + }); + }); + + it("supports sending a normal text message and quick replies", async () => { + const user = userEvent.setup(); + + useChatStore.getState().setSelectedAgent("agent1", "Test Agent"); + useChatStore.getState().setConversationId("conv1"); + useChatStore.getState().setQuickReplies(["Yes", "No"]); + + server.use( + http.post("*/agents/conv1", () => { + return HttpResponse.json({ + conversationOutputs: [ + { + output: [{ type: "text", text: "I processed your request" }], + quickReplies: ["Yes", "No"], + }, + ], + }); + }) + ); + + renderWithProviders(); + + const input = screen.getByTestId("chat-input"); + const sendBtn = screen.getByTestId("chat-send"); + expect(sendBtn).toBeInTheDocument(); + + await user.type(input, "Hello bot"); + await user.click(sendBtn); + + await waitFor(() => { + expect(screen.getByText("I processed your request")).toBeInTheDocument(); + }); + + // Quick reply click + const qrBtns = screen.getAllByTestId("quick-reply-btn"); + expect(qrBtns[0]).toHaveTextContent("Yes"); + await user.click(qrBtns[0]); + + await waitFor(() => { + const messages = useChatStore.getState().messages; + expect(messages.some((m) => m.role === "user" && m.content === "Yes")).toBe(true); + }); + }); + + it("toggles secret mode and sends a masked input", async () => { + const user = userEvent.setup(); + + useChatStore.getState().setSelectedAgent("agent1", "Test Agent"); + useChatStore.getState().setConversationId("conv1"); + + server.use( + http.post("*/agents/conv1", () => { + return HttpResponse.json({ + conversationOutputs: [ + { + output: [{ type: "text", text: "Secret verified" }], + }, + ], + }); + }) + ); + + renderWithProviders(); + + const toggle = screen.getByTestId("chat-secret-toggle"); + await user.click(toggle); + + // Now input is password type + const input = screen.getByTestId("chat-input"); + expect(input).toHaveAttribute("type", "password"); + + await user.type(input, "my-secret-key"); + const sendBtn = screen.getByTestId("chat-send"); + await user.click(sendBtn); + + // User message shown in chat should be masked + expect(await screen.findByText("●●●●●●●●")).toBeInTheDocument(); + }); + + it("supports file attachments uploading and triggers uploadAttachment", async () => { + const user = userEvent.setup(); + useChatStore.getState().setSelectedAgent("agent1", "Test Agent"); + useChatStore.getState().setConversationId("conv1"); + + server.use( + http.post("*/conversations/conv1/attachments", () => { + return HttpResponse.json({ storageRef: "attachments-ref-123" }); + }), + http.post("*/agents/conv1", () => { + return HttpResponse.json({ + conversationOutputs: [], + }); + }) + ); + + renderWithProviders(); + + const attachBtn = screen.getByTestId("chat-attach-btn"); + expect(attachBtn).toBeInTheDocument(); + + const fileInput = screen.getByTestId("chat-file-input"); + + const file = new File(["dummy content"], "test-file.txt", { type: "text/plain" }); + await user.upload(fileInput, file); + + await waitFor(() => { + expect(useChatStore.getState().messages.some(m => m.content.includes("attachments-ref-123"))).toBe(true); + }); + }); + + it("displays rerun button after error and runs rerun mutation", async () => { + const user = userEvent.setup(); + useChatStore.getState().setSelectedAgent("agent1", "Test Agent"); + useChatStore.getState().setConversationId("conv1"); + useChatStore.getState().addMessage({ + id: "m1", + role: "agent", + content: "⚠️ Error: Something went wrong", + timestamp: Date.now(), + }); + + server.use( + http.post("*/agents/conv1/rerun", () => { + return new HttpResponse(null, { status: 200 }); + }), + http.get("*/agents/conv1", () => { + return HttpResponse.json({ + conversationSteps: [ + { + conversationStep: [ + { key: "input:initial", value: "hello" } + ] + } + ], + conversationOutputs: [ + { + output: [{ type: "text", text: "Successfully rerun step!" }], + }, + ], + }); + }) + ); + + renderWithProviders(); + + const rerunBtn = screen.getByTestId("rerun-btn"); + expect(rerunBtn).toBeInTheDocument(); + + await user.click(rerunBtn); + + await waitFor(() => { + expect(screen.getByText("Successfully rerun step!")).toBeInTheDocument(); + }); + }); + + it("handles undo and redo conversation mutations", async () => { + const user = userEvent.setup(); + useChatStore.getState().setSelectedAgent("agent1", "Test Agent"); + useChatStore.getState().setConversationId("conv1"); + useChatStore.getState().setUndoRedo(true, true); + + server.use( + http.post("*/agents/conv1/undo", () => { + return HttpResponse.json({ + agentId: "agent1", + agentVersion: 1, + conversationId: "conv1", + conversationSteps: [], + conversationOutputs: [], + redoAvailable: true, + }); + }), + http.post("*/agents/conv1/redo", () => { + return HttpResponse.json({ + agentId: "agent1", + agentVersion: 1, + conversationId: "conv1", + conversationSteps: [], + conversationOutputs: [], + redoAvailable: false, + }); + }) + ); + + renderWithProviders(); + + const undoBtn = screen.getByTestId("undo-btn"); + const redoBtn = screen.getByTestId("redo-btn"); + + expect(undoBtn).toBeEnabled(); + expect(redoBtn).toBeEnabled(); + + await user.click(undoBtn); + await user.click(redoBtn); + }); + + it("auto-starts conversation if agentId query parameter is present", async () => { + server.use( + http.get("*/agentstore/agents/descriptors", () => { + return HttpResponse.json([ + { + resource: "eddi://ai.labs.agent/agentstore/agents/agent-query-1?version=1", + name: "Auto Started Agent", + description: "Loaded via query param", + }, + ]); + }), + http.get("*/documentdescriptor/descriptors/agentstore/agents/agent-query-1", () => { + return HttpResponse.json({ + resource: "eddi://ai.labs.agent/agentstore/agents/agent-query-1?version=1", + name: "Auto Started Agent", + description: "Loaded via query param", + }); + }), + http.get("*/deployment/production/agentstore/agents/agent-query-1/version/1", () => { + return HttpResponse.json({ status: "READY" }); + }), + http.post("*/agents/agent-query-1/start", () => { + return HttpResponse.json(null, { + status: 201, + headers: { + Location: "eddi://ai.labs.conversation/conversationstore/conversations/conv-query", + }, + }); + }), + http.get("*/agents/conv-query", () => { + return HttpResponse.json({ + conversationSteps: [], + conversationOutputs: [ + { + output: [{ type: "text", text: "Auto hello!" }], + }, + ], + }); + }) + ); + + renderWithProviders(, { initialRoute: "/?agentId=agent-query-1" }); + + await waitFor(() => { + expect(useChatStore.getState().selectedAgentId).toBe("agent-query-1"); + expect(useChatStore.getState().conversationId).toBe("conv-query"); + expect(screen.getByText("Auto hello!")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/chat/__tests__/streaming-toggle.test.tsx b/src/components/chat/__tests__/streaming-toggle.test.tsx new file mode 100644 index 00000000..9422fc3d --- /dev/null +++ b/src/components/chat/__tests__/streaming-toggle.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { StreamingToggle } from "@/components/chat/streaming-toggle"; + +describe("StreamingToggle", () => { + it("renders the toggle button", () => { + renderWithProviders(); + expect(screen.getByTestId("streaming-toggle")).toBeInTheDocument(); + }); + + it("has aria-pressed attribute", () => { + renderWithProviders(); + const toggle = screen.getByTestId("streaming-toggle"); + expect(toggle).toHaveAttribute("aria-pressed"); + }); + + it("has aria-label", () => { + renderWithProviders(); + const toggle = screen.getByTestId("streaming-toggle"); + expect(toggle).toHaveAttribute("aria-label"); + }); + + it("toggles state on click", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const toggle = screen.getByTestId("streaming-toggle"); + const initialState = toggle.getAttribute("aria-pressed"); + + await user.click(toggle); + + const newState = toggle.getAttribute("aria-pressed"); + expect(newState).not.toBe(initialState); + }); + + it("shows Zap or ZapOff icon", () => { + const { container } = renderWithProviders(); + // One of these icons should be present + const zap = container.querySelector("svg.lucide-zap"); + const zapOff = container.querySelector("svg.lucide-zap-off"); + expect(zap || zapOff).not.toBeNull(); + }); +}); diff --git a/src/components/debugger/__tests__/live-log-viewer.test.tsx b/src/components/debugger/__tests__/live-log-viewer.test.tsx new file mode 100644 index 00000000..faa6f4c5 --- /dev/null +++ b/src/components/debugger/__tests__/live-log-viewer.test.tsx @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { LiveLogViewer } from "@/components/debugger/live-log-viewer"; + +// Mock scrollTo for jsdom +beforeAll(() => { + Element.prototype.scrollTo = vi.fn(); +}); + +afterAll(() => { + // @ts-expect-error restore + delete Element.prototype.scrollTo; +}); + +// Mock the logs API module +vi.mock("@/lib/api/logs", () => ({ + createLogEventSource: vi.fn(() => ({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + onmessage: null, + onopen: null, + onerror: null, + close: vi.fn(), + })), + getRecentLogs: vi.fn(() => Promise.resolve([])), +})); + +import { createLogEventSource, getRecentLogs } from "@/lib/api/logs"; +const mockCreateLogEventSource = vi.mocked(createLogEventSource); +const mockGetRecentLogs = vi.mocked(getRecentLogs); + +describe("LiveLogViewer", () => { + it("shows empty state when no agentId", () => { + renderWithProviders( + + ); + expect( + screen.getByText("Select an agent to view logs") + ).toBeInTheDocument(); + }); + + it("shows toolbar when agentId is provided", () => { + renderWithProviders( + + ); + expect(screen.getByTestId("live-log-viewer")).toBeInTheDocument(); + }); + + it("shows waiting message when no logs", () => { + renderWithProviders( + + ); + expect(screen.getByText("Waiting for logs...")).toBeInTheDocument(); + }); + + it("renders level filter buttons", () => { + renderWithProviders( + + ); + expect(screen.getByTestId("filter-ERROR")).toBeInTheDocument(); + expect(screen.getByTestId("filter-WARN")).toBeInTheDocument(); + expect(screen.getByTestId("filter-INFO")).toBeInTheDocument(); + expect(screen.getByTestId("filter-DEBUG")).toBeInTheDocument(); + }); + + it("renders search input", () => { + renderWithProviders( + + ); + expect(screen.getByTestId("log-search")).toBeInTheDocument(); + }); + + it("renders pause button", () => { + renderWithProviders( + + ); + expect(screen.getByTestId("log-pause")).toBeInTheDocument(); + }); + + it("renders clear button", () => { + renderWithProviders( + + ); + expect(screen.getByTestId("log-clear")).toBeInTheDocument(); + }); + + it("shows log entries when loaded from API", async () => { + mockGetRecentLogs.mockResolvedValueOnce([ + { + timestamp: Date.now(), + level: "INFO", + loggerName: "com.example.TestLogger", + message: "Application started successfully", + }, + ]); + + renderWithProviders( + + ); + + await waitFor(() => { + expect(screen.getByText("Application started successfully")).toBeInTheDocument(); + }); + // Logger short name + expect(screen.getByText(/TestLogger/)).toBeInTheDocument(); + }); + + it("toggles level filter on click", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + const errorBtn = screen.getByTestId("filter-ERROR"); + expect(errorBtn).toHaveAttribute("aria-pressed", "false"); + + await user.click(errorBtn); + expect(errorBtn).toHaveAttribute("aria-pressed", "true"); + + // Click again to deactivate + await user.click(errorBtn); + expect(errorBtn).toHaveAttribute("aria-pressed", "false"); + }); + + it("has a log output region with role=log", () => { + renderWithProviders( + + ); + expect(screen.getByRole("log")).toBeInTheDocument(); + }); + + it("creates event source when agentId is provided", () => { + renderWithProviders( + + ); + expect(mockCreateLogEventSource).toHaveBeenCalledWith({ + agentId: "agent-456", + conversationId: "conv-789", + }); + }); + + it("fetches recent logs on mount", () => { + renderWithProviders( + + ); + expect(mockGetRecentLogs).toHaveBeenCalledWith({ + agentId: "agent-456", + conversationId: undefined, + limit: 50, + }); + }); + + it("filters logs by search query", async () => { + mockGetRecentLogs.mockResolvedValueOnce([ + { + timestamp: Date.now(), + level: "INFO", + loggerName: "com.example.AppLogger", + message: "Application started", + }, + { + timestamp: Date.now(), + level: "ERROR", + loggerName: "com.example.DbLogger", + message: "Database connection failed", + }, + ]); + + const user = userEvent.setup(); + renderWithProviders( + + ); + + await waitFor(() => { + expect(screen.getByText("Application started")).toBeInTheDocument(); + }); + expect(screen.getByText("Database connection failed")).toBeInTheDocument(); + + // Search for "Database" + const searchInput = screen.getByTestId("log-search"); + await user.type(searchInput, "Database"); + + // "Application started" should be filtered out + expect(screen.queryByText("Application started")).not.toBeInTheDocument(); + expect(screen.getByText("Database connection failed")).toBeInTheDocument(); + }); + + it("clears all logs when clear button clicked", async () => { + mockGetRecentLogs.mockResolvedValueOnce([ + { + timestamp: Date.now(), + level: "INFO", + loggerName: "com.example.Logger", + message: "Some log message", + }, + ]); + + const user = userEvent.setup(); + renderWithProviders( + + ); + + await waitFor(() => { + expect(screen.getByText("Some log message")).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("log-clear")); + expect(screen.queryByText("Some log message")).not.toBeInTheDocument(); + expect(screen.getByText("Waiting for logs...")).toBeInTheDocument(); + }); + + it("connection status indicator shows disconnected initially", () => { + renderWithProviders( + + ); + const status = screen.getByRole("status"); + expect(status).toHaveAttribute("aria-label", "Disconnected"); + }); +}); diff --git a/src/components/debugger/__tests__/memory-inspector.test.tsx b/src/components/debugger/__tests__/memory-inspector.test.tsx new file mode 100644 index 00000000..c890e8ca --- /dev/null +++ b/src/components/debugger/__tests__/memory-inspector.test.tsx @@ -0,0 +1,180 @@ +import { describe, it, expect, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { MemoryInspector } from "@/components/debugger/memory-inspector"; +import { server } from "@/test/mocks/server"; +import { http, HttpResponse } from "msw"; + +const DETAILED_URL = "*/agents/:id"; + +describe("MemoryInspector", () => { + it("shows empty state when no conversationId", () => { + renderWithProviders(); + expect( + screen.getByText("Start a conversation to inspect memory") + ).toBeInTheDocument(); + }); + + it("shows loading spinner when fetching", () => { + // Delay the response indefinitely + server.use( + http.get(DETAILED_URL, () => { + return new Promise(() => {}); + }) + ); + const { container } = renderWithProviders( + + ); + const spinner = container.querySelector(".animate-spin"); + expect(spinner).not.toBeNull(); + }); + + it("shows error state when API fails", async () => { + server.use( + http.get(DETAILED_URL, () => { + return new HttpResponse(null, { status: 500 }); + }) + ); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId("memory-inspector-error")).toBeInTheDocument(); + }); + expect(screen.getByText("Failed to load memory data")).toBeInTheDocument(); + }); + + it("shows memory data when loaded", async () => { + server.use( + http.get(DETAILED_URL, ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get("returnDetailed") === "true") { + return HttpResponse.json({ + conversationSteps: [ + { + conversationStep: [ + { + key: "input:initial", + value: "Hello", + timestamp: Date.now(), + originWorkflowId: null, + }, + ], + }, + ], + conversationProperties: {}, + }); + } + return new HttpResponse(null, { status: 404 }); + }) + ); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId("memory-inspector")).toBeInTheDocument(); + }); + expect(screen.getByText("Conversation Memory")).toBeInTheDocument(); + expect(screen.getByText(/Step 1/)).toBeInTheDocument(); + }); + + it("shows empty steps message when no steps", async () => { + server.use( + http.get(DETAILED_URL, ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get("returnDetailed") === "true") { + return HttpResponse.json({ + conversationSteps: [], + conversationProperties: {}, + }); + } + return new HttpResponse(null, { status: 404 }); + }) + ); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId("memory-inspector")).toBeInTheDocument(); + }); + expect(screen.getByText("No memory data available")).toBeInTheDocument(); + }); + + it("has a refresh button", async () => { + server.use( + http.get(DETAILED_URL, ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get("returnDetailed") === "true") { + return HttpResponse.json({ + conversationSteps: [], + conversationProperties: {}, + }); + } + return new HttpResponse(null, { status: 404 }); + }) + ); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId("memory-inspector")).toBeInTheDocument(); + }); + expect(screen.getByTestId("memory-refresh")).toBeInTheDocument(); + }); + + it("shows properties when available", async () => { + server.use( + http.get(DETAILED_URL, ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get("returnDetailed") === "true") { + return HttpResponse.json({ + conversationSteps: [], + conversationProperties: { + userName: "Alice", + language: "en", + }, + }); + } + return new HttpResponse(null, { status: 404 }); + }) + ); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId("memory-inspector")).toBeInTheDocument(); + }); + expect(screen.getByText("Properties")).toBeInTheDocument(); + }); + + it("shows grouped items by originWorkflowId", async () => { + server.use( + http.get(DETAILED_URL, ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get("returnDetailed") === "true") { + return HttpResponse.json({ + conversationSteps: [ + { + conversationStep: [ + { + key: "input:initial", + value: "Hello", + timestamp: Date.now(), + originWorkflowId: null, + }, + { + key: "output:text", + value: "Hi there!", + timestamp: Date.now(), + originWorkflowId: "wf1", + }, + ], + }, + ], + conversationProperties: {}, + }); + } + return new HttpResponse(null, { status: 404 }); + }) + ); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId("memory-inspector")).toBeInTheDocument(); + }); + // Step 1 is expanded by default, shows groups + expect(screen.getByText("Input")).toBeInTheDocument(); + expect(screen.getByText("wf1")).toBeInTheDocument(); + expect(screen.getByText("input:initial")).toBeInTheDocument(); + expect(screen.getByText("output:text")).toBeInTheDocument(); + }); +}); diff --git a/src/components/debugger/__tests__/pipeline-trace.test.tsx b/src/components/debugger/__tests__/pipeline-trace.test.tsx index 4175b46e..8a9647eb 100644 --- a/src/components/debugger/__tests__/pipeline-trace.test.tsx +++ b/src/components/debugger/__tests__/pipeline-trace.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import { render } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -6,6 +6,9 @@ import { MemoryRouter } from "react-router-dom"; import { ThemeProvider } from "@/components/layout/theme-provider"; import { PipelineTrace } from "@/components/debugger/pipeline-trace"; import { useDebugStore, type PipelineEvent, type PipelineTurn } from "@/hooks/use-debug-events"; +import { server } from "@/test/mocks/server"; +import { http, HttpResponse } from "msw"; +import { userEvent } from "@/test/test-utils"; function renderTrace(conversationId: string | null = "conv-1") { const queryClient = new QueryClient({ @@ -50,54 +53,186 @@ describe("PipelineTrace", () => { }); }); - it("renders empty state when no events", async () => { + // ── Basic rendering ──────────────────────────────────────────────── + it("renders the pipeline-trace container", () => { renderTrace(); + expect(screen.getByTestId("pipeline-trace")).toBeInTheDocument(); + }); + + it("shows empty state message when no turns and no live events", async () => { + // No live turns, and audit returns empty for this conv + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json([]); + }) + ); + renderTrace("conv-empty"); await waitFor(() => { - expect(screen.getByTestId("pipeline-trace")).toBeInTheDocument(); + expect(screen.getByText(/Send a message to see the pipeline trace/)).toBeInTheDocument(); }); }); - it("renders task bars from completed turn", async () => { + it("renders task bars from live turn data", () => { useDebugStore.setState({ turns: [mockTurn] }); renderTrace(); - await waitFor(() => { - const taskBars = screen.getAllByTestId("task-bar"); - expect(taskBars.length).toBe(2); - }); + const taskBars = screen.getAllByTestId("task-bar"); + expect(taskBars.length).toBe(2); }); - it("task bars have aria-expanded attribute", async () => { + it("task bars show task type labels (stripped ai.labs. prefix)", () => { useDebugStore.setState({ turns: [mockTurn] }); renderTrace(); - await waitFor(() => { - const taskBars = screen.getAllByTestId("task-bar"); - expect(taskBars[0]).toHaveAttribute("aria-expanded", "false"); - }); + expect(screen.getByText("parser")).toBeInTheDocument(); + expect(screen.getByText("llm")).toBeInTheDocument(); + }); + + it("shows duration text for completed tasks", () => { + useDebugStore.setState({ turns: [mockTurn] }); + renderTrace(); + + expect(screen.getByText("42ms")).toBeInTheDocument(); + expect(screen.getByText("250ms")).toBeInTheDocument(); }); - it("shows turn selector when multiple turns", async () => { + it("shows total duration for the turn", () => { + useDebugStore.setState({ turns: [mockTurn] }); + renderTrace(); + + expect(screen.getByText("292ms")).toBeInTheDocument(); + }); + + it("task bars have aria-expanded=false by default", () => { + useDebugStore.setState({ turns: [mockTurn] }); + renderTrace(); + + const taskBars = screen.getAllByTestId("task-bar"); + expect(taskBars[0]).toHaveAttribute("aria-expanded", "false"); + }); + + // ── Task bar expansion ───────────────────────────────────────────── + it("expands task bar on click to show details", async () => { + useDebugStore.setState({ turns: [mockTurn] }); + renderTrace(); + + const user = userEvent.setup(); + const firstBar = screen.getAllByTestId("task-bar")[0]!; + await user.click(firstBar); + + expect(firstBar).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText(/Duration/)).toBeInTheDocument(); + }); + + it("collapses task bar on second click", async () => { + useDebugStore.setState({ turns: [mockTurn] }); + renderTrace(); + + const user = userEvent.setup(); + const firstBar = screen.getAllByTestId("task-bar")[0]!; + + await user.click(firstBar); + expect(firstBar).toHaveAttribute("aria-expanded", "true"); + + await user.click(firstBar); + expect(firstBar).toHaveAttribute("aria-expanded", "false"); + }); + + // ── Actions display ──────────────────────────────────────────────── + it("shows actions summary when events have actions", () => { + const eventsWithActions: PipelineEvent[] = [ + ...mockEvents.slice(0, 3), + { + type: "task_complete", taskId: "t2", taskType: "ai.labs.llm", index: 1, + durationMs: 250, timestamp: 1292, + actions: ["greet", "chat"], + }, + ]; + const turn: PipelineTurn = { + turnIndex: 0, + events: eventsWithActions, + totalDurationMs: 292, + startTime: 1000, + }; + useDebugStore.setState({ turns: [turn] }); + renderTrace(); + + expect(screen.getByText("greet")).toBeInTheDocument(); + expect(screen.getByText("chat")).toBeInTheDocument(); + }); + + it("shows actions in expanded task detail", async () => { + const eventsWithActions: PipelineEvent[] = [ + { type: "task_start", taskId: "t1", taskType: "ai.labs.llm", index: 0, timestamp: 1000 }, + { + type: "task_complete", taskId: "t1", taskType: "ai.labs.llm", index: 0, + durationMs: 250, timestamp: 1250, + actions: ["greet", "chat"], + }, + ]; + const turn: PipelineTurn = { turnIndex: 0, events: eventsWithActions, totalDurationMs: 250, startTime: 1000 }; + useDebugStore.setState({ turns: [turn] }); + renderTrace(); + + const user = userEvent.setup(); + await user.click(screen.getAllByTestId("task-bar")[0]!); + + // "Actions" appears in both the summary and the expanded detail + expect(screen.getAllByText(/Actions/).length).toBeGreaterThanOrEqual(2); + }); + + // ── Confidence display ───────────────────────────────────────────── + it("shows confidence percentage in expanded task detail", async () => { + const eventsWithConf: PipelineEvent[] = [ + { type: "task_start", taskId: "t1", taskType: "ai.labs.behavior", index: 0, timestamp: 1000 }, + { + type: "task_complete", taskId: "t1", taskType: "ai.labs.behavior", index: 0, + durationMs: 5, timestamp: 1005, + confidence: 0.85, actions: ["greet"], + }, + ]; + const turn: PipelineTurn = { turnIndex: 0, events: eventsWithConf, totalDurationMs: 5, startTime: 1000 }; + useDebugStore.setState({ turns: [turn] }); + renderTrace(); + + const user = userEvent.setup(); + await user.click(screen.getAllByTestId("task-bar")[0]!); + + expect(screen.getByText(/Confidence/)).toBeInTheDocument(); + expect(screen.getByText(/85%/)).toBeInTheDocument(); + }); + + // ── Turn selector ────────────────────────────────────────────────── + it("shows turn selector when multiple turns", () => { const turn2: PipelineTurn = { ...mockTurn, turnIndex: 1 }; useDebugStore.setState({ turns: [mockTurn, turn2] }); renderTrace(); - await waitFor(() => { - expect(screen.getByTestId("turn-selector")).toBeInTheDocument(); - }); + expect(screen.getByTestId("turn-selector")).toBeInTheDocument(); }); - it("hides turn selector with single turn", async () => { + it("hides turn selector with single turn", () => { useDebugStore.setState({ turns: [mockTurn] }); renderTrace(); - await waitFor(() => { - expect(screen.queryByTestId("turn-selector")).not.toBeInTheDocument(); + expect(screen.queryByTestId("turn-selector")).not.toBeInTheDocument(); + }); + + // ── Live events ──────────────────────────────────────────────────── + it("shows 'Processing...' text for live events", () => { + useDebugStore.setState({ + currentTurnEvents: [ + { type: "task_start", taskId: "t1", taskType: "ai.labs.llm", index: 0, timestamp: Date.now() }, + ], + currentTurnStart: Date.now(), }); + renderTrace(); + + expect(screen.getByText(/Processing/)).toBeInTheDocument(); }); - it("renders live processing indicator for in-progress events", async () => { + it("shows '...' for running task duration", () => { useDebugStore.setState({ currentTurnEvents: [ { type: "task_start", taskId: "t1", taskType: "ai.labs.parser", index: 0, timestamp: Date.now() }, @@ -106,8 +241,94 @@ describe("PipelineTrace", () => { }); renderTrace(); + expect(screen.getByText("...")).toBeInTheDocument(); + }); + + // ── Null conversationId ──────────────────────────────────────────── + it("renders with null conversationId", () => { + renderTrace(null); + expect(screen.getByTestId("pipeline-trace")).toBeInTheDocument(); + }); + + // ── Error state ──────────────────────────────────────────────────── + it("shows error state when audit fetch fails", async () => { + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json(null, { status: 500 }); + }) + ); + + renderTrace("conv-error"); + await waitFor(() => { - expect(screen.getByTestId("pipeline-trace")).toBeInTheDocument(); - }); + expect(screen.getByTestId("pipeline-trace-error")).toBeInTheDocument(); + expect(screen.getByText(/Failed to load pipeline trace/)).toBeInTheDocument(); + }, { timeout: 5000 }); + }); + + // ── Historical data from audit entries ───────────────────────────── + it("loads historical turns from audit entries when no live turns", async () => { + // Return audit entries so they get converted to historical turns + renderTrace("conv1"); + + await waitFor(() => { + // The mock audit data has 4 entries all with stepIndex: 0 + // This produces 1 turn with 4 tasks + const taskBars = screen.getAllByTestId("task-bar"); + expect(taskBars.length).toBe(4); + }, { timeout: 5000 }); + }); + + // ── Various task type labels ─────────────────────────────────────── + it("renders different task types with correct labels", () => { + const mixedEvents: PipelineEvent[] = [ + { type: "task_start", taskId: "t1", taskType: "ai.labs.httpcalls", index: 0, timestamp: 1000 }, + { type: "task_complete", taskId: "t1", taskType: "ai.labs.httpcalls", index: 0, durationMs: 100, timestamp: 1100 }, + { type: "task_start", taskId: "t2", taskType: "ai.labs.propertysetter", index: 1, timestamp: 1100 }, + { type: "task_complete", taskId: "t2", taskType: "ai.labs.propertysetter", index: 1, durationMs: 5, timestamp: 1105 }, + { type: "task_start", taskId: "t3", taskType: "ai.labs.output", index: 2, timestamp: 1105 }, + { type: "task_complete", taskId: "t3", taskType: "ai.labs.output", index: 2, durationMs: 3, timestamp: 1108 }, + ]; + const turn: PipelineTurn = { turnIndex: 0, events: mixedEvents, totalDurationMs: 108, startTime: 1000 }; + useDebugStore.setState({ turns: [turn] }); + renderTrace(); + + expect(screen.getByText("httpcalls")).toBeInTheDocument(); + expect(screen.getByText("propertysetter")).toBeInTheDocument(); + expect(screen.getByText("output")).toBeInTheDocument(); + }); + + // ── Task without durationMs uses timestamp diff ──────────────────── + it("falls back to timestamp diff when durationMs is undefined", () => { + const eventsNoDuration: PipelineEvent[] = [ + { type: "task_start", taskId: "t1", taskType: "ai.labs.parser", index: 0, timestamp: 1000 }, + { type: "task_complete", taskId: "t1", taskType: "ai.labs.parser", index: 0, timestamp: 1075 }, + ]; + const turn: PipelineTurn = { turnIndex: 0, events: eventsNoDuration, totalDurationMs: 75, startTime: 1000 }; + useDebugStore.setState({ turns: [turn] }); + renderTrace(); + + expect(screen.getAllByText("75ms").length).toBeGreaterThanOrEqual(1); + }); + + // ── De-duplication of actions ────────────────────────────────────── + it("de-duplicates actions across multiple events", () => { + const events: PipelineEvent[] = [ + { type: "task_start", taskId: "t1", taskType: "ai.labs.rules", index: 0, timestamp: 1000 }, + { type: "task_complete", taskId: "t1", taskType: "ai.labs.rules", index: 0, durationMs: 10, timestamp: 1010, actions: ["greet", "chat"] }, + { type: "task_start", taskId: "t2", taskType: "ai.labs.llm", index: 1, timestamp: 1010 }, + { type: "task_complete", taskId: "t2", taskType: "ai.labs.llm", index: 1, durationMs: 200, timestamp: 1210, actions: ["greet", "respond"] }, + ]; + const turn: PipelineTurn = { turnIndex: 0, events, totalDurationMs: 210, startTime: 1000 }; + useDebugStore.setState({ turns: [turn] }); + renderTrace(); + + // "greet" should appear once (deduplicated), "chat" and "respond" each once + const greetBadges = screen.getAllByText("greet"); + // In the actions summary, "greet" should appear once + // But may also appear in task-bar expanded view — find only in actions summary + expect(greetBadges.length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("chat")).toBeInTheDocument(); + expect(screen.getByText("respond")).toBeInTheDocument(); }); }); diff --git a/src/components/debugger/__tests__/prompt-viewer.test.tsx b/src/components/debugger/__tests__/prompt-viewer.test.tsx index a83bace2..35cbe922 100644 --- a/src/components/debugger/__tests__/prompt-viewer.test.tsx +++ b/src/components/debugger/__tests__/prompt-viewer.test.tsx @@ -1,12 +1,15 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import { render } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import { ThemeProvider } from "@/components/layout/theme-provider"; import { PromptViewer } from "@/components/debugger/prompt-viewer"; +import { server } from "@/test/mocks/server"; +import { http, HttpResponse } from "msw"; +import { userEvent } from "@/test/test-utils"; -function renderViewer(conversationId: string | null = "conv-1") { +function renderViewer(conversationId: string | null = "conv1") { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -25,119 +28,404 @@ function renderViewer(conversationId: string | null = "conv-1") { ); } +/** Build a proper audit entry with camelCase llmDetail keys */ +function makeLlmEntry(overrides: Record = {}) { + return { + id: "a1", + conversationId: "conv-test", + agentId: "agent1", + agentVersion: 1, + userId: "user-1", + environment: "production", + stepIndex: 0, + taskId: "ai.labs.llm", + taskType: "langchain", + taskIndex: 0, + durationMs: 300, + input: {}, + output: {}, + llmDetail: { + compiledPrompt: JSON.stringify([ + { role: "system", content: "You are a helpful assistant" }, + { role: "user", content: "Hello there" }, + ]), + modelResponse: "Hi! How can I help?", + modelName: "gpt-4", + tokenUsage: { + inputTokens: 42, + outputTokens: 12, + }, + }, + toolCalls: null, + actions: null, + cost: 0.003, + timestamp: new Date(Date.now() - 60000).toISOString(), + hmac: "h1", + agentSignature: "s1", + ...overrides, + }; +} + describe("PromptViewer", () => { beforeEach(() => { localStorage.clear(); + // Set default audit handler for all tests - returns standard LLM entry + server.use( + http.get("*/auditstore/:conversationId", ({ params }) => { + // Guard so /count doesn't match + if (params.conversationId === "count") return; + return HttpResponse.json([makeLlmEntry()]); + }) + ); }); + // ── Empty / error states ─────────────────────────────────────────── it("renders empty state when no conversationId", () => { renderViewer(null); expect(screen.getByText(/Start a conversation to inspect prompts/i)).toBeInTheDocument(); }); + it("shows error state when API returns error", async () => { + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json(null, { status: 500 }); + }) + ); + + renderViewer("conv-error"); + + await waitFor(() => { + expect(screen.getByTestId("prompt-viewer-error")).toBeInTheDocument(); + expect(screen.getByText(/Failed to load prompt data/i)).toBeInTheDocument(); + }, { timeout: 5000 }); + }); + + it("shows 'No LLM interactions' when audit has no llmDetail entries", async () => { + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json([ + { + id: "a1", conversationId: "conv-no-llm", agentId: "agent1", agentVersion: 1, + userId: "user-1", environment: "production", stepIndex: 0, + taskId: "ai.labs.parser", taskType: "expressions", taskIndex: 0, + durationMs: 10, input: {}, output: {}, + llmDetail: null, toolCalls: null, actions: null, cost: 0, + timestamp: new Date().toISOString(), + hmac: "abc", agentSignature: "sig", + }, + ]); + }) + ); + + renderViewer("conv-no-llm"); + + await waitFor(() => { + expect(screen.getByText(/No LLM interactions found/i)).toBeInTheDocument(); + }, { timeout: 5000 }); + }); + + // ── Data rendering ───────────────────────────────────────────────── it("renders prompt-viewer testid when LLM data is found", async () => { - renderViewer("conv-1"); - await waitFor( - () => { - expect(screen.getByTestId("prompt-viewer")).toBeInTheDocument(); - }, - { timeout: 5000 }, + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json([makeLlmEntry()]); + }) ); + + renderViewer("conv-llm"); + + await waitFor(() => { + expect(screen.getByTestId("prompt-viewer")).toBeInTheDocument(); + }, { timeout: 5000 }); }); it("displays step index number", async () => { - renderViewer("conv-1"); - await waitFor( - () => { - expect(screen.getByText(/Step/i)).toBeInTheDocument(); - }, - { timeout: 5000 }, + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json([makeLlmEntry()]); + }) ); + + renderViewer("conv-step"); + + await waitFor(() => { + expect(screen.getByText(/Step/i)).toBeInTheDocument(); + }, { timeout: 5000 }); }); it("displays task type badge", async () => { - renderViewer("conv-1"); - await waitFor( - () => { - // Audit entries have taskType "langchain" - expect(screen.getByText(/langchain/i)).toBeInTheDocument(); - }, - { timeout: 5000 }, + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json([makeLlmEntry()]); + }) ); + + renderViewer("conv-type"); + + await waitFor(() => { + expect(screen.getByText(/langchain/i)).toBeInTheDocument(); + }, { timeout: 5000 }); }); it("displays cost in the metrics strip", async () => { - renderViewer("conv-1"); - await waitFor( - () => { - expect(screen.getByText(/Cost/i)).toBeInTheDocument(); - }, - { timeout: 5000 }, + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json([makeLlmEntry()]); + }) ); + + renderViewer("conv-cost"); + + await waitFor(() => { + expect(screen.getByText(/Cost/i)).toBeInTheDocument(); + }, { timeout: 5000 }); }); - it("renders Copy Prompt button with testid", async () => { - renderViewer("conv-1"); - await waitFor( - () => { - expect(screen.getByTestId("copy-prompt")).toBeInTheDocument(); - expect(screen.getByText(/Copy Prompt/i)).toBeInTheDocument(); - }, - { timeout: 5000 }, + it("displays model name in metrics strip", async () => { + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json([makeLlmEntry()]); + }) ); + + renderViewer("conv-model"); + + await waitFor(() => { + expect(screen.getByText(/Model/i)).toBeInTheDocument(); + expect(screen.getByText(/gpt-4/i)).toBeInTheDocument(); + }, { timeout: 5000 }); }); - it("renders Replay This Turn button with testid", async () => { - renderViewer("conv-1"); - await waitFor( - () => { - expect(screen.getByTestId("replay-turn")).toBeInTheDocument(); - expect(screen.getByText(/Replay This Turn/i)).toBeInTheDocument(); - }, - { timeout: 5000 }, + it("displays token usage in metrics strip", async () => { + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json([makeLlmEntry()]); + }) ); + + renderViewer("conv-tokens"); + + await waitFor(() => { + expect(screen.getByText(/Tokens/i)).toBeInTheDocument(); + }, { timeout: 5000 }); }); - it("displays duration value in the step header", async () => { - renderViewer("conv-1"); - await waitFor( - () => { - // Duration is formatted as Xms or X.XXs - const viewer = screen.getByTestId("prompt-viewer"); - // Check for at least one span with duration-like content (ms or s) - expect(viewer.textContent).toMatch(/\d+(\.\d+)?[ms]/); - }, - { timeout: 5000 }, + // ── Action buttons ───────────────────────────────────────────────── + it("renders Copy Prompt button", async () => { + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json([makeLlmEntry()]); + }) ); + + renderViewer("conv-copy"); + + await waitFor(() => { + expect(screen.getByTestId("copy-prompt")).toBeInTheDocument(); + expect(screen.getByText(/Copy Prompt/i)).toBeInTheDocument(); + }, { timeout: 5000 }); }); - it("renders within a prompt-viewer container with correct structure", async () => { - renderViewer("conv-1"); - await waitFor( - () => { - const viewer = screen.getByTestId("prompt-viewer"); - // Should have the space-y-3 content area - expect(viewer.querySelector(".space-y-3")).toBeInTheDocument(); - // Should have action buttons area - expect(viewer.querySelector("[data-testid='copy-prompt']")).toBeInTheDocument(); - expect(viewer.querySelector("[data-testid='replay-turn']")).toBeInTheDocument(); - }, - { timeout: 5000 }, + it("renders Replay This Turn button", async () => { + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json([makeLlmEntry()]); + }) ); + + renderViewer("conv-replay"); + + await waitFor(() => { + expect(screen.getByTestId("replay-turn")).toBeInTheDocument(); + expect(screen.getByText(/Replay This Turn/i)).toBeInTheDocument(); + }, { timeout: 5000 }); }); - it("displays turn selector or single entry view", async () => { - renderViewer("conv-1"); - await waitFor( - () => { - const viewer = screen.getByTestId("prompt-viewer"); - // Either shows the turn selector (multiple LLM entries) or just shows content directly - const selector = viewer.querySelector("[data-testid='prompt-turn-selector']"); - const stepBadge = screen.getByText(/Step/i); - // One of these must be present - expect(selector || stepBadge).toBeTruthy(); - }, - { timeout: 5000 }, + // ── Message cards from JSON prompt ───────────────────────────────── + it("renders system and user message cards from JSON prompt", async () => { + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json([makeLlmEntry()]); + }) + ); + + renderViewer("conv-json"); + + await waitFor(() => { + expect(screen.getByText(/System Prompt/i)).toBeInTheDocument(); + expect(screen.getByText(/You are a helpful assistant/)).toBeInTheDocument(); + expect(screen.getByText(/Hello there/)).toBeInTheDocument(); + }, { timeout: 5000 }); + }); + + it("renders assistant response card", async () => { + renderViewer("conv-assistant"); + + await waitFor(() => { + // "Assistant" appears both as role label and in system prompt content + // so we use getAllByText and check at least 2 matches + const matches = screen.getAllByText(/Assistant/i); + expect(matches.length).toBeGreaterThanOrEqual(2); + }, { timeout: 5000 }); + }); + + // ── Text-based prompt parsing ────────────────────────────────────── + it("parses text-based prompts with role markers", async () => { + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json([ + makeLlmEntry({ + llmDetail: { + compiledPrompt: "System: You are helpful\nUser: What is 2+2?", + modelResponse: "4", + modelName: "gpt-4", + }, + }), + ]); + }) + ); + + renderViewer("conv-text"); + + await waitFor(() => { + expect(screen.getByText(/System Prompt/i)).toBeInTheDocument(); + expect(screen.getByText(/You are helpful/)).toBeInTheDocument(); + }, { timeout: 5000 }); + }); + + // ── Tool calls display ───────────────────────────────────────────── + it("displays tool calls section when present", async () => { + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json([ + makeLlmEntry({ + toolCalls: [ + { name: "search", arguments: { query: "test" } }, + ], + }), + ]); + }) + ); + + renderViewer("conv-tools"); + + await waitFor(() => { + expect(screen.getByText(/Tool Calls/i)).toBeInTheDocument(); + expect(screen.getByText(/search/)).toBeInTheDocument(); + }, { timeout: 5000 }); + }); + + // ── Multiple LLM entries with turn selector ──────────────────────── + it("shows turn selector when multiple LLM entries exist", async () => { + server.use( + http.get("*/auditstore/:conversationId", () => { + return HttpResponse.json([ + makeLlmEntry({ id: "a1", stepIndex: 0 }), + makeLlmEntry({ id: "a2", stepIndex: 1, timestamp: new Date(Date.now() - 30000).toISOString() }), + ]); + }) + ); + + renderViewer("conv-multi"); + + await waitFor(() => { + expect(screen.getByTestId("prompt-turn-selector")).toBeInTheDocument(); + }, { timeout: 5000 }); + }); + + // ── Copy button behavior ─────────────────────────────────────────── + it("copy button is clickable without errors", async () => { + renderViewer("conv-clipboard"); + + await waitFor(() => { + expect(screen.getByTestId("copy-prompt")).toBeInTheDocument(); + }, { timeout: 5000 }); + + // Click the copy button — it should not throw even if clipboard API is unavailable + // The component catches clipboard errors gracefully + const user = userEvent.setup(); + await user.click(screen.getByTestId("copy-prompt")); + + // The button text should still be "Copy Prompt" (or "Copied!" if clipboard worked) + expect(screen.getByTestId("copy-prompt")).toBeInTheDocument(); + }); + + // ── Duration display edge case ───────────────────────────────────── + it("hides duration badge when durationMs is 0", async () => { + server.use( + http.get("*/auditstore/:conversationId", ({ params }) => { + if (params.conversationId === "count") return; + return HttpResponse.json([makeLlmEntry({ durationMs: 0 })]); + }) + ); + + renderViewer("conv-zero-dur"); + + await waitFor(() => { + expect(screen.getByTestId("prompt-viewer")).toBeInTheDocument(); + }, { timeout: 5000 }); + + // When durationMs is 0, the duration span should not render + const viewer = screen.getByTestId("prompt-viewer"); + expect(viewer.textContent).not.toContain("300ms"); + }); + + // ── Cost display threshold ───────────────────────────────────────── + it("hides cost when cost is 0", async () => { + server.use( + http.get("*/auditstore/:conversationId", ({ params }) => { + if (params.conversationId === "count") return; + return HttpResponse.json([makeLlmEntry({ cost: 0 })]); + }) + ); + + renderViewer("conv-no-cost"); + + await waitFor(() => { + expect(screen.getByTestId("prompt-viewer")).toBeInTheDocument(); + }, { timeout: 5000 }); + + expect(screen.queryByText(/\bCost\b/)).not.toBeInTheDocument(); + }); + + it("shows cost with 4 decimal places for small amounts", async () => { + server.use( + http.get("*/auditstore/:conversationId", ({ params }) => { + if (params.conversationId === "count") return; + return HttpResponse.json([makeLlmEntry({ cost: 0.0042 })]); + }) ); + + renderViewer("conv-small-cost"); + + await waitFor(() => { + expect(screen.getByText(/0\.0042/)).toBeInTheDocument(); + }, { timeout: 5000 }); + }); + + // ── No compiled prompt fallback ──────────────────────────────────── + it("handles llmDetail without compiledPrompt gracefully", async () => { + server.use( + http.get("*/auditstore/:conversationId", ({ params }) => { + if (params.conversationId === "count") return; + return HttpResponse.json([ + makeLlmEntry({ + llmDetail: { + modelName: "gpt-4", + modelResponse: "Hello!", + }, + }), + ]); + }) + ); + + renderViewer("conv-no-prompt"); + + await waitFor(() => { + expect(screen.getByTestId("prompt-viewer")).toBeInTheDocument(); + // Assistant card label is visible (content is collapsed by default) + expect(screen.getByText(/Assistant/i)).toBeInTheDocument(); + }, { timeout: 5000 }); }); }); + diff --git a/src/components/editors/__tests__/add-extension-dialog.test.tsx b/src/components/editors/__tests__/add-extension-dialog.test.tsx new file mode 100644 index 00000000..7f8a5eb0 --- /dev/null +++ b/src/components/editors/__tests__/add-extension-dialog.test.tsx @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { AddExtensionDialog } from "@/components/editors/add-extension-dialog"; +import type { ExtensionDescriptor } from "@/lib/api/extensions"; + +const mockExtensionTypes: ExtensionDescriptor[] = [ + { + type: "eddi://ai.labs.parser", + displayName: "Input Parser", + configs: {}, + extensions: {}, + }, + { + type: "eddi://ai.labs.llm", + displayName: "LLM Configuration", + configs: {}, + extensions: {}, + }, + { + type: "eddi://ai.labs.rules", + displayName: "Rules", + configs: {}, + extensions: {}, + }, +]; + +// Mock the extension types hook +vi.mock("@/hooks/use-extensions-store", () => ({ + useExtensionTypes: () => ({ + data: mockExtensionTypes, + isLoading: false, + }), +})); + +// Mock resource APIs +vi.mock("@/lib/api/resources", async () => { + const actual = await vi.importActual("@/lib/api/resources"); + return { + ...actual, + getResourceDescriptors: vi.fn().mockResolvedValue([]), + createResource: vi.fn().mockResolvedValue({ + location: "http://localhost/rulestore/rulesets/new-123?version=1", + }), + }; +}); + +vi.mock("@/lib/api/agents", () => ({ + parseResourceUri: vi.fn((uri: string) => ({ + id: "new-123", + version: 1, + })), +})); + +describe("AddExtensionDialog", () => { + const onClose = vi.fn(); + const onSelect = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null when not open", () => { + renderWithProviders( + + ); + expect( + screen.queryByTestId("add-extension-dialog") + ).not.toBeInTheDocument(); + }); + + it("renders dialog when open", () => { + renderWithProviders( + + ); + expect( + screen.getByTestId("add-extension-dialog") + ).toBeInTheDocument(); + }); + + it("shows Add Task title", () => { + renderWithProviders( + + ); + expect(screen.getByText("Add Task")).toBeInTheDocument(); + }); + + it("shows search input", () => { + renderWithProviders( + + ); + expect(screen.getByTestId("extension-search")).toBeInTheDocument(); + }); + + it("displays extension types", () => { + renderWithProviders( + + ); + expect( + screen.getByTestId("ext-option-eddi://ai.labs.parser") + ).toBeInTheDocument(); + expect( + screen.getByTestId("ext-option-eddi://ai.labs.llm") + ).toBeInTheDocument(); + expect( + screen.getByTestId("ext-option-eddi://ai.labs.rules") + ).toBeInTheDocument(); + }); + + it("shows footer hint text", () => { + renderWithProviders( + + ); + expect( + screen.getByText("Select a task type to add to the pipeline") + ).toBeInTheDocument(); + }); + + it("calls onClose when close button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + const closeBtn = screen.getByLabelText("Close"); + await user.click(closeBtn); + expect(onClose).toHaveBeenCalled(); + }); + + it("selects parser (no resource store) directly", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + await user.click( + screen.getByTestId("ext-option-eddi://ai.labs.parser") + ); + // Parser has no resource store, so it should call onSelect directly + expect(onSelect).toHaveBeenCalledWith({ + descriptor: expect.objectContaining({ type: "eddi://ai.labs.parser" }), + }); + expect(onClose).toHaveBeenCalled(); + }); + + it("shows step 2 when selecting LLM (has resource store)", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + await user.click( + screen.getByTestId("ext-option-eddi://ai.labs.llm") + ); + // Should go to step 2 — "Choose Config" title + await waitFor(() => { + expect(screen.getByText("Choose Config")).toBeInTheDocument(); + }); + expect(screen.getByTestId("create-new-config")).toBeInTheDocument(); + expect(screen.getByTestId("back-to-types")).toBeInTheDocument(); + }); + + it("goes back to step 1 when back button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + await user.click( + screen.getByTestId("ext-option-eddi://ai.labs.llm") + ); + await waitFor(() => { + expect(screen.getByText("Choose Config")).toBeInTheDocument(); + }); + await user.click(screen.getByTestId("back-to-types")); + await waitFor(() => { + expect(screen.getByText("Add Task")).toBeInTheDocument(); + }); + }); + + it("closes on Escape key", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + await user.keyboard("{Escape}"); + expect(onClose).toHaveBeenCalled(); + }); + + it("filters extension types by search", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + await user.click(screen.getByTestId("extension-search")); + await user.type(screen.getByTestId("extension-search"), "llm"); + // LLM should still be visible, parser and rules should not + expect( + screen.getByTestId("ext-option-eddi://ai.labs.llm") + ).toBeInTheDocument(); + expect( + screen.queryByTestId("ext-option-eddi://ai.labs.parser") + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/editors/__tests__/apicalls-editor.test.tsx b/src/components/editors/__tests__/apicalls-editor.test.tsx new file mode 100644 index 00000000..5bc6d978 --- /dev/null +++ b/src/components/editors/__tests__/apicalls-editor.test.tsx @@ -0,0 +1,908 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, within } from "@testing-library/react"; +import { renderWithProviders, userEvent } from "@/test/test-utils"; +import { + ApiCallsEditor, + PropertyInstructionsEditor, + OutputBuildInstructionsEditor, + QrBuildInstructionsEditor, + type HttpCallsConfig, + type PropertyInstruction, + type OutputBuildingInstruction, + type QuickRepliesBuildingInstruction, +} from "@/components/editors/apicalls-editor"; + +// Mock monaco +vi.mock("@monaco-editor/react", () => ({ + default: vi.fn(({ value }: { value: string }) => ( +