diff --git a/.env b/.env new file mode 100644 index 0000000..575488d --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +ENV=stg +EZID_VER=v3.3.15-accessability +NOTIFICATION_EMAIL="jing.jiang@ucop.edu" +TEST_NOTES="Test EZID with Docker version EZID tests suite" diff --git a/.github/workflows/ezid-tests.yml b/.github/workflows/ezid-tests.yml new file mode 100644 index 0000000..13a70fd --- /dev/null +++ b/.github/workflows/ezid-tests.yml @@ -0,0 +1,24 @@ +name: Run EZID UI and functional tests in Docker + +on: + workflow_dispatch: + push: + paths: + - '.env' + + +jobs: + ezid-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Run EZID Tests + env: + APITEST_PASSWORD: ${{ secrets.APITEST_PASSWORD }} + run: docker compose up --build --abort-on-container-exit + + - name: Stop and remove containers + if: always() + run: docker compose down --volumes --remove-orphans \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..573db84 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +version: "3.8" + +services: + selenium: + image: seleniarm/standalone-chromium:latest + ports: + - "4444:4444" + shm_size: 2gb + networks: + - selenium-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4444/wd/hub/status"] + interval: 5s + timeout: 3s + retries: 10 + + + test-runner: + build: ./scripts + env_file: + - .env + environment: + - APITEST_PASSWORD=${APITEST_PASSWORD} + - SELENIUM_REMOTE_URL=http://selenium:4444/wd/hub + command: ["./run_ezid_tests.sh", "${ENV}", "apitest", "${APITEST_PASSWORD}", "${NOTIFICATION_EMAIL}", "${EZID_VER}"] + depends_on: + selenium: + condition: service_healthy + networks: + - selenium-network + +networks: + selenium-network: + driver: bridge + diff --git a/how_to_run_ezid_in_docker.md b/how_to_run_ezid_in_docker.md new file mode 100644 index 0000000..061b4ba --- /dev/null +++ b/how_to_run_ezid_in_docker.md @@ -0,0 +1,171 @@ +# How to run EZID tests in Docker +Here are the major components of the Docker version EZID test suite: + +**Test scripts** +Test scripts are located in the `ezid-ops-scripts.git/scripts` directory. +* ezid_ui_tests_docker.py - test EZID UI +* verify_ezid_status.py - Verify EZID status including testing API endpoints + +**The Dockerfile** +The `Dockerfile` is located in the `ezid-ops-scripts.git/scripts` directory. +It provides instructions on how to build a Docker image. + +**The docker-compose file** +The `docker-compose.yml` file is located in the root directory of the `EZID-OPS-SCRIPTS` repo. +It defines and manages multiple Docker containers (services) in one file. + +**The .env file*** +The `.env` file is located in the root directory of the `EZID-OPS-SCRIPTS` repo. +It stores environment variables, which can be used in both `docker-compose.yml` and `Dockerfile`. + +**Services defined in the `docker-compose.yml` file** +* selenium - a standalone-chromium +* test-runner-ui - a sesrvice runs the `ezid_ui_tests_docker.py` script +* test-runner-ezid-status- a service runs the `verify_ezid_status.py` script + +Note: the `docker-compose.yml` was updated later with a single container combining both the UI and EZID status tests. + +## Docker compose commands + +Remove existing container by name: +``` +docker rm -f selenium +``` + +Stop and remove all containers in the docker-compose file +``` +docker compose down +``` + +Build docker images using the docker-compose file +``` +docker compose build +``` + +Build docker images using the docker-compose file and then start the containers +``` +docker compose up --build +``` +* The selenium container starts up and waits on port 4444. +* The test-runner-ui container installs selenium, runs `ezid_ui_tests_docker.py` +* The test-runner-ezid-status container runs `verify_ezid_status.py` + +Pass an environment variable to the docker compose command +``` +APITEST_PASSWORD=xxx docker compose up --build +``` + +Rerunning just the test-runner-ui , remove the container once the command finishes executing +``` +docker compose run --rm test-runner-ui +``` + +Re-run everything (fresh) +``` +docker compose up --force-recreate +``` + +## Test without using docker-compose file + +Start docker standalone Chrome container +``` +docker run -d -p 4444:4444 --name selenium seleniarm/standalone-chromium:latest +``` + +Run the test script +``` +python ezid_ui_tests_docker.py -e stg -u apitest -p apitest_password -n ezid_test_email@ucop.edu +``` + +## Precedence Order for environment variables in Docker Compose + +When using Docker Compose, environment variables can be set in various ways. The precedence order for these variables is as follows: +| Priority | Source | Example | +| --------- | --------- | ------- | +|1 | Inline in docker compose call | MY_SECRET_TOKEN=inline docker compose up | +|2 | Shell or GitHub Actions env: block | `env: MY_SECRET_TOKEN: ${{ secrets.MY_SECRET_TOKEN }}` | +|3 | .env file | .env → MY_SECRET_TOKEN=dotenv_value | +|4 | Hardcoded in docker-compose.yml | MY_SECRET_TOKEN: fallback_value | + +## Run EZID tests using GitHub action +A GitHub action `Run EZID UI and functional tests in Docker` is defined in the `.github/workflow/ezid-tests.yml` file. The action is triggered by content changes to the `.env` file. It runs the `docker compose up --build` command to build the images, start the containers and then run the test suites. + +Sample `.github/workflow/ezid-tests.yml`: +``` +nname: Run EZID UI and functional tests in Docker + +on: + workflow_dispatch: + push: + paths: + - '.env' + + +jobs: + ezid-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Run EZID Tests + env: + APITEST_PASSWORD: ${{ secrets.APITEST_PASSWORD }} + run: docker compose up --build --abort-on-container-exit + + - name: Stop and remove containers + if: always() + run: docker compose down --volumes --remove-orphans +``` + +We ran into problems with the `--abort-on-container-exit` option which stops all containers when one of them exits. Since we have two test containers, one for UI and one for functional tests, using the `--abort-on-container-exit` option will always result in an imcomplete test suite. However, without the `--abort-on-container-exit` option, the selenium container will be up and running until the job time out. + +A wrapper Shell script `run_ezid_test.sh` was developed to resolve this issue. The script combines both the UI and funcitonal tests so we can use a single container for all types of tests. + +## How to run `run_ezid_test.sh` +The `run_ezid_test.sh` script takes 5 or 6 positional parameters: + +Usage: ./run_ezid_tests.sh + +The paramters are all required except the last one `debug_flag` which is optional. + +1. To run the script with the debug option, for example: +``` +./run_ezid_tests.sh stg apitest apitest_password ezid@ucop.edu v3.3.15 debug +``` +The debug option will instruct the script to start a standalone Selenium Chrome container for you. + +2. To run the script without the debug option: + +First, manually start a standalone Selenium Chrome container: +``` +docker run -d -p 4444:4444 --name selenium seleniarm/standalone-chromium:latest +``` +Then run the tests: +``` +./run_ezid_tests.sh stg apitest apitest_password ezid@ucop.edu v3.3.15 +``` +Last, remove the `selenium` container: +``` +docker rm -f selenium +``` + +3. To run the script using docker compose command +The `docker-compose.yml` file is defined with the following command: +``` +command: ["./run_ezid_tests.sh", "${ENV}", "apitest", "${APITEST_PASSWORD}", "${NOTIFICATION_EMAIL}", "${EZID_VER}"] +``` + +The following environment variables are defined in the `.env` file: +- ENV +- NOTIFICATION_EMAIL +- EZID_VER + +Pass the API password in command line: +``` +APITEST_PASSWORD=xxx docker compose up --build +``` + +4. To run the script through GitHub action + +Update the `.env` file then push the changes to GitHub. This will trigger a GitHub action to run the tests. diff --git a/scripts/Dockerfile b/scripts/Dockerfile new file mode 100644 index 0000000..fe634b1 --- /dev/null +++ b/scripts/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim + +WORKDIR /tests + +COPY . . + +RUN pip install -r requirements.txt + + + diff --git a/scripts/ezid_ui_tests.py b/scripts/ezid_ui_tests.py index 13eca70..d9d07d9 100644 --- a/scripts/ezid_ui_tests.py +++ b/scripts/ezid_ui_tests.py @@ -229,7 +229,7 @@ def main(): parser.add_argument('-e', '--env', type=str, required=True, choices=['test', 'dev', 'stg', 'prd'], help='Environment') parser.add_argument('-u', '--user', type=str, required=True, help='user name') parser.add_argument('-p', '--password', type=str, required=True, help='password') - parser.add_argument('-m', '--user_email', type=str, required=True, help='Email address for testing the Contact Us form.') + parser.add_argument('-n', '--user_email', type=str, required=True, help='Email address for testing the Contact Us form.') parser.add_argument('-l', '--headless', action='store_true', required=False, help='Enable headless mode.') args = parser.parse_args() diff --git a/scripts/ezid_ui_tests_docker.py b/scripts/ezid_ui_tests_docker.py new file mode 100644 index 0000000..2ddf898 --- /dev/null +++ b/scripts/ezid_ui_tests_docker.py @@ -0,0 +1,306 @@ +import os +import time +import argparse + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.support.ui import Select + + +class EzidUiTest: + def __init__(self, base_url, user, password, email): + self.base_url = base_url + self.user = user + self.password = password + self.email = email + + def ui_test_page_load(self, driver): + print("## Testing page load...") + try: + # Open a webpage + driver.get(self.base_url) + assert "EZID Home" in driver.title, "Page title does not contain 'EZID'" + print(" ok - Load page - PASSED") + except Exception as e: + print(f"ERROR: An error occurred while loading the page: {e}") + + + def ui_test_login_logout(self, driver): + print("## Testing login/logout ... ") + driver.get(self.base_url) + + # Find and click the login button + login_button = driver.find_element(By.ID, "js-header__loginout-button") + login_button.click() + + # Find the username and password input fields + username_input = driver.find_element(By.ID, "username") + password_input = driver.find_element(By.ID, "password") + + # Enter username and password + username_input.send_keys(self.user) + password_input.send_keys("") + time.sleep(1) + + # submit login + login_button = driver.find_element(By.ID, "login") + login_button.click() + time.sleep(1) + + wait = WebDriverWait(driver, 10) + alert_text = wait.until(EC.visibility_of_element_located((By.CLASS_NAME, "alert-text"))) + assert "Password required" in alert_text.text + print(" ok - Password is required for login - PASSED") + time.sleep(1) + + password_input = driver.find_element("xpath", "//input[@type='password' and @class='fcontrol__text-field-inline']") + password_input.clear() + password_input.send_keys("xxxx") + time.sleep(1) + + # submit login + login_button = driver.find_element("xpath", "//button[@class='button__primary general__form-submit']") + login_button.click() + + time.sleep(1) + wait = WebDriverWait(driver, 10) + alert_text = wait.until(EC.visibility_of_element_located((By.CLASS_NAME, "alert-text"))) + assert "Login failed" in alert_text.text + print(" ok - Login failed due to wrong password - PASSED") + time.sleep(2) + + password_input = driver.find_element("xpath", "//input[@type='password' and @class='fcontrol__text-field-inline']") + password_input.clear() + password_input.send_keys(self.password) + time.sleep(2) + + login_button = driver.find_element("xpath", "//button[@class='button__primary general__form-submit']") + login_button.click() + + time.sleep(1) + wait = WebDriverWait(driver, 10) + alert_text = wait.until(EC.visibility_of_element_located((By.CLASS_NAME, "alert-text"))) + + assert "Login successful" in alert_text.text + assert f"Welcome {self.user}" in driver.page_source + print(" ok - Login successful - PASSED") + time.sleep(2) + + logout_button = driver.find_element("xpath", "//button[@class='header__loginout-link']") + logout_button.click() + + time.sleep(1) + wait = WebDriverWait(driver, 10) + alert_text = wait.until(EC.visibility_of_element_located((By.CLASS_NAME, "alert-text"))) + assert "You have been logged out" in alert_text.text + print(" ok - Logout successful - PASSED") + + time.sleep(3) + print(" ok - Testing login/logout - PASSED") + + def ui_test_creator_ark(self, driver): + print("## Testing create ARK ...") + # Open a webpage + driver.get(self.base_url) + + target_input = driver.find_element(By.ID, "target") + target_input.send_keys("https://google.com") + who_input = driver.find_element(By.ID, "erc.who") + who_input.send_keys("test ark who") + what_input = driver.find_element(By.ID, "erc.what") + what_input.send_keys("test ark what") + when_input = driver.find_element(By.ID, "erc.when") + when_input.send_keys("2024") + + time.sleep(2) + create_button = driver.find_element(By.XPATH, "//button[@class='home__button-primary']") + create_button.click() + + time.sleep(1) + wait = WebDriverWait(driver, 10) + alert_text = wait.until(EC.visibility_of_element_located((By.CLASS_NAME, "alert-text"))) + + assert "Identifier Created" in alert_text.text + assert "Identifier Details" in driver.page_source + assert "ark:/99999/fk4" in driver.page_source + + time.sleep(2) + print(" ok - Testing create ARK - PASSED") + + def ui_test_creator_doi(self, driver): + print("## Testing create DOI ...") + driver.get(self.base_url) + + radio_button = driver.find_element(By.XPATH, "//input[@type='radio' and @id='doi:10.5072/FK2']") + assert radio_button.get_attribute('type') == 'radio' + assert radio_button.get_attribute('id') == 'doi:10.5072/FK2' + + driver.execute_script("document.getElementById('doi:10.5072/FK2').click();") + wait = WebDriverWait(driver, 10) + creator = wait.until(EC.visibility_of_element_located((By.ID, "datacite.creator"))) + + target_input = driver.find_element(By.ID, "target") + target_input.send_keys("https://google.com") + creator =driver.find_element(By.ID, "datacite.creator") + creator.send_keys("test creator") + title = driver.find_element(By.ID, "datacite.title") + title.send_keys("test title") + publisher = driver.find_element(By.ID, "datacite.publisher") + publisher.send_keys("test publisher") + pub_year = driver.find_element(By.ID, "datacite.publicationyear") + pub_year.send_keys("2024") + resource_type = driver.find_element(By.ID, "datacite.resourcetype") + resource_type.send_keys("Book") + + time.sleep(2) + + create_button = driver.find_element(By.XPATH, "//button[@class='home__button-primary']") + create_button.click() + + time.sleep(1) + wait = WebDriverWait(driver, 10) + alert_text = wait.until(EC.visibility_of_element_located((By.CLASS_NAME, "alert-text"))) + + assert "Identifier Created" in alert_text.text + assert "Identifier Details" in driver.page_source + assert "doi:10.5072/FK2" in driver.page_source + + time.sleep(2) + print(" ok - Testing create DOI - PASSED") + + def ui_test_contact(self, driver): + print("## Testing the contact EZID form ...") + driver.get(self.base_url) + + time.sleep(1) + + contact_link = driver.find_element(By.XPATH, "//a[@class='header__nav-item-contact' and contains(text(), 'Contact') ]") + contact_link.click() + + time.sleep(1) + + assert "contact" in driver.current_url + assert "Fill out this form and EZID will get in touch with you" in driver.page_source + + time.sleep(1) + + resaon_select = driver.find_element(By.ID, "id_contact_reason") + + select = Select(resaon_select) + selected_value = "Other" + select.select_by_value(selected_value) + + selected_option = select.first_selected_option + assert selected_option.text == selected_value + + your_name = driver.find_element(By.ID, "id_your_name") + your_name.send_keys("test name") + your_email = driver.find_element(By.ID, "id_email") + your_email.send_keys(self.email) + your_name = driver.find_element(By.ID, "id_your_name") + your_name.send_keys("test name") + your_inst = driver.find_element(By.ID, "id_affiliation") + your_inst.send_keys("CDL") + comment = driver.find_element(By.ID, "id_comment") + comment.send_keys("Test contact EZID form") + dropdown_lists = driver.find_element(By.ID, "id_question") + dropdown_lists.send_keys("2") + + submit_button = driver.find_element(By.XPATH, "//button[@class='button__primary general__form-submit']") + driver.execute_script("arguments[0].scrollIntoView();", submit_button) + + # use wait until didn't work - got error "Element is not clickable at point (367, 863)" + # however using time.sleep() worked + # so, need to figure out what is preventing the submit button become clickable + # wait = WebDriverWait(browser, 10) + # submit_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@class='button__primary general__form-submit']"))) + + time.sleep(1) + submit_button.click() + + time.sleep(2) + assert "Thank you for your message. We will respond as soon as possible." in driver.page_source + + time.sleep(2) + print(" ok - Testing the contact EZID form - PASSED") + +def create_driver(selenium_url, options): + retries = 5 + for attempt in range(retries): + try: + return webdriver.Remote( + command_executor=selenium_url, + options=options + ) + except WebDriverException as e: + print(f"Retry {attempt+1}/{retries} failed: {e}") + time.sleep(5) + raise RuntimeError("Failed to connect to Selenium server.") + + +def main(): + parser = argparse.ArgumentParser(description='Get EZID records by identifier.') + + # add input and output filename arguments to the parser + parser.add_argument('-e', '--env', type=str, required=True, choices=['test', 'dev', 'stg', 'prd'], help='Environment') + parser.add_argument('-u', '--user', type=str, required=True, help='user name') + parser.add_argument('-p', '--password', type=str, required=True, help='password') + parser.add_argument('-n', '--user_email', type=str, required=True, help='Email address for testing the Contact Us form.') + + args = parser.parse_args() + env = args.env + user = args.user + password = args.password + email = args.user_email + + base_urls = { + "dev": "https://ezid-dev.cdlib.org", + "stg": "https://ezid-stg.cdlib.org", + "prd": "https://ezid.cdlib.org" + } + base_url = base_urls.get(env) + + options = Options() + options.add_argument("--headless=new") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("--disable-gpu") + options.add_argument("--window-size=1920,1080") + + ui_test = EzidUiTest(base_url, user, password, email) + + try: + selenium_url = os.environ["SELENIUM_REMOTE_URL"] + except KeyError: + selenium_url = "http://localhost:4444/wd/hub" + print("Selenium URL:", selenium_url) + + try: + print("Initializing WebDriver...") + driver = create_driver(selenium_url, options) + print("WebDriver initialized successfully") + + print("Running UI tests...") + ui_test.ui_test_page_load(driver) + ui_test.ui_test_login_logout(driver) + ui_test.ui_test_creator_ark(driver) + ui_test.ui_test_creator_doi(driver) + ui_test.ui_test_contact(driver) + print("UI completed") + + except Exception as e: + print(f"An error occurred: {e}") + driver.quit() + return + finally: + driver.quit() + + + + +if __name__ == '__main__': + main() diff --git a/scripts/run_ezid_tests.sh b/scripts/run_ezid_tests.sh new file mode 100755 index 0000000..df60328 --- /dev/null +++ b/scripts/run_ezid_tests.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# script name: run_ezid_tests.sh +# This script runs the EZID test suite, including functional and UI tests. +# Usage: ./run_ezid_tests.sh +# : required, the environment to test; should be dev, stg, or prd +# : required, an username of EZID +# : required, the password for the EZID user +# : required, the email address used to receive notifications +# : required, the EZID version to test +# : optional, if set to "debug", the script will start the standalone Chrome container "selenium" +# Example: ./run_ezid_tests.sh dev apitest apitest_password ezid@ucop.edu v3.3.15 debug + +set -e # Exit on first failure + +if [ $# -ne 5 ] && [ $# -ne 6 ]; then + echo "Error: You must provide either 5 or 6 arguments." + echo "Usage: $0 " + exit 1 +fi + +ENV=$1 +USER=$2 +PASSWORD=$3 +EMAIL=$4 +VERSION=$5 +DEBUG=$6 + +if [[ "$ENV" != "dev" && "$ENV" != "stg" && "$ENV" != "prd" ]]; then + echo "Error: Environment must be one of 'dev', 'stg', or 'prd'." + exit 1 +fi + +echo "Starting the EZID test suite..." + +echo "Running functional tests..." +if [[ "$ENV" != "dev" ]]; then + python verify_ezid_status.py -e $ENV -u $USER -p $PASSWORD -n $EMAIL -v $VERSION +else + python verify_ezid_status.py -e $ENV -u $USER -p $PASSWORD -n $EMAIL -v $VERSION -s +fi + + +# Start standalone Chrome container +if [ "$DEBUG" == "debug" ]; then + if docker ps -a --format '{{.Names}}' | grep -Eq '^selenium$'; then + echo "# Debug: Removing existing 'selenium' container..." + docker rm -f selenium + fi + echo "# Debug: Starting Selenium standalone Chrome container..." + docker run -d -p 4444:4444 --name selenium seleniarm/standalone-chromium:latest + echo "# Debug: Waiting for Selenium to be ready..." + until curl -sf http://localhost:4444/wd/hub/status | grep -q '"ready": true'; do + sleep 2 + echo "# Debug: Waiting..." + done +else + echo "Selenium container should have been started. Otherwise, UI tests will fail." +fi + +echo "Running UI tests..." +python ezid_ui_tests_docker.py -e $ENV -u $USER -p $PASSWORD -n $EMAIL + +if [ "$DEBUG" == "debug" ]; then + echo "# Debug: Removing 'selenium' container..." + docker rm -f selenium +fi + +echo "EZID tests completed successfully!" \ No newline at end of file diff --git a/scripts/verify_ezid_status.py b/scripts/verify_ezid_status.py index 964fdef..bc650af 100644 --- a/scripts/verify_ezid_status.py +++ b/scripts/verify_ezid_status.py @@ -505,10 +505,11 @@ def main(): ves.verify_introspection() + ves.check_resolver() + if not args.skip_download: ves.check_batch_download(notify_email) - ves.check_resolver() if __name__ == "__main__": main()