diff --git a/.github/workflows/ai-test-generator.yml b/.github/workflows/ai-test-generator.yml new file mode 100644 index 000000000..6e9b4d91c --- /dev/null +++ b/.github/workflows/ai-test-generator.yml @@ -0,0 +1,247 @@ +# AI Test Generator +# +# Automatically generates a pytest test file from a plain English description. +# Non-technical team members can trigger this workflow from the GitHub Actions UI, +# describe the test in plain English, and get a ready-to-run test file committed +# back to the repository. +# +# Required secret: OPENAI_API_KEY + +name: AI Test Generator + +on: + push: + paths: + - '**/ai_testcases/*.txt' + + workflow_dispatch: + inputs: + suite: + description: 'Target test suite' + required: true + type: choice + options: + - CaseSearch + - DataDictionary + - FindDataById + - Lookuptable + - MultiSelect + - PowerBI + - SplitScreenCaseSearch + - ElasticSearch + - ExportTests + - Formplayer + - HQSmokeTests + - P1P2Tests + - RequestAPI + - USH_CO_BHA + - MobileTest + - BHAStressTest + + description: + description: 'Describe the test in plain English (what should the test do?)' + required: true + type: string + + output_path: + description: 'Output file path (leave blank to auto-generate)' + required: false + type: string + default: '' + + run_after_generate: + description: 'Run the generated test immediately after generating it?' + required: false + type: boolean + default: false + + environment: + description: 'Environment to run the generated test against (only used if run_after_generate is true)' + required: false + type: choice + default: 'staging' + options: + - staging + - production + - eu + - india + + commit_result: + description: 'Commit the generated test file back to the repository?' + required: false + type: boolean + default: true + +jobs: + generate: + name: Generate Test for '${{ inputs.suite }}' + runs-on: ubuntu-latest + + outputs: + generated_file: ${{ steps.generate.outputs.generated_file }} + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python 3.13 + uses: actions/setup-python@v2 + with: + python-version: '3.13' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install openai>=1.0.0 + + - name: Generate from txt files (on push) + if: github.event_name == 'push' + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + # Find only the txt files that were added/changed in this push + CHANGED_TXT=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD | grep "ai_testcases/.*\.txt" || true) + if [ -z "$CHANGED_TXT" ]; then + echo "No new/changed txt files in this push — processing all pending files" + python ai_test_generator/process_testcases.py + else + echo "Processing changed files:" + echo "$CHANGED_TXT" + for f in $CHANGED_TXT; do + python ai_test_generator/process_testcases.py --file "$f" --force + done + fi + + - name: Generate from description (manual trigger) + if: github.event_name == 'workflow_dispatch' + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + python ai_test_generator/generate_test.py \ + --suite "${{ inputs.suite }}" \ + --description "${{ inputs.description }}" \ + ${{ inputs.output_path != '' && format('--output "{0}"', inputs.output_path) || '' }} + + - name: Commit all generated test files + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + # Stage all newly generated test files + git add '**/test_ai_*.py' '**/test_*_ai_generated.py' 2>/dev/null || true + if git diff --cached --quiet; then + echo "No new files to commit" + else + GENERATED_FILES=$(git diff --cached --name-only) + echo "Committing generated files:" + echo "$GENERATED_FILES" + git commit -m "feat(ai-gen): generate tests from ai_testcases txt files + + Generated by: AI Test Generator (run #${{ github.run_number }}) + Triggered by: ${{ github.actor }}" + git push + fi + + - name: Upload all generated tests as artifact + uses: actions/upload-artifact@v4 + with: + name: generated-tests-${{ github.run_id }} + path: | + **/test_ai_*.py + **/test_*_ai_generated.py + retention-days: 30 + + - name: Summary + run: | + echo "## AI Test Generator Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Trigger | ${{ github.event_name }} |" >> $GITHUB_STEP_SUMMARY + echo "| Run | #${{ github.run_number }} |" >> $GITHUB_STEP_SUMMARY + echo "| Triggered by | ${{ github.actor }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Generated Files" >> $GITHUB_STEP_SUMMARY + find . -name "test_ai_*.py" -newer ai_test_generator/process_testcases.py \ + ! -path "./venv/*" ! -path "./.git/*" | while read f; do + echo "- \`$f\`" >> $GITHUB_STEP_SUMMARY + done + + run_generated_test: + name: Run Generated Test on '${{ inputs.environment }}' + needs: generate + if: ${{ inputs.run_after_generate == true }} + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + with: + # Pull latest so we get the newly committed test file + ref: ${{ github.ref }} + + - name: Set up Python 3.13 + uses: actions/setup-python@v2 + with: + python-version: '3.13' + + - name: Pull latest commit (get generated test) + if: ${{ inputs.commit_result == true }} + run: git pull + + - name: Download generated test artifact (if not committed) + if: ${{ inputs.commit_result == false }} + uses: actions/download-artifact@v4 + with: + name: generated-test-${{ inputs.suite }}-${{ github.run_id }} + path: ${{ needs.generate.outputs.generated_file && '' || '.' }} + + - name: Install suite dependencies + run: | + python -m pip install --upgrade pip + # Install the suite's requires.txt if it exists + SUITE_DIR=$(python -c " + suites = { + 'CaseSearch': 'Features/CaseSearch', + 'DataDictionary': 'Features/DataDictionary', + 'FindDataById': 'Features/FindDataById', + 'Lookuptable': 'Features/Lookuptable', + 'MultiSelect': 'Features/MultiSelect', + 'PowerBI': 'Features/Powerbi_integration_exports', + 'SplitScreenCaseSearch': 'Features/SplitScreenCaseSearch', + 'ElasticSearch': 'ElasticSearchTests', + 'ExportTests': 'ExportTests', + 'Formplayer': 'Formplayer', + 'HQSmokeTests': 'HQSmokeTests', + 'P1P2Tests': 'P1P2Tests', + 'RequestAPI': 'RequestAPI', + 'USH_CO_BHA': 'USH_Apps/CO_BHA', + 'MobileTest': 'MobileTest', + 'BHAStressTest': 'QA_Requests/BHAStressTest', + } + print(suites.get('${{ inputs.suite }}', '')) + ") + if [ -f "${SUITE_DIR}/requires.txt" ]; then + echo "Installing from ${SUITE_DIR}/requires.txt" + pip install -r "${SUITE_DIR}/requires.txt" + else + echo "No requires.txt found, installing common deps" + pip install pytest selenium pytest-html pytest-rerunfailures + fi + + - name: Run generated test + env: + DIMAGIQA_ENV: ${{ inputs.environment }} + DIMAGIQA_LOGIN_USERNAME: ${{ secrets.DIMAGIQA_LOGIN_USERNAME }} + DIMAGIQA_LOGIN_PASSWORD: ${{ secrets.DIMAGIQA_LOGIN_PASSWORD }} + ENABLE_WAITS: 'true' + run: | + pytest -v "${{ needs.generate.outputs.generated_file }}" \ + --html=report_generated_test.html \ + --self-contained-html \ + --tb=short + + - name: Upload test results + if: ${{ success() || failure() }} + uses: actions/upload-artifact@v4 + with: + name: generated-test-results-${{ inputs.environment }}-${{ github.run_id }} + path: report_generated_test.html + retention-days: 2 \ No newline at end of file diff --git a/ElasticSearchTests/ai_testcases/example_testcase.txt b/ElasticSearchTests/ai_testcases/example_testcase.txt new file mode 100644 index 000000000..f9b9a654b --- /dev/null +++ b/ElasticSearchTests/ai_testcases/example_testcase.txt @@ -0,0 +1,23 @@ +Suite: + +Test 1: +Tags: +1. +2. +3. +4. +Expected Result: + +Test 2: +Tags: +1. +2. +3. +Expected Result: + +Test 3: +Tags: +1. +2. +3. +Expected Result: diff --git a/ExportTests/ai_testcases/example_testcase.txt b/ExportTests/ai_testcases/example_testcase.txt new file mode 100644 index 000000000..f9b9a654b --- /dev/null +++ b/ExportTests/ai_testcases/example_testcase.txt @@ -0,0 +1,23 @@ +Suite: + +Test 1: +Tags: +1. +2. +3. +4. +Expected Result: + +Test 2: +Tags: +1. +2. +3. +Expected Result: + +Test 3: +Tags: +1. +2. +3. +Expected Result: diff --git a/Features/CaseSearch/ai_testcases/example_testcase.txt b/Features/CaseSearch/ai_testcases/example_testcase.txt new file mode 100644 index 000000000..f9b9a654b --- /dev/null +++ b/Features/CaseSearch/ai_testcases/example_testcase.txt @@ -0,0 +1,23 @@ +Suite: + +Test 1: +Tags: +1. +2. +3. +4. +Expected Result: + +Test 2: +Tags: +1. +2. +3. +Expected Result: + +Test 3: +Tags: +1. +2. +3. +Expected Result: diff --git a/Features/CaseSearch/ai_testcases/search_and_submit_play_song.txt b/Features/CaseSearch/ai_testcases/search_and_submit_play_song.txt new file mode 100644 index 000000000..328f75f69 --- /dev/null +++ b/Features/CaseSearch/ai_testcases/search_and_submit_play_song.txt @@ -0,0 +1,31 @@ +Suite: CaseSearch + +Test 1: Search by song name and submit Play Song form +1. Login as user-1 +2. Open the Music App +3. Open the Songs (Normal) menu +4. Clear selections on the case search page +5. Search for a case by song name using text input +6. Click the search button +7. Select the case and continue to forms +8. Open the Play Song form +9. Submit the form +Expected Result: Form submits successfully and user is returned to the app home screen + +Test 2: Search with no results shows empty list +1. Login as user-1 +2. Open the Music App +3. Open the Songs (Normal) menu +4. Search for a non-existent song name +5. Click the search button +Expected Result: Case list is empty with appropriate message + +Test 3: Search using combobox filter +1. Login as user-2 +2. Open the Music App +3. Open the Songs (Normal) menu +4. Clear selections on the case search page +5. Filter cases using a combobox property +6. Click the search button +7. Verify only matching cases appear in the list +Expected Result: Filtered case list shows only relevant results \ No newline at end of file diff --git a/Features/DataDictionary/ai_testcases/example_testcase.txt b/Features/DataDictionary/ai_testcases/example_testcase.txt new file mode 100644 index 000000000..f9b9a654b --- /dev/null +++ b/Features/DataDictionary/ai_testcases/example_testcase.txt @@ -0,0 +1,23 @@ +Suite: + +Test 1: +Tags: +1. +2. +3. +4. +Expected Result: + +Test 2: +Tags: +1. +2. +3. +Expected Result: + +Test 3: +Tags: +1. +2. +3. +Expected Result: diff --git a/Features/FindDataById/ai_testcases/example_testcase.txt b/Features/FindDataById/ai_testcases/example_testcase.txt new file mode 100644 index 000000000..f9b9a654b --- /dev/null +++ b/Features/FindDataById/ai_testcases/example_testcase.txt @@ -0,0 +1,23 @@ +Suite: + +Test 1: +Tags: +1. +2. +3. +4. +Expected Result: + +Test 2: +Tags: +1. +2. +3. +Expected Result: + +Test 3: +Tags: +1. +2. +3. +Expected Result: diff --git a/Features/Lookuptable/ai_testcases/example_testcase.txt b/Features/Lookuptable/ai_testcases/example_testcase.txt new file mode 100644 index 000000000..f9b9a654b --- /dev/null +++ b/Features/Lookuptable/ai_testcases/example_testcase.txt @@ -0,0 +1,23 @@ +Suite: + +Test 1: +Tags: +1. +2. +3. +4. +Expected Result: + +Test 2: +Tags: +1. +2. +3. +Expected Result: + +Test 3: +Tags: +1. +2. +3. +Expected Result: diff --git a/Features/MultiSelect/ai_testcases/example_testcase.txt b/Features/MultiSelect/ai_testcases/example_testcase.txt new file mode 100644 index 000000000..f9b9a654b --- /dev/null +++ b/Features/MultiSelect/ai_testcases/example_testcase.txt @@ -0,0 +1,23 @@ +Suite: + +Test 1: +Tags: +1. +2. +3. +4. +Expected Result: + +Test 2: +Tags: +1. +2. +3. +Expected Result: + +Test 3: +Tags: +1. +2. +3. +Expected Result: diff --git a/Features/SplitScreenCaseSearch/ai_testcases/example_testcase.txt b/Features/SplitScreenCaseSearch/ai_testcases/example_testcase.txt new file mode 100644 index 000000000..f9b9a654b --- /dev/null +++ b/Features/SplitScreenCaseSearch/ai_testcases/example_testcase.txt @@ -0,0 +1,23 @@ +Suite: + +Test 1: +Tags: +1. +2. +3. +4. +Expected Result: + +Test 2: +Tags: +1. +2. +3. +Expected Result: + +Test 3: +Tags: +1. +2. +3. +Expected Result: diff --git a/HQSmokeTests/ai_testcases/example_testcase.txt b/HQSmokeTests/ai_testcases/example_testcase.txt new file mode 100644 index 000000000..f9b9a654b --- /dev/null +++ b/HQSmokeTests/ai_testcases/example_testcase.txt @@ -0,0 +1,23 @@ +Suite: + +Test 1: +Tags: +1. +2. +3. +4. +Expected Result: + +Test 2: +Tags: +1. +2. +3. +Expected Result: + +Test 3: +Tags: +1. +2. +3. +Expected Result: diff --git a/HQSmokeTests/ai_testcases/reports_and_users.txt b/HQSmokeTests/ai_testcases/reports_and_users.txt new file mode 100644 index 000000000..052eca1ba --- /dev/null +++ b/HQSmokeTests/ai_testcases/reports_and_users.txt @@ -0,0 +1,53 @@ +Suite: HQSmokeTests + +Test 1: Verify all report sections are displayed and load correctly +Tags: report, smoke +1. Navigate to the HQ home page +2. Click on the Reports menu +3. Verify Monitor Workers section is displayed +4. Verify Inspect Data section is displayed +5. Verify Manage Deployments section is displayed +6. Verify Messaging section is displayed +7. Open and run the Worker Activity report +8. Open and run the Daily Form Activity report +9. Open and run the Case Activity report +10. Open and run the Submit History report +Expected Result: All report sections are visible and each report loads with data + +Test 2: Create a new mobile worker and verify it appears in the list +Tags: users, mobileWorker +1. Navigate to Users menu +2. Click on Mobile Workers +3. Click Add Mobile Worker +4. Enter a unique username +5. Enter a password +6. Save the new mobile worker +7. Search for the newly created worker in the list +Expected Result: New mobile worker is created and visible in the mobile workers list + +Test 3: Create a user group and add a mobile worker to it +Tags: users, groups +1. Navigate to Users menu +2. Click on Groups +3. Create a new group with a unique name +4. Add an existing mobile worker to the group +5. Save the group +6. Verify the group appears in the groups list +Expected Result: Group is created and the mobile worker is assigned to it + +Test 4: Verify export data functionality works +Tags: data, exports +1. Navigate to Data menu +2. Click on Export Data +3. Create a new case export +4. Verify the export appears in the exports list +5. Download the export file +Expected Result: Export file is created and downloaded successfully + +Test 5: Verify application is accessible via Web Apps +Tags: webApps, smoke +1. Navigate to Web Apps +2. Verify the list of applications is displayed +3. Open one of the available applications +4. Verify the application menus are displayed +Expected Result: Application loads correctly in Web Apps diff --git a/HQSmokeTests/testCases/test_ai_reports_and_users.py b/HQSmokeTests/testCases/test_ai_reports_and_users.py new file mode 100644 index 000000000..c5c149f83 --- /dev/null +++ b/HQSmokeTests/testCases/test_ai_reports_and_users.py @@ -0,0 +1,177 @@ +""" +Test Module: test_ai_reports_and_users.py + +Contains automated test cases for verifying report sections, user functionalities, +data export capabilities, and application accessibility in Web Apps. +""" + +import pytest +from common_utilities.selenium.webapps import WebApps +from common_utilities.hq_login.login_page import LoginPage + +from HQSmokeTests.testPages.home.home_page import HomePage +from HQSmokeTests.testPages.reports.report_page import ReportPage +from HQSmokeTests.testPages.users.mobile_workers_page import MobileWorkerPage +from HQSmokeTests.testPages.users.group_page import GroupPage +from HQSmokeTests.testPages.data.export_data_page import ExportDataPage +from HQSmokeTests.testPages.webapps.web_apps_page import WebAppsPage +from HQSmokeTests.userInputs.user_inputs import UserData + +@pytest.mark.report +@pytest.mark.smoke +def test_01_verify_all_report_sections_load_correctly(driver, settings): + """ + Verify all report sections are displayed and load correctly. + Steps: + 1. Navigate to the HQ home page + 2. Click on the Reports menu + 3. Verify Monitor Workers section is displayed + 4. Verify Inspect Data section is displayed + 5. Verify Manage Deployments section is displayed + 6. Verify Messaging section is displayed + 7. Open and run Worker Activity report + 8. Open and run Daily Form Activity report + 9. Open and run Case Activity report + 10. Open and run Submit History report + Expected Result: All report sections are visible and each report loads with data. + """ + home_page = HomePage(driver, settings) + home_page.reports_menu() + + report_page = ReportPage(driver) + assert report_page.is_present_and_displayed(report_page.get_element("Monitor Workers")), "Monitor Workers section not displayed" + assert report_page.is_present_and_displayed(report_page.get_element("Inspect Data")), "Inspect Data section not displayed" + assert report_page.is_present_and_displayed(report_page.get_element("Manage Deployments")), "Manage Deployments section not displayed" + assert report_page.is_present_and_displayed(report_page.get_element("Messaging")), "Messaging section not displayed" + + print("All report sections are displayed.") + + report_page.worker_activity_report() + assert report_page.check_if_report_loaded(), "Worker Activity report did not load with data" + print("Worker Activity report loaded successfully.") + + report_page.daily_form_activity_report() + assert report_page.check_if_report_loaded(), "Daily Form Activity report did not load with data" + print("Daily Form Activity report loaded successfully.") + + report_page.case_activity_report() + assert report_page.check_if_report_loaded(), "Case Activity report did not load with data" + print("Case Activity report loaded successfully.") + + report_page.submit_history_report() + assert report_page.check_if_report_loaded(), "Submit History report did not load with data" + print("Submit History report loaded successfully.") + +@pytest.mark.users +@pytest.mark.mobileWorker +def test_02_create_new_mobile_worker_verify_in_list(driver, settings): + """ + Create a new mobile worker and verify it appears in the list. + Steps: + 1. Navigate to Users menu + 2. Click on Mobile Workers + 3. Click Add Mobile Worker + 4. Enter a unique username + 5. Enter a password + 6. Save the new mobile worker + 7. Search for the newly created worker in the list + Expected Result: New mobile worker is created and visible in the mobile workers list. + """ + username = "unique_user_" + str(int(time.time())) # Generating a unique username + + home_page = HomePage(driver, settings) + home_page.users_menu() + + mobile_worker_page = MobileWorkerPage(driver) + mobile_worker_page.mobile_worker_menu() + mobile_worker_page.create_mobile_worker() + mobile_worker_page.mobile_worker_enter_username(username) + mobile_worker_page.mobile_worker_enter_password(UserData.app_password) + mobile_worker_page.click_create(username) + print("New mobile worker created.") + + mobile_worker_page.search_user(username) + assert mobile_worker_page.is_present_and_displayed(mobile_worker_page.get_element(username)), "New mobile worker not found in list" + print("New mobile worker is present in the list.") + +@pytest.mark.users +@pytest.mark.groups +def test_03_create_user_group_add_mobile_worker(driver, settings): + """ + Create a user group and add a mobile worker to it. + Steps: + 1. Navigate to Users menu + 2. Click on Groups + 3. Create a new group with a unique name + 4. Add an existing mobile worker to the group + 5. Save the group + 6. Verify the group appears in the groups list + Expected Result: Group is created and the mobile worker is assigned to it. + """ + group_name = "Group_" + str(int(time.time())) # Generating a unique group name + + home_page = HomePage(driver, settings) + home_page.users_menu() + + group_page = GroupPage(driver) + group_page.click_group_menu() + group_page.add_group(group_name) + print(f"New group '{group_name}' created.") + + group_page.add_user_to_group(UserData.mobile_testuser, group_name) + print(f"Mobile worker '{UserData.mobile_testuser}' added to group '{group_name}'.") + + assert group_page.is_present_and_displayed(group_page.get_element(group_name)), "New group not found in list" + print("Group with mobile worker is present in the list.") + +@pytest.mark.data +@pytest.mark.exports +def test_04_verify_export_data_functionality(driver, settings): + """ + Verify export data functionality works. + Steps: + 1. Navigate to Data menu + 2. Click on Export Data + 3. Create a new case export + 4. Verify the export appears in the exports list + 5. Download the export file + Expected Result: Export file is created and downloaded successfully. + """ + home_page = HomePage(driver, settings) + home_page.data_menu() + + export_data_page = ExportDataPage(driver) + export_name = UserData.case_export_name + + export_data_page.add_case_exports() + export_data_page.case_exports(export_name) + print(f"Case export '{export_name}' created.") + + assert export_data_page.verify_export_count(export_name), "Export not found in exports list" + print("Export appears in the exports list.") + + export_data_page.download_export_without_condition(export_name) + print("Export file downloaded successfully.") + export_data_page.assert_downloaded_file(export_name, "Export file") + +@pytest.mark.webApps +@pytest.mark.smoke +def test_05_verify_application_accessible_via_web_apps(driver, settings): + """ + Verify the application is accessible via Web Apps. + Steps: + 1. Navigate to Web Apps + 2. Verify the list of applications is displayed + 3. Open one of the available applications + 4. Verify the application menus are displayed + Expected Result: Application loads correctly in Web Apps. + """ + webapps = WebApps(driver, settings) + webapps.open_app(UserData.village_application) + webapps_page = WebAppsPage(driver) + assert webapps_page.verify_apps_presence(), "Applications list not displayed" + print("Applications list is displayed.") + + webapps.open_menu("Case List") + assert webapps_page.is_present_and_displayed(webapps_page.get_element("Case List")), "Application menu not displayed correctly" + print("Application menus are displayed correctly.") \ No newline at end of file diff --git a/P1P2Tests/ai_testcases/example_testcase.txt b/P1P2Tests/ai_testcases/example_testcase.txt new file mode 100644 index 000000000..f9b9a654b --- /dev/null +++ b/P1P2Tests/ai_testcases/example_testcase.txt @@ -0,0 +1,23 @@ +Suite: + +Test 1: +Tags: +1. +2. +3. +4. +Expected Result: + +Test 2: +Tags: +1. +2. +3. +Expected Result: + +Test 3: +Tags: +1. +2. +3. +Expected Result: diff --git a/RequestAPI/ai_testcases/example_testcase.txt b/RequestAPI/ai_testcases/example_testcase.txt new file mode 100644 index 000000000..f9b9a654b --- /dev/null +++ b/RequestAPI/ai_testcases/example_testcase.txt @@ -0,0 +1,23 @@ +Suite: + +Test 1: +Tags: +1. +2. +3. +4. +Expected Result: + +Test 2: +Tags: +1. +2. +3. +Expected Result: + +Test 3: +Tags: +1. +2. +3. +Expected Result: diff --git a/USH_Apps/CO_BHA/ai_testcases/example_testcase.txt b/USH_Apps/CO_BHA/ai_testcases/example_testcase.txt new file mode 100644 index 000000000..f9b9a654b --- /dev/null +++ b/USH_Apps/CO_BHA/ai_testcases/example_testcase.txt @@ -0,0 +1,23 @@ +Suite: + +Test 1: +Tags: +1. +2. +3. +4. +Expected Result: + +Test 2: +Tags: +1. +2. +3. +Expected Result: + +Test 3: +Tags: +1. +2. +3. +Expected Result: diff --git a/ai_test_generator/.env.example b/ai_test_generator/.env.example new file mode 100644 index 000000000..35965eb63 --- /dev/null +++ b/ai_test_generator/.env.example @@ -0,0 +1 @@ +ANTHROPIC_API_KEY=your-key-here \ No newline at end of file diff --git a/ai_test_generator/FAQ.md b/ai_test_generator/FAQ.md new file mode 100644 index 000000000..3ddab194e --- /dev/null +++ b/ai_test_generator/FAQ.md @@ -0,0 +1,154 @@ +# AI Test Generator — FAQ + +--- + +### What does the AI agent actually do? + +It generates a ready-to-run pytest test file (`.py`) from plain English steps written in a `.txt` file. +It does **not** run the tests. Your existing GitHub Actions workflows handle test execution on their normal schedule. + +--- + +### How does the agent know which function to call? + +The agent scans all `testPages/` files in your suite using Python's AST parser and extracts every class name and method signature. That full list is sent to the AI along with your description. The AI then matches your plain English steps to the closest matching method. + +For example, when you write: +``` +3. Open the Worker Activity report +``` +The scanner has already told the AI that `ReportPage` has a method called `worker_activity_report()` — so it calls exactly that. + +--- + +### If I add new functionality, do I need to update the agent? + +No configuration needed. Once a developer adds a new method to a page object in `testPages/`, the scanner automatically picks it up the next time the agent runs. The agent always reflects the latest state of your page objects. + +| Task | Who does it | +|---|---| +| Write page object methods + locators | Developer (in `testPages/`) | +| Write test steps in plain English | Anyone (in `ai_testcases/*.txt`) | +| Generate the test function code | Agent (automatically) | + +--- + +### Can the agent generate page object methods and locators too? + +No. The agent cannot see your actual UI or browser, so it cannot generate accurate XPath/CSS locators. Page object methods need to be written by a developer who knows the UI structure. + +Once those methods exist in `testPages/`, the agent can use them freely in generated tests. + +--- + +### One txt file or one test per file? + +One `.txt` file = one `.py` test module with multiple test functions. + +``` +HQSmokeTests/ai_testcases/reports.txt → HQSmokeTests/testCases/test_ai_reports.py + Test 1: Verify report sections def test_01_verify_report_sections(...) + Test 2: Run Worker Activity report def test_02_run_worker_activity_report(...) + Test 3: Run Case Activity report def test_03_run_case_activity_report(...) +``` + +Organise your txt files by feature area — e.g. `reports.txt`, `mobile_workers.txt`, `exports.txt`. + +--- + +### Will the generated test run perfectly straight away? + +Mostly yes, but review it first. The agent uses real method names from your page objects, so the structure and logic will be correct. Occasionally, when a specific UI element has no matching method in the page objects, the AI inserts a placeholder like: + +```python +page.get_element("locator_for_monitor_workers") +``` + +A developer needs to replace these placeholders with the real locator from the page object. Everything else should be runnable as-is. + +--- + +### Does it modify my existing test files? + +Never. The agent only creates new files named `test_ai_.py`. Your existing hand-written test files are never touched. + +--- + +### How do I trigger generation locally? + +```bash +# Single txt file +python ai_test_generator/process_testcases.py --file HQSmokeTests/ai_testcases/reports.txt + +# All txt files across all suites at once +python ai_test_generator/process_testcases.py + +# Preview without writing the file +python ai_test_generator/process_testcases.py --file HQSmokeTests/ai_testcases/reports.txt --dry-run + +# Regenerate a file that already exists +python ai_test_generator/process_testcases.py --file HQSmokeTests/ai_testcases/reports.txt --force +``` + +--- + +### How do I trigger generation from GitHub (no local setup)? + +**Option A — Push a txt file (automatic):** +1. Add or edit a `.txt` file in any suite's `ai_testcases/` folder +2. Push/commit to GitHub +3. The **AI Test Generator** workflow triggers automatically +4. The generated `.py` file is committed back to the repo + +**Option B — Run manually from GitHub UI:** +1. Go to **Actions** tab on GitHub +2. Select **AI Test Generator** from the left sidebar +3. Click **Run workflow** +4. Choose the suite, type your description, click **Run workflow** + +--- + +### Do I need a different workflow for each test suite? + +No. The single `ai-test-generator.yml` workflow handles all 13+ suites. It detects which suite a txt file belongs to automatically from the folder path. + +--- + +### What if I add a new test suite in the future? + +A developer needs to add the new suite to the `SUITES` dictionary in `ai_test_generator/scanner.py` and create an `ai_testcases/` folder in the suite directory. After that, it works the same as all other suites. + +--- + +### How much does it cost to generate a test? + +Each test generation costs approximately **$0.01–$0.03** using GPT-4o. This only applies when generating — your daily test suite runs (Selenium/pytest) are unaffected and cost nothing extra. + +--- + +### Where is the API key stored? + +- **Locally:** `ai_test_generator/.env` — this file is in `.gitignore` and is never committed +- **GitHub Actions:** stored as a repository secret named `OPENAI_API_KEY` (Settings → Secrets and variables → Actions) + +--- + +### What suites are supported? + +| Suite Name | Folder | +|---|---| +| `CaseSearch` | `Features/CaseSearch/` | +| `DataDictionary` | `Features/DataDictionary/` | +| `FindDataById` | `Features/FindDataById/` | +| `Lookuptable` | `Features/Lookuptable/` | +| `MultiSelect` | `Features/MultiSelect/` | +| `PowerBI` | `Features/Powerbi_integration_exports/` | +| `SplitScreenCaseSearch` | `Features/SplitScreenCaseSearch/` | +| `ElasticSearch` | `ElasticSearchTests/` | +| `ExportTests` | `ExportTests/` | +| `Formplayer` | `Formplayer/` | +| `HQSmokeTests` | `HQSmokeTests/` | +| `P1P2Tests` | `P1P2Tests/` | +| `RequestAPI` | `RequestAPI/` | +| `USH_CO_BHA` | `USH_Apps/CO_BHA/` | +| `BHAStressTest` | `QA_Requests/BHAStressTest/` | diff --git a/ai_test_generator/README.md b/ai_test_generator/README.md new file mode 100644 index 000000000..bda6d9351 --- /dev/null +++ b/ai_test_generator/README.md @@ -0,0 +1,177 @@ +# AI Test Generator + +Automatically generates ready-to-run pytest test files from plain English test descriptions. +Non-technical team members write simple steps in a `.txt` file — the AI writes the code. + +--- + +## How It Works + +``` +You write a .txt file → AI reads it → .py test module is created +HQSmokeTests/ (OpenAI) HQSmokeTests/ + ai_testcases/ testCases/ + reports.txt test_ai_reports.py + Test 1: ... def test_01_... + Test 2: ... def test_02_... +``` + +The generated `.py` file is placed in the suite's existing `testCases/` or `test_cases/` folder +and follows the exact same structure as your hand-written tests — same imports, fixtures, +page objects, and naming conventions. + +--- + +## Quick Start (Local) + +### Step 1 — Install the dependency +```bash +pip install openai +``` + +### Step 2 — Add your OpenAI API key to the `.env` file +Open `ai_test_generator/.env` and set: +``` +OPENAI_API_KEY=sk-proj-your-key-here +``` + +### Step 3 — Write your test cases in a `.txt` file + +Go to your suite's `ai_testcases/` folder (e.g. `HQSmokeTests/ai_testcases/`), +copy `example_testcase.txt`, rename it, and fill in your steps: + +``` +Suite: HQSmokeTests + +Test 1: Verify reports module loads all sections +1. Navigate to Reports +2. Click View All +3. Verify Monitor Workers section is displayed +4. Verify Inspect Data section is displayed +Expected Result: All report sections are visible + +Test 2: Create a new mobile worker +1. Navigate to Users > Mobile Workers +2. Click Add Mobile Worker +3. Enter a username and password +4. Save the new worker +Expected Result: Worker is created and appears in the list +``` + +### Step 4 — Run the generator +```bash +# Generate from a specific txt file +python ai_test_generator/process_testcases.py --file HQSmokeTests/ai_testcases/reports.txt + +# Generate from ALL txt files across ALL suites at once +python ai_test_generator/process_testcases.py + +# Preview without writing the file +python ai_test_generator/process_testcases.py --file HQSmokeTests/ai_testcases/reports.txt --dry-run + +# Regenerate an already-generated file +python ai_test_generator/process_testcases.py --file HQSmokeTests/ai_testcases/reports.txt --force +``` + +### Step 5 — Review and run the generated test +```bash +pytest HQSmokeTests/testCases/test_ai_reports.py -v +``` + +--- + +## GitHub Actions (No Local Setup Needed) + +Your team members can generate tests directly from GitHub without any local setup. + +### Option A — Push a txt file (automatic) +1. Add or edit a `.txt` file in any suite's `ai_testcases/` folder +2. Push/commit to GitHub +3. The **AI Test Generator** workflow triggers automatically +4. The generated `.py` file is committed back to the repo + +### Option B — Run manually from GitHub UI +1. Go to **Actions** tab on GitHub +2. Select **AI Test Generator** from the left sidebar +3. Click **Run workflow** +4. Choose the suite, type your test description, click **Run workflow** + +--- + +## Available Suites + +| Suite Name | Folder | +|---|---| +| `CaseSearch` | `Features/CaseSearch/` | +| `DataDictionary` | `Features/DataDictionary/` | +| `FindDataById` | `Features/FindDataById/` | +| `Lookuptable` | `Features/Lookuptable/` | +| `MultiSelect` | `Features/MultiSelect/` | +| `PowerBI` | `Features/Powerbi_integration_exports/` | +| `SplitScreenCaseSearch` | `Features/SplitScreenCaseSearch/` | +| `ElasticSearch` | `ElasticSearchTests/` | +| `ExportTests` | `ExportTests/` | +| `Formplayer` | `Formplayer/` | +| `HQSmokeTests` | `HQSmokeTests/` | +| `P1P2Tests` | `P1P2Tests/` | +| `RequestAPI` | `RequestAPI/` | +| `USH_CO_BHA` | `USH_Apps/CO_BHA/` | +| `BHAStressTest` | `QA_Requests/BHAStressTest/` | + +--- + +## txt File Format + +``` +Suite: + +Test 1: +1. +2. +3. +Expected Result: + +Test 2: +1. +2. +Expected Result: +``` + +**Rules:** +- `Suite:` line is required (or place the file in the correct `ai_testcases/` folder — it auto-detects) +- Each test block starts with `Test 1:`, `Test 2:`, etc. +- Steps can be numbered (`1.`) or plain bullet points +- `Expected Result:` is optional but recommended +- One `.txt` file → one `.py` test module with multiple test functions + +--- + +## File Structure + +``` +ai_test_generator/ +├── generate_test.py # Core generator — calls OpenAI API +├── process_testcases.py # Processes txt files → generates test modules +├── scanner.py # Scans page objects and user inputs from the framework +├── requirements.txt # openai>=1.0.0 +├── .env # Your API key (never committed) +├── .env.example # Template for the .env file +├── TESTCASE_TEMPLATE.txt # Blank template to copy into ai_testcases/ +└── README.md # This file + +Each suite/ +└── ai_testcases/ + ├── example_testcase.txt # Template showing the format + └── your_tests.txt # Your test cases → generates test_ai_your_tests.py +``` + +--- + +## Tips + +- **Be specific in your steps** — mention usernames, menu names, form names, and field names + that exist in your application. The AI uses these to pick the right page object methods. +- **One txt file per feature area** — e.g. `reports.txt`, `mobile_workers.txt`, `exports.txt` +- **Review the generated file** before running — the AI may occasionally use a placeholder + locator if a specific UI element isn't in the existing page objects. Add it manually if needed. +- **The generator never modifies existing test files** — it only creates new `test_ai_*.py` files. \ No newline at end of file diff --git a/ai_test_generator/TESTCASE_TEMPLATE.txt b/ai_test_generator/TESTCASE_TEMPLATE.txt new file mode 100644 index 000000000..f9b9a654b --- /dev/null +++ b/ai_test_generator/TESTCASE_TEMPLATE.txt @@ -0,0 +1,23 @@ +Suite: + +Test 1: +Tags: +1. +2. +3. +4. +Expected Result: + +Test 2: +Tags: +1. +2. +3. +Expected Result: + +Test 3: +Tags: +1. +2. +3. +Expected Result: diff --git a/ai_test_generator/__init__.py b/ai_test_generator/__init__.py new file mode 100644 index 000000000..b836304b9 --- /dev/null +++ b/ai_test_generator/__init__.py @@ -0,0 +1 @@ +# ai_test_generator package \ No newline at end of file diff --git a/ai_test_generator/generate_test.py b/ai_test_generator/generate_test.py new file mode 100644 index 000000000..8f9c5da2b --- /dev/null +++ b/ai_test_generator/generate_test.py @@ -0,0 +1,367 @@ +""" +generate_test.py +================ +AI-powered test generator for the dimagi-qa framework. + +Usage (local): + python ai_test_generator/generate_test.py \ + --suite CaseSearch \ + --description "Login as user-1, open the Music App, search for a case by song name using text input, select the case and submit the Play Song form" \ + --output Features/CaseSearch/test_cases/test_99_generated.py + +Usage (GitHub Actions): + Triggered via workflow_dispatch - see .github/workflows/ai-test-generator.yml + +Requirements: + pip install openai + +Environment variable: + OPENAI_API_KEY Your OpenAI API key (required) +""" + +import argparse +import os +import sys +import textwrap +from pathlib import Path + +# Allow running from project root or from ai_test_generator/ +ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(ROOT)) + +try: + from openai import OpenAI +except ImportError: + print("[ERROR] openai package not installed. Run: pip install openai") + sys.exit(1) + +from ai_test_generator.scanner import ( + scan_suite, + scan_common_utilities, + format_suite_context, + list_suites, + SUITES, + ROOT as PROJECT_ROOT, +) + + +# ─── System prompt ──────────────────────────────────────────────────────────── + +SYSTEM_PROMPT = textwrap.dedent(""" +You are an expert test automation engineer for the dimagi-qa Selenium framework. +Your job is to write a complete, production-ready pytest test file from a plain English description. + +## FRAMEWORK RULES (follow these exactly): + +0. **Constructor signatures** – this is critical: + Every page object class has an `INSTANTIATE AS:` line in the context below. + You MUST use EXACTLY those arguments — no more, no less. + Examples: + - `INSTANTIATE AS: ReportPage(driver)` → `page = ReportPage(driver)` ✓ + - `INSTANTIATE AS: HomePage(driver, settings)` → `page = HomePage(driver, settings)` ✓ + - Never guess or add extra parameters like `settings` if not in the constructor. + +1. **Imports** – always include: + ```python + import pytest + from common_utilities.selenium.webapps import WebApps + from common_utilities.hq_login.login_page import LoginPage + ``` + Import page objects from the suite's test_pages folder. + Import user input constants from the suite's user_inputs module (use the provided constants — never hardcode strings). + +2. **Fixtures** – every test function takes `(driver, settings)` as arguments. + These come from conftest.py — do NOT redefine them. + +3. **Page object instantiation inside each test**: + ```python + webapps = WebApps(driver, settings) + page = SomePageClass(driver) + ``` + +4. **Test function naming**: `test__` (e.g. `test_01_search_and_submit`) + +5. **Docstring**: Every test must have a docstring describing the test scenario in plain English. + +6. **Assertions**: Use `assert` with meaningful messages. + Example: `assert webapps.is_present_and_displayed(locator), "Element not found"` + Or simply verify via methods that already assert internally. + +7. **Logging**: Use `print()` for step-by-step logging (the framework uses print, not logging). + +8. **No hardcoded strings**: Always use constants from user_inputs classes or locally defined constants. + +9. **File header**: Include a module-level docstring explaining what the file tests. + +10. **Only use methods that actually exist** in the page objects provided below. + Do not invent method names. If a method does not exist, use BasePage primitives + (wait_to_click, wait_to_clear_and_send_keys, wait_for_element, etc.). + +11. **BasePage key methods** (inherited by all page objects and WebApps): + - wait_to_click(locator) + - wait_to_clear_and_send_keys(locator, text) + - wait_for_element(locator, timeout=30) + - wait_for_disappear(locator) + - wait_to_get_text(locator) → str + - is_present_and_displayed(locator, timeout) → bool + - is_displayed(locator) → bool + - find_elements_texts(locator) → list[str] + - js_click(locator) + - get_element(format_string, value) → locator tuple + - scroll_to_element(locator) + - get_url(url) + - select_by_text(locator, value) + +12. **WebApps key methods** (navigation & form submission): + - open_app(app_name) + - open_menu(menu_name) + - open_form(form_name) + - submit_the_form() + - search_all_cases() + - omni_search(case_name) + - select_case_and_continue(case_name) → list[str] + - select_first_case_on_list_and_continue() + - navigate_to_breadcrumb(value) + - login_as(username) + - clear_selections_on_case_search_page() + - search_button_on_case_search_page() + +## OUTPUT FORMAT: +Return ONLY valid Python code. No markdown fences, no explanation outside the code. +The code should be ready to save directly as a .py file and run with pytest. +""").strip() + + +# ─── User prompt builder ─────────────────────────────────────────────────────── + +def build_user_prompt(description: str, suite_context: str, output_filename: str) -> str: + suite_name_hint = Path(output_filename).stem if output_filename else "test_generated" + return textwrap.dedent(f""" + Generate a complete pytest test file for the following test scenario: + + TEST DESCRIPTION: + {description} + + OUTPUT FILE: {output_filename} + + {suite_context} + + IMPORTANT: + - Use ONLY the page objects and methods listed above. + - Use ONLY the user input constants listed above (or define new ones at the top of the file if needed). + - Follow all framework rules exactly. + - Return ONLY Python code, no markdown. + """).strip() + + +# ─── Claude API call ────────────────────────────────────────────────────────── + +def _load_env_file(): + """Load .env file from ai_test_generator/ if it exists.""" + env_file = Path(__file__).parent / ".env" + if env_file.exists(): + for line in env_file.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip()) + + +def generate_test_code(description: str, suite_name: str, output_filename: str, + model: str = "gpt-4o") -> str: + _load_env_file() + api_key = os.environ.get("OPENAI_API_KEY") + if not api_key: + print("[ERROR] OPENAI_API_KEY not set.") + print(" Option 1: Create ai_test_generator/.env with OPENAI_API_KEY=your-key") + print(" Option 2: Set environment variable OPENAI_API_KEY before running") + sys.exit(1) + + print(f"[INFO] Scanning suite: {suite_name}") + suite_data = scan_suite(suite_name) + common_utils = scan_common_utilities() + suite_context = format_suite_context(suite_data, common_utils) + + print(f"[INFO] Found {len(suite_data['page_classes'])} page object class(es)") + print(f"[INFO] Found {len(suite_data['user_inputs'])} user input constant(s)") + print(f"[INFO] Calling OpenAI ({model}) to generate test...") + + client = OpenAI(api_key=api_key) + + response = client.chat.completions.create( + model=model, + max_tokens=4096, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": build_user_prompt(description, suite_context, output_filename)}, + ], + ) + + return response.choices[0].message.content.strip() + + +# ─── Output helpers ─────────────────────────────────────────────────────────── + +def resolve_output_path(suite_name: str, output_arg: str | None) -> Path: + """Determine where to write the generated test file.""" + if output_arg: + p = Path(output_arg) + if not p.is_absolute(): + p = PROJECT_ROOT / p + return p + + # Auto-generate path based on suite + suite_path = SUITES.get(suite_name) + if not suite_path: + return PROJECT_ROOT / "generated_test.py" + + for subdir in ["test_cases", "testCases"]: + tests_dir = suite_path / subdir + if tests_dir.exists(): + # Find next available test number + existing = sorted(tests_dir.glob("test_*.py")) + if existing: + # Try to parse the highest test number + nums = [] + for f in existing: + parts = f.stem.split("_") + if len(parts) >= 2 and parts[1].isdigit(): + nums.append(int(parts[1])) + next_num = (max(nums) + 1) if nums else 99 + else: + next_num = 1 + return tests_dir / f"test_{next_num:02d}_ai_generated.py" + + return PROJECT_ROOT / "generated_test.py" + + +def write_output(code: str, output_path: Path) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(code, encoding="utf-8") + print(f"\n[SUCCESS] Test file written to: {output_path}") + print(f"[INFO] Run with: pytest {output_path.relative_to(PROJECT_ROOT)}") + + +# ─── CLI ────────────────────────────────────────────────────────────────────── + +def parse_args(): + parser = argparse.ArgumentParser( + description="Generate a pytest test file from a plain English description.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=textwrap.dedent(""" + Examples: + # Local usage + python ai_test_generator/generate_test.py \\ + --suite CaseSearch \\ + --description "Login as user-1, open Music App, search for Song Name, submit the form" + + # Specify custom output path + python ai_test_generator/generate_test.py \\ + --suite HQSmokeTests \\ + --description "Verify that the Reports module shows Worker Activity report" \\ + --output HQSmokeTests/testCases/test_99_worker_activity_check.py + + # List all available suites + python ai_test_generator/generate_test.py --list-suites + """), + ) + parser.add_argument( + "--suite", "-s", + help="Target test suite name (use --list-suites to see all options)", + ) + parser.add_argument( + "--description", "-d", + help="Plain English description of the test scenario", + ) + parser.add_argument( + "--output", "-o", + help="Output file path (relative to project root). Auto-generated if not specified.", + default=None, + ) + parser.add_argument( + "--model", "-m", + help="OpenAI model to use (default: gpt-4o)", + default="gpt-4o", + ) + parser.add_argument( + "--list-suites", + action="store_true", + help="List all available test suites and exit", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the generated code to stdout instead of writing to a file", + ) + return parser.parse_args() + + +def main(): + args = parse_args() + + if args.list_suites: + suites = list_suites() + print("Available test suites:") + for s in suites: + print(f" - {s}") + return + + # Support env vars for GitHub Actions usage + suite = args.suite or os.environ.get("INPUT_SUITE") + description = args.description or os.environ.get("INPUT_DESCRIPTION") + output_arg = args.output or os.environ.get("INPUT_OUTPUT") + + if not suite: + print("[ERROR] --suite is required. Use --list-suites to see available suites.") + sys.exit(1) + if not description: + print("[ERROR] --description is required.") + sys.exit(1) + + if suite not in SUITES: + print(f"[ERROR] Unknown suite '{suite}'. Available: {', '.join(list_suites())}") + sys.exit(1) + + output_path = resolve_output_path(suite, output_arg) + + print("=" * 60) + print(" dimagi-qa AI Test Generator") + print("=" * 60) + print(f" Suite : {suite}") + print(f" Description: {description[:80]}{'...' if len(description) > 80 else ''}") + print(f" Output : {output_path.relative_to(PROJECT_ROOT)}") + print(f" Model : {args.model}") + print("=" * 60) + + code = generate_test_code( + description=description, + suite_name=suite, + output_filename=str(output_path.relative_to(PROJECT_ROOT)), + model=args.model, + ) + + # Strip accidental markdown fences if model adds them + if code.startswith("```"): + lines = code.splitlines() + # Remove first and last fence lines + if lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + code = "\n".join(lines) + + if args.dry_run: + print("\n" + "=" * 60) + print("GENERATED CODE (dry run — not written to file):") + print("=" * 60) + print(code) + else: + write_output(code, output_path) + print("\nNext steps:") + print(" 1. Review the generated file and adjust any locators or inputs") + print(" 2. Make sure settings.cfg is populated for your environment") + print(f" 3. Run: pytest {output_path.relative_to(PROJECT_ROOT)} -v") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ai_test_generator/process_testcases.py b/ai_test_generator/process_testcases.py new file mode 100644 index 000000000..922932be1 --- /dev/null +++ b/ai_test_generator/process_testcases.py @@ -0,0 +1,311 @@ +""" +process_testcases.py +==================== +Scans all ai_testcases/ folders across every test suite, finds unprocessed +.txt files, and generates a pytest test MODULE for each one. + +Each .txt file = one test module (.py file) with multiple test functions. + +TXT file format (save in /ai_testcases/.txt): +------------------------------------------------------------------ +Suite: CaseSearch + +Test 1: Search by song name and submit form +1. Login as user-1 +2. Open the Music App +3. Search for a case by song name using text input +4. Select the case and continue +5. Submit the Play Song form +Expected Result: Form submits successfully + +Test 2: Search with no results returns empty list +1. Login as user-1 +2. Open the Music App +3. Search for a non-existent song name +4. Verify the list shows empty message +Expected Result: Case list is empty + +Test 3: Search using combobox filter +1. Login as user-2 +2. Open the Music App +3. Filter cases using a combobox property +4. Verify filtered results appear +Expected Result: Only matching cases appear in the list +------------------------------------------------------------------ + +Generated output goes to: /test_cases/test_ai_.py + → Contains test_01_..., test_02_..., test_03_... functions + +Usage: + # Process all pending txt files across all suites + python ai_test_generator/process_testcases.py + + # Process a specific txt file + python ai_test_generator/process_testcases.py --file Features/CaseSearch/ai_testcases/casesearch_workflows.txt + + # Dry run - show what would be generated without writing files + python ai_test_generator/process_testcases.py --dry-run + + # Force regenerate even if .py already exists + python ai_test_generator/process_testcases.py --force +""" + +import argparse +import os +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(ROOT)) + +from ai_test_generator.generate_test import generate_test_code, _load_env_file +from ai_test_generator.scanner import SUITES, list_suites + +# Map suite folder paths back to suite names for auto-detection +SUITE_PATH_TO_NAME = {str(v.resolve()): k for k, v in SUITES.items()} + + +def detect_suite_from_path(txt_path: Path) -> str | None: + """Detect suite name by matching the txt file's parent folders against known suite paths.""" + for parent in txt_path.parents: + resolved = str(parent.resolve()) + if resolved in SUITE_PATH_TO_NAME: + return SUITE_PATH_TO_NAME[resolved] + return None + + +def parse_txt_file(txt_path: Path) -> dict: + """ + Parse a test case .txt file supporting multiple test cases per file. + + Returns: + { + "suite": str, + "module_name": str, + "tests": [ + { + "name": str, + "tags": list, # e.g. ["report", "smoke"] + "steps": list, + "expected": str, + }, + ... + ] + } + """ + content = txt_path.read_text(encoding="utf-8", errors="ignore").strip() + lines = content.splitlines() + + suite = None + tests = [] + current_test = None + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + # Suite header + if stripped.lower().startswith("suite:"): + suite = stripped.split(":", 1)[1].strip() + continue + + # New test block: "Test 1:", "Test 2:", "Test:", etc. + test_header = re.match(r'^test\s*\d*\s*:\s*(.+)$', stripped, re.IGNORECASE) + if test_header: + if current_test: + tests.append(current_test) + current_test = { + "name": test_header.group(1).strip(), + "tags": [], + "steps": [], + "expected": "", + } + continue + + # Tags line (within a test block): "Tags: report, smoke" + if current_test and re.match(r'^tags?\s*:', stripped, re.IGNORECASE): + raw_tags = stripped.split(":", 1)[1].strip() + current_test["tags"] = [t.strip() for t in raw_tags.split(",") if t.strip()] + continue + + # Expected result line (within a test block) + if current_test and re.match(r'^expected\s*(result)?\s*:', stripped, re.IGNORECASE): + current_test["expected"] = stripped.split(":", 1)[1].strip() + continue + + # Numbered step line (1. ... or 1) ...) + if current_test and re.match(r'^\d+[\.\)]\s+', stripped): + current_test["steps"].append(stripped) + continue + + # Plain text inside a test block (treat as a step) + if current_test and stripped: + current_test["steps"].append(stripped) + + # Don't forget the last test + if current_test: + tests.append(current_test) + + # Auto-detect suite from path if not in file + if not suite: + suite = detect_suite_from_path(txt_path) + + return { + "suite": suite, + "module_name": txt_path.stem, + "tests": tests, + } + + +def build_module_description(parsed: dict) -> str: + """Build a single description string covering all test cases for the AI prompt.""" + lines = [f"Generate a complete pytest test MODULE named test_ai_{parsed['module_name']}.py"] + lines.append(f"The module should contain {len(parsed['tests'])} test function(s), numbered test_01_, test_02_, etc.") + lines.append("") + + for i, test in enumerate(parsed["tests"], start=1): + lines.append(f"--- Test {i}: {test['name']} ---") + if test.get("tags"): + marks = " ".join(f"@pytest.mark.{t}" for t in test["tags"]) + lines.append(f"Pytest markers: {marks}") + lines.append("Steps:") + for step in test["steps"]: + lines.append(f" {step}") + if test["expected"]: + lines.append(f"Expected Result: {test['expected']}") + lines.append("") + + return "\n".join(lines) + + +def find_all_txt_files() -> list[Path]: + """Find all .txt files across all ai_testcases/ folders in every suite.""" + txt_files = [] + for suite_name, suite_path in SUITES.items(): + ai_dir = suite_path / "ai_testcases" + if ai_dir.exists(): + txt_files.extend( + f for f in ai_dir.glob("*.txt") + if f.name != "example_testcase.txt" # skip the template + ) + return sorted(txt_files) + + +def output_path_for(txt_path: Path, suite_path: Path) -> Path: + """Determine the output .py path in the suite's test_cases/ folder.""" + for subdir in ["test_cases", "testCases"]: + tests_dir = suite_path / subdir + if tests_dir.exists(): + return tests_dir / f"test_ai_{txt_path.stem}.py" + # Fallback: same folder as txt + return txt_path.parent / f"test_ai_{txt_path.stem}.py" + + +def process_file(txt_path: Path, dry_run: bool = False, force: bool = False) -> bool: + """Process a single txt file → generate a test module. Returns True if generated.""" + parsed = parse_txt_file(txt_path) + + if not parsed["suite"]: + print(f"[SKIP] {txt_path.name} — could not detect suite. Add 'Suite: ' to the file.") + return False + + if not parsed["tests"]: + print(f"[SKIP] {txt_path.name} — no test cases found. Use 'Test 1: ' to define tests.") + return False + + suite_name = parsed["suite"] + if suite_name not in SUITES: + print(f"[SKIP] {txt_path.name} — unknown suite '{suite_name}'. Available: {', '.join(list_suites())}") + return False + + suite_path = SUITES[suite_name] + out_path = output_path_for(txt_path, suite_path) + + if out_path.exists() and not force: + print(f"[SKIP] {txt_path.name} — {out_path.name} already exists. Use --force to regenerate.") + return False + + print(f"\n[GENERATE] {txt_path.name} -> {out_path.relative_to(ROOT)}") + print(f" Suite : {suite_name}") + print(f" Test cases : {len(parsed['tests'])}") + for i, t in enumerate(parsed["tests"], 1): + print(f" {i}. {t['name']}") + + description = build_module_description(parsed) + + code = generate_test_code( + description=description, + suite_name=suite_name, + output_filename=str(out_path.relative_to(ROOT)), + ) + + # Strip accidental markdown fences + if code.startswith("```"): + lines = code.splitlines() + if lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + code = "\n".join(lines) + + if dry_run: + print(f"\n--- DRY RUN: would write to {out_path.relative_to(ROOT)} ---") + print(code) + print("--- END ---") + else: + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(code, encoding="utf-8") + print(f"[SUCCESS] Written: {out_path.relative_to(ROOT)}") + + return True + + +def main(): + parser = argparse.ArgumentParser( + description="Process ai_testcases/*.txt files and generate pytest test modules.", + ) + parser.add_argument("--file", "-f", help="Process a specific .txt file only", default=None) + parser.add_argument("--suite", "-s", help="Process only txt files for a specific suite", default=None) + parser.add_argument("--dry-run", action="store_true", help="Print generated code without writing files") + parser.add_argument("--force", action="store_true", help="Regenerate even if .py already exists") + args = parser.parse_args() + + _load_env_file() + + if args.file: + txt_path = Path(args.file) + if not txt_path.is_absolute(): + txt_path = ROOT / txt_path + if not txt_path.exists(): + print(f"[ERROR] File not found: {txt_path}") + sys.exit(1) + process_file(txt_path, dry_run=args.dry_run, force=args.force) + return + + txt_files = find_all_txt_files() + + if args.suite: + suite_path = SUITES.get(args.suite) + if not suite_path: + print(f"[ERROR] Unknown suite '{args.suite}'") + sys.exit(1) + txt_files = [f for f in txt_files if suite_path in f.parents] + + if not txt_files: + print("[INFO] No txt files found in any ai_testcases/ folder.") + print(" Create a .txt file using the format in ai_test_generator/TESTCASE_TEMPLATE.txt") + return + + print(f"[INFO] Found {len(txt_files)} test case file(s) to process") + generated = 0 + for txt_path in txt_files: + if process_file(txt_path, dry_run=args.dry_run, force=args.force): + generated += 1 + + print(f"\n[DONE] Generated {generated} test module(s).") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ai_test_generator/requirements.txt b/ai_test_generator/requirements.txt new file mode 100644 index 000000000..3ceaffc34 --- /dev/null +++ b/ai_test_generator/requirements.txt @@ -0,0 +1 @@ +openai>=1.0.0 \ No newline at end of file diff --git a/ai_test_generator/scanner.py b/ai_test_generator/scanner.py new file mode 100644 index 000000000..47f8c3868 --- /dev/null +++ b/ai_test_generator/scanner.py @@ -0,0 +1,334 @@ +""" +scanner.py +Scans all test suites in the dimagi-qa project and extracts: +- Available page object classes and their public methods +- Suite structure (test_pages/, test_cases/, user_inputs/) +- User input classes and their attributes +""" + +import ast +import os +from pathlib import Path + + +ROOT = Path(__file__).parent.parent + +# All known test suites mapped to their folder paths +SUITES = { + "CaseSearch": ROOT / "Features" / "CaseSearch", + "DataDictionary": ROOT / "Features" / "DataDictionary", + "FindDataById": ROOT / "Features" / "FindDataById", + "Lookuptable": ROOT / "Features" / "Lookuptable", + "MultiSelect": ROOT / "Features" / "MultiSelect", + "PowerBI": ROOT / "Features" / "Powerbi_integration_exports", + "SplitScreenCaseSearch": ROOT / "Features" / "SplitScreenCaseSearch", + "ElasticSearch": ROOT / "ElasticSearchTests", + "ExportTests": ROOT / "ExportTests", + "Formplayer": ROOT / "Formplayer", + "HQSmokeTests": ROOT / "HQSmokeTests", + "P1P2Tests": ROOT / "P1P2Tests", + "RequestAPI": ROOT / "RequestAPI", + "USH_CO_BHA": ROOT / "USH_Apps" / "CO_BHA", + "MobileTest": ROOT / "MobileTest", + "BHAStressTest": ROOT / "QA_Requests" / "BHAStressTest", +} + +COMMON_UTILITIES = ROOT / "common_utilities" + + +def _get_init_signature(node: ast.ClassDef) -> str: + """Extract the __init__ constructor args (excluding self) for a class.""" + for item in node.body: + if isinstance(item, ast.FunctionDef) and item.name == "__init__": + args = [a.arg for a in item.args.args if a.arg != "self"] + return ", ".join(args) + return "" + + +def _extract_classes_and_methods(filepath: Path) -> list[dict]: + """Parse a Python file and return class info with constructor + public method signatures.""" + try: + source = filepath.read_text(encoding="utf-8", errors="ignore") + tree = ast.parse(source) + except Exception: + return [] + + results = [] + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + + init_args = _get_init_signature(node) + instantiation = f"{node.name}({init_args})" + + methods = [] + for item in node.body: + if not isinstance(item, ast.FunctionDef): + continue + if item.name.startswith("_"): + continue + + # Build readable signature + args = [a.arg for a in item.args.args if a.arg != "self"] + defaults = item.args.defaults + if defaults: + num_defaults = len(defaults) + required = args[:-num_defaults] if num_defaults < len(args) else [] + optional = args[-num_defaults:] if num_defaults <= len(args) else args + sig_parts = required + [f"{a}=..." for a in optional] + else: + sig_parts = args + + # Grab first line of docstring if present + docstring = "" + if (item.body and isinstance(item.body[0], ast.Expr) + and isinstance(item.body[0].value, ast.Constant)): + docstring = item.body[0].value.value.strip().splitlines()[0] + + methods.append({ + "name": item.name, + "signature": f"{item.name}({', '.join(sig_parts)})", + "doc": docstring, + }) + + if methods: + results.append({ + "class": node.name, + "file": str(filepath.relative_to(ROOT)), + "instantiation": instantiation, # e.g. "ReportPage(driver)" + "methods": methods, + }) + + return results + + +def _extract_constants(filepath: Path) -> dict: + """Extract top-level string constants and class attributes from a Python file.""" + try: + source = filepath.read_text(encoding="utf-8", errors="ignore") + tree = ast.parse(source) + except Exception: + return {} + + constants = {} + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + for item in node.body: + if isinstance(item, ast.Assign): + for target in item.targets: + if isinstance(target, ast.Name): + if isinstance(item.value, ast.Constant): + constants[f"{node.name}.{target.id}"] = item.value.s + elif isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name): + if isinstance(node.value, ast.Constant): + constants[target.id] = node.value.s + return constants + + +def _find_page_files(suite_path: Path) -> list[Path]: + """Find all page object files in a suite.""" + page_files = [] + for subdir in ["test_pages", "testPages", "pages"]: + pages_dir = suite_path / subdir + if pages_dir.exists(): + page_files.extend(pages_dir.rglob("*.py")) + return page_files + + +def _find_test_files(suite_path: Path) -> list[Path]: + """Find existing test files to use as reference examples.""" + test_files = [] + for subdir in ["test_cases", "testCases"]: + tests_dir = suite_path / subdir + if tests_dir.exists(): + test_files.extend( + f for f in tests_dir.glob("test_*.py") + if "conftest" not in f.name + ) + return sorted(test_files) + + +def _find_user_input_files(suite_path: Path) -> list[Path]: + """Find user input / test data files.""" + input_files = [] + for subdir in ["user_inputs", "userInputs", "UserInputs"]: + inputs_dir = suite_path / subdir + if inputs_dir.exists(): + input_files.extend(inputs_dir.rglob("*.py")) + return input_files + + +def scan_common_utilities() -> str: + """Return a summary of BasePage and WebApps methods.""" + lines = [] + + base_page = COMMON_UTILITIES / "selenium" / "base_page.py" + webapps = COMMON_UTILITIES / "selenium" / "webapps.py" + login_page = COMMON_UTILITIES / "hq_login" / "login_page.py" + + for filepath in [base_page, webapps, login_page]: + if not filepath.exists(): + continue + classes = _extract_classes_and_methods(filepath) + for cls in classes: + lines.append(f"\nClass: {cls['class']} (from {cls['file']})") + lines.append(f" INSTANTIATE AS: {cls['instantiation']}") + for m in cls["methods"]: + line = f" - {m['signature']}" + if m["doc"]: + line += f" # {m['doc']}" + lines.append(line) + + return "\n".join(lines) + + +def scan_suite(suite_name: str) -> dict: + """ + Scan a specific test suite and return structured context: + { + "suite_name": str, + "suite_path": str, + "page_classes": [{"class": str, "file": str, "methods": [...]}], + "user_inputs": {attr: value, ...}, + "test_dirs": {"test_cases": str, "test_pages": str, "user_inputs": str}, + "existing_test_example": str, # content of first test file + "conftest_example": str, # content of conftest.py + } + """ + suite_path = SUITES.get(suite_name) + if not suite_path or not suite_path.exists(): + available = ", ".join(SUITES.keys()) + raise ValueError( + f"Suite '{suite_name}' not found. Available suites: {available}" + ) + + result = { + "suite_name": suite_name, + "suite_path": str(suite_path.relative_to(ROOT)), + "page_classes": [], + "user_inputs": {}, + "test_dirs": {}, + "existing_test_example": "", + "conftest_example": "", + } + + # Page objects + for pf in _find_page_files(suite_path): + result["page_classes"].extend(_extract_classes_and_methods(pf)) + + # Directory names + for subdir in ["test_cases", "testCases"]: + if (suite_path / subdir).exists(): + result["test_dirs"]["test_cases"] = subdir + break + for subdir in ["test_pages", "testPages"]: + if (suite_path / subdir).exists(): + result["test_dirs"]["test_pages"] = subdir + break + for subdir in ["user_inputs", "userInputs", "UserInputs"]: + if (suite_path / subdir).exists(): + result["test_dirs"]["user_inputs"] = subdir + break + + # User inputs (constants for test data) + for uf in _find_user_input_files(suite_path): + result["user_inputs"].update(_extract_constants(uf)) + + # Grab first existing test as reference example (truncated to 100 lines) + test_files = _find_test_files(suite_path) + if test_files: + try: + lines = test_files[0].read_text(encoding="utf-8", errors="ignore").splitlines() + result["existing_test_example"] = "\n".join(lines[:100]) + except Exception: + pass + + # Grab conftest.py + for subdir in ["test_cases", "testCases"]: + conftest = suite_path / subdir / "conftest.py" + if conftest.exists(): + try: + lines = conftest.read_text(encoding="utf-8", errors="ignore").splitlines() + result["conftest_example"] = "\n".join(lines[:60]) + except Exception: + pass + break + + return result + + +def list_suites() -> list[str]: + """Return all available suite names.""" + return [name for name, path in SUITES.items() if path.exists()] + + +def format_suite_context(suite_data: dict, common_utils: str) -> str: + """Format suite scan data into a readable context string for the AI prompt.""" + lines = [] + + lines.append(f"=== SUITE: {suite_data['suite_name']} ===") + lines.append(f"Path: {suite_data['suite_path']}") + lines.append("") + + # Directory structure + dirs = suite_data["test_dirs"] + lines.append("Directory layout:") + lines.append(f" Tests: {suite_data['suite_path']}/{dirs.get('test_cases', 'test_cases')}/") + lines.append(f" Page objects:{suite_data['suite_path']}/{dirs.get('test_pages', 'test_pages')}/") + lines.append(f" User inputs: {suite_data['suite_path']}/{dirs.get('user_inputs', 'user_inputs')}/") + lines.append("") + + # Common utilities + lines.append("=== COMMON UTILITIES (available in ALL suites) ===") + lines.append(common_utils) + lines.append("") + + # Suite-specific page objects + if suite_data["page_classes"]: + lines.append("=== SUITE-SPECIFIC PAGE OBJECTS ===") + lines.append("IMPORTANT: Instantiate each class EXACTLY as shown — do not add or remove parameters.") + for cls in suite_data["page_classes"]: + lines.append(f"\nClass: {cls['class']} (from {cls['file']})") + lines.append(f" INSTANTIATE AS: {cls['instantiation']}") + for m in cls["methods"]: + line = f" - {m['signature']}" + if m["doc"]: + line += f" # {m['doc']}" + lines.append(line) + else: + lines.append("=== NO SUITE-SPECIFIC PAGE OBJECTS FOUND ===") + lines.append("Use only common_utilities (BasePage, WebApps, LoginPage).") + lines.append("") + + # User inputs sample + if suite_data["user_inputs"]: + lines.append("=== AVAILABLE USER INPUT VALUES (sample) ===") + for k, v in list(suite_data["user_inputs"].items())[:40]: + lines.append(f" {k} = '{v}'") + lines.append("") + + # Existing test as reference + if suite_data["existing_test_example"]: + lines.append("=== EXISTING TEST EXAMPLE (reference only) ===") + lines.append(suite_data["existing_test_example"]) + lines.append("") + + # Conftest structure + if suite_data["conftest_example"]: + lines.append("=== CONFTEST.PY (fixture reference) ===") + lines.append(suite_data["conftest_example"]) + + return "\n".join(lines) + + +if __name__ == "__main__": + # Quick smoke test + print("Available suites:", list_suites()) + print("\nCommon utilities:") + print(scan_common_utilities()[:500]) + print("\nScanning CaseSearch...") + data = scan_suite("CaseSearch") + print(f"Found {len(data['page_classes'])} page classes") + print(f"Found {len(data['user_inputs'])} user input values")