diff --git a/.github/actions/run-end-to-end-tests/action.yml b/.github/actions/run-end-to-end-tests/action.yml index f026c56bee7..beb9e83b74c 100644 --- a/.github/actions/run-end-to-end-tests/action.yml +++ b/.github/actions/run-end-to-end-tests/action.yml @@ -2,34 +2,34 @@ name: Run End-to-End tests description: Execute tests inputs: tests: - default: '' + default: "" github_ref: - default: 'main' + default: "main" required: false accessibility_tests: - default: 'false' + default: "false" reporting_tests: - default: 'false' + default: "false" imms_api_tests: - default: 'false' + default: "false" pds_api_tests: - default: 'false' + default: "false" device: required: true base_url: required: true programmes_enabled: - default: 'FLU,HPV,MENACWY,MMR,TD_IPV' + default: "FLU,HPV,MENACWY,MMR,TD_IPV" screenshot_all_steps: required: true enable_reruns: - default: 'true' + default: "true" test_workers: - default: '4' + default: "4" set_feature_flags: - default: 'false' + default: "false" additional_feature_flags: - default: '' + default: "" playwright_cache_hit: required: true @@ -44,11 +44,13 @@ runs: run: uv run playwright install shell: bash - - name: Install Playwright dependencies if using webkit - if: >- - inputs.device == 'Desktop Safari' || - inputs.device == 'iPad (gen 7) landscape' || - inputs.device == 'iPhone 15' + - name: Install Playwright dependencies if using webkit on Linux + if: | + inputs.playwright_cache_hit != 'true' && + runner.os == 'Linux' && + (inputs.device == 'Desktop Safari' || + inputs.device == 'iPad (gen 7) landscape' || + inputs.device == 'iPhone 15') run: uv run playwright install-deps webkit shell: bash diff --git a/.github/workflows/end-to-end-tests-all-devices.yaml b/.github/workflows/end-to-end-tests-all-devices.yaml index 4091454d12b..0ba08378734 100644 --- a/.github/workflows/end-to-end-tests-all-devices.yaml +++ b/.github/workflows/end-to-end-tests-all-devices.yaml @@ -11,22 +11,40 @@ jobs: contents: write id-token: write - name: End-to-End tests + name: ${{ matrix.device }} strategy: fail-fast: false matrix: - device: - - Desktop Chrome - - Desktop Edge - - Desktop Firefox - - Desktop Safari - - Galaxy S9+ - - Pixel 7 - - iPad (gen 7) landscape - - iPhone 15 - max-parallel: 2 + include: + # Linux devices + - device: Desktop Chrome + os: ubuntu-latest + cache_path: ~/.cache/ms-playwright + - device: Desktop Edge + os: ubuntu-latest + cache_path: ~/.cache/ms-playwright + - device: Desktop Firefox + os: ubuntu-latest + cache_path: ~/.cache/ms-playwright + - device: Galaxy S9+ + os: ubuntu-latest + cache_path: ~/.cache/ms-playwright + - device: Pixel 7 + os: ubuntu-latest + cache_path: ~/.cache/ms-playwright + # macOS devices + - device: Desktop Safari + os: macos-latest + cache_path: ~/Library/Caches/ms-playwright + - device: iPad (gen 7) landscape + os: macos-latest + cache_path: ~/Library/Caches/ms-playwright + - device: iPhone 15 + os: macos-latest + cache_path: ~/Library/Caches/ms-playwright + max-parallel: 1 - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} env: TZ: "Europe/London" @@ -38,11 +56,20 @@ jobs: with: environment_url: ${{ vars.BASE_URL }} + - name: Set cache path + id: cache-path + run: | + if [ "$RUNNER_OS" == "macOS" ]; then + echo "path=$HOME/Library/Caches/ms-playwright" >> "$GITHUB_OUTPUT" + else + echo "path=$HOME/.cache/ms-playwright" >> "$GITHUB_OUTPUT" + fi + - name: Cache Playwright browsers uses: actions/cache@v5 id: playwright-cache with: - path: /home/runner/.cache/ms-playwright + path: ${{ steps.cache-path.outputs.path }} key: playwright-${{ runner.os }}-${{ hashFiles('**/pyproject.toml') }} - name: Run tests @@ -50,11 +77,11 @@ jobs: with: device: ${{ matrix.device }} base_url: ${{ vars.BASE_URL }} - imms_api_tests: 'true' - pds_api_tests: 'true' - reporting_tests: 'true' + imms_api_tests: "false" + pds_api_tests: "false" + reporting_tests: "false" playwright_cache_hit: ${{ steps.playwright-cache.outputs.cache-hit }} - screenshot_all_steps: 'false' + screenshot_all_steps: "false" env: BASIC_AUTH_TOKEN: ${{ secrets.HTTP_AUTH_TOKEN_FOR_TESTS }} IMMS_BASE_URL: ${{ vars.IMMS_BASE_URL }} diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml index aaa9b7d6c1f..c75a40db68d 100644 --- a/.github/workflows/end-to-end-tests.yaml +++ b/.github/workflows/end-to-end-tests.yaml @@ -4,24 +4,24 @@ on: workflow_call: inputs: tests: - description: 'Tests to run (leave blank to run all tests!)' + description: "Tests to run (leave blank to run all tests!)" required: false - default: '' + default: "" type: string cross_service_tests: - description: 'Include imms api, pds api and reporting tests' + description: "Include imms api, pds api and reporting tests" required: false default: false type: boolean github_ref: - description: 'Github reference to get tests from' + description: "Github reference to get tests from" required: false - default: 'main' + default: "main" type: string device: - description: 'Device to test' + description: "Device to test" required: false - default: 'Desktop Chrome' + default: "Desktop Chrome" type: string programmes: description: Programmes to use (FLU, HPV, MENACWY, MMR or TD_IPV) @@ -29,24 +29,24 @@ on: default: FLU,HPV,MENACWY,MMR,TD_IPV type: string endpoint: - description: 'Environment to run tests against' + description: "Environment to run tests against" required: false - default: 'https://qa.mavistesting.com' + default: "https://qa.mavistesting.com" type: string screenshot_all_steps: - description: 'Take screenshots for all steps (in addition to failures)' + description: "Take screenshots for all steps (in addition to failures)" required: false default: false type: boolean enable_reruns: - description: 'Enable test reruns on failure (up to 3 attempts)' + description: "Enable test reruns on failure (up to 3 attempts)" required: false default: true type: boolean test_workers: - description: 'Number of parallel test workers to use' + description: "Number of parallel test workers to use" required: false - default: '4' + default: "4" type: string set_feature_flags: description: >- @@ -60,7 +60,7 @@ on: (If enabled above) Additional feature flags to set. api, basic_auth, dev_tools will be set by default required: false - default: '' + default: "" type: string secrets: IMMS_API_KEY: @@ -81,21 +81,21 @@ on: workflow_dispatch: inputs: tests: - description: 'Tests to run (leave blank to run all tests!)' + description: "Tests to run (leave blank to run all tests!)" required: false - default: '' + default: "" cross_service_tests: - description: 'Include imms api, pds api and reporting tests' + description: "Include imms api, pds api and reporting tests" required: false - default: 'true' + default: "false" type: choice options: - true - false device: - description: 'Device to test' + description: "Device to test" required: true - default: 'Desktop Chrome' + default: "Desktop Chrome" type: choice options: - Desktop Chrome @@ -111,9 +111,9 @@ on: required: true default: FLU,HPV,MENACWY,MMR,TD_IPV environment: - description: 'Environment to run tests on' + description: "Environment to run tests on" required: true - default: 'qa' + default: "qa" type: choice options: - qa @@ -121,37 +121,37 @@ on: - sandbox-alpha - sandbox-beta screenshot_all_steps: - description: 'Take screenshots for all steps (in addition to failures)' + description: "Take screenshots for all steps (in addition to failures)" required: true - default: 'false' + default: "false" type: choice options: - true - false enable_reruns: - description: 'Enable test reruns on failure (up to 3 attempts)' + description: "Enable test reruns on failure (up to 3 attempts)" required: true - default: 'true' + default: "true" type: choice options: - true - false test_workers: - description: 'Number of parallel test workers to use' + description: "Number of parallel test workers to use" required: true - default: '4' + default: "4" type: choice options: - - '1' - - '2' - - '3' - - '4' + - "1" + - "2" + - "3" + - "4" set_feature_flags: description: >- Set feature flags in the flipper page before running tests (affects all users of the environment being tested!) required: true - default: 'false' + default: "false" type: choice options: - true @@ -161,7 +161,7 @@ on: (If enabled above) Additional feature flags to set. api, basic_auth, dev_tools will be set by default required: false - default: '' + default: "" jobs: test: @@ -170,7 +170,14 @@ jobs: id-token: write name: End-to-End tests - runs-on: ubuntu-latest + runs-on: | + ${{ + (inputs.device == 'Desktop Safari' || + inputs.device == 'iPad (gen 7) landscape' || + inputs.device == 'iPhone 15') && + 'macos-latest' || + 'ubuntu-latest' + }} env: TZ: Europe/London @@ -188,7 +195,7 @@ jobs: if [ "${{ github.event_name }}" = "push" ]; then { echo "tests=" - echo "cross_service_tests=true" + echo "cross_service_tests=false" echo "device=Desktop Chrome" echo "environment=${env_map[qa]}" echo "programmes=FLU,HPV,MENACWY,MMR,TD_IPV" @@ -238,24 +245,36 @@ jobs: uses: actions/cache@v5 id: playwright-cache with: - path: /home/runner/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('**/pyproject.toml') }} + path: | + ${{ + runner.os == 'macOS' && + '~/Library/Caches/ms-playwright' || + '~/.cache/ms-playwright' + }} + key: | + playwright-${{ runner.os }}-${{ hashFiles('**/pyproject.toml') }} - name: Run tests uses: ./.github/actions/run-end-to-end-tests with: tests: ${{ steps.set-variables.outputs.tests }} - imms_api_tests: ${{ steps.set-variables.outputs.cross_service_tests }} - pds_api_tests: ${{ steps.set-variables.outputs.cross_service_tests }} - reporting_tests: ${{ steps.set-variables.outputs.cross_service_tests }} + imms_api_tests: | + ${{ steps.set-variables.outputs.cross_service_tests }} + pds_api_tests: | + ${{ steps.set-variables.outputs.cross_service_tests }} + reporting_tests: | + ${{ steps.set-variables.outputs.cross_service_tests }} github_ref: ${{ inputs.github_ref || github.head_ref }} device: ${{ steps.set-variables.outputs.device }} base_url: ${{ steps.set-variables.outputs.environment }} - programmes_enabled: ${{ steps.set-variables.outputs.programmes }} - screenshot_all_steps: ${{ steps.set-variables.outputs.screenshot_all_steps }} + programmes_enabled: | + ${{ steps.set-variables.outputs.programmes }} + screenshot_all_steps: | + ${{ steps.set-variables.outputs.screenshot_all_steps }} enable_reruns: ${{ steps.set-variables.outputs.enable_reruns }} test_workers: ${{ steps.set-variables.outputs.test_workers }} - set_feature_flags: ${{ steps.set-variables.outputs.set_feature_flags }} + set_feature_flags: | + ${{ steps.set-variables.outputs.set_feature_flags }} additional_feature_flags: |- ${{ steps.set-variables.outputs.additional_feature_flags }} playwright_cache_hit: ${{ steps.playwright-cache.outputs.cache-hit }} @@ -267,14 +286,19 @@ jobs: IMMS_API_PEM: ${{ secrets.IMMS_API_PEM }} - name: Configure AWS credentials - if: always() && steps.set-variables.outputs.deploy_report == 'true' + if: | + always() && + steps.set-variables.outputs.deploy_report == 'true' uses: aws-actions/configure-aws-credentials@v6 with: aws-region: eu-west-2 - role-to-assume: arn:aws:iam::393416225559:role/GitHubAssuranceTestRole + role-to-assume: | + arn:aws:iam::393416225559:role/GitHubAssuranceTestRole - name: Process reports - if: always() && steps.set-variables.outputs.deploy_report == 'true' + if: | + always() && + steps.set-variables.outputs.deploy_report == 'true' continue-on-error: true uses: ./.github/actions/deploy-reports with: diff --git a/mavis/test/pages/dashboard_page.py b/mavis/test/pages/dashboard_page.py index 54d26396611..215743ace24 100644 --- a/mavis/test/pages/dashboard_page.py +++ b/mavis/test/pages/dashboard_page.py @@ -90,10 +90,12 @@ def verify_imports_link_visible(self) -> None: @step("Verify header Children link is visible") def verify_header_children_link_visible(self) -> None: + self.header.ensure_menu_visible() expect(self.header.children_link).to_be_visible() @step("Verify header Import Records link is visible") def verify_header_imports_link_visible(self) -> None: + self.header.ensure_menu_visible() expect(self.header.imports_link).to_be_visible() @step("Verify Service Guidance link for National Reporting") diff --git a/mavis/test/pages/header_component.py b/mavis/test/pages/header_component.py index c2357891617..2f85b34f841 100644 --- a/mavis/test/pages/header_component.py +++ b/mavis/test/pages/header_component.py @@ -1,3 +1,5 @@ +import re + from playwright.sync_api import Page from mavis.test.annotations import step @@ -12,19 +14,34 @@ def __init__(self, page: Page) -> None: name="Manage vaccinations in schools", ) - links = page.get_by_label("Menu", exact=True) + # Header navigation links are within the element labeled "Menu" + # On mobile, some links may be hidden until "More" button is clicked + menu = page.get_by_label("Menu") + + self.children_link = menu.get_by_role("link", name="Children", exact=True) + # In header nav, "Imports" might be labeled as "Import Records" + self.imports_link = menu.get_by_role("link", name="Import") + self.programmes_link = menu.get_by_role("link", name="Programmes", exact=True) + self.school_moves_link = menu.get_by_role( + "link", name="School Moves", exact=True + ) + self.schools_link = menu.get_by_role("link", name="Schools", exact=True) + self.sessions_link = menu.get_by_role("link", name="Sessions", exact=True) + self.unmatched_responses_link = menu.get_by_role( + "link", name="Unmatched Responses", exact=True + ) + self.vaccines_link = menu.get_by_role("link", name="Vaccines", exact=True) + self.your_team_link = menu.get_by_role("link", name="Your Team", exact=True) - self.children_link = links.get_by_role("link", name="Children") - self.imports_link = links.get_by_role("link", name="Imports") - self.programmes_link = links.get_by_role("link", name="Programmes") - self.school_moves_link = links.get_by_role("link", name="School Moves") - self.schools_link = links.get_by_role("link", name="Schools") - self.sessions_link = links.get_by_role("link", name="Sessions") - self.unmatched_responses_link = links.get_by_role( - "link", name="Unmatched Responses" + def ensure_menu_visible(self) -> None: + browse_button = self.page.get_by_role( + "button", name=re.compile(r"browse\s*more", re.IGNORECASE) ) - self.vaccines_link = links.get_by_role("link", name="Vaccines") - self.your_team_link = links.get_by_role("link", name="Your Team") + if ( + browse_button.count() > 0 + and browse_button.get_attribute("aria-expanded") != "true" + ): + browse_button.click() @step("Click on Manage vaccinations in schools") def click_mavis(self) -> None: @@ -32,36 +49,45 @@ def click_mavis(self) -> None: @step("Click on Children") def click_children(self) -> None: + self.ensure_menu_visible() self.children_link.click() @step("Click on Imports") def click_imports(self) -> None: + self.ensure_menu_visible() self.imports_link.click() @step("Click on Programmes") def click_programmes(self) -> None: + self.ensure_menu_visible() self.programmes_link.click() @step("Click on School Moves") def click_school_moves(self) -> None: + self.ensure_menu_visible() self.school_moves_link.click() @step("Click on Schools") def click_schools(self) -> None: + self.ensure_menu_visible() self.schools_link.click() @step("Click on Sessions") def click_sessions(self) -> None: + self.ensure_menu_visible() self.sessions_link.click() @step("Click on Unmatched Responses") def click_unmatched_responses(self) -> None: + self.ensure_menu_visible() self.unmatched_responses_link.click() @step("Click on Vaccines") def click_vaccines(self) -> None: + self.ensure_menu_visible() self.vaccines_link.click() @step("Click on Your Team") def click_your_team(self) -> None: + self.ensure_menu_visible() self.your_team_link.click() diff --git a/mavis/test/pages/imports/import_records_wizard_page.py b/mavis/test/pages/imports/import_records_wizard_page.py index 559305f7b15..1b9fa550af6 100644 --- a/mavis/test/pages/imports/import_records_wizard_page.py +++ b/mavis/test/pages/imports/import_records_wizard_page.py @@ -268,7 +268,7 @@ def wait_for_completed_status(self) -> None: ).first reload_until_element_is_visible(self.page, status_text, seconds=60) - @step("Click import link for {1}") + @step("Click import link for {1}") def click_import_link(self, file_path: Path) -> None: import_link = ( self.page.get_by_role("cell", name=file_path.name).get_by_role("link").first @@ -279,7 +279,9 @@ def click_import_link(self, file_path: Path) -> None: if not import_link.is_visible(): click_secondary_navigation_item(self.completed_imports_tab) - import_link.click() + # On mobile viewports, ensure element is scrolled into view before clicking + import_link.scroll_into_view_if_needed() + import_link.click(force=True) self.page.wait_for_load_state() @step("Verify upload output for {file_path}") diff --git a/mavis/test/pages/reports/vaccination_report_page.py b/mavis/test/pages/reports/vaccination_report_page.py index 217d4519158..0de01f46c02 100644 --- a/mavis/test/pages/reports/vaccination_report_page.py +++ b/mavis/test/pages/reports/vaccination_report_page.py @@ -2,6 +2,7 @@ import pandas as pd from playwright.sync_api import Page +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError from mavis.test.annotations import step from mavis.test.constants import Programme, ReportFormat @@ -38,19 +39,28 @@ def _download_and_verify_report_headers(self, expected_headers: str) -> None: getattr(browser, "browser_type", None), "name", None ) - # Playwright's webkit browser always opens CSVs in the browser - # unlike Chromium and Firefox - if browser_type_name == "webkit": - self.click_download_report() - csv_content = self.page.locator("pre").inner_text() - _actual_df = pd.read_csv(StringIO(csv_content)) - self.page.go_back() - else: - with self.page.expect_download() as download_info: + # Try to download the file (set shorter timeout for WebKit) + download_timeout = 5000 if browser_type_name == "webkit" else None + page_load_timeout = 10000 if browser_type_name == "webkit" else None + try: + with self.page.expect_download(timeout=download_timeout) as download_info: self.click_download_report() download = download_info.value download.save_as(_file_path) _actual_df = pd.read_csv(_file_path) + except PlaywrightTimeoutError: + # WebKit may open CSV in browser - read from
element
+ pages = self.page.context.pages
+ page_to_read = pages[-1] if len(pages) > 1 else self.page
+ page_to_read.wait_for_load_state("load", timeout=page_load_timeout)
+ csv_content = page_to_read.locator("pre").inner_text()
+ _actual_df = pd.read_csv(StringIO(csv_content))
+
+ # Clean up: close new page or go back on current page
+ if page_to_read != self.page:
+ page_to_read.close()
+ else:
+ self.page.go_back()
expected_set = set(expected_headers.split(","))
actual_set = set(_actual_df.columns)
diff --git a/mavis/test/pages/school_moves/download_school_moves_page.py b/mavis/test/pages/school_moves/download_school_moves_page.py
index 73da8e0aac6..06e5e4c96f2 100644
--- a/mavis/test/pages/school_moves/download_school_moves_page.py
+++ b/mavis/test/pages/school_moves/download_school_moves_page.py
@@ -4,6 +4,7 @@
import pandas as pd
from pandas import DataFrame, Series
from playwright.sync_api import Page
+from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from mavis.test.annotations import step
from mavis.test.constants import SCHOOL_MOVE_HEADERS
@@ -54,13 +55,41 @@ def confirm_and_get_school_moves_csv(self) -> DataFrame:
None,
)
- # Playwright's webkit browser always opens CSVs in the browser
- # unlike Chromium and Firefox
+ # Playwright's webkit browser may open CSVs in the browser
+ # unlike Chromium and Firefox, but behavior varies by environment
if browser_type_name == "webkit":
- self.click_download_csv()
- csv_content = self.page.locator("pre").inner_text()
- self.page.go_back()
- return pd.read_csv(StringIO(csv_content), dtype={"NHS_REF": str})
+ # WebKit behavior varies: may download file, open in new page,
+ # or open in current page. Try download first (macOS CI behavior)
+ try:
+ with self.page.expect_download(timeout=5000) as download_info:
+ self.click_download_csv()
+ return pd.read_csv(download_info.value.path(), dtype={"NHS_REF": str})
+ except PlaywrightTimeoutError:
+ # No download - check if CSV opened in a new page or current page
+ try:
+ # Check for new page
+ new_page = self.page.context.pages[-1]
+ if new_page != self.page:
+ new_page.wait_for_load_state("load", timeout=10000)
+ pre_element = new_page.locator("pre")
+ csv_content = pre_element.inner_text(timeout=5000)
+ new_page.close()
+ return pd.read_csv(
+ StringIO(csv_content), dtype={"NHS_REF": str}
+ )
+ # No new page, so treat as current page case
+ self.page.wait_for_load_state("load", timeout=10000)
+ pre_element = self.page.locator("pre")
+ csv_content = pre_element.inner_text(timeout=10000)
+ self.page.go_back()
+ return pd.read_csv(StringIO(csv_content), dtype={"NHS_REF": str})
+ except (PlaywrightTimeoutError, IndexError):
+ # CSV opened in current page
+ self.page.wait_for_load_state("load", timeout=10000)
+ pre_element = self.page.locator("pre")
+ csv_content = pre_element.inner_text(timeout=10000)
+ self.page.go_back()
+ return pd.read_csv(StringIO(csv_content), dtype={"NHS_REF": str})
with self.page.expect_download() as download_info:
self.click_download_csv()
diff --git a/mavis/test/pages/sessions/sessions_vaccination_wizard_page.py b/mavis/test/pages/sessions/sessions_vaccination_wizard_page.py
index 744ee15f5fe..82c3c2f688a 100644
--- a/mavis/test/pages/sessions/sessions_vaccination_wizard_page.py
+++ b/mavis/test/pages/sessions/sessions_vaccination_wizard_page.py
@@ -2,10 +2,7 @@
from mavis.test.annotations import step
from mavis.test.constants import MAVIS_NOTE_LENGTH_LIMIT, Programme
-from mavis.test.data_models import (
- Parent,
- VaccinationRecord,
-)
+from mavis.test.data_models import Parent, VaccinationRecord
from mavis.test.pages.header_component import HeaderComponent
from mavis.test.utils import (
expect_alert_text,
@@ -46,7 +43,11 @@ def click_change_date_link(self) -> None:
@step("Click on Confirm")
def click_confirm_button(self) -> None:
- self.confirm_button.click()
+ # Wait for navigation after clicking confirm (redirects to patient page)
+ with self.page.expect_navigation(wait_until="domcontentloaded", timeout=10000):
+ self.confirm_button.click()
+ # Assert that the header is visible to ensure the page is ready
+ expect(self.header.page.locator("header")).to_be_visible()
@step("Click on Confirm in two tabs simultaneously")
def click_confirm_button_in_two_tabs(self) -> None:
diff --git a/mavis/test/utils.py b/mavis/test/utils.py
index fcb955b9bdd..3ba1a45ba46 100644
--- a/mavis/test/utils.py
+++ b/mavis/test/utils.py
@@ -224,7 +224,10 @@ def reload_until_element_is_not_visible(
def expect_alert_text(page: Page, text: str) -> None:
- expect(page.get_by_role("alert")).to_contain_text(text)
+ alert = page.get_by_role("alert")
+ # Wait for alert to appear with longer timeout (Safari can be slower)
+ alert.wait_for(state="visible", timeout=10000)
+ expect(alert).to_contain_text(text)
def expect_details(page: Page, key: str, value: str) -> None: