Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: use Playwright for E2E tests #56

Merged
merged 2 commits into from
Sep 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci-cd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 30 additions & 13 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -256,15 +272,16 @@ 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
frozen focus-time app that being tested: in general, the credentials created in a keychain by app #A (e.g. the
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)
Expand Down
57 changes: 29 additions & 28 deletions tests/utils/outlook365_testing_calendar_adapter.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"