Skip to content

Commit 38b4d17

Browse files
authored
Cursor Mock Rule Creation & GenAI Mock Test Creation (using rules) (opendatahub-io#5541)
* feat(testing): Add comprehensive Cypress mock test rules - RHOAIENG-34223 Created detailed Cursor rules for mock test creation: - cypress-mock.mdc (995 lines): Comprehensive guidelines for creating and maintaining Cypress mock tests with fully mocked backends - MOCK_TEST_RULES_SUMMARY.md: Quick reference guide for developers - Minor update to cypress-e2e.mdc globs config Key features of mock test rules: - Test structure and organization patterns (describe/beforeEach/it) - Backend mocking with interceptors (cy.interceptOdh, cy.interceptK8sList) - Mandatory use of page objects (no direct cy.findByTestId in tests) - Mock data management (always use __mocks__ folder) - Common test patterns (tables, modals, forms, drag-drop, routing) - Access control and feature flag testing - Code quality and linting requirements - Test execution and debugging guidelines - Complete implementation checklists Addresses acceptance criteria for RHOAIENG-34223: - ✅ Draft markdown-formatted Cursor rule for Mock Test Generation - ✅ Include intelligent patterns for API endpoint mocking - ⏳ Review with team and incorporate feedback (next step) - ⏳ Test rules with real codebase examples (next step) * chore: simplify workflow name to 'Cypress e2e Test' * Add Cypress mock test rules and groupSettings mock tests - Add comprehensive Cypress mock test rules document (.cursor/rules/cypress-mock.mdc) - Create groupSettings page object with test selectors - Implement 15 mock tests for groupSettings page covering: - Page display and navigation - Admin and user group selection - Save functionality and validation - Error handling (save errors, loading errors) - New group creation - Loading states Related to RHOAIENG-34223 * Update Cypress mock test rules with learnings from existing tests - Add Contextual pattern for reusable page object sections - Add appChrome usage for navigation items - Add proper wait validation with specific text checks - Add success/error validation with semantic HTML roles - Add scope management with .document() for portaled content - Add test structure best practices (focus on flows, not micro-tests) - Add minimal mocking principle - Add proper payload validation patterns - Emphasize reviewing existing tests before creating new ones These learnings come from comparing duplicate groupSettings tests with existing userManagement tests and identifying best practices. * Remove duplicate groupSettings tests The groupSettings page is already tested by userManagement.cy.ts since both test the same GroupSettings component at /settings/user-management. The duplicate tests were created without checking for existing coverage. Removed: - frontend/src/__tests__/cypress/cypress/tests/mocked/groupSettings/groupSettings.cy.ts - frontend/src/__tests__/cypress/cypress/pages/groupSettings.ts * feat(gen-ai): Add comprehensive mock tests for AI Playground chatbot - Add Cypress mock test rules with strict testID requirements - Create chatbot mock tests covering UI interactions, configuration, and messaging - Add data-testid attributes to chatbot components for robust test selectors - Update page objects to use testIDs instead of brittle selectors - Add rule: never add timeouts to page objects, only in tests - Enhance mcpServersTestHelpers to support custom namespaces Components updated with testIDs: - ChatbotHeaderActions: view-code-button, kebab-menu-toggle, menu items - ChatbotSettingsPanel: rag-section, mcp-servers-section, system-instructions - ModelParameterFormGroup: temperature-input - SystemInstructionFormGroup: system-instructions-input - NoData: empty-state-action-button Tests cover: - Page load and message input verification - Model configuration (temperature, streaming, system instructions) - MCP servers panel visibility - Header actions (view code, kebab menu, delete) - Message sending and bot response verification All 8 tests passing ✅ * fix(gen-ai): Fix chatbot mock tests CI failures - Fix ReDoS vulnerability in chatbotPage.ts by removing dynamic RegExp - Create cypress:server:bff-mock script to run BFF with all mock clients - Update test:cypress-ci to run BFF + frontend + Cypress tests concurrently This fixes the CI failures where chatbot.cy.ts tests were failing because they expected a running BFF with mock data, but CI was only running the frontend dev server. Now both BFF (with mocks) and frontend run during tests. All 17 mock tests now pass (ci-smoke, mcpServers, chatbot). * ci(gen-ai): Add Go setup and BFF support to frontend CI workflow - Add Go setup step to install Go for running the BFF - Configure Go version from bff/go.mod with caching This enables the BFF to run in mock mode during frontend tests. * fix: Add data-testid to disabled View Code button The View Code button's data-testid was only on the enabled state, causing the test to fail when the button is disabled (default state when no message has been sent). Added data-testid to both states.
1 parent 2b7f69f commit 38b4d17

12 files changed

Lines changed: 2459 additions & 11 deletions

File tree

.cursor/rules/cypress-e2e.mdc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
description: Comprehensive guidelines for creating and maintaining Cypress E2E tests including Robot Framework migrations and new feature testing
3-
globs: []
3+
globs:
44
alwaysApply: false
55
---
66
# Cypress E2E Test Rules

.cursor/rules/cypress-mock.mdc

Lines changed: 1937 additions & 0 deletions
Large diffs are not rendered by default.

.github/workflows/gen-ai-frontend-build.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ jobs:
2828
with:
2929
node-version: ${{ env.NODE_VERSION }}
3030

31+
- name: Set up Go
32+
uses: actions/setup-go@v5
33+
with:
34+
go-version-file: 'packages/gen-ai/bff/go.mod'
35+
cache-dependency-path: 'packages/gen-ai/bff/go.sum'
36+
3137
- name: Cache npm dependencies
3238
uses: actions/cache@v4
3339
with:

packages/gen-ai/frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,14 @@
2929
"clean": "rimraf dist",
3030
"cypress:server:dev": "webpack serve --color --progress --config ./config/webpack.dev.js --port 8080 --no-open",
3131
"cypress:server": "cd ../bff && make run PORT=9001",
32+
"cypress:server:bff-mock": "cd ../bff && MOCK_LS_CLIENT=true MOCK_K8S_CLIENT=true MOCK_MCP_CLIENT=true MOCK_MAAS_CLIENT=true make run PORT=9001",
3233
"cypress:open": "cypress open --project src/__tests__/cypress",
3334
"cypress:open:e2e": "BASE_URL=http://localhost:4010 npm run cypress:open",
3435
"cypress:open:mock": "CY_MOCK=1 CY_WS_PORT=9002 BASE_URL=http://localhost:8080 npm run cypress:open -- ",
3536
"cypress:run": "cypress run -b chrome --project src/__tests__/cypress",
3637
"cypress:run:e2e": "BASE_URL=http://localhost:4010 npm run cypress:run",
3738
"cypress:run:mock": "CY_MOCK=1 CY_WS_PORT=9002 BASE_URL=http://localhost:8080 npm run cypress:run -- ",
38-
"test:cypress-ci": "npx concurrently -P -k -s first \"npm run cypress:server:dev\" \"npx wait-on http://localhost:8080 && npm run cypress:run:mock\" -- "
39+
"test:cypress-ci": "npx concurrently -P -k -s first \"npm run cypress:server:bff-mock\" \"PROXY_HOST=localhost PROXY_PORT=9001 npm run cypress:server:dev\" \"npx wait-on http://localhost:8080 http://localhost:9001 && npm run cypress:run:mock\" -- "
3940
},
4041
"devDependencies": {
4142
"@module-federation/enhanced": "^0.13.1",
Lines changed: 282 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,42 @@
1+
import { TableRow } from './components/table';
2+
3+
class ChatbotMCPServerRow extends TableRow {
4+
constructor(
5+
parentSelector: () => Cypress.Chainable<JQuery<HTMLTableRowElement>>,
6+
private serverName: string,
7+
private serverId: string,
8+
) {
9+
super(parentSelector);
10+
}
11+
12+
findConfigureButton(): Cypress.Chainable<JQuery<HTMLElement>> {
13+
return this.find().findByTestId(`mcp-server-configure-button-${this.serverId}`);
14+
}
15+
16+
findToolsButton(): Cypress.Chainable<JQuery<HTMLElement>> {
17+
return this.find().findByTestId(`mcp-server-tools-button-${this.serverId}`);
18+
}
19+
20+
isChecked(): Cypress.Chainable<boolean> {
21+
return this.findCheckbox().then(($cb) => cy.wrap($cb.is(':checked')));
22+
}
23+
}
24+
125
class ChatbotPage {
226
visit(namespace?: string): void {
327
cy.visit(namespace ? `/gen-ai-studio/playground/${namespace}` : '/gen-ai-studio/playground');
428
this.waitForPageLoad();
529
}
630

731
private waitForPageLoad(): void {
8-
cy.findByTestId('page-title').should('be.visible').and('contain.text', 'Playground');
32+
cy.findByTestId('page-title', { timeout: 30000 })
33+
.should('be.visible')
34+
.and('contain.text', 'Playground');
935
}
1036

1137
verifyOnChatbotPage(expectedNamespace?: string): void {
1238
if (expectedNamespace) {
13-
cy.location('pathname').should((pathname) => {
39+
cy.location('pathname', { timeout: 60000 }).should((pathname) => {
1440
expect([
1541
`/gen-ai-studio/playground/${expectedNamespace}`,
1642
'/gen-ai-studio/playground',
@@ -19,6 +45,260 @@ class ChatbotPage {
1945
}
2046
this.waitForPageLoad();
2147
}
48+
49+
findMCPPanel(): Cypress.Chainable<JQuery<HTMLElement>> {
50+
return cy.findByTestId('mcp-servers-panel-table');
51+
}
52+
53+
expandMCPPanelIfNeeded(): void {
54+
this.findMCPPanel().should('exist', { timeout: 30000 }).and('be.visible');
55+
}
56+
57+
verifyMCPPanelVisible(): void {
58+
this.findMCPPanel().should('be.visible', { timeout: 30000 });
59+
}
60+
61+
private findCheckedCheckboxes(): Cypress.Chainable<JQuery<HTMLElement>> {
62+
return this.findMCPPanel().within(() => cy.get('input[type="checkbox"]:checked'));
63+
}
64+
65+
getCheckedServer(): Cypress.Chainable<string> {
66+
return this.findCheckedCheckboxes()
67+
.should('have.length', 1)
68+
.closest('tr')
69+
.within(() => cy.get('td').eq(1))
70+
.invoke('text')
71+
.then((text) => {
72+
const name = text.trim();
73+
return cy.wrap(name).should('not.be.empty');
74+
});
75+
}
76+
77+
getServerRow(serverName: string, serverUrl: string): ChatbotMCPServerRow {
78+
const rowSelector = () =>
79+
this.findMCPPanel().contains('tr', serverName) as unknown as Cypress.Chainable<
80+
JQuery<HTMLTableRowElement>
81+
>;
82+
return new ChatbotMCPServerRow(rowSelector, serverName, serverUrl);
83+
}
84+
85+
findConfigureModal(): Cypress.Chainable<JQuery<HTMLElement>> {
86+
return cy.findByRole('dialog');
87+
}
88+
89+
hasConfigureModal(): Cypress.Chainable<boolean> {
90+
return cy.findByRole('dialog').then(($dialog) => cy.wrap($dialog.length > 0));
91+
}
92+
93+
verifyOnlyOneServerChecked(): void {
94+
this.findCheckedCheckboxes().should('have.length', 1);
95+
}
96+
97+
findCheckedServersCount(): Cypress.Chainable<JQuery<HTMLElement>> {
98+
return this.findCheckedCheckboxes();
99+
}
100+
101+
// Chatbot Main UI Elements
102+
findChatbotHeader(): Cypress.Chainable<JQuery<HTMLElement>> {
103+
return cy.findByTestId('page-title');
104+
}
105+
106+
findChatbotPlayground(): Cypress.Chainable<JQuery<HTMLElement>> {
107+
return cy.findByTestId('chatbot');
108+
}
109+
110+
findMessageBar(): Cypress.Chainable<JQuery<HTMLElement>> {
111+
return cy.findByTestId('chatbot-message-bar');
112+
}
113+
114+
findMessageInput(): Cypress.Chainable<JQuery<HTMLElement>> {
115+
// data-testid is directly on the textarea element
116+
return cy.findByTestId('chatbot-message-bar');
117+
}
118+
119+
findSendButton(): Cypress.Chainable<JQuery<HTMLElement>> {
120+
// PatternFly MessageBar component - Send button appears after typing
121+
// NOTE: Cannot add testID to external library component, using semantic role
122+
return cy.findByRole('button', { name: 'Send' });
123+
}
124+
125+
findWelcomeMessage(): Cypress.Chainable<JQuery<HTMLElement>> {
126+
return cy.findByText(/Welcome to the model playground/i);
127+
}
128+
129+
findMessage(text: string): Cypress.Chainable<JQuery<HTMLElement>> {
130+
return cy.findByText(text);
131+
}
132+
133+
findEmptyState(): Cypress.Chainable<JQuery<HTMLElement>> {
134+
return cy.findByTestId('empty-state');
135+
}
136+
137+
findEmptyStateTitle(): Cypress.Chainable<JQuery<HTMLElement>> {
138+
return cy.findByTestId('empty-state').find('h1');
139+
}
140+
141+
findEmptyStateMessage(): Cypress.Chainable<JQuery<HTMLElement>> {
142+
return cy.findByTestId('empty-state-message');
143+
}
144+
145+
findCreatePlaygroundButton(): Cypress.Chainable<JQuery<HTMLElement>> {
146+
return cy.findByTestId('empty-state-action-button');
147+
}
148+
149+
findEmptyStateActionButton(buttonText: string): Cypress.Chainable<JQuery<HTMLElement>> {
150+
return cy.findByRole('button', { name: buttonText });
151+
}
152+
153+
findInitializingState(): Cypress.Chainable<JQuery<HTMLElement>> {
154+
return cy.findByText(/Creating playground/i);
155+
}
156+
157+
findFailedState(): Cypress.Chainable<JQuery<HTMLElement>> {
158+
return cy.findByText(/Playground creation failed/i);
159+
}
160+
161+
// Configuration Modal
162+
findConfigurationModal(): Cypress.Chainable<JQuery<HTMLElement>> {
163+
return cy.findByRole('dialog');
164+
}
165+
166+
findViewCodeButton(): Cypress.Chainable<JQuery<HTMLElement>> {
167+
return cy.findByTestId('view-code-button');
168+
}
169+
170+
// Delete Playground Modal
171+
findDeletePlaygroundModal(): Cypress.Chainable<JQuery<HTMLElement>> {
172+
return cy.findByTestId('delete-modal');
173+
}
174+
175+
// View Code Modal
176+
findViewCodeModal(): Cypress.Chainable<JQuery<HTMLElement>> {
177+
return cy.findByRole('dialog');
178+
}
179+
180+
// Model Selection
181+
findModelDropdown(): Cypress.Chainable<JQuery<HTMLElement>> {
182+
return cy.findByText(/Model/i).parent().find('button') as unknown as Cypress.Chainable<
183+
JQuery<HTMLElement>
184+
>;
185+
}
186+
187+
selectModel(modelName: string): void {
188+
this.findModelDropdown().click();
189+
cy.findByText(modelName).click();
190+
}
191+
192+
// System Instructions
193+
findSystemInstructionsSection(): Cypress.Chainable<JQuery<HTMLElement>> {
194+
return cy.findByTestId('system-instructions-section');
195+
}
196+
197+
findSystemInstructionInput(): Cypress.Chainable<JQuery<HTMLElement>> {
198+
return cy.findByTestId('system-instructions-input');
199+
}
200+
201+
setSystemInstructions(instructions: string): void {
202+
this.findSystemInstructionInput().clear().type(instructions);
203+
}
204+
205+
// Temperature Configuration
206+
findTemperatureSection(): Cypress.Chainable<JQuery<HTMLElement>> {
207+
return cy.findByText(/Temperature/i).parent();
208+
}
209+
210+
findTemperatureInput(): Cypress.Chainable<JQuery<HTMLElement>> {
211+
return cy.findByTestId('temperature-input');
212+
}
213+
214+
setTemperature(value: string): void {
215+
this.findTemperatureInput().clear().type(value);
216+
}
217+
218+
// Streaming Configuration
219+
findStreamingSection(): Cypress.Chainable<JQuery<HTMLElement>> {
220+
return cy.findByText(/Streaming/i).parent();
221+
}
222+
223+
findStreamingToggle(): Cypress.Chainable<JQuery<HTMLElement>> {
224+
return this.findStreamingSection().find('input[type="checkbox"]');
225+
}
226+
227+
toggleStreaming(enable: boolean): void {
228+
this.findStreamingToggle().then(($toggle) => {
229+
const isChecked = $toggle.is(':checked');
230+
if ((enable && !isChecked) || (!enable && isChecked)) {
231+
this.findStreamingToggle().click({ force: true });
232+
}
233+
});
234+
}
235+
236+
// RAG Section
237+
findRAGSection(): Cypress.Chainable<JQuery<HTMLElement>> {
238+
return cy.findByTestId('rag-section-title').parent();
239+
}
240+
241+
findRAGToggle(): Cypress.Chainable<JQuery<HTMLElement>> {
242+
return cy.findByTestId('rag-toggle-switch');
243+
}
244+
245+
// MCP Servers Section
246+
findMCPServersSection(): Cypress.Chainable<JQuery<HTMLElement>> {
247+
return cy.findByTestId('mcp-servers-section-title').parent();
248+
}
249+
250+
findMCPServersTable(): Cypress.Chainable<JQuery<HTMLElement>> {
251+
return cy.findByTestId('mcp-servers-panel-table');
252+
}
253+
254+
// Kebab Menu (Actions Menu)
255+
findKebabMenuButton(): Cypress.Chainable<JQuery<HTMLElement>> {
256+
return cy.findByTestId('header-kebab-menu-toggle');
257+
}
258+
259+
openKebabMenu(): void {
260+
this.findKebabMenuButton().click();
261+
}
262+
263+
findDeleteMenuItem(): Cypress.Chainable<JQuery<HTMLElement>> {
264+
return cy.findByTestId('delete-playground-menu-item');
265+
}
266+
267+
findConfigureMenuItem(): Cypress.Chainable<JQuery<HTMLElement>> {
268+
return cy.findByTestId('configure-playground-menu-item');
269+
}
270+
271+
// Chat Messages
272+
findChatMessage(text: string): Cypress.Chainable<JQuery<HTMLElement>> {
273+
// Use Cypress string matching instead of RegExp to avoid ReDoS
274+
return cy.findByText(text);
275+
}
276+
277+
findBotMessages(): Cypress.Chainable<JQuery<HTMLElement>> {
278+
// PatternFly Chatbot component - Cannot add testID to external library
279+
// NOTE: Using class selector as last resort for external component
280+
return cy.get('[class*="pf-chatbot__message--bot"]');
281+
}
282+
283+
findUserMessages(): Cypress.Chainable<JQuery<HTMLElement>> {
284+
// PatternFly Chatbot component - Cannot add testID to external library
285+
// NOTE: Using class selector as last resort for external component
286+
return cy.get('[class*="pf-chatbot__message--user"]');
287+
}
288+
289+
// Helper methods for chat interactions
290+
sendMessage(message: string): void {
291+
this.findMessageInput().type(message);
292+
this.findSendButton().click();
293+
}
294+
295+
verifyUserMessageInChat(message: string): void {
296+
this.findUserMessages().should('contain.text', message);
297+
}
298+
299+
verifyBotResponseContains(text: string): void {
300+
this.findBotMessages().should('contain.text', text);
301+
}
22302
}
23303

24304
export const chatbotPage = new ChatbotPage();

packages/gen-ai/frontend/src/__tests__/cypress/cypress/support/helpers/mcpServers/mcpServersTestHelpers.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import yaml from 'js-yaml';
33
import {
44
mockNamespaces,
5+
mockNamespace,
56
mockEmptyList,
67
mockStatus,
78
mockAAModels,
@@ -55,15 +56,26 @@ export const setupBaseMCPServerMocks = (
5556
lsdStatus: 'Ready' | 'NotReady';
5657
includeLsdModel?: boolean;
5758
includeAAModel?: boolean;
59+
namespace?: string;
5860
} = {
5961
lsdStatus: 'NotReady',
6062
includeLsdModel: false,
6163
includeAAModel: false,
6264
},
6365
): void => {
64-
const namespace = config.defaultNamespace;
65-
66-
cy.interceptGenAi('GET /api/v1/namespaces', mockNamespaces());
66+
const namespace = options.namespace ?? config.defaultNamespace;
67+
68+
// If a custom namespace is provided, include it in the namespaces list
69+
const namespacesData =
70+
options.namespace && options.namespace !== config.defaultNamespace
71+
? [
72+
// eslint-disable-next-line camelcase
73+
mockNamespace({ name: options.namespace, display_name: options.namespace }),
74+
...mockNamespaces().data,
75+
]
76+
: mockNamespaces().data;
77+
78+
cy.interceptGenAi('GET /api/v1/namespaces', { data: namespacesData });
6779

6880
// Mock AAA models endpoint
6981
if (options.includeAAModel) {

0 commit comments

Comments
 (0)