From b04fa1d4e7088df9854473dfba6d0a4dc945077a Mon Sep 17 00:00:00 2001 From: MShekow Date: Mon, 25 Sep 2023 17:50:00 +0200 Subject: [PATCH] feat: use Playwright for E2E tests (#56) --- .github/workflows/ci-cd.yaml | 1 + requirements.txt | 2 +- tests/conftest.py | 43 +++++++++----- .../outlook365_testing_calendar_adapter.py | 57 ++++++++++--------- 4 files changed, 61 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci-cd.yaml b/.github/workflows/ci-cd.yaml index 51fd929..24e8fbb 100644 --- a/.github/workflows/ci-cd.yaml +++ b/.github/workflows/ci-cd.yaml @@ -40,6 +40,7 @@ jobs: - name: Run integration tests if: startsWith(github.ref, 'refs/tags/') == false run: | + playwright install --with-deps chromium pytest tests --junitxml=junit/test-results.xml env: CI: "1" # keep variable name in sync with CI_ENV_VAR_NAME diff --git a/requirements.txt b/requirements.txt index e93ad15..e346eaf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ tendo==0.3.0 pywin32==306; sys_platform == 'win32' pyinstaller==6.0.0 pytest==7.4.2 -selenium==4.12.0 +pytest-playwright==0.4.2 caldav==1.3.6 pwinput==1.0.3 windows-toasts==1.0.1; platform_system == "Windows" diff --git a/tests/conftest.py b/tests/conftest.py index 32c988a..3625fb3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ import marshmallow_dataclass import pytest +from playwright.sync_api import BrowserContext, Browser from focus_time_app.cli.background_scheduler import BackgroundSchedulerImpl from focus_time_app.command_execution import CommandExecutorImpl @@ -74,6 +75,18 @@ def stop_commands(tmp_path: Path) -> ConfiguredCommands: raise NotImplementedError +@pytest.fixture(scope="session") +def browser_context(browser: Browser) -> BrowserContext: + """ + Implements a BrowserContext fixture (which the Playwright folks forgot (?) to implement). + This speeds up tests, because we are using the same browser session in all tests, which e.g. saves us from having + to log into the Microsoft 365 account multiple times. + """ + context = browser.new_context() + yield context + context.close() + + def write_line_to_stream(stream: IO, input: Any): stream.write(str(input) + '\n') stream.flush() @@ -87,19 +100,20 @@ class ConfiguredCLI: @pytest.fixture(params=[CalendarType.Outlook365, CalendarType.CalDAV]) -def configured_cli_no_bg_jobs(request, start_commands, stop_commands) -> ConfiguredCLI: +def configured_cli_no_bg_jobs(request, start_commands, stop_commands, browser_context) -> ConfiguredCLI: yield from configured_cli(request.param, skip_background_scheduler_setup=True, start_commands=start_commands, - stop_commands=stop_commands) + stop_commands=stop_commands, browser_context=browser_context) @pytest.fixture(params=[CalendarType.Outlook365]) -def configured_cli_with_bg_jobs(request, start_commands, stop_commands) -> ConfiguredCLI: +def configured_cli_with_bg_jobs(request, start_commands, stop_commands, browser_context) -> ConfiguredCLI: yield from configured_cli(request.param, skip_background_scheduler_setup=False, start_commands=start_commands, - stop_commands=stop_commands) + stop_commands=stop_commands, browser_context=browser_context) @pytest.fixture(params=[CalendarType.Outlook365, CalendarType.CalDAV]) -def configured_calendar_adapter(request, start_commands, stop_commands) -> AbstractTestingCalendarAdapter: +def configured_calendar_adapter(request, start_commands, stop_commands, + browser_context) -> AbstractTestingCalendarAdapter: dnd_profile_name = CommandExecutorConstants.WINDOWS_FOCUS_ASSIST_PRIORITY_ONLY_PROFILE if sys.platform == "win32" \ else "unused" focustime_event_name = "Focustime-" + get_random_event_name_postfix() @@ -109,11 +123,12 @@ def configured_calendar_adapter(request, start_commands, stop_commands) -> Abstr dnd_profile_name=dnd_profile_name, set_event_reminder=True, event_reminder_time_minutes=15, show_notification=False) - return get_configured_calendar_adapter_for_testing(config) + return get_configured_calendar_adapter_for_testing(config, browser_context=browser_context) def configured_cli(calendar_type: CalendarType, skip_background_scheduler_setup: bool, - start_commands: ConfiguredCommands, stop_commands: ConfiguredCommands) -> ConfiguredCLI: + start_commands: ConfiguredCommands, stop_commands: ConfiguredCommands, + browser_context: BrowserContext) -> ConfiguredCLI: reset_dnd_and_bg_scheduler() if Persistence.ongoing_focustime_markerfile_exists(): Persistence.set_ongoing_focustime(ongoing=False) @@ -144,7 +159,7 @@ def configured_cli(calendar_type: CalendarType, skip_background_scheduler_setup: out = config_process.stdout.readline() # asks for Calendar adapter if calendar_type is CalendarType.Outlook365: - configure_outlook365_calendar_adapter(config_process, config) + configure_outlook365_calendar_adapter(config_process, config, browser_context=browser_context) elif calendar_type is CalendarType.CalDAV: configure_caldav_calendar_adapter(config_process, config) @@ -190,7 +205,7 @@ def configured_cli(calendar_type: CalendarType, skip_background_scheduler_setup: f"Stdout output:\n{stdout}\n" \ f"Stderr output:\n{stderr}" - calendar_adapter = get_configured_calendar_adapter_for_testing(config) + calendar_adapter = get_configured_calendar_adapter_for_testing(config, browser_context=browser_context) yield ConfiguredCLI(configuration=config, calendar_adapter=calendar_adapter, verification_file_path=start_commands.verification_file_path) @@ -205,7 +220,8 @@ def reset_dnd_and_bg_scheduler(): BackgroundSchedulerImpl.uninstall_background_scheduler() -def configure_outlook365_calendar_adapter(config_process: Popen, config: ConfigurationV1): +def configure_outlook365_calendar_adapter(config_process: Popen, config: ConfigurationV1, + browser_context: BrowserContext): """ Provides the inputs to the "configure" command that are necessary to configure the Outlook 365 calendar adapter. Requires the environment variables OUTLOOK365_EMAIL and OUTLOOK365_PASSWORD to be set for the corresponding @@ -224,7 +240,7 @@ def configure_outlook365_calendar_adapter(config_process: Popen, config: Configu assert url.startswith("https://login.microsoftonline.com/common/oauth2/v2.0/authorize?response_type=code") out = config_process.stdout.readline() # more Outlook-specific instructions out = config_process.stdout.readline() # more Outlook-specific instructions - auth_code_url = get_outlook365_authorization_code_url(url) + auth_code_url = get_outlook365_authorization_code_url(browser_context, url) write_line_to_stream(config_process.stdin, auth_code_url) assert "Authentication Flow Completed. Oauth Access Token Stored. You can now use the API." \ in config_process.stdout.readline() @@ -256,7 +272,8 @@ def configure_caldav_calendar_adapter(config_process: Popen, config: Configurati config.adapter_configuration = caldav_configuration_v1_schema.dump(adapter_configuration) -def get_configured_calendar_adapter_for_testing(configuration: ConfigurationV1) -> AbstractTestingCalendarAdapter: +def get_configured_calendar_adapter_for_testing(configuration: ConfigurationV1, + browser_context: BrowserContext) -> AbstractTestingCalendarAdapter: """ Creates a dedicated calendar adapter for the automated test. This is particularly necessary because on macOS, there are issues with the Keychain access, which make it impossible to "reuse" the credentials created by the @@ -264,7 +281,7 @@ def get_configured_calendar_adapter_for_testing(configuration: ConfigurationV1) frozen focus-time binary) cannot be read by any other apps #B (e.g. Python running pytest). """ if configuration.calendar_type is CalendarType.Outlook365: - adapter = Outlook365TestingCalendarAdapter(configuration) + adapter = Outlook365TestingCalendarAdapter(configuration, browser_context) elif configuration.calendar_type is CalendarType.CalDAV: adapter = CaldavTestingCalendarAdapter(configuration) diff --git a/tests/utils/outlook365_testing_calendar_adapter.py b/tests/utils/outlook365_testing_calendar_adapter.py index 6bea3f6..afe560c 100644 --- a/tests/utils/outlook365_testing_calendar_adapter.py +++ b/tests/utils/outlook365_testing_calendar_adapter.py @@ -1,53 +1,54 @@ +import re +import time from os import getenv from typing import Optional from O365.calendar import Calendar -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait +from playwright.sync_api import BrowserContext, expect from focus_time_app.configuration.configuration import ConfigurationV1 -from focus_time_app.focus_time_calendar.impl.outlook365_calendar_adapter import Outlook365CalendarAdapter, \ - OUTLOOK365_REDIRECT_URL +from focus_time_app.focus_time_calendar.impl.outlook365_calendar_adapter import Outlook365CalendarAdapter from tests import OUTLOOK365_TEST_CLIENT_ID from tests.utils import CI_ENV_NAMESPACE_OVERRIDE from tests.utils.abstract_testing_calendar_adapter import AbstractTestingCalendarAdapter -def get_outlook365_authorization_code_url(request_url: str) -> str: - EMAILFIELD = (By.ID, "i0116") - PASSWORDFIELD = (By.ID, "i0118") - NEXTBUTTON = (By.ID, "idSIButton9") - +def get_outlook365_authorization_code_url(browser_context: BrowserContext, request_url: str) -> str: email = getenv("OUTLOOK365_EMAIL", None) password = getenv("OUTLOOK365_PASSWORD", None) if email is None or password is None: raise ValueError("Environment variables OUTLOOK365_EMAIL and OUTLOOK365_PASSWORD must be set") - options = webdriver.ChromeOptions() - options.add_argument('--headless') - driver = webdriver.Chrome(options=options) - driver.get('https://login.live.com') + authenticated_page_url_pattern = re.compile(r'^https://focus-time\.github\.io/focus-time-app.*') + + page = browser_context.new_page() + page.goto(request_url) + + try: + expect(page).to_have_url(authenticated_page_url_pattern) + return page.url # happy path, we are already logged in + except AssertionError: + pass + + # Fill email, click next + page.locator('id=i0116').fill(email) + page.locator('id=idSIButton9').click() + + time.sleep(5) - # wait for email field and enter email - WebDriverWait(driver, 10).until(EC.element_to_be_clickable(EMAILFIELD)).send_keys(email) - # Click Next - WebDriverWait(driver, 10).until(EC.element_to_be_clickable(NEXTBUTTON)).click() + # Fill password, click next + page.locator('id=i0118').fill(password) + page.locator('id=idSIButton9').click() - # wait for password field - WebDriverWait(driver, 10).until(EC.element_to_be_clickable(PASSWORDFIELD)).send_keys(password) - # Click Next - WebDriverWait(driver, 10).until(EC.element_to_be_clickable(NEXTBUTTON)).click() + expect(page).to_have_url(authenticated_page_url_pattern) - driver.get(request_url) - WebDriverWait(driver, 10).until(EC.url_matches(OUTLOOK365_REDIRECT_URL)) - return driver.current_url + return page.url class Outlook365TestingCalendarAdapter(AbstractTestingCalendarAdapter, Outlook365CalendarAdapter): - def __init__(self, configuration: ConfigurationV1): + def __init__(self, configuration: ConfigurationV1, browser_context: BrowserContext): super().__init__(configuration, environment_namespace_override=CI_ENV_NAMESPACE_OVERRIDE) + self._browser_context = browser_context def _get_client_id(self) -> str: return OUTLOOK365_TEST_CLIENT_ID @@ -56,7 +57,7 @@ def _get_tenant_id(self) -> Optional[str]: return None def _get_consent_callback(self, consent_url: str) -> str: - return get_outlook365_authorization_code_url(consent_url) + return get_outlook365_authorization_code_url(self._browser_context, consent_url) def _get_calendar_name(self, calendars: list[Calendar]) -> str: return "Calendar"