From 70a364ebdfbf86efe0c1a0c7a092998a178ac78d Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Sat, 20 Dec 2025 07:33:02 -0700 Subject: [PATCH 01/23] Update e2e url --- cypress/cypress.e2e.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/cypress.e2e.config.ts b/cypress/cypress.e2e.config.ts index 7539dbc06..95afe30b7 100644 --- a/cypress/cypress.e2e.config.ts +++ b/cypress/cypress.e2e.config.ts @@ -35,7 +35,7 @@ export default defineConfig({ fixturesFolder: `${PROJECT_ROOT}/src/e2e/fixtures`, setupNodeEvents () { }, - baseUrl: 'https://5bma74uv9c.execute-api.us-east-1.amazonaws.com/dev', + baseUrl: 'https://chat.dev.lisa.aiml-adc.aws.dev', env: { TEST_ACCOUNT_PASSWORD: process.env.TEST_ACCOUNT_PASSWORD, }, From 059370dac7bfb697344e1624f26b9e2ac8adeb37 Mon Sep 17 00:00:00 2001 From: bedanley Date: Mon, 5 Jan 2026 11:23:04 -0700 Subject: [PATCH 02/23] fix session history --- .../components/adapters/lisa-chat-history.ts | 1 + .../react/src/components/chatbot/Chat.tsx | 2 + .../components/DocumentSummarizationModal.tsx | 13 ++- .../components/chatbot/components/Message.tsx | 18 +-- .../chatbot/components/RagOptions.tsx | 2 + .../components/SessionConfiguration.tsx | 31 ++---- .../chatbot/components/Sessions.tsx | 23 ++-- .../chatbot/hooks/useSession.hooks.tsx | 4 +- .../McpWorkbenchManagementComponent.tsx | 4 +- .../components/ModelComparisonComponents.tsx | 1 + .../create-model/BaseModelConfig.tsx | 2 +- .../PromptTemplateForm.tsx | 103 +++++++++++------- .../PromptTemplateModal.tsx | 1 + .../KnowledgeBaseSelector.tsx | 1 + .../src/shared/model/configuration.model.ts | 4 +- .../src/shared/reducers/session.reducer.ts | 72 ++++++------ .../react/src/shared/util/chat-memory.ts | 13 ++- 17 files changed, 167 insertions(+), 128 deletions(-) diff --git a/lib/user-interface/react/src/components/adapters/lisa-chat-history.ts b/lib/user-interface/react/src/components/adapters/lisa-chat-history.ts index 28b1e0e5c..8a2fb41e7 100644 --- a/lib/user-interface/react/src/components/adapters/lisa-chat-history.ts +++ b/lib/user-interface/react/src/components/adapters/lisa-chat-history.ts @@ -44,6 +44,7 @@ export class LisaChatMessageHistory extends BaseChatMessageHistory { void message; // noop since messages are managed at the session level } + async addAIChatMessage (message: string): Promise { void message; // noop since messages are managed at the session level diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx index 08cc9058f..1b3b58bee 100644 --- a/lib/user-interface/react/src/components/chatbot/Chat.tsx +++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx @@ -785,6 +785,7 @@ export default function Chat ({ sessionId }) { onChange={({ detail: { value } }) => handleUserModelChange(value)} options={modelsOptions} ref={modelSelectRef} + controlId='model-selection-autosuggest' /> {window.env.RAG_ENABLED && !isImageGenerationMode && ( @@ -812,6 +813,7 @@ export default function Chat ({ sessionId }) { onChange={({ detail }) => setUserPrompt(detail.value)} onAction={handleAction} onKeyDown={handleKeyPress} + controlId='chat-prompt-input' secondaryActions={ : null} - - - setCreateNewChatSession(detail.checked) - } /> + + setCreateNewChatSession(detail.checked)} + controlId='create-new-chat-session-toggle' + /> diff --git a/lib/user-interface/react/src/components/chatbot/components/Message.tsx b/lib/user-interface/react/src/components/chatbot/components/Message.tsx index f23084d84..a8aa4d5e7 100644 --- a/lib/user-interface/react/src/components/chatbot/components/Message.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/Message.tsx @@ -216,12 +216,12 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami }, }), [isStreaming, onMermaidRenderComplete]); // Include isStreaming and onMermaidRenderComplete so the component can access them - const renderContent = (messageType: string, content: MessageContent, metadata?: LisaChatMessageMetadata) => { + const renderContent = (content: MessageContent, metadata?: LisaChatMessageMetadata) => { if (Array.isArray(content)) { - return content.map((item, index) => { - if (item.type === 'text') { - return item.text.startsWith('File context:') ? <> :
{getDisplayableMessage(item.text, message.type === MessageTypes.AI ? ragCitations : undefined)}
; - } else if (item.type === 'image_url') { + return content.map((item: any, index) => { + if (item.type === 'text' && typeof item.text === 'string') { + return item.text.startsWith('File context:') ? null :
{getDisplayableMessage(item.text, message.type === MessageTypes.AI ? ragCitations : undefined)}
; + } else if (item.type === 'image_url' && item.image_url?.url) { return message.type === MessageTypes.HUMAN ? User provided : @@ -349,7 +349,7 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami } actions={showUsage ? : undefined} > - {renderContent(message.type, message.content, message.metadata)} + {renderContent(message.content, message.metadata)} {showMetadata && !isStreaming &&
- {renderContent(message.type, message.content)} + {renderContent(message.content)}
)} {message?.type === MessageTypes.TOOL && ( - + diff --git a/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx b/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx index 47a46d5fc..c1db76ef5 100644 --- a/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx @@ -234,6 +234,7 @@ export default function RagControls ({ isRunning, setUseRag, setRagConfig, ragCo value: repository.repositoryId, label: repository?.repositoryName?.length ? repository?.repositoryName : repository.repositoryId })) || []} + controlId='rag-repository-autosuggest' /> `Use: "${text}"`} onChange={handleCollectionChange} options={collectionOptions} + controlId='rag-collection-autosuggest' />
diff --git a/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx b/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx index 2bbb73dd4..4586b31c9 100644 --- a/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx @@ -16,7 +16,6 @@ import { AttributeEditor, - Box, Container, FormField, Grid, @@ -105,7 +104,7 @@ export const SessionConfiguration = ({ size='large' > - + updateSessionConfiguration('streaming', detail.checked)} checked={chatConfiguration.sessionConfiguration.streaming} @@ -128,11 +127,7 @@ export const SessionConfiguration = ({ Show Message Metadata } {systemConfig && systemConfig.configuration.enabledComponents.editChatHistoryBuffer && !isImageModel && !modelOnly && - - - - + updateSessionConfiguration('ragTopK', parseInt(detail.selectedOption.value))} options={oneThroughTenOptions} /> - } +
} {systemConfig && systemConfig.configuration.enabledComponents.editKwargs && !isImageModel && - {!modelOnly && - + {!modelOnly && + +
+ Stop +
{ @@ -391,8 +385,7 @@ export const SessionConfiguration = ({ ]} empty='No stop sequences provided.' /> -
-
+ } {!modelOnly && {searchQuery && ( @@ -239,13 +239,13 @@ export function Sessions ({ newSession }) { iconName='search' variant='inline-link' ariaLabel='Search sessions' - > + /> + + ) +})); + +const mockAuth = { + user: { + profile: { + sub: 'test-user-id' + } + } +}; + +const createMockStore = () => configureStore({ + reducer: { + // Minimal store for testing + test: (state = {}) => state, + }, +}); + +describe('Chatbot', () => { + const mockSetNav = vi.fn(); + + const renderWithRouter = (initialEntry: string) => { + return render( + + + + } /> + } /> + + + + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + (useAuth as any).mockReturnValue(mockAuth); + }); + + it('renders Chat component with correct sessionId from URL', () => { + renderWithRouter('/ai-assistant/test-session-123'); + + const chatComponent = screen.getByTestId('chat-component'); + expect(chatComponent).toHaveAttribute('data-session-id', 'test-session-123'); + }); + + it('renders Chat component without sessionId for new session', () => { + renderWithRouter('/ai-assistant'); + + const chatComponent = screen.getByTestId('chat-component'); + expect(chatComponent).toHaveAttribute('data-session-id', ''); + }); + + it('re-renders Chat component when navigating between sessions', () => { + // Test with sessionId first + const { unmount } = renderWithRouter('/ai-assistant/existing-session'); + + let chatComponent = screen.getByTestId('chat-component'); + expect(chatComponent).toHaveAttribute('data-session-id', 'existing-session'); + + // Unmount and render with different route + unmount(); + renderWithRouter('/ai-assistant'); + + // Component should render with empty sessionId + chatComponent = screen.getByTestId('chat-component'); + expect(chatComponent).toHaveAttribute('data-session-id', ''); + }); + + it('calls setNav with Sessions component', () => { + renderWithRouter('/ai-assistant'); + + // The component may call setNav multiple times due to useEffect dependencies + // What matters is that it was called at least once with the Sessions component + expect(mockSetNav).toHaveBeenCalled(); + expect(mockSetNav.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify that setNav was called with a React element + const setNavCall = mockSetNav.mock.calls[0][0]; + expect(setNavCall).toBeDefined(); + expect(setNavCall.type).toBeDefined(); // Should be a React component + }); +}); diff --git a/lib/user-interface/react/src/pages/Chatbot.tsx b/lib/user-interface/react/src/pages/Chatbot.tsx index 52d010a75..52a8251c4 100644 --- a/lib/user-interface/react/src/pages/Chatbot.tsx +++ b/lib/user-interface/react/src/pages/Chatbot.tsx @@ -14,22 +14,41 @@ limitations under the License. */ -import { useParams } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router-dom'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import Chat from '../components/chatbot/Chat'; import Sessions from '../components/chatbot/components/Sessions'; +import { useAppDispatch } from '@/config/store'; +import { sessionApi } from '@/shared/reducers/session.reducer'; export function Chatbot ({ setNav }) { const { sessionId } = useParams(); - const [key, setKey] = useState(new Date().toISOString()); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const [key, setKey] = useState(() => new Date().toISOString()); + const handleNewSession = useCallback(() => { + // Clear specific cached session data that might interfere with new session creation + if (sessionId) { + dispatch(sessionApi.util.invalidateTags([{ type: 'session', id: sessionId }])); + } + + // Navigate first, then let the route change trigger the key update + navigate('/ai-assistant', { replace: true }); + }, [navigate, dispatch, sessionId]); + + // Update key when sessionId changes to undefined (new session) useEffect(() => { - setNav( { + if (sessionId === undefined) { setKey(new Date().toISOString()); - }} />); - }, [setNav]); + } + }, [sessionId]); + + useEffect(() => { + setNav(); + }, [setNav, handleNewSession]); return ; } diff --git a/lib/user-interface/react/src/shared/form/TagsInput.tsx b/lib/user-interface/react/src/shared/form/TagsInput.tsx index 11f86268d..5f0495072 100644 --- a/lib/user-interface/react/src/shared/form/TagsInput.tsx +++ b/lib/user-interface/react/src/shared/form/TagsInput.tsx @@ -58,7 +58,11 @@ export function TagsInput (props: TagsInputProps): ReactElement { label: tag, dismissLabel: `Remove ${tag}` }))} - onDismiss={disabled ? undefined : ({ detail: { itemIndex } }) => handleRemove(itemIndex)} + onDismiss={({ detail: { itemIndex } }) => { + if (!disabled) { + handleRemove(itemIndex); + } + }} /> { - const oidcString = sessionStorage.getItem(`oidc.user:${window.env.AUTHORITY}:${window.env.CLIENT_ID}`); + const oidcString = sessionStorage.getItem(`oidc.user:${OidcConfig.authority}:${OidcConfig.client_id}`); const token = oidcString ? JSON.parse(oidcString).id_token : ''; if (config.headers === undefined) { diff --git a/lib/user-interface/react/src/shared/reducers/session.reducer.ts b/lib/user-interface/react/src/shared/reducers/session.reducer.ts index 247fa84bd..88e576ee1 100644 --- a/lib/user-interface/react/src/shared/reducers/session.reducer.ts +++ b/lib/user-interface/react/src/shared/reducers/session.reducer.ts @@ -44,7 +44,7 @@ const extractErrorMessage = (baseQueryReturnValue: any): string => { export const sessionApi = createApi({ reducerPath: 'sessions', baseQuery: lisaBaseQuery(), - tagTypes: ['sessions'], + tagTypes: ['sessions', 'session'], refetchOnFocus: true, refetchOnMountOrArgChange: true, endpoints: (builder) => ({ @@ -52,6 +52,10 @@ export const sessionApi = createApi({ query: (sessionId: string) => ({ url: `/session/${sessionId}` }), + // Provide specific tags for individual sessions + providesTags: (result, error, sessionId) => [ + { type: 'session', id: sessionId } + ], }), getSessionHealth: builder.query({ query: () => ({ @@ -62,7 +66,8 @@ export const sessionApi = createApi({ query: () => ({ url: '/session' }), - providesTags:['sessions'], + // Simple tag for session list + providesTags: ['sessions'], }), updateSession: builder.mutation({ query: (session) => ({ @@ -88,7 +93,11 @@ export const sessionApi = createApi({ name: 'Update Session Error', message: extractErrorMessage(baseQueryReturnValue) }), - invalidatesTags: ['sessions'], + // Invalidate session list (for updated metadata) and specific session details + invalidatesTags: (result, error, session) => [ + 'sessions', + { type: 'session', id: session.sessionId } + ], }), updateSessionName: builder.mutation({ query: (session) => ({ @@ -98,7 +107,11 @@ export const sessionApi = createApi({ name: session.name } }), - invalidatesTags: ['sessions'], + // Only invalidate the specific session and session list + invalidatesTags: (result, error, { sessionId }) => [ + 'sessions', + { type: 'session', id: sessionId } + ], transformErrorResponse: (baseQueryReturnValue) => ({ name: 'Rename Session Error', message: extractErrorMessage(baseQueryReturnValue) @@ -116,7 +129,10 @@ export const sessionApi = createApi({ name: 'Attach Image to Session Error', message: extractErrorMessage(baseQueryReturnValue) }), - invalidatesTags: ['sessions'], + // Only invalidate the specific session + invalidatesTags: (result, error, { sessionId }) => [ + { type: 'session', id: sessionId } + ], }), deleteSessionById: builder.mutation({ query: (sessionId: string) => ({ @@ -127,7 +143,11 @@ export const sessionApi = createApi({ name: 'Delete Session Error', message: extractErrorMessage(baseQueryReturnValue) }), - invalidatesTags: ['sessions'], + // Invalidate session list and the specific session + invalidatesTags: (result, error, sessionId) => [ + 'sessions', + { type: 'session', id: sessionId } + ], }), deleteAllSessionsForUser: builder.mutation({ query: () => ({ @@ -138,7 +158,8 @@ export const sessionApi = createApi({ name: 'Delete All Sessions Error', message: extractErrorMessage(baseQueryReturnValue) }), - invalidatesTags: ['sessions'], + // Invalidate everything when deleting all sessions + invalidatesTags: ['sessions', 'session'], }), }), }); From 8b9e035e4925706e628f028ccaac2ba861ef783c Mon Sep 17 00:00:00 2001 From: bedanley Date: Thu, 8 Jan 2026 09:42:57 -0700 Subject: [PATCH 09/23] Add Tailwind CSS overrides and GFM markdown (#657) --- lib/user-interface/react/package.json | 2 +- .../components/chatbot/components/Message.tsx | 23 +- lib/user-interface/react/src/index.css | 46 +++ .../src/shared/constants/systemPrompt.ts | 38 +-- package-lock.json | 276 ++++++++++++++++-- 5 files changed, 334 insertions(+), 51 deletions(-) diff --git a/lib/user-interface/react/package.json b/lib/user-interface/react/package.json index c2de3e411..cf55d28d0 100644 --- a/lib/user-interface/react/package.json +++ b/lib/user-interface/react/package.json @@ -55,7 +55,7 @@ "redux-persist": "^6.0.0", "regenerator-runtime": "^0.14.1", "rehype-katex": "^7.0.1", - "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "tailwindcss": "^4.1.18", "tinyglobby": "^0.2.15", diff --git a/lib/user-interface/react/src/components/chatbot/components/Message.tsx b/lib/user-interface/react/src/components/chatbot/components/Message.tsx index a8aa4d5e7..32d1839be 100644 --- a/lib/user-interface/react/src/components/chatbot/components/Message.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/Message.tsx @@ -28,8 +28,8 @@ import { selectCurrentUsername } from '@/shared/reducers/user.reducer'; import ChatBubble from '@cloudscape-design/chat-components/chat-bubble'; import Avatar from '@cloudscape-design/chat-components/avatar'; -import remarkBreaks from 'remark-breaks'; import remarkMath from 'remark-math'; +import remarkGfm from 'remark-gfm'; import rehypeKatex from 'rehype-katex'; import 'katex/dist/katex.min.css'; @@ -220,7 +220,24 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami if (Array.isArray(content)) { return content.map((item: any, index) => { if (item.type === 'text' && typeof item.text === 'string') { - return item.text.startsWith('File context:') ? null :
{getDisplayableMessage(item.text, message.type === MessageTypes.AI ? ragCitations : undefined)}
; + if (item.text.startsWith('File context:')) return null; + + const displayableText = getDisplayableMessage(item.text, message.type === MessageTypes.AI ? ragCitations : undefined); + + return ( +
+ {markdownDisplay ? ( + + ) : ( +
{displayableText}
+ )} +
+ ); } else if (item.type === 'image_url' && item.image_url?.url) { return message.type === MessageTypes.HUMAN ? User provided : @@ -278,7 +295,7 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami
{markdownDisplay ? ( Date: Thu, 8 Jan 2026 11:22:59 -0700 Subject: [PATCH 10/23] Bug/markdown css module (#663) * Scope message css --- .../chatbot/components/Message.module.css | 48 ++++++++++++++++++ .../components/chatbot/components/Message.tsx | 7 +-- lib/user-interface/react/src/index.css | 49 ------------------- 3 files changed, 52 insertions(+), 52 deletions(-) create mode 100644 lib/user-interface/react/src/components/chatbot/components/Message.module.css diff --git a/lib/user-interface/react/src/components/chatbot/components/Message.module.css b/lib/user-interface/react/src/components/chatbot/components/Message.module.css new file mode 100644 index 000000000..2cdf65f41 --- /dev/null +++ b/lib/user-interface/react/src/components/chatbot/components/Message.module.css @@ -0,0 +1,48 @@ +/* Scoped styles for Message component markdown content */ + +/* First child margin reset */ +.messageContent div :first-child { + margin-top: 0; +} + +/* List styling */ +.messageContent ol { + list-style-type: decimal; + padding-left: 1.5rem; + margin-bottom: 1rem; +} + +.messageContent ul { + list-style-type: disc; + padding-left: 1.5rem; + margin-bottom: 1rem; +} + +.messageContent li { + margin-bottom: 0.5rem; + line-height: 1.5; +} + +/* Paragraph styling */ +.messageContent p { + margin-bottom: 1rem; + line-height: 1.6; +} + +/* Table styling */ +.messageContent table { + border-collapse: collapse; + margin-bottom: 1rem; + width: 100%; +} + +.messageContent th, +.messageContent td { + border: 1px solid #d1d5db; + padding: 0.5rem 0.75rem; + text-align: left; +} + +.messageContent tbody tr:nth-child(even) { + background-color: #f9fafb; +} diff --git a/lib/user-interface/react/src/components/chatbot/components/Message.tsx b/lib/user-interface/react/src/components/chatbot/components/Message.tsx index 32d1839be..b82feb072 100644 --- a/lib/user-interface/react/src/components/chatbot/components/Message.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/Message.tsx @@ -32,6 +32,7 @@ import remarkMath from 'remark-math'; import remarkGfm from 'remark-gfm'; import rehypeKatex from 'rehype-katex'; import 'katex/dist/katex.min.css'; +import styles from './Message.module.css'; import { MessageContent } from '@langchain/core/messages'; import { base64ToBlob, fetchImage, getDisplayableMessage, messageContainsImage } from '@/components/utils'; @@ -225,7 +226,7 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami const displayableText = getDisplayableMessage(item.text, message.type === MessageTypes.AI ? ragCitations : undefined); return ( -
+
{markdownDisplay ? ( +
{markdownDisplay ? ( } > -
+
{renderContent(message.content)}
diff --git a/lib/user-interface/react/src/index.css b/lib/user-interface/react/src/index.css index 1432039ec..83e6cd73b 100644 --- a/lib/user-interface/react/src/index.css +++ b/lib/user-interface/react/src/index.css @@ -1,51 +1,2 @@ @import "tailwindcss"; @import "katex/dist/katex.min.css"; - -div :first-child { - margin-top: 0; -} - -/* Restore list styling after Tailwind reset */ -ol { - list-style-type: decimal; - padding-left: 1.5rem; - margin-bottom: 1rem; -} - -ul { - list-style-type: disc; - padding-left: 1.5rem; - margin-bottom: 1rem; -} - -li { - margin-bottom: 0.5rem; - line-height: 1.5; -} - -p { - margin-bottom: 1rem; - line-height: 1.6; -} - -/* Restore table styling after Tailwind reset */ -table { - border-collapse: collapse; - margin-bottom: 1rem; - width: 100%; -} - -th, td { - border: 1px solid #d1d5db; - padding: 0.5rem 0.75rem; - text-align: left; -} - -th { - background-color: #f9fafb; - font-weight: 600; -} - -tbody tr:nth-child(even) { - background-color: #f9fafb; -} From 63f270e8cb1cb1260866caf9e6194cd101e97fa3 Mon Sep 17 00:00:00 2001 From: Joseph Harold <121983012+jmharold@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:36:21 -0700 Subject: [PATCH 11/23] API token user metrics --- Makefile | 2 +- lambda/authorizer/lambda_functions.py | 6 +- lambda/session/lambda_functions.py | 13 +- lib/metrics/metricsConstruct.ts | 119 +++++++++------ .../api/endpoints/v2/litellm_passthrough.py | 10 ++ lib/serve/rest-api/src/auth.py | 37 +++++ lib/serve/rest-api/src/utils/metrics.py | 138 ++++++++++++++++++ lib/serve/serveApplicationConstruct.ts | 27 ++++ lib/stages.ts | 36 +++-- package-lock.json | 65 ++------- 10 files changed, 331 insertions(+), 122 deletions(-) create mode 100644 lib/serve/rest-api/src/utils/metrics.py diff --git a/Makefile b/Makefile index 3c18123bf..ea53341d1 100644 --- a/Makefile +++ b/Makefile @@ -277,7 +277,7 @@ define print_config endef ## Deploy all infrastructure -deploy: installPythonRequirements dockerCheck dockerLogin cleanMisc modelCheck buildNpmModules +deploy: install dockerCheck dockerLogin cleanMisc modelCheck buildNpmModules $(call print_config) ifeq ($(HEADLESS),true) npx cdk deploy ${STACK} $(if $(PROFILE),--profile ${PROFILE}) --require-approval never -c ${ENV}='$(shell echo '${${ENV}}')'; diff --git a/lambda/authorizer/lambda_functions.py b/lambda/authorizer/lambda_functions.py index db6d3a667..243048a99 100644 --- a/lambda/authorizer/lambda_functions.py +++ b/lambda/authorizer/lambda_functions.py @@ -66,7 +66,7 @@ def lambda_handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: i # Add management token to Admin groups groups = json.dumps([admin_group]) allow_policy = generate_policy(effect="Allow", resource=event["methodArn"], username=username) - allow_policy["context"] = {"username": username, "groups": groups} + allow_policy["context"] = {"username": username, "groups": groups, "authType": "management"} logger.debug(f"Generated policy: {allow_policy}") return allow_policy @@ -78,7 +78,7 @@ def lambda_handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: i groups = json.dumps(token_info.get("groups", [])) allow_policy = generate_policy(effect="Allow", resource=event["methodArn"], username=username) - allow_policy["context"] = {"username": username, "groups": groups} + allow_policy["context"] = {"username": username, "groups": groups, "authType": "api_token"} logger.debug(f"Generated policy: {allow_policy}") return allow_policy @@ -88,7 +88,7 @@ def lambda_handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: i groups = json.dumps(get_property_path(jwt_data, jwt_groups_property) or []) username = find_jwt_username(jwt_data) allow_policy = generate_policy(effect="Allow", resource=event["methodArn"], username=username) - allow_policy["context"] = {"username": username, "groups": groups} + allow_policy["context"] = {"username": username, "groups": groups, "authType": "jwt"} if not is_in_user_group: return deny_policy diff --git a/lambda/session/lambda_functions.py b/lambda/session/lambda_functions.py index b331072f1..9d25d5a46 100644 --- a/lambda/session/lambda_functions.py +++ b/lambda/session/lambda_functions.py @@ -570,9 +570,16 @@ def put_session(event: dict, context: dict) -> dict: ReturnValues="UPDATED_NEW", ) - # Publish event to SQS queue for metrics processing (use unencrypted data for metrics) + # Publish metrics to SQS queue for non-API-token users + # API token users have their metrics tracked in litellm_passthrough.py try: - if "USAGE_METRICS_QUEUE_NAME" in os.environ: + # Get auth type from authorizer context + request_context = event.get("requestContext", {}) + authorizer_context = request_context.get("authorizer", {}) + auth_type = authorizer_context.get("authType", "jwt") # Default to jwt for backwards compatibility + + # Only publish metrics for non-API-token users (JWT/UI users) + if auth_type != "api_token" and "USAGE_METRICS_QUEUE_NAME" in os.environ: # Create a copy of the event to send to SQS metrics_event = { "userId": user_id, @@ -585,7 +592,7 @@ def put_session(event: dict, context: dict) -> dict: QueueUrl=os.environ["USAGE_METRICS_QUEUE_NAME"], MessageBody=json.dumps(convert_decimal(metrics_event)), ) - logger.info(f"Published event to metrics queue for user {user_id}") + logger.info(f"Published metrics event to queue for user: {user_id}") else: logger.warning("USAGE_METRICS_QUEUE_NAME environment variable not set, metrics not published") except Exception as e: diff --git a/lib/metrics/metricsConstruct.ts b/lib/metrics/metricsConstruct.ts index 7c72585ba..d86b75499 100644 --- a/lib/metrics/metricsConstruct.ts +++ b/lib/metrics/metricsConstruct.ts @@ -93,6 +93,12 @@ export class MetricsConstruct extends Construct { stringValue: usageMetricsQueue.queueName, }); + // Store queue URL in SSM for cross-stack access + new StringParameter(this, 'UsageMetricsQueueUrl', { + parameterName: `${config.deploymentPrefix}/queue-url/usage-metrics`, + stringValue: usageMetricsQueue.queueUrl, + }); + const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { restApiId: restApiId, rootResourceId: rootResourceId, @@ -107,9 +113,18 @@ export class MetricsConstruct extends Construct { dashboard.addWidgets( // Dashboard Title new cloudwatch.TextWidget({ - markdown: '# LISA Metrics Dashboard', + markdown: '# **LISA Metrics Dashboard**', + width: 24, + height: 1, + background: cloudwatch.TextWidgetBackground.TRANSPARENT + }), + + // Overview (Aggregate) metrics section + new cloudwatch.TextWidget({ + markdown: '## **Overview (Aggregate)**', width: 24, height: 1, + background: cloudwatch.TextWidgetBackground.TRANSPARENT }), // Total Prompts Widget new cloudwatch.GraphWidget({ @@ -122,7 +137,7 @@ export class MetricsConstruct extends Construct { period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), // Total RAG Usage Widget @@ -136,7 +151,7 @@ export class MetricsConstruct extends Construct { period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), // Total MCP Tool Calls Widget @@ -150,89 +165,105 @@ export class MetricsConstruct extends Construct { period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), - // Prompts by User Widget + // MCP Tool Calls by Tool Widget new cloudwatch.GraphWidget({ - title: 'Prompts by User', + title: 'MCP Tool Calls by Tool', left: [ new cloudwatch.MathExpression({ - expression: 'SEARCH(\'{LISA/UsageMetrics,UserId} MetricName="UserPromptCount"\', \'Sum\', 3600)', + expression: 'SEARCH(\'{LISA/UsageMetrics,ToolName} MetricName="MCPToolCallsByTool"\', \'Sum\', 3600)', label: '', period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), - // RAG Usage by User Widget + // Unique Users Widget + new cloudwatch.SingleValueWidget({ + title: 'Total User Count', + metrics: [ + new cloudwatch.Metric({ + namespace: 'LISA/UsageMetrics', + metricName: 'UniqueUsers', + statistic: 'Maximum', + period: Duration.days(1), + }), + ], + width: 8, + height: 6, + }), + // Users by Group Widget new cloudwatch.GraphWidget({ - title: 'RAG Usage by User', + title: 'Groups by Membership Count', left: [ new cloudwatch.MathExpression({ - expression: 'SEARCH(\'{LISA/UsageMetrics,UserId} MetricName="UserRAGUsageCount"\', \'Sum\', 3600)', + expression: 'SEARCH(\'{LISA/UsageMetrics,GroupName} MetricName="UsersPerGroup"\', \'Maximum\', 86400)', label: '', - period: Duration.hours(1), + period: Duration.days(1), }), ], - width: 12, + view: cloudwatch.GraphWidgetView.PIE, + width: 8, height: 6, }), - // MCP Tool Calls by User Widget + + // User Metrics section + new cloudwatch.TextWidget({ + markdown: '## **User Usage Metrics**', + width: 24, + height: 1, + background: cloudwatch.TextWidgetBackground.TRANSPARENT + }), + // Prompts by User Widget new cloudwatch.GraphWidget({ - title: 'MCP Tool Calls by User', + title: 'Prompts by User', left: [ new cloudwatch.MathExpression({ - expression: 'SEARCH(\'{LISA/UsageMetrics,UserId} MetricName="UserMCPToolCalls"\', \'Sum\', 3600)', + expression: 'SEARCH(\'{LISA/UsageMetrics,UserId} MetricName="UserPromptCount"\', \'Sum\', 3600)', label: '', period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), - // MCP Tool Calls by Tool Widget + // RAG Usage by User Widget new cloudwatch.GraphWidget({ - title: 'MCP Tool Calls by Tool', + title: 'RAG Usage by User', left: [ new cloudwatch.MathExpression({ - expression: 'SEARCH(\'{LISA/UsageMetrics,ToolName} MetricName="MCPToolCallsByTool"\', \'Sum\', 3600)', + expression: 'SEARCH(\'{LISA/UsageMetrics,UserId} MetricName="UserRAGUsageCount"\', \'Sum\', 3600)', label: '', period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), - // Unique Users Widget - new cloudwatch.SingleValueWidget({ - title: 'Total User Count', - metrics: [ - new cloudwatch.Metric({ - namespace: 'LISA/UsageMetrics', - metricName: 'UniqueUsers', - statistic: 'Maximum', - period: Duration.days(1), - }), - ], - width: 12, - height: 6, - }), - // Users by Group Widget + // MCP Tool Calls by User Widget new cloudwatch.GraphWidget({ - title: 'Groups by Membership Count', + title: 'MCP Tool Calls by User', left: [ new cloudwatch.MathExpression({ - expression: 'SEARCH(\'{LISA/UsageMetrics,GroupName} MetricName="UsersPerGroup"\', \'Maximum\', 86400)', + expression: 'SEARCH(\'{LISA/UsageMetrics,UserId} MetricName="UserMCPToolCalls"\', \'Sum\', 3600)', label: '', - period: Duration.days(1), + period: Duration.hours(1), }), ], - view: cloudwatch.GraphWidgetView.PIE, - width: 12, + width: 8, height: 6, }), + + // Group Metrics section + new cloudwatch.TextWidget({ + markdown: '## **Group Usage Metrics**', + width: 24, + height: 1, + background: cloudwatch.TextWidgetBackground.TRANSPARENT + }), // Group Prompt Counts Widget new cloudwatch.GraphWidget({ title: 'Group Prompt Counts', @@ -243,7 +274,7 @@ export class MetricsConstruct extends Construct { period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), // Group RAG Usage Widget @@ -256,7 +287,7 @@ export class MetricsConstruct extends Construct { period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), // Group MCP Usage Widget @@ -269,7 +300,7 @@ export class MetricsConstruct extends Construct { period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), ); diff --git a/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py b/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py index 5643c2e32..8b64ed0db 100644 --- a/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py +++ b/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py @@ -35,6 +35,7 @@ get_model_guardrails, is_guardrail_violation, ) +from ....utils.metrics import publish_metrics_event # Local LiteLLM installation URL. By default, LiteLLM runs on port 4000. Change the port here if the # port was changed as part of the LiteLLM startup in entrypoint.sh @@ -293,6 +294,10 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response: if guardrail_response: return guardrail_response + # Publish metrics for streaming chat completions (API users) + if api_path in ["chat/completions", "v1/chat/completions"] and response.status_code == 200: + await publish_metrics_event(request, params, response.status_code) + # Normal streaming (no error or non-guardrail error) # Use guardrail-aware generator for chat/completions endpoints if api_path in ["chat/completions", "v1/chat/completions"]: @@ -314,4 +319,9 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response: if response.status_code != 200: logger.error(f"LiteLLM error response: {response.text}") + + # Publish metrics for chat completions (API users) + if api_path in ["chat/completions", "v1/chat/completions"]: + publish_metrics_event(request, params, response.status_code) + return JSONResponse(response.json(), status_code=response.status_code) diff --git a/lib/serve/rest-api/src/auth.py b/lib/serve/rest-api/src/auth.py index f31d6f20c..6fee3708b 100644 --- a/lib/serve/rest-api/src/auth.py +++ b/lib/serve/rest-api/src/auth.py @@ -444,3 +444,40 @@ def _set_token_context(self, request: Request, token_info: Dict[str, Any]) -> No request.state.api_token_info = token_info request.state.username = token_info.get("username", "api-token") request.state.groups = token_info.get("groups", []) + + +def is_api_user(request: Request) -> bool: + """ + Check if the user authenticated with an API token. + + Args: + request: The FastAPI request object + + Returns: + True if user authenticated via API token, False otherwise + """ + return hasattr(request.state, "api_token_info") + + +def get_user_context(request: Request) -> tuple[str, list[str]]: + """ + Get user information from the request. + + Works with both API token and JWT authentication. + + Args: + request: The FastAPI request object + + Returns: + Tuple of (username, groups) + """ + if is_api_user(request): + token_info = request.state.api_token_info + username = token_info.get("username", "api-token") + groups = token_info.get("groups", []) + else: + # JWT-authenticated user or management token + username = getattr(request.state, "username", "unknown") + groups = getattr(request.state, "groups", []) + + return username, groups diff --git a/lib/serve/rest-api/src/utils/metrics.py b/lib/serve/rest-api/src/utils/metrics.py new file mode 100644 index 000000000..f6b43b967 --- /dev/null +++ b/lib/serve/rest-api/src/utils/metrics.py @@ -0,0 +1,138 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Metrics utilities for publishing usage data.""" + +import json +import logging +import os +import uuid +from datetime import datetime + +import boto3 +from fastapi import Request + +from ..auth import get_user_context + +logger = logging.getLogger(__name__) + +sqs_client = boto3.client("sqs", region_name=os.environ["AWS_REGION"]) + + +def extract_messages_for_metrics(params: dict) -> list[dict]: + """ + Extract messages from chat completion request parameters. + + Args: + params: The request parameters containing messages + + Returns: + List of message dictionaries suitable for metrics calculation + """ + messages = params.get("messages", []) + + # Convert to a format that matches what session lambda sends + formatted_messages = [] + for msg in messages: + role = msg.get("role", "user") + + # Map OpenAI roles to LISA message types + if role == "user": + msg_type = "human" + elif role == "assistant": + msg_type = "ai" + elif role == "system": + msg_type = "system" + else: + msg_type = role + + # Extract content - handle both string and array formats + content = msg.get("content", "") + content_text = "" + + if isinstance(content, str): + # Simple string content (from direct API calls) + content_text = content + elif isinstance(content, list): + # Array of content objects (from UI) + # Extract text from all text-type content items + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + content_text += item.get("text", "") + " " + content_text = content_text.strip() + + formatted_msg = { + "type": msg_type, + "content": content, # Keep original format for session compatibility + "metadata": {}, + } + + # Check if this message has RAG context in the extracted text + # RAG context is typically indicated by "File context:" in the message + if content_text and "file context:" in content_text.lower(): + # Mark this message as using RAG + formatted_msg["metadata"]["ragContext"] = True + + # Handle tool calls if present (for MCP metrics) + if "tool_calls" in msg: + formatted_msg["toolCalls"] = msg["tool_calls"] + + formatted_messages.append(formatted_msg) + + return formatted_messages + + +def publish_metrics_event(request: Request, params: dict, response_status: int) -> None: + """ + Publish metrics event to SQS queue for API users + + Args: + request: The FastAPI request object + params: The request parameters + response_status: HTTP response status code + """ + # Only publish metrics for successful completions + if response_status != 200: + return + + # Only publish if metrics queue is configured + queue_url = os.environ.get("USAGE_METRICS_QUEUE_URL") + if not queue_url: + logger.debug("Metrics queue URL not configured, skipping metrics") + return + + try: + username, groups = get_user_context(request) + messages = extract_messages_for_metrics(params) + + # Generate a synthetic session ID for API users + session_id = f"api-{int(datetime.now().timestamp())}-{uuid.uuid4().hex[:8]}" + + # Create metrics event in the same format as session lambda + metrics_event = { + "userId": username, + "sessionId": session_id, + "messages": messages, + "userGroups": groups, + "timestamp": datetime.now().isoformat(), + } + + # Publish to SQS + sqs_client.send_message(QueueUrl=queue_url, MessageBody=json.dumps(metrics_event)) + + logger.info(f"Published metrics event for API user: {username}") + + except Exception as e: + # Don't fail the request if metrics publishing fails + logger.error(f"Failed to publish metrics event: {e}") diff --git a/lib/serve/serveApplicationConstruct.ts b/lib/serve/serveApplicationConstruct.ts index 028e90157..72b6665b7 100644 --- a/lib/serve/serveApplicationConstruct.ts +++ b/lib/serve/serveApplicationConstruct.ts @@ -45,6 +45,7 @@ import { GuardrailsTable } from '../models/guardrails-table'; export type LisaServeApplicationProps = { vpc: Vpc; securityGroups: ISecurityGroup[]; + metricsQueueUrl?: string; } & BaseProps & StackProps; /** @@ -248,6 +249,12 @@ export class LisaServeApplicationConstruct extends Construct { container.addEnvironment('REGISTERED_MODELS_PS_NAME', this.modelsPs.parameterName); container.addEnvironment('LITELLM_DB_INFO_PS_NAME', litellmDbConnectionInfoPs.parameterName); container.addEnvironment('GUARDRAILS_TABLE_NAME', guardrailsTableName); + // Add metrics queue URL if provided + if (props.metricsQueueUrl) { + // Get the queue URL from SSM parameter + const queueUrl = StringParameter.valueForStringParameter(scope, props.metricsQueueUrl); + container.addEnvironment('USAGE_METRICS_QUEUE_URL', queueUrl); + } } restApi.node.addDependency(this.modelsPs); restApi.node.addDependency(litellmDbConnectionInfoPs); @@ -308,6 +315,26 @@ export class LisaServeApplicationConstruct extends Construct { litellmDbConnectionInfoPs.grantRead(restRole); restRole.attachInlinePolicy(invocation_permissions); restRole.attachInlinePolicy(guardrails_permissions); + + // Grant SQS send permissions if metrics queue URL is provided + if (props.metricsQueueUrl) { + // Get the queue name from SSM parameter + const queueName = StringParameter.valueForStringParameter( + scope, + `${config.deploymentPrefix}/queue-name/usage-metrics` + ); + const sqs_permissions = new Policy(scope, 'SQSMetricsPerms', { + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['sqs:SendMessage'], + resources: [`arn:${config.partition}:sqs:${config.region}:${config.accountNumber}:${queueName}`], + }), + ] + }); + restRole.attachInlinePolicy(sqs_permissions); + } + if (serveRole) { this.modelsPs.grantRead(serveRole); litellmDbConnectionInfoPs.grantRead(serveRole); diff --git a/lib/stages.ts b/lib/stages.ts index 5982bc10d..83e75f5fc 100644 --- a/lib/stages.ts +++ b/lib/stages.ts @@ -317,6 +317,22 @@ export class LisaServeApplicationStage extends Stage { mcpApiStack.addDependency(apiBaseStack); this.stacks.push(mcpApiStack); } + let metricsStack: LisaMetricsStack | undefined; + if (config.deployMetrics) { + metricsStack = new LisaMetricsStack(this, 'LisaMetrics', { + ...baseStackProps, + authorizer: apiBaseStack.authorizer!, + stackName: createCdkId([config.deploymentName, config.appName, 'metrics', config.deploymentStage]), + description: `LISA-metrics: ${config.deploymentName}-${config.deploymentStage}`, + restApiId: apiBaseStack.restApiId, + rootResourceId: apiBaseStack.rootResourceId, + securityGroups: [networkingStack.vpc.securityGroups.lambdaSg], + vpc: networkingStack.vpc, + }); + metricsStack.addDependency(apiBaseStack); + metricsStack.addDependency(coreStack); + this.stacks.push(metricsStack); + } if (config.deployServe) { const serveStack = new LisaServeApplicationStack(this, 'LisaServe', { @@ -325,6 +341,7 @@ export class LisaServeApplicationStage extends Stage { stackName: createCdkId([config.deploymentName, config.appName, 'serve', config.deploymentStage]), vpc: networkingStack.vpc, securityGroups: [networkingStack.vpc.securityGroups.lambdaSg], + metricsQueueUrl: metricsStack ? `${config.deploymentPrefix}/queue-url/usage-metrics` : undefined, }); this.stacks.push(serveStack); serveStack.addDependency(networkingStack); @@ -385,25 +402,6 @@ export class LisaServeApplicationStage extends Stage { apiDeploymentStack.addDependency(ragStack); } - // Declare metricsStack here so that we can reference it in chatStack - let metricsStack: LisaMetricsStack | undefined; - if (config.deployMetrics) { - metricsStack = new LisaMetricsStack(this, 'LisaMetrics', { - ...baseStackProps, - authorizer: apiBaseStack.authorizer!, - stackName: createCdkId([config.deploymentName, config.appName, 'metrics', config.deploymentStage]), - description: `LISA-metrics: ${config.deploymentName}-${config.deploymentStage}`, - restApiId: apiBaseStack.restApiId, - rootResourceId: apiBaseStack.rootResourceId, - securityGroups: [networkingStack.vpc.securityGroups.lambdaSg], - vpc: networkingStack.vpc, - }); - metricsStack.addDependency(apiBaseStack); - metricsStack.addDependency(coreStack); - apiDeploymentStack.addDependency(metricsStack); - this.stacks.push(metricsStack); - } - if (config.deployChat) { const chatStack = new LisaChatApplicationStack(this, 'LisaChat', { ...baseStackProps, diff --git a/package-lock.json b/package-lock.json index 1cb0c0fb3..330efff1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -480,7 +480,6 @@ "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.46.1.tgz", "integrity": "sha512-NL76o/BoEgU4ObY5oBEC3o6KSPpuXsnSta00tAxTm1iKUWOGR34DQEKhUt8xMHhMKleUNPM/rLPFiIVtfsGU8w==", "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-common": "5.46.1", "@algolia/requester-browser-xhr": "5.46.1", @@ -1481,7 +1480,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1996,8 +1994,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@chevrotain/cst-dts-gen": { "version": "11.0.3", @@ -2089,7 +2086,6 @@ "resolved": "https://registry.npmjs.org/@cloudscape-design/components/-/components-3.0.1157.tgz", "integrity": "sha512-5dv5PFlaZAD8Kb8tftRGinfhqTSAxD1SsESYMTKMXK+fMJoEn5X5WipoVQHfI5AzCmHQa2UUw1uQ7FRrCECqlA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@cloudscape-design/collection-hooks": "^1.0.0", "@cloudscape-design/component-toolkit": "^1.0.0-beta", @@ -2257,7 +2253,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2301,7 +2296,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2417,7 +2411,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -3232,7 +3225,6 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz", "integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==", "license": "MIT", - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" }, @@ -3959,7 +3951,6 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.6.tgz", "integrity": "sha512-db5H+nLb6rwTW7noIlspN9nDi8b6NeuRWpelzS3+/BuasrJlZ6QbTDxkIqJRTEyLB2av3xOHlYHNmDHKal0XYA==", "license": "MIT", - "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -6120,6 +6111,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -6130,6 +6122,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6144,7 +6137,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -6266,7 +6260,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/aws-lambda": { "version": "8.10.159", @@ -6752,7 +6747,6 @@ "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6775,7 +6769,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6786,7 +6779,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6962,7 +6954,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -7601,7 +7592,6 @@ "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", @@ -7928,7 +7918,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8044,7 +8033,6 @@ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.46.1.tgz", "integrity": "sha512-39ol8Ulqb3MntofkXHlrcXKyU8BU0PXvQrXPBIX6eXj/EO4VT7651mhGVORI2oF8ydya9nFzT3fYDoqme/KL6w==", "license": "MIT", - "peer": true, "dependencies": { "@algolia/abtesting": "1.12.1", "@algolia/client-abtesting": "5.46.1", @@ -8472,7 +8460,6 @@ "mime-types" ], "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-cdk/asset-awscli-v1": "2.2.242", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0", @@ -9134,7 +9121,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9878,8 +9864,7 @@ "version": "10.4.4", "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.4.tgz", "integrity": "sha512-lP0qC1oViYf1cutHo9/KQ8QL637f/W29tDmv/6sy35F5zs+MD9f66nbAAIjicwc7fwyuF3rkg6PhZh4sfvWIpA==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/content-disposition": { "version": "1.0.1", @@ -10492,7 +10477,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -10884,7 +10868,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -11420,7 +11403,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -11792,7 +11776,6 @@ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -11863,7 +11846,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11924,7 +11906,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -12836,7 +12817,6 @@ "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.6.tgz", "integrity": "sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==", "license": "MIT", - "peer": true, "dependencies": { "tabbable": "^6.3.0" } @@ -14764,7 +14744,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -15562,7 +15541,6 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -15717,6 +15695,7 @@ "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -16537,6 +16516,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -17053,7 +17033,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -17089,7 +17068,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", @@ -18768,7 +18746,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -19008,7 +18985,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -19035,7 +19011,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -19118,7 +19093,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -19297,8 +19271,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-mock-store": { "version": "1.5.5", @@ -21192,7 +21165,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -21279,7 +21251,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -21487,7 +21458,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -21988,7 +21958,6 @@ "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/runtime": "0.97.0", "fdir": "^6.5.0", @@ -22158,7 +22127,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -22219,7 +22187,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -22346,7 +22313,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25", @@ -22760,7 +22726,6 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -22839,7 +22804,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -22983,7 +22947,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -22993,7 +22956,6 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", "license": "ISC", - "peer": true, "peerDependencies": { "zod": "^3.25 || ^4" } @@ -23046,7 +23008,6 @@ "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" } From 93d11988cf954154045f13b2c22cc6044285c8f3 Mon Sep 17 00:00:00 2001 From: Joseph Harold <121983012+jmharold@users.noreply.github.com> Date: Thu, 8 Jan 2026 13:55:07 -0700 Subject: [PATCH 12/23] break out of auto scrolling (#664) --- .../react/src/components/chatbot/Chat.tsx | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx index 8ab63dc02..c3922782f 100644 --- a/lib/user-interface/react/src/components/chatbot/Chat.tsx +++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx @@ -86,6 +86,7 @@ export default function Chat ({ sessionId }) { const notificationService = useNotificationService(dispatch); const modelSelectRef = useRef(null); const bottomRef = useRef(null); + const scrollContainerRef = useRef(null); const auth = useAuth(); const userName = useAppSelector(selectCurrentUsername); @@ -127,6 +128,7 @@ export default function Chat ({ sessionId }) { const [hasUserInteractedWithModel, setHasUserInteractedWithModel] = useState(false); const [mermaidRenderComplete, setMermaidRenderComplete] = useState(0); const [dynamicMaxRows, setDynamicMaxRows] = useState(8); + const [shouldAutoScroll, setShouldAutoScroll] = useState(true); // Callback to handle Mermaid diagram rendering completion const handleMermaidRenderComplete = useCallback(() => { @@ -480,10 +482,42 @@ export default function Chat ({ sessionId }) { }, [sessionHealth]); useEffect(() => { - if (bottomRef.current) { - bottomRef.current.scrollIntoView({ behavior: 'smooth' }); + if (shouldAutoScroll && bottomRef.current) { + // Use 'auto' instead of 'smooth' to prevent jagged scrolling during rapid streaming + // which was breaking AT_BOTTOM_THRESHOLD disabling auto-scroll without user input + bottomRef.current.scrollIntoView({ behavior: 'auto' }); } - }, [isStreaming, session, mermaidRenderComplete]); + }, [isStreaming, session, mermaidRenderComplete, shouldAutoScroll]); + + // Scroll event listener to detect scroll position + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer) return; + + const handleScroll = () => { + // Check if we're at the bottom + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + + // Small threshold to account for rounding issues + const AT_BOTTOM_THRESHOLD = 30; + + if (distanceFromBottom <= AT_BOTTOM_THRESHOLD) { + // At bottom - ensure auto-scroll is enabled + if (!shouldAutoScroll) { + setShouldAutoScroll(true); + } + } else { + // Not at bottom - disable auto-scroll + if (shouldAutoScroll) { + setShouldAutoScroll(false); + } + } + }; + + scrollContainer.addEventListener('scroll', handleScroll); + return () => scrollContainer.removeEventListener('scroll', handleScroll); + }, [shouldAutoScroll]); // Reset tool call counter when session changes useEffect(() => { @@ -513,6 +547,9 @@ export default function Chat ({ sessionId }) { // Reset tool call counter when human provides input consecutiveToolCallCount.current = 0; + // Re-enable auto-scroll when user sends a new message + setShouldAutoScroll(true); + setSession((prev) => ({ ...prev, history: prev.history.concat(new LisaChatMessage({ @@ -718,7 +755,7 @@ export default function Chat ({ sessionId }) { } /> )} -
+
{loadingSession && ( From c075e44de28391eeff348091f1e69d7857ca0e35 Mon Sep 17 00:00:00 2001 From: bedanley Date: Thu, 8 Jan 2026 15:17:42 -0700 Subject: [PATCH 13/23] cypress smoke refactor --- cypress/README.md | 1 - cypress/cypress.e2e.config.ts | 1 - ...stration.e2e.spec.ts => admin.e2e.spec.ts} | 31 +- .../specs/bedrock-model-workflow.e2e.spec.ts | 36 +++ cypress/src/e2e/specs/chat.e2e.spec.ts | 37 +++ cypress/src/e2e/specs/user.e2e.spec.ts | 37 +++ cypress/src/e2e/support/commands.ts | 190 +++++++++--- cypress/src/e2e/support/index.ts | 23 ++ cypress/src/shared/specs/admin.shared.spec.ts | 46 +-- .../bedrock-model-workflow.shared.spec.ts | 131 ++++++++ cypress/src/shared/specs/chat.shared.spec.ts | 290 +++--------------- cypress/src/shared/specs/user.shared.spec.ts | 7 +- cypress/src/smoke/fixtures/env.json | 1 + cypress/src/smoke/fixtures/openid-config.json | 39 +-- cypress/src/smoke/specs/chat.smoke.spec.ts | 5 +- cypress/src/smoke/specs/user.smoke.spec.ts | 5 +- cypress/src/smoke/support/commands.ts | 231 ++++++++------ cypress/src/smoke/support/utils.ts | 2 +- cypress/src/support/adminHelpers.ts | 119 +++---- cypress/src/support/chatHelpers.ts | 60 ++++ cypress/src/support/dataHelpers.ts | 23 +- cypress/src/support/modelFormHelpers.ts | 186 +++++++++++ cypress/src/support/repositoryHelpers.ts | 184 +++++++++++ lib/schema/ragSchema.ts | 60 ++-- .../react/src/components/app-configured.tsx | 73 ++--- .../react/src/components/chatbot/Chat.tsx | 5 +- .../components/DocumentSummarizationModal.tsx | 11 - .../chatbot/hooks/useMemory.hooks.test.tsx | 244 +++++++++++++++ .../chatbot/hooks/useMemory.hooks.tsx | 21 +- .../chatbot/hooks/useSession.hooks.tsx | 9 +- .../ModelManagementActions.tsx | 1 + .../model-management/ModelManagementUtils.tsx | 2 +- .../create-model/BaseModelConfig.tsx | 1 + .../create-model/CreateModelModal.tsx | 114 +++---- .../BedrockKnowledgeBaseConfigForm.tsx | 2 + .../CreateRepositoryModal.tsx | 30 +- .../createRepository/RepositoryConfigForm.tsx | 10 +- .../src/shared/modal/confirmation-modal.tsx | 1 + .../src/lib/pipeline-stack.ts | 12 +- 39 files changed, 1555 insertions(+), 726 deletions(-) rename cypress/src/e2e/specs/{administration.e2e.spec.ts => admin.e2e.spec.ts} (52%) create mode 100644 cypress/src/e2e/specs/bedrock-model-workflow.e2e.spec.ts create mode 100644 cypress/src/e2e/specs/chat.e2e.spec.ts create mode 100644 cypress/src/e2e/specs/user.e2e.spec.ts create mode 100644 cypress/src/shared/specs/bedrock-model-workflow.shared.spec.ts create mode 100644 cypress/src/support/modelFormHelpers.ts create mode 100644 cypress/src/support/repositoryHelpers.ts create mode 100644 lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.test.tsx diff --git a/cypress/README.md b/cypress/README.md index bcdf0de67..c53b6d0e7 100644 --- a/cypress/README.md +++ b/cypress/README.md @@ -54,7 +54,6 @@ DevTools listening on ws://127.0.0.1:51352/devtools/browser/2f804c68-414e-4004-9 │ Node Version: v18.20.4 (/.../.nvm/versions/node/v18.20.4/bin/node) │ │ Specs: 1 found (administration.e2e.spec.ts) │ │ Searched: src/e2e/specs/**/*.e2e.spec.ts │ - │ Experiments: experimentalStudio=true │ └────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/cypress/cypress.e2e.config.ts b/cypress/cypress.e2e.config.ts index a5731214c..6ce1bf660 100644 --- a/cypress/cypress.e2e.config.ts +++ b/cypress/cypress.e2e.config.ts @@ -43,7 +43,6 @@ export default defineConfig({ e2e: { specPattern: `${PROJECT_ROOT}/src/e2e/specs/**/*.e2e.spec.ts`, supportFile: `${PROJECT_ROOT}/src/e2e/support/index.ts`, - experimentalStudio: true, fixturesFolder: `${PROJECT_ROOT}/src/e2e/fixtures`, setupNodeEvents () { }, diff --git a/cypress/src/e2e/specs/administration.e2e.spec.ts b/cypress/src/e2e/specs/admin.e2e.spec.ts similarity index 52% rename from cypress/src/e2e/specs/administration.e2e.spec.ts rename to cypress/src/e2e/specs/admin.e2e.spec.ts index e18553d25..8ecb6383f 100644 --- a/cypress/src/e2e/specs/administration.e2e.spec.ts +++ b/cypress/src/e2e/specs/admin.e2e.spec.ts @@ -17,35 +17,22 @@ /// /** - * E2E suite for Administration features: - * - Ensures admin users can view and interact with the Administration menu - * - Verifies correct menu items and expansion behavior - * - Confirms non-admin users do not see the Administration option + * E2E suite for Admin Navigation features. + * Uses shared test suite against real deployment. */ +import { runAdminTests } from '../../shared/specs/admin.shared.spec'; -import { - checkAdminButtonExists, - expandAdminMenu, - checkNoAdminButton, -} from '../../support/adminHelpers'; - -describe('Administration features (E2E)', () => { - beforeEach(() => { +describe('Admin Navigation (E2E)', () => { + before(() => { cy.clearAllSessionStorage(); }); - it('Admin sees the button', () => { - cy.loginAs('admin'); - checkAdminButtonExists(); - }); - - it('Admin can expand menu', () => { + beforeEach(() => { cy.loginAs('admin'); - expandAdminMenu(); }); - it('Non-admin does not see the button', () => { - cy.loginAs('user'); - checkNoAdminButton(); + runAdminTests({ + expectMinItems: false, + verifyFixtureData: false, }); }); diff --git a/cypress/src/e2e/specs/bedrock-model-workflow.e2e.spec.ts b/cypress/src/e2e/specs/bedrock-model-workflow.e2e.spec.ts new file mode 100644 index 000000000..25b795189 --- /dev/null +++ b/cypress/src/e2e/specs/bedrock-model-workflow.e2e.spec.ts @@ -0,0 +1,36 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/// + +/** + * E2E test for Bedrock model creation and chat workflow. + * Creates a Bedrock model, then uses it in chat. + */ + +import { runBedrockModelWorkflowTests } from '../../shared/specs/bedrock-model-workflow.shared.spec'; + +describe('Bedrock Model Workflow (E2E)', () => { + before(() => { + cy.clearAllSessionStorage(); + }); + + beforeEach(() => { + cy.loginAs('admin'); + }); + + runBedrockModelWorkflowTests(); +}); diff --git a/cypress/src/e2e/specs/chat.e2e.spec.ts b/cypress/src/e2e/specs/chat.e2e.spec.ts new file mode 100644 index 000000000..7aa20aecb --- /dev/null +++ b/cypress/src/e2e/specs/chat.e2e.spec.ts @@ -0,0 +1,37 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/// + +/** + * E2E suite for Chat Page features. + * Uses shared test suite against real deployment. + */ +import { runChatTests } from '../../shared/specs/chat.shared.spec'; + +describe('Chat Page (E2E)', () => { + before(() => { + cy.clearAllSessionStorage(); + }); + + beforeEach(() => { + cy.loginAs('user'); + }); + + runChatTests({ + verifyFixtureData: false, + }); +}); diff --git a/cypress/src/e2e/specs/user.e2e.spec.ts b/cypress/src/e2e/specs/user.e2e.spec.ts new file mode 100644 index 000000000..699929d92 --- /dev/null +++ b/cypress/src/e2e/specs/user.e2e.spec.ts @@ -0,0 +1,37 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/// + +/** + * E2E suite for User role features. + * Uses shared test suite against real deployment. + */ +import { runUserTests } from '../../shared/specs/user.shared.spec'; + +describe('User features (E2E)', () => { + before(() => { + cy.clearAllSessionStorage(); + }); + + beforeEach(() => { + cy.loginAs('user'); + }); + + runUserTests({ + verifyFixtureData: false, + }); +}); diff --git a/cypress/src/e2e/support/commands.ts b/cypress/src/e2e/support/commands.ts index 054e233f6..6ec87d14d 100644 --- a/cypress/src/e2e/support/commands.ts +++ b/cypress/src/e2e/support/commands.ts @@ -16,87 +16,177 @@ /// -// Base application URL from Cypress config import { getTopLevelDomain } from './utils'; const BASE_URL = Cypress.config('baseUrl') as string; +const APIS = [ + { pattern: '**/configuration*', alias: 'getConfiguration', critical: true }, + { pattern: '**/models*', alias: 'getModels', chat: true }, + { pattern: '**/session*', alias: 'getSessions', chat: true }, + { pattern: '**/api-tokens*', alias: 'getApiTokens' }, + { pattern: '**/repository*', alias: 'getRepositories', chat: true }, + { pattern: '**/collection*', alias: 'getCollections' }, + { pattern: '**/mcp*', alias: 'getMcp' }, + { pattern: '**/mcp-management*', alias: 'getMcpServers' }, + { pattern: '**/mcp-workbench*', alias: 'getMcpWorkbench' }, + { pattern: '**/prompt-templates*', alias: 'getPromptTemplates' }, + { pattern: '**/user-preferences*', alias: 'getUserPreferences' }, +]; + +/** + * Setup intercepts for critical API calls. + * Call this before visiting the app. + */ +function setupApiIntercepts () { + APIS.forEach(({ pattern, alias }) => { + cy.intercept('GET', pattern).as(alias); + }); +} + +/** + * Wait for all critical API calls to complete. + */ +function waitForCriticalApis () { + const aliases = APIS.filter(({ critical }) => critical) + .map(({ alias }) => `@${alias}`); + cy.wait(aliases, { timeout: 30000 }); +} + +/** + * Wait for the app to be fully loaded after authentication. + */ +function waitForAppReady () { + // Wait for "Loading configuration..." to disappear + cy.contains('Loading configuration...', { timeout: 15000 }).should('not.exist'); + + // Wait for any loading spinners to complete + cy.get('body').then(($body) => { + if ($body.find('[class*="awsui_spinner"]').length > 0) { + cy.get('[class*="awsui_spinner"]', { timeout: 10000 }).should('not.exist'); + } + }); + + // Wait for header to be visible (indicates app is ready) + cy.get('header', { timeout: 15000 }).should('be.visible'); +} + /** - * Custom command to log in a user via stubbed OAuth2/OIDC. - * Can log in as an 'admin' or a normal 'user'. + * Custom command to log in a user via Cognito OAuth2/OIDC. + * Uses cy.session() for caching and role-specific credentials. * - * @param {'admin'|'user'} username - The username to simulate (defaults to 'user'). + * @param {'admin'|'user'} role - The role to log in as (defaults to 'user'). */ -Cypress.Commands.add('loginAs', (username = 'user') => { +Cypress.Commands.add('loginAs', (role = 'user') => { const log = Cypress.log({ displayName: 'Cognito Login', - message: [`🔐 Authenticating | ${username}`], + message: [`🔐 Authenticating | ${role}`], autoEnd: false, }); - let cognitoOathEndpoint = ''; - let cognitoOathClientName = ''; - let cognitoAuthEndpoint = ''; + log.snapshot('before'); - // Temporarily suppress exceptions. We expect to get 401's which will trigger the login redirect - cy.on('uncaught:exception', () => { - return false; - }); + + // Temporarily suppress exceptions during login flow + cy.on('uncaught:exception', () => false); + cy.session( - `cognito-${username}`, + `cognito-${role}`, () => { - // Handle cognito portal information cy.request(BASE_URL + '/env.js').then((resp) => { const OIDC_URL_REGEX = /["']?AUTHORITY['"]?:\s*['"]?([A-Za-z:\-._/0-9]+)['"]?/; - const OIDC_APP_NAME_REGEX = /["']?CLIENT_ID['"]?:\s*['"]?([A-Za-z:\-._/0-9]+)['"]?/; + const oidcUrlMatches = OIDC_URL_REGEX.exec(resp.body); - if (oidcUrlMatches && oidcUrlMatches.length === 2) { - cognitoOathEndpoint = oidcUrlMatches[1]; - } - const oidcClientNameMatches = OIDC_APP_NAME_REGEX.exec(resp.body); - if (oidcClientNameMatches && oidcClientNameMatches.length === 2) { - cognitoOathClientName = oidcClientNameMatches[1]; - } + const cognitoOathEndpoint = oidcUrlMatches?.[1] || ''; + cy.request(`${cognitoOathEndpoint}/.well-known/openid-configuration`).then((oathResponse) => { - cognitoAuthEndpoint = getTopLevelDomain(oathResponse.body.authorization_endpoint); - // click the sign in link + const cognitoAuthEndpoint = getTopLevelDomain(oathResponse.body.authorization_endpoint); + + // Start the login flow cy.visit(BASE_URL); cy.contains('button', 'Sign in').click(); - cy.origin(cognitoAuthEndpoint, { args: username }, (username: string) => { - cy.on('uncaught:exception', () => { - return false; - }); - // This is a lot of overhead to put in username, but there are intermittent results while waiting - // for the DOM to stabilize after the redirect and this way is more foolproof + + // Perform login on Cognito hosted UI + cy.origin(cognitoAuthEndpoint, { args: role }, (userRole: string) => { + cy.on('uncaught:exception', () => false); + + // Get credentials based on role + const username = userRole === 'admin' + ? Cypress.env('ADMIN_USER_NAME') + : Cypress.env('USER_NAME'); + const password = userRole === 'admin' + ? Cypress.env('ADMIN_PASSWORD') + : Cypress.env('USER_PASSWORD'); + + // Wait for username field and fill it cy.get('input[name="username"]', { timeout: 10000 }) .filter(':visible') .first() - .as('usernameInput') - .then(() => { - // click may re‑render; re‑query afterwards - cy.get('@usernameInput').click({ force: true }); - }) - .then(() => { - cy.get('@usernameInput').clear({ force: true }); - cy.get('@usernameInput').type(username, { force: true }); - }); - cy.get('input[name="password"]').filter(':visible').type(Cypress.env('TEST_ACCOUNT_PASSWORD'), { force: true }); - cy.get('input[aria-label="submit"]').filter(':visible').click({ force: true }); - }, - ); + .as('usernameInput'); + cy.get('@usernameInput').click({ force: true }); + cy.get('@usernameInput').clear({ force: true }); + cy.get('@usernameInput').type(username, { force: true }); + + // Fill password + cy.get('input[name="password"]') + .filter(':visible') + .type(password, { force: true, log: false }); + + // Submit + cy.get('input[type="submit"], input[aria-label="submit"], button[type="submit"]') + .filter(':visible') + .first() + .click({ force: true }); + }); + + // Wait for redirect back to app + cy.wait(2000); }); - cy.wait(2000); }); }, { validate: () => { - cy.wrap(sessionStorage) - .invoke('getItem', `oidc.user:${cognitoOathEndpoint}:${cognitoOathClientName}`) - .should('exist'); - + // Check that we have an OIDC token in sessionStorage + // The key format is: oidc.user:: + // We check for any key starting with 'oidc.user:' since we don't have the exact values here + cy.window().then((win) => { + const hasOidcToken = Object.keys(win.sessionStorage).some((key) => + key.startsWith('oidc.user:') + ); + expect(hasOidcToken).to.equal(true); + }); }, - }, + cacheAcrossSpecs: true, + } ); + + // After session restore/setup, Cypress clears the page + // We must visit again and wait for APIs + setupApiIntercepts(); cy.visit(BASE_URL); + waitForAppReady(); + waitForCriticalApis(); + log.snapshot('after'); log.end(); }); + +/** + * Custom command to ensure the app is ready for testing. + * Use in beforeEach when you need to ensure APIs have loaded. + * Does not re-visit if already on the app. + */ +Cypress.Commands.add('waitForApp', () => { + // Check if we're already on the app + cy.url().then((url) => { + const isOnApp = url.includes(new URL(BASE_URL).host); + + if (!isOnApp) { + // Need to visit the app + setupApiIntercepts(); + cy.visit(BASE_URL); + waitForCriticalApis(); + } + + waitForAppReady(); + }); +}); diff --git a/cypress/src/e2e/support/index.ts b/cypress/src/e2e/support/index.ts index 1e16bacb4..c9c2ac24c 100644 --- a/cypress/src/e2e/support/index.ts +++ b/cypress/src/e2e/support/index.ts @@ -16,3 +16,26 @@ import './commands'; import '../../support/adminHelpers'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Chainable { + /** + * Custom command to log in a user via Cognito OAuth2/OIDC. + * Uses cy.session() for caching across specs. + * @param role - The role to log in as ('admin' or 'user') + * @example cy.loginAs('admin') + */ + loginAs(role?: 'admin' | 'user'): Chainable; + + /** + * Custom command to ensure the app is ready for testing. + * Waits for critical APIs to complete without re-visiting if already on app. + * @example cy.waitForApp() + */ + waitForApp(): Chainable; + } + } +} diff --git a/cypress/src/shared/specs/admin.shared.spec.ts b/cypress/src/shared/specs/admin.shared.spec.ts index 49eab8c27..d5c041a05 100644 --- a/cypress/src/shared/specs/admin.shared.spec.ts +++ b/cypress/src/shared/specs/admin.shared.spec.ts @@ -23,8 +23,8 @@ import { navigateAndVerifyAdminPage, - checkAdminButtonExists, expandAdminMenu, + collapseAdminMenu, } from '../../support/adminHelpers'; export function runAdminTests (options: { @@ -33,35 +33,11 @@ export function runAdminTests (options: { } = {}) { const { expectMinItems = false, verifyFixtureData = false } = options; - it('Admin sees the Administration button', () => { - // Wait for the page to fully load and initial API calls to complete - // This is especially important for the first test after login - cy.wait(2000); - checkAdminButtonExists(); - }); - - it('Admin can expand menu and see all menu items', () => { + it('Admin sees the Administration button and can expand/collapse menu', () => { + // Expand and verify menu items expandAdminMenu(); - }); - - it('Admin menu collapses when clicked again', () => { - // Expand menu first - cy.get('button[aria-label="Administration"]') - .filter(':visible') - .click() - .should('have.attr', 'aria-expanded', 'true'); - - cy.get('[role="menu"]') - .should('be.visible'); - - // Collapse menu - cy.get('button[aria-label="Administration"]') - .filter(':visible') - .click() - .should('have.attr', 'aria-expanded', 'false'); - - cy.get('[role="menu"]') - .should('not.be.visible'); + // Collapse and verify + collapseAdminMenu(); }); it('Admin can access Configuration page', () => { @@ -78,7 +54,6 @@ export function runAdminTests (options: { navigateAndVerifyAdminPage('Model Management', '/model-management', 'Model', 'cards', minItems); if (verifyFixtureData) { - // Verify specific model data from fixtures cy.contains('mistral-vllm').should('be.visible'); cy.contains('claude-3-7').should('be.visible'); cy.contains('InService').should('be.visible'); @@ -89,8 +64,10 @@ export function runAdminTests (options: { const minItems = expectMinItems ? 3 : 0; navigateAndVerifyAdminPage('API Token Management', '/api-token-management', 'API Token', 'table', minItems); + // Wait for API tokens to load + cy.wait('@getApiTokens', { timeout: 30000 }); + if (verifyFixtureData) { - // Verify specific token data from fixtures cy.contains('Development Token').should('be.visible'); cy.contains('Production API Key').should('be.visible'); cy.contains('Test Environment Token').should('be.visible'); @@ -100,9 +77,9 @@ export function runAdminTests (options: { it('RAG Management page loads and shows repositories table', () => { const minItems = expectMinItems ? 3 : 0; navigateAndVerifyAdminPage('RAG Management', '/repository-management', 'RAG', 'table', minItems); + cy.wait('@getRepositories', { timeout: 30000 }); if (verifyFixtureData) { - // Verify specific repository data from fixtures cy.contains('Technical Documentation').should('be.visible'); cy.contains('Product Knowledge Base').should('be.visible'); cy.contains('Training Materials').should('be.visible'); @@ -112,9 +89,9 @@ export function runAdminTests (options: { it('MCP Management page loads and shows servers table', () => { const minItems = expectMinItems ? 3 : 0; navigateAndVerifyAdminPage('MCP Management', '/mcp-management', 'MCP', 'table', minItems); + cy.wait('@getMcp', { timeout: 30000 }); if (verifyFixtureData) { - // Verify specific MCP server data from fixtures cy.contains('Weather Service').should('be.visible'); cy.contains('Database Connector').should('be.visible'); cy.contains('File Processing Service').should('be.visible'); @@ -124,7 +101,6 @@ export function runAdminTests (options: { it('MCP Workbench page loads', () => { const minItems = expectMinItems ? 3 : 0; const contentType = expectMinItems ? 'list' : 'custom'; - cy.wait('@stubConfiguration'); navigateAndVerifyAdminPage( 'MCP Workbench', '/mcp-workbench', @@ -132,9 +108,9 @@ export function runAdminTests (options: { contentType, minItems ); + cy.wait('@getMcpWorkbench', { timeout: 30000 }); if (verifyFixtureData) { - // Verify specific tool files from fixtures cy.get('li[data-testid="bad_actors_db.py"]').should('be.visible'); cy.get('li[data-testid="calculator.py"]').should('be.visible'); cy.get('li[data-testid="weather.py"]').should('be.visible'); diff --git a/cypress/src/shared/specs/bedrock-model-workflow.shared.spec.ts b/cypress/src/shared/specs/bedrock-model-workflow.shared.spec.ts new file mode 100644 index 000000000..6d85a1fe8 --- /dev/null +++ b/cypress/src/shared/specs/bedrock-model-workflow.shared.spec.ts @@ -0,0 +1,131 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/// + +/** + * Shared test suite for Bedrock model creation and chat workflow. + * Can be used by both smoke tests (with fixtures) and e2e tests (with real data). + */ + +import { navigateToAdminPage } from '../../support/adminHelpers'; +import { navigateAndVerifyChatPage } from '../../support/chatHelpers'; +import { + BedrockModelConfig, + openCreateModelWizard, + fillBedrockModelConfig, + completeBedrockModelWizard, + waitForModelCreationSuccess, + verifyModelInList, + deleteModelIfExists, + selectModelInChat, + sendChatMessage, + verifyChatResponse, + deleteAllSessions, +} from '../../support/modelFormHelpers'; +import { + RepositoryConfig, + navigateToRepositoryManagement, + openCreateRepositoryWizard, + fillRepositoryConfig, + selectKnowledgeBase, + selectDataSource, + skipToCreateRepository, + completeRepositoryWizard, + waitForRepositoryCreationSuccess, + verifyRepositoryInList, + deleteRepositoryIfExists, +} from '../../support/repositoryHelpers'; + +// Amazon Nova Micro - cheapest Bedrock serverless model +const DEFAULT_TEST_MODEL: BedrockModelConfig = { + modelId: `e2e-nova-micro-${Date.now()}`, + modelName: 'bedrock/us.amazon.nova-micro-v1:0', + modelDescription: 'E2E test model - Amazon Nova Micro', + streaming: true, +}; + +export type BedrockWorkflowTestOptions = { + modelConfig?: BedrockModelConfig; + repositoryConfig?: RepositoryConfig; + skipChat?: boolean; + skipCleanup?: boolean; +}; + +export function runBedrockModelWorkflowTests (options: BedrockWorkflowTestOptions = {}) { + const testModel = options.modelConfig || DEFAULT_TEST_MODEL; + const testRepository: RepositoryConfig = options.repositoryConfig || { + repositoryId: `e2e-repo-${Date.now()}`, + knowledgeBaseName: 'test-bedrock-kb', + dataSourceIndex: 0, + }; + const testPrompt = 'Hello, respond with one word: working'; + + it('Admin creates a Bedrock model via wizard', () => { + navigateToAdminPage('Model Management'); + + openCreateModelWizard(); + fillBedrockModelConfig(testModel); + completeBedrockModelWizard(); + waitForModelCreationSuccess(testModel.modelId); + }); + + it('New model appears in Model Management list', () => { + navigateToAdminPage('Model Management'); + verifyModelInList(testModel.modelId); + }); + + it('Admin creates a repository with the new Bedrock model', () => { + navigateToRepositoryManagement(); + + openCreateRepositoryWizard(); + fillRepositoryConfig(testRepository); + selectKnowledgeBase(testRepository.knowledgeBaseName); + selectDataSource(testRepository.dataSourceIndex); + skipToCreateRepository(); + completeRepositoryWizard(); + waitForRepositoryCreationSuccess(testRepository.repositoryId); + }); + + it('New repository appears in RAG Management list', () => { + navigateToRepositoryManagement(); + verifyRepositoryInList(testRepository.repositoryId); + }); + + it('User selects new model in Chat and sends a message', () => { + navigateAndVerifyChatPage(); + selectModelInChat(testModel.modelId); + sendChatMessage(testPrompt); + verifyChatResponse(testPrompt); + }); + + it('Cleanup: delete all chat sessions', () => { + navigateAndVerifyChatPage(); + deleteAllSessions(); + }); + + it('Cleanup: delete test repository', () => { + navigateToRepositoryManagement(); + cy.wait(2000); + deleteRepositoryIfExists(testRepository.repositoryId); + }); + + it('Cleanup: delete test model', () => { + navigateToAdminPage('Model Management'); + cy.wait(2000); + deleteModelIfExists(testModel.modelId); + }); +} diff --git a/cypress/src/shared/specs/chat.shared.spec.ts b/cypress/src/shared/specs/chat.shared.spec.ts index b44da92e1..6f86be58a 100644 --- a/cypress/src/shared/specs/chat.shared.spec.ts +++ b/cypress/src/shared/specs/chat.shared.spec.ts @@ -19,283 +19,81 @@ /** * Shared test suite for Chat Page features. * Can be used by both smoke tests (with fixtures) and e2e tests (with real data). + * + * Interceptors should be set up in beforeEach by the calling spec. */ import { navigateAndVerifyChatPage, - selectSessionByName, - verifySessionLoaded, - verifyChatHistory, + getModelInput, + getRagRepoInput, + getMessageInput, + getDropdownOptions, + selectModel, } from '../../support/chatHelpers'; export function runChatTests (options: { - testSessionSelection?: boolean; - testRAGConfiguration?: boolean; + verifyFixtureData?: boolean; } = {}) { - const { testSessionSelection = false, testRAGConfiguration = false } = options; - - it('User can navigate to chat home page', () => { - navigateAndVerifyChatPage(); - }); + const { verifyFixtureData = false } = options; it('Model dropdown is populated and selectable', () => { - if (testSessionSelection) { - cy.fixture('env.json').then((env) => { - const apiBase = env.API_BASE_URL.replace(/\/+$/, ''); - - cy.fixture('models.json').then((modelsData) => { - cy.intercept('GET', `**/${apiBase}/models*`, { - statusCode: 200, - body: modelsData, - }).as('getModels'); - }); - - navigateAndVerifyChatPage(); - cy.wait('@getModels'); - - cy.get('input[placeholder*="model" i], input[aria-label*="model" i]') - .first() - .should('exist') - .click(); - - cy.contains('mistral-vllm').should('be.visible'); - cy.contains('claude-3-7').should('be.visible'); - }); - } else { - navigateAndVerifyChatPage(); + navigateAndVerifyChatPage(); - // Wait for models API call to complete by checking that the input is populated - // The component will show a loading state or be disabled while fetching - cy.get('input[placeholder*="model" i], input[aria-label*="model" i]', { timeout: 45000 }) - .first() - .should('exist') - .and('be.visible') - .and('not.be.disabled'); + // Wait for models to load + cy.wait('@getModels', { timeout: 30000 }); - // Additional wait to ensure the component has fully processed the models data - cy.wait(2000); + getModelInput() + .should('be.visible') + .and('not.be.disabled') + .click({ force: true }); - // Click to open dropdown - cy.get('input[placeholder*="model" i], input[aria-label*="model" i]') - .first() - .click({ force: true }); + // Wait for dropdown options to appear + getDropdownOptions() + .should('be.visible') + .and('have.length.at.least', 1); - // Wait for dropdown options to appear - cy.get('[role="option"], [role="menuitem"]') - .should('be.visible') - .and('have.length.at.least', 1); + if (verifyFixtureData) { + cy.contains('mistral-vllm').should('be.visible'); + cy.contains('claude-3-7').should('be.visible'); } }); - if (testRAGConfiguration) { - it('RAG repository and collection dropdowns populate when selected', () => { - cy.fixture('env.json').then((env) => { - const apiBase = env.API_BASE_URL.replace(/\/+$/, ''); - - cy.fixture('repository.json').then((repos) => { - cy.intercept('GET', `**/${apiBase}/repository*`, { - statusCode: 200, - body: repos, - }).as('getRepositories'); - }); - - cy.fixture('collections.json').then((collections) => { - cy.intercept('GET', `**/${apiBase}/repository/repo-001/collection*`, { - statusCode: 200, - body: collections, - }).as('getCollections'); - }); - - navigateAndVerifyChatPage(); - - cy.wait('@getRepositories'); - - cy.get('input#rag-repository-autosuggest, input[placeholder*="RAG Repository" i]') - .should('be.visible') - .clear() - .type('Technical'); - - cy.get('[role="option"], [role="menuitem"]') - .contains('Technical Documentation') - .should('be.visible') - .click(); - - cy.wait('@getCollections'); - - cy.get('input#rag-repository-autosuggest, input[placeholder*="RAG Repository" i]') - .should('have.value', 'repo-001'); - - cy.get('input#collection-autosuggest, input[placeholder*="collection" i]') - .should('not.be.disabled'); + it('RAG repository dropdown is accessible', () => { + navigateAndVerifyChatPage(); - cy.get('input#collection-autosuggest, input[placeholder*="collection" i]') - .clear() - .type('API'); + // Wait for repositories to load + cy.wait('@getRepositories', { timeout: 30000 }); - cy.get('[role="option"], [role="menuitem"]') - .contains('API Documentation Collection') - .should('be.visible'); - }); - }); - } else { - it('RAG repository dropdown is accessible', () => { - navigateAndVerifyChatPage(); + getRagRepoInput() + .should('be.visible') + .and('not.be.disabled') + .click({ force: true }); - // Wait for the input to be ready - cy.get('input#rag-repository-autosuggest, input[placeholder*="RAG Repository" i]') - .should('be.visible') - .and('not.be.disabled') - .click({ force: true }); - }); - } + if (verifyFixtureData) { + getDropdownOptions() + .should('be.visible'); + cy.contains('Technical Documentation').should('be.visible'); + } + }); it('Chat interface has message input that requires model selection', () => { navigateAndVerifyChatPage(); + // Wait for models to load + cy.wait('@getModels', { timeout: 30000 }); + // Initially, message input should be disabled until model is selected - cy.get('textarea[placeholder*="message" i]') + getMessageInput() .should('be.visible') .and('be.disabled'); - // Select a model first - cy.get('input[placeholder*="model" i], input[aria-label*="model" i]', { timeout: 45000 }) - .first() - .should('exist') - .and('be.visible') - .and('not.be.disabled') - .click({ force: true }); - - // Wait for dropdown options and select the first available model - cy.get('[role="option"], [role="menuitem"]') - .should('be.visible') - .and('have.length.at.least', 1) - .first() - .click(); + // Select a model + selectModel(); // Now message input should be enabled - cy.get('textarea[placeholder*="message" i]') + getMessageInput() .should('be.visible') .and('not.be.disabled'); }); - - if (testSessionSelection) { - it('User can select a session from history', () => { - cy.fixture('env.json').then((env) => { - const apiBase = env.API_BASE_URL.replace(/\/+$/, ''); - - cy.fixture('session-detail.json').then((session) => { - cy.intercept('GET', `**/${apiBase}/session/f56fc284-629c-4ba7-ab3d-56f4a21c13ee`, { - statusCode: 200, - body: session, - }).as('getSession'); - }); - - navigateAndVerifyChatPage(); - - cy.wait('@stubSession'); - - selectSessionByName('Technical Discussion'); - - cy.wait('@getSession'); - verifySessionLoaded('f56fc284-629c-4ba7-ab3d-56f4a21c13ee'); - }); - }); - - it('Chat interface displays session history when session is loaded', () => { - cy.fixture('env.json').then((env) => { - const apiBase = env.API_BASE_URL.replace(/\/+$/, ''); - - cy.fixture('session-detail.json').then((session) => { - cy.intercept('GET', `**/${apiBase}/session/f56fc284-629c-4ba7-ab3d-56f4a21c13ee`, { - statusCode: 200, - body: session, - }).as('getSession'); - }); - - navigateAndVerifyChatPage(); - - // Wait for sessions to load using the existing stubSession alias - cy.wait('@stubSession'); - - selectSessionByName('Technical Discussion'); - - cy.wait('@getSession'); - - verifySessionLoaded('f56fc284-629c-4ba7-ab3d-56f4a21c13ee'); - - verifyChatHistory([ - 'What is the difference between REST and GraphQL?', - 'REST and GraphQL are both API architectures' - ]); - }); - }); - - it('Model selector shows correct model from loaded session', () => { - cy.fixture('env.json').then((env) => { - const apiBase = env.API_BASE_URL.replace(/\/+$/, ''); - - cy.fixture('session-detail.json').then((session) => { - cy.intercept('GET', `**/${apiBase}/session/f56fc284-629c-4ba7-ab3d-56f4a21c13ee`, { - statusCode: 200, - body: session, - }).as('getSession'); - }); - - cy.fixture('models.json').then((modelsData) => { - cy.intercept('GET', `**/${apiBase}/models*`, { - statusCode: 200, - body: modelsData, - }).as('getModels'); - }); - - navigateAndVerifyChatPage(); - cy.visit('#/ai-assistant/f56fc284-629c-4ba7-ab3d-56f4a21c13ee'); - - cy.wait('@getSession'); - cy.wait('@getModels'); - - cy.get('input[placeholder*="model" i], input[aria-label*="model" i]') - .should('have.value', 'mistral-vllm'); - }); - }); - - it('RAG configuration loads from session', () => { - cy.fixture('env.json').then((env) => { - const apiBase = env.API_BASE_URL.replace(/\/+$/, ''); - - cy.fixture('session-detail.json').then((session) => { - cy.intercept('GET', `**/${apiBase}/session/f56fc284-629c-4ba7-ab3d-56f4a21c13ee`, { - statusCode: 200, - body: session, - }).as('getSession'); - }); - - cy.fixture('repository.json').then((repos) => { - cy.intercept('GET', `**/${apiBase}/repository*`, { - statusCode: 200, - body: repos, - }).as('getRepositories'); - }); - - cy.fixture('collections.json').then((collections) => { - cy.intercept('GET', `**/${apiBase}/collections*`, { - statusCode: 200, - body: collections, - }).as('getCollections'); - }); - - navigateAndVerifyChatPage(); - cy.visit('#/ai-assistant/f56fc284-629c-4ba7-ab3d-56f4a21c13ee'); - - cy.wait('@getSession'); - - cy.get('input#rag-repository-autosuggest, input[placeholder*="RAG Repository" i]') - .should('have.value', 'repo-001'); - - cy.get('input#collection-autosuggest, input[placeholder*="collection" i]') - .should('not.be.disabled') - .and('have.value', 'API Documentation Collection'); - }); - }); - } } diff --git a/cypress/src/shared/specs/user.shared.spec.ts b/cypress/src/shared/specs/user.shared.spec.ts index 56a6610e2..109bef61f 100644 --- a/cypress/src/shared/specs/user.shared.spec.ts +++ b/cypress/src/shared/specs/user.shared.spec.ts @@ -19,12 +19,17 @@ /** * Shared test suite for User role features. * Can be used by both smoke tests (with fixtures) and e2e tests (with real data). + * + * Interceptors should be set up in beforeEach by the calling spec. */ import { checkNoAdminButton } from '../../support/adminHelpers'; export function runUserTests () { it('Non-admin does not see the Administration button', () => { + // Wait for configuration to load before checking UI + cy.wait('@getConfiguration', { timeout: 30000 }); + checkNoAdminButton(); }); @@ -41,7 +46,7 @@ export function runUserTests () { adminPaths.forEach((path) => { cy.visit(path, { failOnStatusCode: false, timeout: 10000 }); - cy.url({ timeout: 10000 }).should('satisfy', (url) => { + cy.url({ timeout: 10000 }).should('satisfy', (url: string) => { // Should be redirected away from admin path, or show access denied // Accept homepage redirect as valid (which is what AdminRoute does) return !url.includes(path.replace('#/', '')) || diff --git a/cypress/src/smoke/fixtures/env.json b/cypress/src/smoke/fixtures/env.json index 60a171023..f46c22504 100644 --- a/cypress/src/smoke/fixtures/env.json +++ b/cypress/src/smoke/fixtures/env.json @@ -2,6 +2,7 @@ "AUTHORITY": "http://localhost", "CLIENT_ID": "1234", "ADMIN_GROUP": "admin", + "USER_GROUP": "", "JWT_GROUPS_PROP": "cognito:groups", "CUSTOM_SCOPES": [], "RESTAPI_URI": "", diff --git a/cypress/src/smoke/fixtures/openid-config.json b/cypress/src/smoke/fixtures/openid-config.json index 8872d9de5..93e3479d5 100644 --- a/cypress/src/smoke/fixtures/openid-config.json +++ b/cypress/src/smoke/fixtures/openid-config.json @@ -1,29 +1,14 @@ { - "authorization_endpoint": "https://us-east-1l8pixpq9v.auth.us-east-1.amazoncognito.com/oauth2/authorize", - "end_session_endpoint": "https://us-east-1l8pixpq9v.auth.us-east-1.amazoncognito.com/logout", - "id_token_signing_alg_values_supported": [ - "RS256" - ], - "issuer": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_L8pIxpQ9v", - "jwks_uri": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_L8pIxpQ9v/.well-known/jwks.json", - "response_types_supported": [ - "code", - "token" - ], - "revocation_endpoint": "https://us-east-1l8pixpq9v.auth.us-east-1.amazoncognito.com/oauth2/revoke", - "scopes_supported": [ - "openid", - "email", - "phone", - "profile" - ], - "subject_types_supported": [ - "public" - ], - "token_endpoint": "https://us-east-1l8pixpq9v.auth.us-east-1.amazoncognito.com/oauth2/token", - "token_endpoint_auth_methods_supported": [ - "client_secret_basic", - "client_secret_post" - ], - "userinfo_endpoint": "https://us-east-1l8pixpq9v.auth.us-east-1.amazoncognito.com/oauth2/userInfo" + "authorization_endpoint": "http://localhost/oauth2/authorize", + "end_session_endpoint": "http://localhost/logout", + "id_token_signing_alg_values_supported": ["RS256", "none"], + "issuer": "http://localhost", + "jwks_uri": "http://localhost/.well-known/jwks.json", + "response_types_supported": ["code", "token"], + "revocation_endpoint": "http://localhost/oauth2/revoke", + "scopes_supported": ["openid", "email", "phone", "profile"], + "subject_types_supported": ["public"], + "token_endpoint": "http://localhost/oauth2/token", + "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], + "userinfo_endpoint": "http://localhost/oauth2/userInfo" } diff --git a/cypress/src/smoke/specs/chat.smoke.spec.ts b/cypress/src/smoke/specs/chat.smoke.spec.ts index 9a1657c4a..21003c075 100644 --- a/cypress/src/smoke/specs/chat.smoke.spec.ts +++ b/cypress/src/smoke/specs/chat.smoke.spec.ts @@ -27,11 +27,12 @@ describe('Chat Page (Smoke)', () => { beforeEach(() => { cy.loginAs('user'); }); + after(() => { cy.clearAllSessionStorage(); }); + runChatTests({ - testSessionSelection: true, - testRAGConfiguration: true, + verifyFixtureData: true, }); }); diff --git a/cypress/src/smoke/specs/user.smoke.spec.ts b/cypress/src/smoke/specs/user.smoke.spec.ts index ff0751a0b..969fc7611 100644 --- a/cypress/src/smoke/specs/user.smoke.spec.ts +++ b/cypress/src/smoke/specs/user.smoke.spec.ts @@ -24,7 +24,6 @@ import { runUserTests } from '../../shared/specs/user.shared.spec'; describe('User features (Smoke)', () => { - beforeEach(() => { cy.loginAs('user'); }); @@ -33,5 +32,7 @@ describe('User features (Smoke)', () => { cy.clearAllSessionStorage(); }); - runUserTests(); + runUserTests({ + verifyFixtureData: true, + }); }); diff --git a/cypress/src/smoke/support/commands.ts b/cypress/src/smoke/support/commands.ts index 2b54719ce..05fe3dc5e 100644 --- a/cypress/src/smoke/support/commands.ts +++ b/cypress/src/smoke/support/commands.ts @@ -18,130 +18,157 @@ import { randomUUID, randomString, toBase64Url } from './utils'; -// Base application URL from Cypress config -const BASE_URL = Cypress.config('baseUrl'); - -// List of endpoints to stub with fixtures +// API endpoints with their aliases (matching E2E pattern) const API_STUBS = [ - 'models', - 'prompt-templates', - 'repository', - 'configuration', - 'health', - 'session', - 'api-tokens', - 'mcp', - 'mcp-server', - 'mcp-workbench', - 'collections', + { endpoint: 'models', alias: 'getModels' }, + { endpoint: 'prompt-templates', alias: 'getPromptTemplates' }, + { endpoint: 'repository', alias: 'getRepositories' }, + { endpoint: 'configuration', alias: 'getConfiguration' }, + { endpoint: 'health', alias: 'getHealth' }, + { endpoint: 'session', alias: 'getSessions' }, + { endpoint: 'api-tokens', alias: 'getApiTokens' }, + { endpoint: 'mcp', alias: 'getMcp' }, + { endpoint: 'mcp-server', alias: 'getMcpServers' }, + { endpoint: 'mcp-workbench', alias: 'getMcpWorkbench' }, + { endpoint: 'collections', alias: 'getCollections' }, ]; /** - * Setup API stubs and OIDC mocks for a given role. - * This is the core login logic extracted for reuse. - * - * @param {'admin'|'user'} role - The role to simulate. + * Setup API stubs for smoke tests. */ -function setupLoginStubs (role: 'admin' | 'user') { - const isAdmin = role === 'admin'; - - let apiBase: string = '/dev/'; - // --- Stub env.js so window.env is correct --- - cy.fixture('env.json').then((env) => { - const script = `window.env = ${JSON.stringify(env)};`; - apiBase = env.API_BASE_URL.replace(/\/+$/, ''); - cy.intercept('GET', '**/env.js', { - body: script, - headers: { 'Content-Type': 'application/javascript' }, - }).as('stubEnv'); - }); - - // --- Stub all API endpoints --- - API_STUBS.forEach((name) => { - const alias = `stub${name.charAt(0).toUpperCase()}${name.slice(1)}`; - cy.intercept('GET', `**/${apiBase}/${name}*`, { fixture: `${name}.json` }).as(alias); +function setupApiStubs (env: Record) { + const script = `window.env = ${JSON.stringify(env)};`; + const apiBase = String(env.API_BASE_URL).replace(/\/+$/, ''); + + cy.intercept('GET', '**/env.js', { + body: script, + headers: { 'Content-Type': 'application/javascript' }, + }).as('stubEnv'); + + // Stub all API endpoints with consistent aliases + API_STUBS.forEach(({ endpoint, alias }) => { + cy.intercept('GET', `**${apiBase}/${endpoint}*`, { fixture: `${endpoint}.json` }).as(alias); }); +} - // --- Additional alias for session endpoint (used in chat tests) --- - cy.intercept('GET', `**/${apiBase}/session*`, { fixture: 'session.json' }).as('stubSession'); - - // --- Stub the OIDC /token endpoint with a fresh, valid-looking JWT --- - cy.fixture('oidc-user.json').then((user) => { - const now = Math.floor(Date.now() / 1000); - const profile = { - ...user.profile, - iat: now, - exp: now + 3600, - 'cognito:groups': isAdmin ? ['admin'] : ['user'], - sub: randomUUID(), - 'cognito:username': randomUUID(), - preferred_username: randomString(8), - origin_jti: randomUUID(), - event_id: randomUUID(), - aud: randomUUID(), - name: `User ${randomString(5)}`, - email: `${randomString(6)}@example.com`, - }; - - // --- Build an signed JWT that the OIDC client will accept --- - const header = { alg: 'none', typ: 'JWT' }; - const payload = { ...profile, token_use: 'id' }; - const id_token = `${toBase64Url(header)}.${toBase64Url(payload)}.`; - - // --- Build the stubbed response --- - const stubbed = { - ...user, - profile, - id_token, - access_token: randomString(30), - refresh_token: randomString(40), - expires_at: now + 3600, - }; - - cy.intercept('POST', '**/token', { - statusCode: 200, - headers: { 'Content-Type': 'application/json' }, - body: stubbed, - }).as('stubToken'); - }); +/** + * Build a mock OIDC user object. + */ +function buildOidcUser (role: 'admin' | 'user', env: Record) { + const isAdmin = role === 'admin'; + const groups = isAdmin ? ['admin'] : ['user']; + const now = Math.floor(Date.now() / 1000); + + const jwtPayload = { + sub: randomUUID(), + iss: env.AUTHORITY, + aud: env.CLIENT_ID, + exp: now + 3600, + iat: now, + token_use: 'id', + 'cognito:groups': groups, + 'cognito:username': randomUUID(), + preferred_username: `test-${role}`, + name: `Test ${role.charAt(0).toUpperCase() + role.slice(1)}`, + email: `test-${role}@example.com`, + origin_jti: randomUUID(), + event_id: randomUUID(), + }; + + const header = { alg: 'none', typ: 'JWT' }; + const id_token = `${toBase64Url(header)}.${toBase64Url(jwtPayload)}.`; + + return { + id_token, + access_token: randomString(30), + refresh_token: randomString(40), + token_type: 'Bearer', + expires_at: now + 3600, + profile: jwtPayload, + session_state: null, + scope: 'openid profile email', + }; +} - // --- Stub the OAuth2 authorize callback to redirect straight into the app --- - cy.intercept('GET', '**/authorize?*', (req) => { - const { state } = req.query; - req.redirect(`${BASE_URL}?code=1234&state=${state}`); - }).as('stubSigninCallback'); +/** + * Setup OIDC stubs for the login flow. + */ +function setupOidcStubs (role: 'admin' | 'user', env: Record) { + const oidcUser = buildOidcUser(role, env); - // --- Stub OIDC discovery document --- + // Stub OIDC discovery cy.intercept('GET', '**/.well-known/openid-configuration', { statusCode: 200, fixture: 'openid-config.json', }).as('stubOidc'); + + // Stub the token endpoint to return our mock user + cy.intercept('POST', '**/token', { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + id_token: oidcUser.id_token, + access_token: oidcUser.access_token, + refresh_token: oidcUser.refresh_token, + token_type: 'Bearer', + expires_in: 3600, + }, + }).as('stubToken'); + + // Stub the authorize endpoint to redirect back with a code + cy.intercept('GET', '**/authorize?*', (req) => { + const url = new URL(req.url); + const state = url.searchParams.get('state'); + const redirectUri = url.searchParams.get('redirect_uri') || 'http://localhost:3000'; + + // Redirect back to the app with auth code + req.redirect(`${redirectUri}?code=mock-auth-code&state=${state}`); + }).as('stubAuthorize'); } /** - * Custom command to log in a user via stubbed OAuth2/OIDC. - * Can log in as an 'admin' or a normal 'user'. - * This performs the actual login flow and should be wrapped in cy.session() by the caller. - * - * @param {'admin'|'user'} role - The role to simulate (defaults to 'user'). + * Wait for the app to be fully loaded. + */ +function waitForAppReady () { + // Wait for "Loading configuration..." to disappear + cy.contains('Loading configuration...', { timeout: 15000 }).should('not.exist'); + + // Wait for spinners to disappear + cy.get('body').then(($body) => { + if ($body.find('[class*="awsui_spinner"]').length > 0) { + cy.get('[class*="awsui_spinner"]', { timeout: 10000 }).should('not.exist'); + } + }); +} + +/** + * Custom command to log in a user via stubbed OIDC flow. */ Cypress.Commands.add('loginAs', (role = 'user') => { - setupLoginStubs(role); + cy.fixture('env.json').then((env) => { + // Setup all stubs + setupApiStubs(env); + setupOidcStubs(role, env); + + // Visit the app + cy.visit('/'); + + // Click sign in to trigger OIDC flow + cy.contains('Sign in').click(); - // --- Trigger the login flow in the UI --- - cy.visit('/'); - cy.contains('Sign in').click(); + // Wait for the redirect and login to complete + cy.contains('Sign in', { timeout: 10000 }).should('not.exist'); - // Wait for login to complete by checking we're no longer on login screen - cy.contains('Sign in').should('not.exist'); + // Wait for app to be ready + waitForAppReady(); + }); }); /** - * Custom command to setup API stubs for a given role. - * This should be called in beforeEach after cy.session() to re-establish intercepts. - * - * @param {'admin'|'user'} role - The role to simulate. + * Custom command to setup API stubs. */ -Cypress.Commands.add('setupStubs', (role = 'user') => { - setupLoginStubs(role); +Cypress.Commands.add('setupStubs', () => { + cy.fixture('env.json').then((env) => { + setupApiStubs(env); + }); }); diff --git a/cypress/src/smoke/support/utils.ts b/cypress/src/smoke/support/utils.ts index 829ee11e0..43dbabe1a 100644 --- a/cypress/src/smoke/support/utils.ts +++ b/cypress/src/smoke/support/utils.ts @@ -16,7 +16,7 @@ import { times, random } from 'lodash'; -export const toBase64Url = (obj: { alg: string; typ: string; }) => +export const toBase64Url = (obj: Record) => btoa(JSON.stringify(obj)) .replace(/=+$/, '') .replace(/\+/g, '-') diff --git a/cypress/src/support/adminHelpers.ts b/cypress/src/support/adminHelpers.ts index 6c1c58683..93f76c187 100644 --- a/cypress/src/support/adminHelpers.ts +++ b/cypress/src/support/adminHelpers.ts @@ -26,61 +26,76 @@ import { waitForContentToLoad, } from './dataHelpers'; -export function checkAdminButtonExists () { - cy.get('button[aria-label="Administration"]') - .should('exist') - .and('be.visible') - .and('have.attr', 'aria-expanded', 'false'); +// Cloudscape TopNavigation selectors +// Use aria-label to target the specific Administration menu, not just any [role="menu"] +const ADMIN_MENU_SELECTOR = '[role="menu"][aria-label="Administration"]'; +const MENU_ITEM_SELECTOR = '[role="menuitem"]'; + +// Core menu items that are always present for admin users +const EXPECTED_MENU_ITEMS = [ + 'Configuration', + 'Model Management', + 'RAG Management', + 'API Token Management', + 'MCP Management', + 'MCP Workbench', +]; + +/** + * Get the visible admin button with built-in retry. + * Cloudscape TopNavigation buttons have aria-label for accessibility. + */ +export function getAdminButton (): Cypress.Chainable { + // Use aria-label which is reliable in Cloudscape TopNavigation + return cy.get('header button[aria-label="Administration"]'); } +export function getLibraryButton (): Cypress.Chainable { + // Use aria-label which is reliable in Cloudscape TopNavigation + return cy.get('header button[aria-label="Libraries"]'); +} +/** + * Expand the admin menu and verify all items are present + */ export function expandAdminMenu () { - // click → verify expanded → verify menu items - cy.get('button[aria-label="Administration"]') - .filter(':visible') - .first() + // Wait for both Administration and Libraries buttons to be visible + // This prevents clicking Administration before the header is fully rendered + getLibraryButton().should('be.visible'); + getAdminButton().should('be.visible'); + + getAdminButton() .click() .should('have.attr', 'aria-expanded', 'true'); - // Wait for dropdown animation to complete - cy.wait(500); - - // Get the button-dropdown container once and reuse it - cy.get('button[aria-label="Administration"]') - .filter(':visible') - .first() - .closest('[class*="awsui_button-dropdown_"]') - .as('adminDropdown'); - - // Verify menu is visible - use filter to get only visible menu - cy.get('@adminDropdown') - .find('[role="menu"]') - .filter(':visible') - .should('exist') - .and('be.visible'); - - // Verify menu items - cy.get('@adminDropdown') - .find('[role="menuitem"]') + // Wait for the Administration menu specifically (not Libraries or other menus) + cy.get(ADMIN_MENU_SELECTOR) + .should('be.visible') + .find(MENU_ITEM_SELECTOR) .filter(':visible') - .should('have.length', 6) + .should('have.length.at.least', EXPECTED_MENU_ITEMS.length) .then(($items) => { - const labels = $items - .map((_, el) => Cypress.$(el).text().trim()) - .get(); - expect(labels).to.deep.equal([ - 'Configuration', - 'Model Management', - 'RAG Management', - 'API Token Management', - 'MCP Management', - 'MCP Workbench' - ]); + const labels = $items.map((_, el) => Cypress.$(el).text().trim()).get(); + // Verify core items are present + EXPECTED_MENU_ITEMS.forEach((item) => { + expect(labels).to.include(item); + }); }); } +/** + * Collapse the admin menu + */ +export function collapseAdminMenu () { + getAdminButton() + .click() + .should('have.attr', 'aria-expanded', 'false'); + + cy.get(ADMIN_MENU_SELECTOR).should('not.be.visible'); +} + export function checkNoAdminButton () { - cy.get('button[aria-label="Administration"]') - .should('not.exist'); + // Use the specific selector for the Administration button + cy.get('header button[aria-label="Administration"]').should('not.exist'); } /** @@ -88,20 +103,11 @@ export function checkNoAdminButton () { * @param menuItemName - The exact text of the menu item to click */ export function navigateToAdminPage (menuItemName: string) { - checkAdminButtonExists(); - - // Click to expand menu - cy.get('button[aria-label="Administration"]') - .filter(':visible') - .first() - .click() - .should('have.attr', 'aria-expanded', 'true'); - - // Wait for dropdown animation - cy.wait(500); + // First expand the menu using the same pattern as expandAdminMenu + expandAdminMenu(); - // Find and click the menu item by text content - cy.contains('[role="menuitem"]', menuItemName) + // Then click the specific menu item + cy.contains(MENU_ITEM_SELECTOR, menuItemName) .filter(':visible') .click(); } @@ -119,7 +125,6 @@ export function verifyAdminPageLoaded (urlFragment: string, pageTitle?: string) .should('be.visible') .and('contain.text', pageTitle); } else { - // Just verify some main content is visible cy.get('h1, h2, [data-testid="page-title"], main, [role="main"]') .should('be.visible'); } diff --git a/cypress/src/support/chatHelpers.ts b/cypress/src/support/chatHelpers.ts index 92923e468..7b74919ed 100644 --- a/cypress/src/support/chatHelpers.ts +++ b/cypress/src/support/chatHelpers.ts @@ -19,6 +19,15 @@ * Contains reusable helpers for chat page navigation and verification. */ +// Chat page selectors +export const CHAT_SELECTORS = { + MODEL_INPUT: 'input[placeholder*="model" i], input[aria-label*="model" i]', + RAG_REPO_INPUT: 'input#rag-repository-autosuggest, input[placeholder*="RAG Repository" i]', + COLLECTION_INPUT: 'input#collection-autosuggest, input[placeholder*="collection" i]', + MESSAGE_INPUT: 'textarea[placeholder*="message" i]', + DROPDOWN_OPTION: '[role="option"], [role="menuitem"]', +}; + /** * Navigate to the AI Assistant (chat) page by clicking the menu item */ @@ -109,3 +118,54 @@ export function verifyChatHistory (messageTexts: string[]) { cy.contains(text).should('be.visible'); }); } + + +/** + * Get the model input element + */ +export function getModelInput (): Cypress.Chainable { + return cy.get(CHAT_SELECTORS.MODEL_INPUT).first(); +} + +/** + * Get the RAG repository input element + */ +export function getRagRepoInput (): Cypress.Chainable { + return cy.get(CHAT_SELECTORS.RAG_REPO_INPUT); +} + +/** + * Get the message input textarea + */ +export function getMessageInput (): Cypress.Chainable { + return cy.get(CHAT_SELECTORS.MESSAGE_INPUT); +} + +/** + * Get dropdown options + */ +export function getDropdownOptions (): Cypress.Chainable { + return cy.get(CHAT_SELECTORS.DROPDOWN_OPTION); +} + +/** + * Select a model from the dropdown + * @param modelName - Optional specific model name to select, otherwise selects first available + */ +export function selectModel (modelName?: string) { + getModelInput() + .should('be.visible') + .and('not.be.disabled') + .click({ force: true }); + + if (modelName) { + getDropdownOptions() + .contains(modelName) + .click(); + } else { + getDropdownOptions() + .should('be.visible') + .first() + .click(); + } +} diff --git a/cypress/src/support/dataHelpers.ts b/cypress/src/support/dataHelpers.ts index 9ed0f889b..333a0d0df 100644 --- a/cypress/src/support/dataHelpers.ts +++ b/cypress/src/support/dataHelpers.ts @@ -19,6 +19,9 @@ * Contains reusable helpers for verifying data rendering in tables, cards, and lists. */ +// Common loading indicator selectors +const LOADING_SELECTORS = '[data-testid="loading"], .awsui-spinner, [class*="awsui_spinner"]'; + /** * Verify that a table contains at least one data row (excluding headers) * @param tableSelector - Optional CSS selector for the table (defaults to finding any table) @@ -30,7 +33,6 @@ export function verifyTableHasData (tableSelector?: string, minRows: number = 1) cy.get(selector) .should('be.visible') .within(() => { - // Check for table rows that contain data (not just headers) cy.get('tbody tr, [role="row"]:not([role="columnheader"])') .should('have.length.at.least', minRows); }); @@ -42,7 +44,6 @@ export function verifyTableHasData (tableSelector?: string, minRows: number = 1) */ export function verifyCloudscapeTableHasData (minRows: number = 1) { // Cloudscape tables use specific CSS classes and structure - // Look for table body rows with dynamic class names cy.get('tbody tr, [class*="awsui_row_"]') .should('have.length.at.least', minRows); } @@ -53,7 +54,6 @@ export function verifyCloudscapeTableHasData (minRows: number = 1) { */ export function verifyCardsHaveData (minCards: number = 1) { // Cloudscape cards use dynamic class names with hashes - // Look for list items within the cards container that have the card class pattern cy.get('[class*="awsui_card_"][class*="awsui_card-selectable_"]') .should('have.length.at.least', minCards); } @@ -71,14 +71,13 @@ export function verifyListHasData (minItems: number = 1) { /** * Wait for loading to complete and verify content is rendered - * @param loadingSelector - Selector for loading indicator (optional) + * Uses Cypress's built-in retry mechanism instead of arbitrary waits */ -export function waitForContentToLoad (loadingSelector?: string) { - if (loadingSelector) { - cy.get(loadingSelector).should('not.exist'); - } - - // Wait for common loading indicators to disappear - cy.get('[data-testid="loading"], .awsui-spinner, .loading') - .should('not.exist'); +export function waitForContentToLoad () { + // Wait for loading indicators to disappear (Cypress will retry automatically) + cy.get('body').then(($body) => { + if ($body.find(LOADING_SELECTORS).length > 0) { + cy.get(LOADING_SELECTORS, { timeout: 10000 }).should('not.exist'); + } + }); } diff --git a/cypress/src/support/modelFormHelpers.ts b/cypress/src/support/modelFormHelpers.ts new file mode 100644 index 000000000..3df8e952d --- /dev/null +++ b/cypress/src/support/modelFormHelpers.ts @@ -0,0 +1,186 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/** + * modelFormHelpers.ts + * Reusable helpers for model creation wizard interactions. + */ + +export type BedrockModelConfig = { + modelId: string; + modelName: string; + modelDescription?: string; + streaming?: boolean; +}; + +/** + * Open the Create Model wizard modal + */ +export function openCreateModelWizard () { + cy.contains('button', 'Create Model').should('be.visible').click(); + cy.contains('Base Model Configuration').should('be.visible'); +} + +/** + * Fill in the base model configuration for a third-party (Bedrock) model + */ +export function fillBedrockModelConfig (config: BedrockModelConfig) { + cy.get('input[placeholder="mistral-vllm"]').clear().type(config.modelId); + cy.get('input[placeholder*="mistralai/Mistral"]').clear().type(config.modelName); + + if (config.modelDescription) { + cy.get('input[placeholder*="Brief description"]').clear().type(config.modelDescription); + } + + if (config.streaming) { + cy.get('[data-testid="streaming-toggle"]') + .find('input[type="checkbox"]') + .then(($checkbox) => { + if (!$checkbox.is(':checked')) { + cy.wrap($checkbox).click({ force: true }); + } + }); + } +} + +/** + * Navigate through wizard steps for a third-party model and submit + */ +export function completeBedrockModelWizard () { + // Step 1 -> Guardrails (skip LISA-hosted steps) + cy.contains('button', 'Next').click(); + cy.contains('Guardrails Configuration', { timeout: 5000 }).should('be.visible'); + + // Guardrails -> Review + cy.contains('button', 'Next').click(); + cy.contains('Review and Create', { timeout: 5000 }).should('be.visible'); + + // Submit - target the primary button within the wizard container + cy.get('[data-testid="create-model-wizard"]') + .contains('button', 'Create Model') + .click(); +} + +/** + * Wait for model creation success notification + */ +export function waitForModelCreationSuccess (modelId: string) { + cy.contains(`Successfully created model: ${modelId}`, { timeout: 30000 }).should('be.visible'); +} + +/** + * Verify model appears in the model management list + */ +export function verifyModelInList (modelId: string) { + cy.contains(modelId, { timeout: 10000 }).should('be.visible'); +} + +/** + * Delete a model by ID (for cleanup) + */ +export function deleteModelIfExists (modelId: string) { + cy.get('body').then(($body) => { + if ($body.text().includes(modelId)) { + // Select the model card by clicking its radio button + cy.get(`[data-testid="model-card-${modelId}"]`) + .closest('[data-selection-item="item"]') + .find('input[type="radio"]') + .click({ force: true }); + + // Click the Actions dropdown + cy.get('[data-testid="model-actions-dropdown"]').click(); + + // Click Delete from the dropdown menu + cy.contains('[role="menuitem"]', 'Delete').click(); + + // Wait for confirmation modal and click Delete button + cy.get('[data-testid="confirmation-modal-delete-btn"]', { timeout: 5000 }) + .should('be.visible') + .click(); + + cy.wait(2000); + } + }); +} + +/** + * Select a model in the chat interface + */ +export function selectModelInChat (modelId: string) { + cy.get('input[placeholder*="model" i], input[aria-label*="model" i]', { timeout: 45000 }) + .first() + .should('not.be.disabled') + .click({ force: true }) + .type(modelId); + + cy.get('[role="option"], [role="menuitem"]') + .contains(modelId) + .should('be.visible') + .click(); +} + +/** + * Send a chat message and wait for response + * Sets up an intercept for the inference API before sending + */ +export function sendChatMessage (message: string) { + // Intercept the chat completions API call + cy.intercept('POST', '**/v2/serve/chat/completions').as('chatInference'); + + cy.get('textarea[placeholder*="message" i]') + .should('not.be.disabled') + .type(message); + + cy.get('button[aria-label="Send message"]').click(); +} + +/** + * Verify chat received a response by waiting for the inference API to complete + */ +export function verifyChatResponse (userMessage: string) { + // Wait for the inference API call to complete + cy.wait('@chatInference', { timeout: 60000 }).then((interception) => { + expect(interception.response.statusCode).to.be.oneOf([200, 201]); + }); + + // Verify the user message is displayed + cy.contains(userMessage).should('be.visible'); + + // Verify at least 2 messages exist (user + AI response) + cy.get('[class*="message"], [data-testid*="message"]', { timeout: 10000 }) + .should('have.length.at.least', 2); + + // Verify no error indicators + cy.get('[class*="status-indicator-error"]').should('not.exist'); +} + +/** + * Delete all chat sessions for the current user + */ +export function deleteAllSessions () { + // Click the Delete All Sessions button + cy.get('button[aria-label="Delete All Sessions"]') + .should('be.visible') + .click(); + + // Wait for confirmation modal and click Delete button + cy.get('[data-testid="confirmation-modal-delete-btn"]', { timeout: 5000 }) + .should('be.visible') + .click(); + + // Wait for deletion to complete + cy.wait(2000); +} diff --git a/cypress/src/support/repositoryHelpers.ts b/cypress/src/support/repositoryHelpers.ts new file mode 100644 index 000000000..233313ece --- /dev/null +++ b/cypress/src/support/repositoryHelpers.ts @@ -0,0 +1,184 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/** + * repositoryHelpers.ts + * Reusable helpers for repository creation and management interactions. + */ + +export type RepositoryConfig = { + repositoryId: string; + knowledgeBaseName: string; + dataSourceIndex?: number; +}; + +/** + * Navigate to the repository management page + */ +export function navigateToRepositoryManagement () { + cy.visit('/#/repository-management'); + cy.url().should('include', '/repository-management'); + cy.wait(1000); +} + +/** + * Open the Create Repository wizard modal + */ +export function openCreateRepositoryWizard () { + cy.contains('button', 'Create Repository').should('be.visible').click(); + cy.contains('Repository Configuration').should('be.visible'); +} + +/** + * Fill in the repository configuration with Bedrock Knowledge Base type + */ +export function fillRepositoryConfig (config: RepositoryConfig) { + // Set up intercept for knowledge bases API before selecting repository type + cy.intercept('GET', '**/bedrock-kb').as('getKnowledgeBases'); + + // Fill repository ID + cy.get('[data-testid="repository-id-input"]') + .should('be.visible') + .clear() + .type(config.repositoryId); + + // Select repository type: BEDROCK_KNOWLEDGE_BASE + cy.get('[data-testid="repository-type-select"]') + .find('button') + .click(); + + cy.get('[role="option"]') + .contains('BEDROCK_KNOWLEDGE_BASE') + .should('be.visible') + .click(); + + // Wait for knowledge bases to load after selecting repository type + cy.wait('@getKnowledgeBases', { timeout: 30000 }); +} + +/** + * Wait for knowledge bases to load and select a specific one + */ +export function selectKnowledgeBase (knowledgeBaseName: string) { + // Set up intercept for data sources API before selecting KB + cy.intercept('GET', '**/bedrock-kb/*/data-sources').as('getDataSources'); + + // Wait for the select to be visible (API already loaded in fillRepositoryConfig) + cy.get('[data-testid="knowledge-base-select"]').should('be.visible'); + + // Click the Knowledge Base dropdown button + cy.get('[data-testid="knowledge-base-select"]') + .find('button') + .click(); + + // Select the knowledge base by name + cy.get('[role="option"]') + .contains(knowledgeBaseName) + .should('be.visible') + .click(); + + // Wait for data sources to load after selecting KB + cy.wait('@getDataSources', { timeout: 30000 }); +} + +/** + * Wait for data sources to load and select one by index + */ +export function selectDataSource (index: number = 0) { + // Data sources API already loaded in selectKnowledgeBase + // Wait for the table to be visible + cy.get('[data-testid="data-sources-table"]').should('be.visible'); + + // Wait for table rows to be present + cy.get('[data-testid="data-sources-table"] tbody tr[data-selection-item="item"]') + .should('have.length.at.least', 1); + + // Select the data source checkbox by index + cy.get('[data-testid="data-sources-table"] tbody tr[data-selection-item="item"]') + .eq(index) + .find('input[type="checkbox"]') + .first() + .click({ force: true }); +} + +/** + * Skip to the create step in the repository wizard + */ +export function skipToCreateRepository () { + cy.contains('button', 'Skip to Create').should('be.visible').click(); +} + +/** + * Complete the repository creation wizard + */ +export function completeRepositoryWizard () { + // Scope the Create Repository button to the modal to avoid clicking the page button + cy.get('[data-testid="create-repository-modal"]') + .contains('button', 'Create Repository') + .should('be.visible') + .should('not.be.disabled') + .click(); +} + +/** + * Wait for repository creation success notification + */ +export function waitForRepositoryCreationSuccess (repositoryId: string) { + cy.contains(`Successfully created repository: ${repositoryId}`, { timeout: 30000 }) + .should('be.visible'); +} + +/** + * Verify repository appears in the repository management list + */ +export function verifyRepositoryInList (repositoryId: string) { + cy.contains(repositoryId, { timeout: 10000 }).should('be.visible'); +} + +/** + * Delete a repository by ID (for cleanup) + */ +export function deleteRepositoryIfExists (repositoryId: string) { + cy.get('body').then(($body) => { + if ($body.text().includes(repositoryId)) { + // Select the repository + cy.contains(repositoryId) + .closest('tr, [data-testid*="repository"]') + .find('input[type="radio"], input[type="checkbox"]') + .first() + .click({ force: true }); + + // Click the Actions dropdown or Delete button + cy.get('[data-testid="repository-actions-dropdown"], button') + .contains(/actions|delete/i) + .click(); + + // Click Delete from the dropdown menu if needed + cy.get('body').then(($body) => { + if ($body.find('[role="menuitem"]').length > 0) { + cy.contains('[role="menuitem"]', 'Delete').click(); + } + }); + + // Wait for confirmation modal and click Delete button + cy.get('[data-testid="confirmation-modal-delete-btn"]', { timeout: 5000 }) + .should('be.visible') + .click(); + + cy.wait(2000); + } + }); +} diff --git a/lib/schema/ragSchema.ts b/lib/schema/ragSchema.ts index f5ce99478..4f5b21b2c 100644 --- a/lib/schema/ragSchema.ts +++ b/lib/schema/ragSchema.ts @@ -163,28 +163,29 @@ export const RagRepositoryMetadata = MetadataSchema.extend({ customFields: z.record(z.string(), z.any()).optional().describe('Custom metadata fields for the repository.'), }); -export const RagRepositoryConfigSchema = z - .object({ - repositoryId: z.string() - .nonempty() - .regex(/^[a-z0-9-]{3,20}/, 'Only lowercase alphanumeric characters and \'-\' are supported.') - .regex(/^(?!-).*(? { return !((input.type === RagRepositoryType.OPENSEARCH && input.opensearchConfig === undefined) || (input.type === RagRepositoryType.PGVECTOR && input.rdsConfig === undefined) || @@ -199,9 +200,16 @@ export type RDSConfig = RagRepositoryConfig['rdsConfig']; * Schema for RAG repository configuration used during deployment. * Omits database-managed fields like updatedAt that are set during DB operations. */ -export const RagRepositoryDeploymentConfigSchema = RagRepositoryConfigSchema.omit({ - updatedAt: true, - status: true, -}); +export const RagRepositoryDeploymentConfigSchema = BaseRagRepositoryConfigSchema + .omit({ + updatedAt: true, + status: true, + }) + .refine((input) => { + return !((input.type === RagRepositoryType.OPENSEARCH && input.opensearchConfig === undefined) || + (input.type === RagRepositoryType.PGVECTOR && input.rdsConfig === undefined) || + (input.type === RagRepositoryType.BEDROCK_KNOWLEDGE_BASE && input.bedrockKnowledgeBaseConfig === undefined)); + }) + .describe('Configuration schema for RAG repository used during deployment.'); export type RagRepositoryDeploymentConfig = z.infer; diff --git a/lib/user-interface/react/src/components/app-configured.tsx b/lib/user-interface/react/src/components/app-configured.tsx index f0f0e7b30..28e0d63e6 100644 --- a/lib/user-interface/react/src/components/app-configured.tsx +++ b/lib/user-interface/react/src/components/app-configured.tsx @@ -15,7 +15,7 @@ */ // es-lint-disable -import { AuthProvider, useAuth } from 'react-oidc-context'; +import { AuthProvider } from 'react-oidc-context'; import { HashRouter, Routes, Route } from 'react-router-dom'; import App from '../App'; import { onMcpAuthorization } from 'use-mcp'; @@ -45,10 +45,39 @@ function OAuthCallback () { ); } +const getGroups = (oidcUserProfile: UserProfile): any => { + if (window.env.JWT_GROUPS_PROP) { + const props: string[] = window.env.JWT_GROUPS_PROP.split('.'); + let currentNode: any = oidcUserProfile; + let found = true; + props.forEach((prop) => { + if (prop in currentNode) { + currentNode = currentNode[prop]; + } else { + found = false; + } + }); + return found ? currentNode : undefined; + } else { + return undefined; + } +}; + +const isAdmin = (userGroups: any): boolean => { + return window.env.ADMIN_GROUP ? userGroups.includes(window.env.ADMIN_GROUP) : false; +}; + +const isUser = (userGroups: any): boolean => { + return window.env.USER_GROUP ? userGroups.includes(window.env.USER_GROUP) : false; +}; + +const isApiUser = (userGroups: any): boolean => { + return window.env.API_GROUP ? userGroups.includes(window.env.API_GROUP) : false; +}; + function AppConfigured () { const dispatch = useAppDispatch(); const [oidcUser, setOidcUser] = useState(); - const auth = useAuth(); useEffect(() => { if (oidcUser) { @@ -67,36 +96,6 @@ function AppConfigured () { } }, [dispatch, oidcUser]); - const getGroups = (oidcUserProfile: UserProfile): any => { - if (window.env.JWT_GROUPS_PROP) { - const props: string[] = window.env.JWT_GROUPS_PROP.split('.'); - let currentNode: any = oidcUserProfile; - let found = true; - props.forEach((prop) => { - if (prop in currentNode) { - currentNode = currentNode[prop]; - } else { - found = false; - } - }); - return found ? currentNode : undefined; - } else { - return undefined; - } - }; - - const isAdmin = (userGroups: any): boolean => { - return window.env.ADMIN_GROUP ? userGroups.includes(window.env.ADMIN_GROUP) : false; - }; - - const isUser = (userGroups: any): boolean => { - return window.env.USER_GROUP ? userGroups.includes(window.env.USER_GROUP) : false; - }; - - const isApiUser = (userGroups: any): boolean => { - return window.env.API_GROUP ? userGroups.includes(window.env.API_GROUP) : false; - }; - const baseHref = document?.querySelector('base')?.getAttribute('href')?.replace(/\/$/, ''); // Check if we're on an OAuth callback URL (without hash) @@ -115,12 +114,16 @@ function AppConfigured () { { - if ((window.env.USER_GROUP && user && isUser(getGroups(user.profile))) || !window.env.USER_GROUP){ + if ((window.env.USER_GROUP && user && isUser(getGroups(user.profile))) || !window.env.USER_GROUP) { window.history.replaceState({}, document.title, `${window.location.pathname}${window.location.hash}`); setOidcUser(user); - } else { + } else { + // User not authorized - purge store and remove user from OIDC storage await purgeStore(); - await auth.signoutSilent(); + // Clear OIDC session storage to force re-authentication + const oidcStorageKey = `oidc.user:${window.env.AUTHORITY}:${window.env.CLIENT_ID}`; + sessionStorage.removeItem(oidcStorageKey); + window.location.href = window.location.origin; } }} > diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx index c3922782f..946e00aa2 100644 --- a/lib/user-interface/react/src/components/chatbot/Chat.tsx +++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx @@ -227,7 +227,7 @@ export default function Chat ({ sessionId }) { setModelFilterValue(selectedModel?.modelId ?? ''); }, [selectedModel]); - const { memory, setMemory, metadata } = useMemory( + const { memory, metadata } = useMemory( session, chatConfiguration, selectedModel, @@ -680,9 +680,8 @@ export default function Chat ({ sessionId }) { setInternalSessionId={setInternalSessionId} setSession={setSession} handleSendGenerateRequest={handleSendGenerateRequest} - setMemory={setMemory} // eslint-disable-next-line react-hooks/exhaustive-deps - />), conditionalDeps([modals.documentSummarization], [modals.documentSummarization], [modals.documentSummarization, openModal, closeModal, fileContext, setFileContext, setUserPrompt, userPrompt, selectedModel, setSelectedModel, chatConfiguration, setChatConfiguration, auth.user?.profile.sub, setInternalSessionId, setSession, handleSendGenerateRequest, setMemory])) } + />), conditionalDeps([modals.documentSummarization], [modals.documentSummarization], [modals.documentSummarization, openModal, closeModal, fileContext, setFileContext, setUserPrompt, userPrompt, selectedModel, setSelectedModel, chatConfiguration, setChatConfiguration, auth.user?.profile.sub, setInternalSessionId, setSession, handleSendGenerateRequest])) } {useMemo(() => ( void; userName: string; handleSendGenerateRequest: () => void; - setMemory: (state: ChatMemory) => void; }; export const DocumentSummarizationModal = ({ @@ -70,7 +67,6 @@ export const DocumentSummarizationModal = ({ setSession, userName, handleSendGenerateRequest, - setMemory }: DocumentSummarizationModalProps) => { const [selectedFiles, setSelectedFiles] = useState([]); const [successfulUploads, setSuccessfulUpload] = useState(undefined); @@ -167,13 +163,6 @@ export const DocumentSummarizationModal = ({ startTime: new Date(Date.now()).toISOString(), }; setSession(newSession); - - setMemory(new ChatMemory({ - chatHistory: new LisaChatMessageHistory(newSession), - returnMessages: false, - memoryKey: 'history', - k: chatConfiguration.sessionConfiguration.chatHistoryBufferSize, - })); } setSummarize(true); diff --git a/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.test.tsx b/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.test.tsx new file mode 100644 index 000000000..82704fdc4 --- /dev/null +++ b/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.test.tsx @@ -0,0 +1,244 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { renderHook, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useAuth } from 'react-oidc-context'; + +import { useMemory } from './useMemory.hooks'; +import { LisaChatSession } from '@/components/types'; +import { IChatConfiguration } from '@/shared/model/chat.configurations.model'; +import { IModel } from '@/shared/model/model-management.model'; +import { ChatMemory } from '@/shared/util/chat-memory'; + +vi.mock('react-oidc-context'); + +const createMockSession = (overrides?: Partial): LisaChatSession => ({ + sessionId: 'test-session-id', + history: [], + userId: 'test-user', + startTime: '2024-01-01T00:00:00.000Z', + ...overrides, +}); + +const createMockChatConfiguration = (overrides?: Partial): IChatConfiguration => ({ + sessionConfiguration: { + chatHistoryBufferSize: 10, + max_tokens: 1024, + modelArgs: {}, + ...overrides, + }, +} as IChatConfiguration); + +const createMockModel = (overrides?: Partial): IModel => ({ + modelId: 'test-model', + modelName: 'Test Model', + features: [], + ...overrides, +} as IModel); + +const mockNotificationService = { + generateNotification: vi.fn(), +}; + +describe('useMemory', () => { + beforeEach(() => { + vi.clearAllMocks(); + (useAuth as ReturnType).mockReturnValue({ + isAuthenticated: true, + user: { profile: { sub: 'test-user' } }, + }); + }); + + it('creates memory with correct configuration from session and chat config', () => { + const session = createMockSession(); + const chatConfig = createMockChatConfiguration({ chatHistoryBufferSize: 15 }); + + const { result } = renderHook(() => + useMemory(session, chatConfig, undefined, '', '', mockNotificationService) + ); + + expect(result.current.memory).toBeInstanceOf(ChatMemory); + expect(result.current.memory.k).toBe(15); + expect(result.current.memory.memoryKey).toBe('history'); + expect(result.current.memory.returnMessages).toBe(false); + }); + + it('updates memory when session changes', () => { + const session1 = createMockSession({ sessionId: 'session-1' }); + const session2 = createMockSession({ sessionId: 'session-2' }); + const chatConfig = createMockChatConfiguration(); + + const { result, rerender } = renderHook( + ({ session }) => useMemory(session, chatConfig, undefined, '', '', mockNotificationService), + { initialProps: { session: session1 } } + ); + + const firstMemory = result.current.memory; + + rerender({ session: session2 }); + + expect(result.current.memory).not.toBe(firstMemory); + }); + + it('updates memory when buffer size changes', () => { + const session = createMockSession(); + const chatConfig1 = createMockChatConfiguration({ chatHistoryBufferSize: 10 }); + const chatConfig2 = createMockChatConfiguration({ chatHistoryBufferSize: 20 }); + + const { result, rerender } = renderHook( + ({ chatConfig }) => useMemory(session, chatConfig, undefined, '', '', mockNotificationService), + { initialProps: { chatConfig: chatConfig1 } } + ); + + expect(result.current.memory.k).toBe(10); + + rerender({ chatConfig: chatConfig2 }); + + expect(result.current.memory.k).toBe(20); + }); + + it('does not recreate memory when unrelated props change', async () => { + const session = createMockSession(); + const chatConfig = createMockChatConfiguration(); + const model = createMockModel(); + + const { result, rerender } = renderHook( + ({ userPrompt }) => useMemory(session, chatConfig, model, userPrompt, '', mockNotificationService), + { initialProps: { userPrompt: 'initial prompt' } } + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const firstMemory = result.current.memory; + + await act(async () => { + rerender({ userPrompt: 'updated prompt' }); + }); + + expect(result.current.memory).toBe(firstMemory); + }); + + it('updates metadata when model and auth are available', async () => { + const session = createMockSession(); + const chatConfig = createMockChatConfiguration({ max_tokens: 2048, modelArgs: { temperature: 0.7 } }); + const model = createMockModel({ modelId: 'claude-v2' }); + + const { result } = renderHook(() => + useMemory(session, chatConfig, model, '', '', mockNotificationService) + ); + + await waitFor(() => { + expect(result.current.metadata.modelName).toBe('claude-v2'); + }); + + expect(result.current.metadata.modelKwargs).toEqual({ + max_tokens: 2048, + modelKwargs: { temperature: 0.7 }, + }); + }); + + it('does not update metadata when not authenticated', async () => { + (useAuth as ReturnType).mockReturnValue({ + isAuthenticated: false, + user: null, + }); + + const session = createMockSession(); + const chatConfig = createMockChatConfiguration(); + const model = createMockModel(); + + const { result } = renderHook(() => + useMemory(session, chatConfig, model, '', '', mockNotificationService) + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + expect(result.current.metadata).toEqual({}); + }); + + it('shows notification when model without image support receives image context', async () => { + const session = createMockSession(); + const chatConfig = createMockChatConfiguration(); + const modelWithoutImageSupport = createMockModel({ features: [] }); + + await act(async () => { + renderHook(() => + useMemory( + session, + chatConfig, + modelWithoutImageSupport, + '', + 'File context: data:image/png;base64,abc123', + mockNotificationService + ) + ); + }); + + expect(mockNotificationService.generateNotification).toHaveBeenCalledWith( + 'Removed file from context as new model doesn\'t support image input', + 'info' + ); + }); + + it('does not show notification when model supports image input', async () => { + const session = createMockSession(); + const chatConfig = createMockChatConfiguration(); + const modelWithImageSupport = createMockModel({ + features: [{ name: 'imageInput' }], + }); + + await act(async () => { + renderHook(() => + useMemory( + session, + chatConfig, + modelWithImageSupport, + '', + 'File context: data:image/png;base64,abc123', + mockNotificationService + ) + ); + }); + + expect(mockNotificationService.generateNotification).not.toHaveBeenCalled(); + }); + + it('does not show notification when file context is not an image', async () => { + const session = createMockSession(); + const chatConfig = createMockChatConfiguration(); + const modelWithoutImageSupport = createMockModel({ features: [] }); + + await act(async () => { + renderHook(() => + useMemory( + session, + chatConfig, + modelWithoutImageSupport, + '', + 'File context: some text content', + mockNotificationService + ) + ); + }); + + expect(mockNotificationService.generateNotification).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.tsx b/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.tsx index 8bdd04427..2e1aa03ec 100644 --- a/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.tsx +++ b/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useAuth } from 'react-oidc-context'; import { ChatMemory } from '@/shared/util/chat-memory'; import { LisaChatMessageHistory } from '@/components/adapters/lisa-chat-history'; @@ -32,26 +32,18 @@ export const useMemory = ( ) => { const auth = useAuth(); const [metadata, setMetadata] = useState({}); - const [memory, setMemory] = useState( - new ChatMemory({ - chatHistory: new LisaChatMessageHistory(session), - returnMessages: false, - memoryKey: 'history', - k: chatConfiguration.sessionConfiguration.chatHistoryBufferSize, - }), - ); - // Update memory when session history or buffer size changes - useEffect(() => { - setMemory( + // Memoize memory to update when session history or buffer size changes + const memory = useMemo( + () => new ChatMemory({ chatHistory: new LisaChatMessageHistory(session), returnMessages: false, memoryKey: 'history', k: chatConfiguration.sessionConfiguration.chatHistoryBufferSize, }), - ); - }, [session, chatConfiguration.sessionConfiguration.chatHistoryBufferSize]); + [session, chatConfiguration.sessionConfiguration.chatHistoryBufferSize] + ); // Update metadata when model or configuration changes useEffect(() => { @@ -83,7 +75,6 @@ export const useMemory = ( return { memory, - setMemory, metadata, }; }; diff --git a/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx b/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx index 7ab096cdd..1723d8f8e 100644 --- a/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx +++ b/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx @@ -96,11 +96,14 @@ export const useSession = (sessionId: string, getSessionById: any) => { setSession((prev) => ({ ...prev, history: [] })); loadSession(sessionId); } - } else { - // Create new session when no sessionId is provided + } else if (!internalSessionId || internalSessionId !== session.sessionId || session.history.length > 0) { + // Create new session when: + // - No sessionId provided AND no internal session yet, OR + // - Transitioning from an existing session (internalSessionId doesn't match current session or has history) createNewSession(); } - }, [sessionId, dispatch, loadSession, createNewSession]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId, dispatch, loadSession]); return { session, diff --git a/lib/user-interface/react/src/components/model-management/ModelManagementActions.tsx b/lib/user-interface/react/src/components/model-management/ModelManagementActions.tsx index 879f225e4..478b6c884 100644 --- a/lib/user-interface/react/src/components/model-management/ModelManagementActions.tsx +++ b/lib/user-interface/react/src/components/model-management/ModelManagementActions.tsx @@ -142,6 +142,7 @@ function ModelActionButton (dispatch: ThunkDispatch, notificat return ( { }; export const createCardDefinitions = (defaultModelId?: string) => ({ - header: (model: IModel) =>
{model.modelId} {model.modelId === defaultModelId && DEFAULT}
, + header: (model: IModel) =>
{model.modelId} {model.modelId === defaultModelId && DEFAULT}
, sections: [ { id: 'modelName', diff --git a/lib/user-interface/react/src/components/model-management/create-model/BaseModelConfig.tsx b/lib/user-interface/react/src/components/model-management/create-model/BaseModelConfig.tsx index f36dc1f78..f4158045b 100644 --- a/lib/user-interface/react/src/components/model-management/create-model/BaseModelConfig.tsx +++ b/lib/user-interface/react/src/components/model-management/create-model/BaseModelConfig.tsx @@ -188,6 +188,7 @@ export function BaseModelConfig (props: FormProps & BaseModelConf errorText={props.formErrors?.streaming} > props.setFields({'streaming': detail.checked}) } diff --git a/lib/user-interface/react/src/components/model-management/create-model/CreateModelModal.tsx b/lib/user-interface/react/src/components/model-management/create-model/CreateModelModal.tsx index 032b6993c..80395f1f4 100644 --- a/lib/user-interface/react/src/components/model-management/create-model/CreateModelModal.tsx +++ b/lib/user-interface/react/src/components/model-management/create-model/CreateModelModal.tsx @@ -495,64 +495,66 @@ export function CreateModelModal (props: CreateModelModalProps) : ReactElement { description: 'Are you sure you want to discard your changes?' })); }} visible={props.visible} header={`${props.isEdit ? 'Update' : 'Create'} Model`}> - `Step ${stepNumber}`, - collapsedStepsLabel: (stepNumber, stepsCount) => `Step ${stepNumber} of ${stepsCount}`, - skipToButtonLabel: () => `Skip to ${props.isEdit ? 'Update' : 'Create'}`, - navigationAriaLabel: 'Steps', - cancelButton: 'Cancel', - previousButton: 'Previous', - nextButton: 'Next', - optional: 'LISA hosted models only' - }} - onNavigate={(event) => { - switch (event.detail.reason) { - case 'step': - case 'previous': - setState({ - ...state, - activeStepIndex: event.detail.requestedStepIndex, - }); - break; - case 'next': - case 'skip': - { - if (touchFields(requiredFields[state.activeStepIndex]) && isValid) { - setState({ - ...state, - activeStepIndex: event.detail.requestedStepIndex, - }); - break; +
+ `Step ${stepNumber}`, + collapsedStepsLabel: (stepNumber, stepsCount) => `Step ${stepNumber} of ${stepsCount}`, + skipToButtonLabel: () => `Skip to ${props.isEdit ? 'Update' : 'Create'}`, + navigationAriaLabel: 'Steps', + cancelButton: 'Cancel', + previousButton: 'Previous', + nextButton: 'Next', + optional: 'LISA hosted models only' + }} + onNavigate={(event) => { + switch (event.detail.reason) { + case 'step': + case 'previous': + setState({ + ...state, + activeStepIndex: event.detail.requestedStepIndex, + }); + break; + case 'next': + case 'skip': + { + if (touchFields(requiredFields[state.activeStepIndex]) && isValid) { + setState({ + ...state, + activeStepIndex: event.detail.requestedStepIndex, + }); + break; + } } - } - break; - } + break; + } - scrollToInvalid(); - }} - onCancel={() => { - dispatch( - setConfirmationModal({ - action: 'Discard', - resourceName: 'Model Creation', - onConfirm: () => { - props.setVisible(false); - props.setIsEdit(false); - resetState(); - }, - description: 'Are you sure you want to discard your changes?' - })); - }} - onSubmit={() => { - handleSubmit(); - }} - activeStepIndex={state.activeStepIndex} - isLoadingNextStep={isCreating || isUpdating || isScheduleUpdating || isScheduleDeleting} - allowSkipTo - steps={steps} - /> + scrollToInvalid(); + }} + onCancel={() => { + dispatch( + setConfirmationModal({ + action: 'Discard', + resourceName: 'Model Creation', + onConfirm: () => { + props.setVisible(false); + props.setIsEdit(false); + resetState(); + }, + description: 'Are you sure you want to discard your changes?' + })); + }} + onSubmit={() => { + handleSubmit(); + }} + activeStepIndex={state.activeStepIndex} + isLoadingNextStep={isCreating || isUpdating || isScheduleUpdating || isScheduleDeleting} + allowSkipTo + steps={steps} + /> +
); } diff --git a/lib/user-interface/react/src/components/repository-management/createRepository/BedrockKnowledgeBaseConfigForm.tsx b/lib/user-interface/react/src/components/repository-management/createRepository/BedrockKnowledgeBaseConfigForm.tsx index 8b69b1c0d..b608f7ffc 100644 --- a/lib/user-interface/react/src/components/repository-management/createRepository/BedrockKnowledgeBaseConfigForm.tsx +++ b/lib/user-interface/react/src/components/repository-management/createRepository/BedrockKnowledgeBaseConfigForm.tsx @@ -172,6 +172,7 @@ export function BedrockKnowledgeBaseConfigForm (
) : ( touchFields(['repositoryId'])} onChange={({ detail }) => { setFields({ 'repositoryId': detail.value }); - }} disabled={isEdit} placeholder='postgres-rag' /> + }} + disabled={isEdit} + placeholder='postgres-rag' /> & Re errorText={formErrors?.type} description={shape.type.description}> setSearchQuery(detail.value)} - placeholder='Search sessions by name...' - clearAriaLabel='Clear search' - type='search' - controlId='session-search-input' - /> - {searchQuery && ( - - Found {filteredSessions.length} session{filteredSessions.length !== 1 ? 's' : ''} - - )} -
- } - > - - - {config?.configuration.enabledComponents.deleteSessionHistory && - } - -
- } - > +
+ +
History
+ + + setSearchQuery(detail.value)} + placeholder='Search sessions by name...' + clearAriaLabel='Clear search' + type='search' + controlId='session-search-input' + /> + {searchQuery && ( + + Found {filteredSessions.length} session{filteredSessions.length !== 1 ? 's' : ''} + + )} + + } + > + - +
@@ -189,6 +192,7 @@ export function PromptTemplateForm (props: PromptTemplateFormProps) { disabled={disabled} placeholder='Enter template title' controlId='prompt-template-title-input' + data-testid='prompt-template-title-input' /> @@ -199,17 +203,22 @@ export function PromptTemplateForm (props: PromptTemplateFormProps) { setFields({'type': detail.selectedOption.value}); }} options={Object.entries(PromptTemplateType).map(([key, value]) => ({label: key, value}))} + data-testid='prompt-template-type-select' /> - { - setSharePublic(detail.checked); - setFields({groups: detail.checked ? ['lisa:public'] : []}); - touchFields(['groups'], ModifyMethod.Unset); - setTokenText(''); - }} - disabled={disabled} /> + { + setSharePublic(detail.checked); + setFields({groups: detail.checked ? ['lisa:public'] : []}); + touchFields(['groups'], ModifyMethod.Unset); + setTokenText(''); + }} + disabled={disabled} + data-testid='prompt-template-share-public-toggle' + /> @@ -249,11 +258,16 @@ export function PromptTemplateForm (props: PromptTemplateFormProps) {
-