diff --git a/.github/workflows/test-automation.yml b/.github/workflows/test-automation.yml new file mode 100644 index 0000000..4922318 --- /dev/null +++ b/.github/workflows/test-automation.yml @@ -0,0 +1,131 @@ +name: Test Automation Code Modernization + +on: + push: + branches: + - main + - dev + paths: + - 'tests/e2e-test/**' + schedule: + - cron: '0 13 * * *' # Runs at 1 PM UTC + workflow_dispatch: + +env: + url: ${{ vars.CODEMOD_WEB_URL }} + accelerator_name: "Code Modernization" + +jobs: + test: + + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.13' + + - name: Azure CLI Login + uses: azure/login@v2 + with: + creds: '{"clientId":"${{ secrets.AZURE_CLIENT_ID }}","clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ secrets.AZURE_SUBSCRIPTION_ID }}","tenantId":"${{ secrets.AZURE_TENANT_ID }}"}' + + - name: Start Container App + id: start-container-app + uses: azure/cli@v2 + with: + azcliversion: 'latest' + inlineScript: | + az rest -m post -u "/subscriptions/${{ secrets.AZURE_SUBSCRIPTION_ID }}/resourceGroups/${{ vars.CODEMOD_RG }}/providers/Microsoft.App/containerApps/${{ vars.CODEMOD_FRONTEND_CONTAINER_NAME }}/start?api-version=2025-01-01" + az rest -m post -u "/subscriptions/${{ secrets.AZURE_SUBSCRIPTION_ID }}/resourceGroups/${{ vars.CODEMOD_RG }}/providers/Microsoft.App/containerApps/${{ vars.CODEMOD_BACKEND_CONTAINER_NAME }}/start?api-version=2025-01-01" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r tests/e2e-test/requirements.txt + + - name: Ensure browsers are installed + run: python -m playwright install --with-deps chromium + + - name: Run tests(1) + id: test1 + run: | + xvfb-run pytest --headed --html=report/report.html --self-contained-html + working-directory: tests/e2e-test + continue-on-error: true + + - name: Sleep for 30 seconds + if: ${{ steps.test1.outcome == 'failure' }} + run: sleep 30s + shell: bash + + - name: Run tests(2) + id: test2 + if: ${{ steps.test1.outcome == 'failure' }} + run: | + xvfb-run pytest --headed --html=report/report.html --self-contained-html + working-directory: tests/e2e-test + continue-on-error: true + + - name: Sleep for 60 seconds + if: ${{ steps.test2.outcome == 'failure' }} + run: sleep 60s + shell: bash + + - name: Run tests(3) + id: test3 + if: ${{ steps.test2.outcome == 'failure' }} + run: | + xvfb-run pytest --headed --html=report/report.html --self-contained-html + working-directory: tests/e2e-test + + - name: Upload test report + id: upload_report + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: test-report + path: tests/e2e-test/report/* + + - name: Send Notification + if: always() + run: | + RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + REPORT_URL=${{ steps.upload_report.outputs.artifact-url }} + IS_SUCCESS=${{ steps.test1.outcome == 'success' || steps.test2.outcome == 'success' || steps.test3.outcome == 'success' }} + # Construct the email body + if [ "$IS_SUCCESS" = "true" ]; then + EMAIL_BODY=$(cat <Dear Team,

We would like to inform you that the ${{ env.accelerator_name }} Test Automation process has completed successfully.

Run URL: ${RUN_URL}

Test Report: ${REPORT_URL}

Best regards,
Your Automation Team

", + "subject": "${{ env.accelerator_name }} Test Automation - Success" + } + EOF + ) + else + EMAIL_BODY=$(cat <Dear Team,

We would like to inform you that the ${{ env.accelerator_name }} Test Automation process has encountered an issue and has failed to complete successfully.

Run URL: ${RUN_URL}

Test Report: ${REPORT_URL}

Please investigate the matter at your earliest convenience.

Best regards,
Your Automation Team

", + "subject": "${{ env.accelerator_name }} Test Automation - Failure" + } + EOF + ) + fi + + # Send the notification + curl -X POST "${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }}" \ + -H "Content-Type: application/json" \ + -d "$EMAIL_BODY" || echo "Failed to send notification" + + - name: Stop Container App + if: always() + uses: azure/cli@v2 + with: + azcliversion: 'latest' + inlineScript: | + az rest -m post -u "/subscriptions/${{ secrets.AZURE_SUBSCRIPTION_ID }}/resourceGroups/${{ vars.CODEMOD_RG }}/providers/Microsoft.App/containerApps/${{ vars.CODEMOD_FRONTEND_CONTAINER_NAME }}/stop?api-version=2025-01-01" + az rest -m post -u "/subscriptions/${{ secrets.AZURE_SUBSCRIPTION_ID }}/resourceGroups/${{ vars.CODEMOD_RG }}/providers/Microsoft.App/containerApps/${{ vars.CODEMOD_BACKEND_CONTAINER_NAME }}/stop?api-version=2025-01-01" + az logout \ No newline at end of file diff --git a/tests/e2e-test/.gitignore b/tests/e2e-test/.gitignore new file mode 100644 index 0000000..79644b6 --- /dev/null +++ b/tests/e2e-test/.gitignore @@ -0,0 +1,169 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +microsoft/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ +archive/ +report/ +screenshots/ +report.html +assets/ +.vscode/ diff --git a/tests/e2e-test/base/__init__.py b/tests/e2e-test/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e-test/base/base.py b/tests/e2e-test/base/base.py new file mode 100644 index 0000000..5992ab6 --- /dev/null +++ b/tests/e2e-test/base/base.py @@ -0,0 +1,10 @@ +class BasePage: + def __init__(self, page): + self.page = page + + def scroll_into_view(self, locator): + reference_list = locator + locator.nth(reference_list.count() - 1).scroll_into_view_if_needed() + + def is_visible(self, locator): + locator.is_visible() diff --git a/tests/e2e-test/config/constants.py b/tests/e2e-test/config/constants.py new file mode 100644 index 0000000..f5f4c9a --- /dev/null +++ b/tests/e2e-test/config/constants.py @@ -0,0 +1,8 @@ +import os + +from dotenv import load_dotenv + +load_dotenv() +URL = os.getenv("url") +if URL.endswith("/"): + URL = URL[:-1] diff --git a/tests/e2e-test/pages/HomePage.py b/tests/e2e-test/pages/HomePage.py new file mode 100644 index 0000000..294b503 --- /dev/null +++ b/tests/e2e-test/pages/HomePage.py @@ -0,0 +1,75 @@ +import os.path + +from base.base import BasePage + +from playwright.sync_api import expect + + +class HomePage(BasePage): + TITLE_TEXT = "//h1[normalize-space()='Modernize your code']" + BROWSE_FILES = "//button[normalize-space()='Browse files']" + SUCCESS_MSG = "//span[contains(text(),'All valid files uploaded successfully!')]" + TRANSLATE_BTN = "//button[normalize-space()='Start translating']" + BATCH_HISTORY = "//button[@aria-label='View batch history']" + CLOSE_BATCH_HISTORY = "//button[@aria-label='Close panel']" + BATCH_DETAILS = "//div[@class='batch-details']" + DOWNLOAD_FILES = "//button[normalize-space()='Download all as .zip']" + RETURN_HOME = "//button[normalize-space()='Return home']" + SUMMARY = "//span[normalize-space()='Summary']" + FILE_PROCESSED_MSG = "//span[normalize-space()='3 files processed successfully']" + + def __init__(self, page): + self.page = page + + def validate_home_page(self): + expect(self.page.locator(self.TITLE_TEXT)).to_be_visible() + + def upload_files(self): + with self.page.expect_file_chooser() as fc_info: + self.page.locator(self.BROWSE_FILES).click() + self.page.wait_for_timeout(5000) + self.page.wait_for_load_state("networkidle") + file_chooser = fc_info.value + current_working_dir = os.getcwd() + file_path1 = os.path.join(current_working_dir, "testdata/q1_informix.sql") + file_path2 = os.path.join(current_working_dir, "testdata/f1.sql") + file_path3 = os.path.join(current_working_dir, "testdata/f2.sql") + file_chooser.set_files([file_path1, file_path2, file_path3]) + self.page.wait_for_timeout(10000) + self.page.wait_for_load_state("networkidle") + expect(self.page.locator(self.SUCCESS_MSG)).to_be_visible() + + def upload_unsupported_files(self): + with self.page.expect_file_chooser() as fc_info: + self.page.locator(self.BROWSE_FILES).click() + self.page.wait_for_timeout(5000) + self.page.wait_for_load_state("networkidle") + file_chooser = fc_info.value + current_working_dir = os.getcwd() + file_path = os.path.join(current_working_dir, "testdata/invalid.py") + file_chooser.set_files([file_path]) + self.page.wait_for_timeout(4000) + self.page.wait_for_load_state("networkidle") + expect(self.page.locator(self.TRANSLATE_BTN)).to_be_disabled() + + def validate_translate(self): + self.page.locator(self.TRANSLATE_BTN).click() + expect(self.page.locator(self.DOWNLOAD_FILES)).to_be_enabled(timeout=200000) + self.page.locator(self.SUMMARY).click() + expect(self.page.locator(self.FILE_PROCESSED_MSG)).to_be_visible() + self.page.wait_for_timeout(3000) + + def validate_batch_history(self): + self.page.locator(self.BATCH_HISTORY).click() + self.page.wait_for_timeout(3000) + batch_details = self.page.locator(self.BATCH_DETAILS) + for i in range(batch_details.count()): + expect(batch_details.nth(i)).to_be_visible() + self.page.locator(self.CLOSE_BATCH_HISTORY).click() + + def validate_download_files(self): + self.page.locator(self.DOWNLOAD_FILES).click() + self.page.wait_for_timeout(7000) + self.page.locator(self.RETURN_HOME).click() + self.page.wait_for_timeout(3000) + expect(self.page.locator(self.TITLE_TEXT)).to_be_visible() diff --git a/tests/e2e-test/pages/__init__.py b/tests/e2e-test/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e-test/pages/loginPage.py b/tests/e2e-test/pages/loginPage.py new file mode 100644 index 0000000..0b41255 --- /dev/null +++ b/tests/e2e-test/pages/loginPage.py @@ -0,0 +1,36 @@ +from base.base import BasePage + + +class LoginPage(BasePage): + + EMAIL_TEXT_BOX = "//input[@type='email']" + NEXT_BUTTON = "//input[@type='submit']" + PASSWORD_TEXT_BOX = "//input[@type='password']" + SIGNIN_BUTTON = "//input[@id='idSIButton9']" + YES_BUTTON = "//input[@id='idSIButton9']" + PERMISSION_ACCEPT_BUTTON = "//input[@type='submit']" + + def __init__(self, page): + self.page = page + + def authenticate(self, username, password): + # login with username and password in web url + self.page.locator(self.EMAIL_TEXT_BOX).fill(username) + self.page.locator(self.NEXT_BUTTON).click() + # Wait for the password input field to be available and fill it + self.page.wait_for_load_state("networkidle") + # Enter password + self.page.locator(self.PASSWORD_TEXT_BOX).fill(password) + # Click on SignIn button + self.page.locator(self.SIGNIN_BUTTON).click() + # Wait for 5 seconds to ensure the login process completes + self.page.wait_for_timeout(20000) # Wait for 20 seconds + if self.page.locator(self.PERMISSION_ACCEPT_BUTTON).is_visible(): + self.page.locator(self.PERMISSION_ACCEPT_BUTTON).click() + self.page.wait_for_timeout(10000) + else: + # Click on YES button + self.page.locator(self.YES_BUTTON).click() + self.page.wait_for_timeout(10000) + # Wait for the "Articles" button to be available and click it + self.page.wait_for_load_state("networkidle") diff --git a/tests/e2e-test/pytest.ini b/tests/e2e-test/pytest.ini new file mode 100644 index 0000000..76eb64f --- /dev/null +++ b/tests/e2e-test/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +log_cli = true +log_cli_level = INFO +log_file = logs/tests.log +log_file_level = INFO +addopts = -p no:warnings diff --git a/tests/e2e-test/readme.MD b/tests/e2e-test/readme.MD new file mode 100644 index 0000000..941d365 --- /dev/null +++ b/tests/e2e-test/readme.MD @@ -0,0 +1,35 @@ +# cto-test-automation + +Write end-to-end tests for your web apps with [Playwright](https://github.com/microsoft/playwright-python) and [pytest](https://docs.pytest.org/en/stable/). + +- Support for **all modern browsers** including Chromium, WebKit and Firefox. +- Support for **headless and headed** execution. +- **Built-in fixtures** that provide browser primitives to test functions. + +Pre-Requisites: + +- Install Visual Studio Code: Download and Install Visual Studio Code(VSCode). +- Install NodeJS: Download and Install Node JS + +Create and Activate Python Virtual Environment + +- From your directory open and run cmd : "python -m venv microsoft" +This will create a virtual environment directory named microsoft inside your current directory +- To enable virtual environment, copy location for "microsoft\Scripts\activate.bat" and run from cmd + +Installing Playwright Pytest from Virtual Environment + +- To install libraries run "pip install -r requirements.txt" +- Install the required browsers "playwright install" + +Run test cases + +- To run test cases from your 'tests' folder : "pytest --html=report.html --self-contained-html" + +Create .env file in project root level with web app url and client credentials + +- create a .env file in project root level and the application url. please refer 'sample_dotenv_file.txt' file. + +## Documentation + +See on [playwright.dev](https://playwright.dev/python/docs/test-runners) for examples and more detailed information. diff --git a/tests/e2e-test/requirements.txt b/tests/e2e-test/requirements.txt new file mode 100644 index 0000000..1b0ac0d --- /dev/null +++ b/tests/e2e-test/requirements.txt @@ -0,0 +1,6 @@ +pytest-playwright +pytest-reporter-html1 +python-dotenv +pytest-check +pytest-html +py diff --git a/tests/e2e-test/testdata/f1.sql b/tests/e2e-test/testdata/f1.sql new file mode 100644 index 0000000..4260c28 --- /dev/null +++ b/tests/e2e-test/testdata/f1.sql @@ -0,0 +1,49 @@ +CREATE FUNCTION INCOMEBANDOFMAXBUYCUSTOMER(storeNumber INTEGER) + RETURNING VARCHAR(50); + + DEFINE incomeband INTEGER; + DEFINE cust INTEGER; + DEFINE hhdemo INTEGER; + DEFINE cnt INTEGER; + DEFINE cLevel VARCHAR(50); +begin + + SELECT ss_customer_sk, c_current_hdemo_sk, COUNT(*) + INTO cust, hhdemo, cnt + FROM store_sales_history, customer + WHERE ss_store_sk = storeNumber + AND c_customer_sk = ss_customer_sk + GROUP BY ss_customer_sk, c_current_hdemo_sk + HAVING COUNT(*) = ( + SELECT MAX(cnt) + FROM ( + SELECT ss_customer_sk, c_current_hdemo_sk, COUNT(*) AS cnt + FROM store_sales_history, customer + WHERE ss_store_sk = storeNumber + AND c_customer_sk = ss_customer_sk + GROUP BY ss_customer_sk, c_current_hdemo_sk + HAVING ss_customer_sk IS NOT NULL + ) tbl + ); + + SELECT hd_income_band_sk + INTO incomeband + FROM household_demographics + WHERE hd_demo_sk = hhdemo; + + IF (incomeband >= 0 AND incomeband <= 3) THEN + LET cLevel = 'low'; + ELIF (incomeband >= 4 AND incomeband <= 7) THEN + LET cLevel = 'lowerMiddle'; + ELIF (incomeband >= 8 AND incomeband <= 11) THEN + LET cLevel = 'upperMiddle'; + ELIF (incomeband >= 12 AND incomeband <= 16) THEN + LET cLevel = 'high'; + ELIF (incomeband >= 17 AND incomeband <= 20) THEN + LET cLevel = 'affluent'; + END IF; + + + RETURN cLevel; +END; +END FUNCTION; \ No newline at end of file diff --git a/tests/e2e-test/testdata/f2.sql b/tests/e2e-test/testdata/f2.sql new file mode 100644 index 0000000..18b0c20 --- /dev/null +++ b/tests/e2e-test/testdata/f2.sql @@ -0,0 +1,9 @@ +CREATE FUNCTION GENRANDOMINT(lower INT, upper INT, rand FLOAT) +RETURNING INT; + DEFINE result INT; + DEFINE range INT; + + LET range = upper - lower + 1; + LET result = FLOOR(rand * range + lower); + RETURN result; +END FUNCTION; \ No newline at end of file diff --git a/tests/e2e-test/testdata/invalid.py b/tests/e2e-test/testdata/invalid.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e-test/testdata/q1_informix.sql b/tests/e2e-test/testdata/q1_informix.sql new file mode 100644 index 0000000..fb936c2 --- /dev/null +++ b/tests/e2e-test/testdata/q1_informix.sql @@ -0,0 +1,3 @@ +-- Return the first 5 rows from the "employees" table +SELECT FIRST 5 * +FROM employees; diff --git a/tests/e2e-test/tests/__init__.py b/tests/e2e-test/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e-test/tests/conftest.py b/tests/e2e-test/tests/conftest.py new file mode 100644 index 0000000..664b776 --- /dev/null +++ b/tests/e2e-test/tests/conftest.py @@ -0,0 +1,52 @@ +import os + +from config.constants import URL + +from playwright.sync_api import sync_playwright + +from py.xml import html # type: ignore + +import pytest + + +@pytest.fixture(scope="session") +def login_logout(): + # perform login and browser close once in a session + with sync_playwright() as p: + browser = p.chromium.launch(headless=False, args=["--start-maximized"]) + context = browser.new_context(no_viewport=True) + context.set_default_timeout(80000) + page = context.new_page() + # Navigate to the login URL + page.goto(URL, wait_until="domcontentloaded") + + yield page + # perform close the browser + browser.close() + + +@pytest.hookimpl(tryfirst=True) +def pytest_html_report_title(report): + report.title = "Automation_CodeGen" + + +# Add a column for descriptions +def pytest_html_results_table_header(cells): + cells.insert(1, html.th("Description")) + + +def pytest_html_results_table_row(report, cells): + cells.insert( + 1, html.td(report.description if hasattr(report, "description") else "") + ) + + +# Add logs and docstring to report +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + report = outcome.get_result() + report.description = str(item.function.__doc__) + os.makedirs("logs", exist_ok=True) + extra = getattr(report, "extra", []) + report.extra = extra diff --git a/tests/e2e-test/tests/test_codegen_gp_tc.py b/tests/e2e-test/tests/test_codegen_gp_tc.py new file mode 100644 index 0000000..78217ef --- /dev/null +++ b/tests/e2e-test/tests/test_codegen_gp_tc.py @@ -0,0 +1,31 @@ +import logging +import time + +from pages.HomePage import HomePage + +import pytest + + +logger = logging.getLogger(__name__) + + +@pytest.mark.testcase_id("TC001") +def test_CodeGen_Golden_path_test(login_logout): + """Validate Golden path test case for Modernize your code Accelerator""" + page = login_logout + home_page = HomePage(page) + logger.info("Step 1: Validate home page is loaded.") + home_page.validate_home_page() + logger.info("Step 2: Validate Upload of other than SQL files.") + home_page.upload_unsupported_files() + logger.info("Step 3: Validate Upload input files for SQL only.") + home_page.upload_files() + logger.info("Step 4: Validate translation process for uploaded files.") + start = time.time() + home_page.validate_translate() + end = time.time() + print(f"Translation process for uploaded files took {end - start:.2f} seconds") + logger.info("Step 5: Check batch history.") + home_page.validate_batch_history() + logger.info("Step 6: Download all files and return home.") + home_page.validate_download_files()