diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e6cd38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,101 @@ +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +/package-lock.json +/.pnp +.pnp.js + +# Dependency directories +bower_components/ + +# Cypress logs and screenshots +cypress/screenshots/ +cypress/videos/ +results/ + +# dotenv environment variables file +.env +.env.local +.env.*.local + +# Cypress local settings (to prevent committing Cypress-related settings that are local) +cypress.json +cypress.env.json + +# Build artifacts (general) +dist/ +build/ +out/ +coverage/ +*.log + +# Python bytecode +*.py[cod] +__pycache__/ +*.so +*.dylib + +# Virtual environments +.venv/ +venv/ +ENV/ +env.bak/ +env/ + +# Python-specific packaging +*.egg +*.egg-info/ +dist/ +build/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.tgz +*.bak +*.manifest +*.spec +*.lock + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Pyre type checker +.pyre/ + +# macOS +.DS_Store + +# VS Code +.vscode/ + +# JetBrains IDEs +.idea/ + +# Docker related files +Dockerfile +docker-compose.override.yml + +# Pytest +.pytest_cache/ +htmlcov/ +.tox/ + +# Jest +/coverage/ + +# Parcel +.cache/ +.parcel-cache/ + +# Lock files +*.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..e841dc7 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# Cypress BDD with AI + +This project implements BDD (Behavior-Driven Development) testing using Cypress. The setup leverages Docker to create consistent testing environments and integrates AI-driven Python scripts for utility automation, including step definition generation and DOM verification. + +## Project Goals + +- **BDD Testing**: Use Cypress for end-to-end testing with a behavior-driven approach. +- **Dockerized Setup**: Ensure reproducibility and isolated environments using Docker Compose. +- **AI Integration**: Automate step generation and DOM element verification with Python scripts interacting with AI: + - Automatically generate step definitions from feature files. + - Ensure that utility functions (selectors, DOM interactions) remain synchronized with the latest REDCap UI changes. + +## Services Overview + +The project is orchestrated with Docker Compose and has two main services: + +- **ai\_scripts**: Handles AI-driven file generation and DOM consistency checks, ensuring the `redcap_dom.js` utility functions remain aligned with REDCap's structure. +- **cypress**: Executes headless BDD tests and is integrated into the CI/CD pipeline. + +Additionally, Cypress can be run locally in an interactive UI mode for test development and debugging. + +## Interaction with REDCap + +This project is designed to interact with a Dockerized REDCap instance. Ensure the REDCap instance is running and accessible at the `CYPRESS_BASE_URL` before executing Cypress tests. The URL should point to the REDCap instance's address and port. + +### Prerequisites + +Ensure the following are installed: + +- [Docker](https://www.docker.com/) +- [Docker Compose](https://docs.docker.com/compose/) +- [Node.js and npm](https://nodejs.org/) (for local Cypress UI) +- [Python 3](https://www.python.org/) (for running AI scripts) + +### Environment Variables + +Create a `.env` file in the root directory of your project with the following variables: + +```env +CYPRESS_BASE_URL=http://localhost:80 +CYPRESS_USERNAME=your-username +CYPRESS_PASSWORD=your-password +GPT_ENDPOINT=https://your-institution-gpt-endpoint +SUBSCRIPTION_KEY=your-subscription-key +``` + +### Key Features + +- **Cypress UI Mode**: For local testing and debugging. +- **Headless Mode**: For CI/CD pipelines, executed via Docker Compose. +- **AI Scripts**: Generate Cypress step definitions and verify utility functions, keeping your test and DOM interactions up to date. + + +## Gherkin Syntax and Usage + +Gherkin is a language used to write feature files for BDD. It is designed to be easy to understand by non-developers, providing a clear syntax for specifying test scenarios. + +### Basic Syntax + +Gherkin uses several keywords to define features, scenarios, and steps: + +- **Feature**: Describes the feature being tested. +- **Scenario**: Represents a specific test case or example. +- **Given**, **When**, **Then**, **And**, **But**: Define steps within a scenario. + +### Example + +Here’s an example of a Gherkin feature file to test user login functionality with parameterized username and password: + +**login.feature**: +```gherkin +Feature: User Login + + Scenario: Successful login with valid credentials + Given the user navigates to the login page + When the user enters username "user1" and password "password123" + And the user clicks the login button + Then the user should be redirected to the dashboard + And the user should see the welcome message "Welcome, user1!" +``` + +### Storing Gherkin Files + +In this system, Gherkin feature files are stored in the \`e2e/\` directory. You can create multiple \`.feature\` files for different scenarios and features. + +### Transforming Gherkin to Steps + +AI scripts are used to transform Gherkin feature files into step definitions needed for Cypress to execute the tests. The \`ai_worker.py\` script is responsible for this transformation. + +Here’s an example of what the above Gherkin scenario might transform into: + +**login_steps.js**: +```js +const { Given, When, Then } = require('cypress-cucumber-preprocessor/steps'); + +Given('the user navigates to the login page', () => { + cy.visit('/login'); +}); + +When('the user enters username {string} and password {string}', (username, password) => { + cy.get('#username').type(username); + cy.get('#password').type(password); +}); + +When('the user clicks the login button', () => { + cy.get('button[type=submit]').click(); +}); + +Then('the user should be redirected to the dashboard', () => { + cy.url().should('include', '/dashboard'); +}); + +Then('the user should see the welcome message {string}', (welcomeMessage) => { + cy.contains(welcomeMessage).should('be.visible'); +}); +``` + +## Docker Setup + +Build the Docker Containers: + +``` +docker compose build +``` + + +## Usage + +### Running Tests + +You have two options for running Cypress tests. + +#### 1. Running Tests in Headless Mode (One-Time Execution) + +This option runs the tests once in headless mode and automatically shuts down the container when the tests finish. You don't need to start the services separately for this. + +To run the tests in headless mode: +``` +docker compose up cypress +``` + + +#### 2. Running Tests with Cypress UI (Interactive Mode) + +This option opens the Cypress UI, where you can interactively view and debug tests. For this, you need to run the tests locally (outside of Docker) using `npx` after installing the necessary dependencies: + +First, ensure you have installed Cypress locally: +``` +cd /cypress/ +npm install +``` +Then, run Cypress in interactive mode: +``` +npx cypress open +``` + + + +### AI Scripts + +The 'ai' service is designed for one-time executions to assist in generating the necessary files locally. +The generated files should then be checked into version control for use in testing. + +**update_utilities.sh**: Updates utility functions based on the latest page structure. +``` +docker compose run --rm ai sh /app/ai_scripts/update_utilities.sh +``` + +**regenerate_steps.sh**: Regenerates step definitions from Gherkin feature files. +``` +docker compose run --rm ai sh /app/ai_scripts/regenerate_steps.sh +``` \ No newline at end of file diff --git a/ai_scripts/ai_worker.py b/ai_scripts/ai_worker.py new file mode 100644 index 0000000..c28b1b4 --- /dev/null +++ b/ai_scripts/ai_worker.py @@ -0,0 +1,92 @@ +import argparse +import os +import openai +from pyppeteer import launch +import re +import asyncio +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +GPT_ENDPOINT = os.getenv("GPT_ENDPOINT") +SUBSCRIPTION_KEY = os.getenv("SUBSCRIPTION_KEY") + +# Initialize the OpenAI client with custom endpoint and subscription key +openai.api_base = GPT_ENDPOINT +openai.api_key = SUBSCRIPTION_KEY + +# Function to scrape and update selectors +async def scrape_and_update_selectors(): + browser = await launch() + page = await browser.newPage() + await page.goto('http://localhost:8888/login') + + # Scrape a selector + username_selector = await page.evaluate("() => document.querySelector('input[name=\"username\"]').selectorText") + password_selector = await page.evaluate("() => document.querySelector('input[name=\"password\"]').selectorText") + + await browser.close() + + # Update Utility.js + auth_js_path = '/e2e/cypress/support/utility-functions/auth.js' + + with open(auth_js_path, 'w') as file: + file.write(f""" + export const login = () => {{ + cy.visit(Cypress.env('baseUrl') + '/login'); + cy.get('{username_selector}').type(Cypress.env('USERNAME')); + cy.get('{password_selector}').type(Cypress.env('PASSWORD')); + cy.get('button[type="submit"]').click(); + }}; + """) + +# Function to rename functions to be descriptive +def update_function_names(): + auth_js_path = '/e2e/cypress/support/utility-functions/auth.js' + + with open(auth_js_path, 'r') as file: + content = file.read() + + content = re.sub(r'const login =', 'const userLoginWithValidCredentials =', content) + + with open(auth_js_path, 'w') as file: + file.write(content) + +# Function to generate step definitions from Gherkin +def generate_step_definitions(): + for root, dirs, files in os.walk('/e2e/cypress/integration'): + for file in files: + if file.endswith('.feature'): + filepath = os.path.join(root, file) + with open(filepath, 'r') as feature_file: + steps = [line.strip() for line in feature_file if line.startswith(('Given', 'When', 'Then', 'And'))] + + step_definitions = "" + for step in steps: + # Use OpenAI to generate step definitions + response = openai.Completion.create( + engine="davinci-codex", + prompt=f"Generate a step definition for '{step}':", + max_tokens=150 + ) + step_definitions += response.choices[0].text.strip() + "\n" + + with open(filepath.replace('.feature', '_steps.js'), 'w') as step_file: + step_file.write(step_definitions) + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--task', required=True, help="Specify the task to run: regenerate-steps, update-utilities") + args = parser.parse_args() + + if args.task == 'regenerate-steps': + generate_step_definitions() + elif args.task == 'update-utilities': + asyncio.get_event_loop().run_until_complete(scrape_and_update_selectors()) + update_function_names() + else: + print(f"Unknown task: {args.task}") + +if __name__ == '__main__': + main() diff --git a/ai_scripts/regenerate_steps.sh b/ai_scripts/regenerate_steps.sh new file mode 100644 index 0000000..545c194 --- /dev/null +++ b/ai_scripts/regenerate_steps.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +echo "Starting regenerate_steps.sh in Docker container..." + +# Navigate to the script directory +cd /app/ai_scripts + +# Run your Python script +# python3 ai_worker.py --task regenerate_steps + +echo "Finished regenerate_steps.sh" diff --git a/ai_scripts/requirements.txt b/ai_scripts/requirements.txt new file mode 100644 index 0000000..76d82c9 --- /dev/null +++ b/ai_scripts/requirements.txt @@ -0,0 +1,62 @@ +aiohappyeyeballs==2.4.0 +aiohttp==3.10.5 +aiosignal==1.3.1 +annotated-types==0.7.0 +anyio==4.4.0 +appdirs==1.4.4 +attrs==24.2.0 +blis==0.7.11 +catalogue==2.0.10 +certifi==2024.8.30 +charset-normalizer==3.3.2 +click==8.1.7 +cloudpathlib==0.19.0 +confection==0.1.5 +cymem==2.0.8 +distro==1.9.0 +frozenlist==1.4.1 +h11==0.14.0 +httpcore==1.0.5 +httpx==0.27.2 +idna==3.8 +importlib_metadata==8.5.0 +Jinja2==3.1.4 +jiter==0.5.0 +langcodes==3.4.0 +language_data==1.2.0 +marisa-trie==1.2.0 +markdown-it-py==3.0.0 +MarkupSafe==2.1.5 +mdurl==0.1.2 +multidict==6.1.0 +murmurhash==1.0.10 +numpy==1.26.4 +openai==1.44.1 +packaging==24.1 +preshed==3.0.9 +pydantic==2.9.1 +pydantic_core==2.23.3 +pyee==11.1.1 +Pygments==2.18.0 +pyppeteer==2.0.0 +python-dotenv==1.0.1 +requests==2.32.3 +rich==13.8.1 +setuptools==74.1.2 +shellingham==1.5.4 +smart-open==7.0.4 +sniffio==1.3.1 +spacy-legacy==3.0.12 +spacy-loggers==1.0.5 +srsly==2.4.8 +thinc==8.2.5 +tqdm==4.66.5 +typer==0.12.5 +typing_extensions==4.12.2 +urllib3==1.26.20 +wasabi==1.1.3 +weasel==0.4.1 +websockets==10.4 +wrapt==1.16.0 +yarl==1.11.1 +zipp==3.20.1 diff --git a/ai_scripts/update_utilities.sh b/ai_scripts/update_utilities.sh new file mode 100644 index 0000000..14feb00 --- /dev/null +++ b/ai_scripts/update_utilities.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +echo "Starting update_utilities.sh in Docker container..." + +# Navigate to the script directory +cd /app/ai_scripts + +# Run your Python script +# python3 ai_worker.py --task update-utilities + +echo "Finished update_utilities.sh" diff --git a/cypress/cypress.config.js b/cypress/cypress.config.js new file mode 100644 index 0000000..eb2cad7 --- /dev/null +++ b/cypress/cypress.config.js @@ -0,0 +1,32 @@ +const { defineConfig } = require('cypress'); +const addCucumberPreprocessorPlugin = require('@badeball/cypress-cucumber-preprocessor').addCucumberPreprocessorPlugin; +const browserify = require('@badeball/cypress-cucumber-preprocessor/browserify').default; + +module.exports = defineConfig({ + e2e: { + async setupNodeEvents(on, config) { + await addCucumberPreprocessorPlugin(on, config); + + on('file:preprocessor', browserify(config)); + + on('task', { + log(message) { + console.log(message); + return null; + } + }); + + return config; + }, + env: { + CYPRESS_USERNAME: process.env.CYPRESS_USERNAME, + CYPRESS_PASSWORD: process.env.CYPRESS_PASSWORD, + CYPRESS_BASE_URL: process.env.CYPRESS_BASE_URL + }, + baseUrl: process.env.CYPRESS_BASE_URL, + specPattern: 'e2e/*.feature', + stepDefinitions: 'e2e/*.js', + supportFile: false, + pageLoadTimeout: 60000 + }, +}); diff --git a/cypress/e2e/login.feature b/cypress/e2e/login.feature new file mode 100644 index 0000000..e5b1705 --- /dev/null +++ b/cypress/e2e/login.feature @@ -0,0 +1,8 @@ +Feature: User Login + + Scenario: Successful login with valid credentials + Given the user navigates to the login page + When the user enters username and password + And the user clicks the login button + Then the user should be redirected to the dashboard + And the user should see a welcome message diff --git a/cypress/e2e/login.js b/cypress/e2e/login.js new file mode 100644 index 0000000..341934b --- /dev/null +++ b/cypress/e2e/login.js @@ -0,0 +1,26 @@ +const { Given, When, Then } = require('@badeball/cypress-cucumber-preprocessor'); +import { visitLoginPage, enterUsername, enterPassword, clickLoginButton, checkUrlForDashboard, checkWelcomeMessage } from '../support/utility-functions/redcap_dom'; + +const username = Cypress.env('CYPRESS_USERNAME'); +const password = Cypress.env('CYPRESS_PASSWORD'); + +Given('the user navigates to the login page', () => { + visitLoginPage(); +}); + +When('the user enters username and password', () => { + enterUsername(username); + enterPassword(password); +}); + +When('the user clicks the login button', () => { + clickLoginButton(); +}); + +Then('the user should be redirected to the dashboard', () => { + checkUrlForDashboard(); +}); + +Then('the user should see a welcome message', () => { + checkWelcomeMessage(); +}); diff --git a/cypress/package.json b/cypress/package.json new file mode 100644 index 0000000..a095600 --- /dev/null +++ b/cypress/package.json @@ -0,0 +1,18 @@ +{ + "name": "cypress", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@badeball/cypress-cucumber-preprocessor": "^20.1.2", + "browserify": "^17.0.0", + "cypress": "^13.14.2", + "cypress-cucumber-preprocessor": "^4.3.1" + } +} diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 0000000..03f81cf --- /dev/null +++ b/cypress/plugins/index.js @@ -0,0 +1,7 @@ +const cucumber = require('@badeball/cypress-cucumber-preprocessor').default +const browserify = require('@badeball/cypress-cucumber-preprocessor/browserify').default + +module.exports = (on, config) => { + on('file:preprocessor', browserify(config)) + return config +} diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js new file mode 100644 index 0000000..03e750b --- /dev/null +++ b/cypress/support/e2e.js @@ -0,0 +1,6 @@ +// cypress/support/e2e.js +// This file is loaded automatically before your test files. + +// You can put global setup code here. + + diff --git a/cypress/support/utility-functions/redcap_dom.js b/cypress/support/utility-functions/redcap_dom.js new file mode 100644 index 0000000..86bf636 --- /dev/null +++ b/cypress/support/utility-functions/redcap_dom.js @@ -0,0 +1,25 @@ +// support/utility-functions/redcap_dom.js + +export function visitLoginPage() { + cy.visit('/', { timeout: 60000 }); // Adjust URL and timeout if needed +} + +export function enterUsername(username) { + cy.get('#username', { timeout: 60000 }).type(username); +} + +export function enterPassword(password) { + cy.get('#password', { timeout: 60000 }).type(password); +} + +export function clickLoginButton() { + cy.get('#login_btn', { timeout: 60000 }).click(); +} + +export function checkUrlForDashboard() { + cy.url({ timeout: 60000 }).should('include', 'action=myprojects'); +} + +export function checkWelcomeMessage() { + cy.contains('My Projects', { timeout: 60000 }).should('be.visible'); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e6d422e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + cypress: + build: + context: . + dockerfile: ./cypress/Dockerfile + volumes: + - ./cypress/cypress.config.js:/app/cypress.config.js + - ./cypress/e2e:/app/e2e + - ./cypress/plugins:/app/plugins + - ./cypress/support:/app/support + working_dir: /app + network_mode: "host" + env_file: + - .env + entrypoint: ["npx", "cypress", "run"] + + ai: + build: + context: . + dockerfile: ./ai_scripts/Dockerfile + volumes: + - ./ai_scripts:/app/ai_scripts + - ./cypress/e2e:/app/cypress/e2e + - ./cypress/support:/app/cypress/support + working_dir: /app + env_file: + - .env