MIL-003: profile, navigation and automated login #42

Merged
Tirsvad merged 2 commits from mil-003-login into main 2026-10-28 10:42:09 +01:00
26 changed files with 852 additions and 40 deletions
+20 -20
View File
@@ -1,20 +1,20 @@
# Headless Chromium for real-browser runs and screenshots (ADR-0003). # Headless Chromium for real-browser runs and screenshots (ADR-0003).
FROM python:3.13-slim FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
CHROME_BIN=/usr/bin/chromium \ CHROME_BIN=/usr/bin/chromium \
CHROMEDRIVER_PATH=/usr/bin/chromedriver \ CHROMEDRIVER_PATH=/usr/bin/chromedriver \
SNACK_AND_LIFT_E2E=1 SNACK_AND_LIFT_E2E=1
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation \ && apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY pyproject.toml README.md LICENSE ./ COPY pyproject.toml README.md LICENSE ./
COPY src ./src COPY src ./src
RUN python -m pip install --upgrade pip && python -m pip install -e ".[dev]" RUN python -m pip install --upgrade pip && python -m pip install -e ".[dev]"
COPY tests ./tests COPY tests ./tests
ENTRYPOINT ["python", "-m", "snack_and_lift"] ENTRYPOINT ["python", "-m", "snack_and_lift"]
+18
View File
@@ -75,6 +75,24 @@ Leave the environment with `deactivate`. The folder `.venv` is ignored by git.
## ▶️ Run ## ▶️ Run
### Your gym account
The bot never contains a password. Register your own account once, by hand, as the lecture does: run the bot with `--keep-open` so Chrome stays open (the `chrome_profile` folder keeps the site's data between runs), click "Join Today... Or Tomorrow" and register with an e-mail such as `yourname@test.com` and a password you remember (not a trivial one such as `12346`, Chrome warns about those). Then give the bot the account through two environment variables:
```bash
# Linux and macOS
export SNACK_AND_LIFT_EMAIL="yourname@test.com"
export SNACK_AND_LIFT_PASSWORD="your password"
```
```powershell
# Windows PowerShell
$env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
$env:SNACK_AND_LIFT_PASSWORD = "your password"
```
### Start the bot
With the environment active: With the environment active:
```bash ```bash
+1 -1
View File
@@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| PP | Project Plan | docs/project-plan.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 009 | | MIL | Milestone / Gateway | docs/milestones/*.md | 009 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 017 | | RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 018 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,61 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-017 |
| CrossReference | [MIL-003], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review of the source and tests of MIL-003 | pending |
---
## Artifact Under Review
- Instance reviewed: [MIL-003]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests of MIL-003
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Modules, functions and variables are `snake_case`, classes `PascalCase`, constants `UPPER_SNAKE`; `ruff check` with the N rules passes. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (for example `retry`, `login`, `book_class`, `get_my_bookings`); no single-letter names outside comprehensions. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` leaves 0 files changed and `ruff check` reports 0 findings; there is no suppression. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every function and method has annotations (ruff ANN rules pass), including `-> None`. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; specific exceptions are caught, and re-raised with `raise ... from` where the cause matters. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default arguments; no builtin is shadowed (ruff B and A-style checks pass). |
| 7 | Files, locks and connections are managed with context managers | Pass | Files and the browser are managed with `with`/`try-finally`; no unmanaged file handle exists. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings (`@brief`, `@param`, `@return`). |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No print calls; messages go through `logging`. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists; ADR-0001 records that design artifacts are skipped. Classes follow ADR-0002. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 36 unit tests (credentials, login use case, command line, Chrome options) need no browser or network; 3 container tests run the adapter against local HTML fixtures with dummy credentials; coverage of the non-infrastructure modules is 100%. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | N-A | No strict type checker is configured; annotations are complete and checked by ruff. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` declares Selenium (runtime) and pytest, pytest-cov, ruff (dev) with lower bounds; none is unused. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Each rule, constant and message exists once (constants in `constants.py`, rules in `domain/`). |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` and `application` import no Selenium; only `infrastructure` imports it; no import cycle exists. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | One reason to change per module; the adapter hides Selenium behind a port; fakes honour the port contract. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layout follows `src/snack_and_lift/{domain,application,infrastructure}` and `tests/{unit,integration,e2e}`; there is no presentation layer for a command-line bot (ADR-0002). |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | All file and folder names are English lowercase with `_`, `-` and `.` only. |
## Overall Verdict
Go-with-conditions - the code of MIL-003 meets every Mandatory criterion of QC-PY-001 and there is no blocking finding, but part of the gate evidence cannot be produced by the stand-in. Gate criteria 2 and 4 are met (36 unit tests pass; this review). Criterion 3 is half met: `docs/images/step-1.png` is a real screenshot of the home page taken in the container; `step-2.png` shows the logged-in schedule and needs an account. Criterion 1 (a container run that logs in to the live site) is not verified. The framework forbids the stand-in to enter passwords or create accounts on a third-party site, so the live login was not run; the adapter is verified against local fixtures only, and its selectors were read from the site's public scripts. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| Run the bot once with your own account (README, section Screenshots) and keep `docs/images/step-2.png`; report any selector that failed | S01 | when S01 has time |
---
[MIL-003]: ../../milestones/mil-003-login.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
+1
View File
@@ -37,6 +37,7 @@ Tracks backward and forward links between artifact instances so that the Busines
| [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | [MIL-008] | RC-014 | | [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | [MIL-008] | RC-014 |
| [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-015 | | [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-015 |
| Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002], [ADR-0003] | - | RC-016 | | Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002], [ADR-0003] | - | RC-016 |
| Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002], [ADR-0003] | - | RC-017 (Go-with-conditions) |
## Concern Coverage ## Concern Coverage
+1 -1
View File
@@ -29,7 +29,7 @@ where = ["src"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
addopts = "-ra --strict-markers" addopts = "-ra --strict-markers"
pythonpath = ["src"] pythonpath = ["src", "."]
markers = [ markers = [
"browser: needs a real browser (run in the Docker container)", "browser: needs a real browser (run in the Docker container)",
] ]
@@ -0,0 +1,35 @@
"""@file credentials.py
@brief The account the bot logs in with, read from the environment (never from the code).
"""
from collections.abc import Mapping
from dataclasses import dataclass, field
from snack_and_lift import constants
from snack_and_lift.domain.errors import MissingCredentialsError
@dataclass(frozen=True, slots=True)
class Credentials:
"""@brief E-mail and password of the gym account; the password is never printed."""
email: str
password: str = field(repr=False)
def load_credentials(environment: Mapping[str, str]) -> Credentials:
"""@brief Read the account from the environment.
@param environment The environment variables, for example `os.environ`.
@return The credentials.
@exception MissingCredentialsError A variable is missing or empty; the message names
the variables and never a value.
"""
email = environment.get(constants.ACCOUNT_EMAIL_ENV, "").strip()
password = environment.get(constants.ACCOUNT_PASSWORD_ENV, "")
if not email or not password:
raise MissingCredentialsError(
f"Set {constants.ACCOUNT_EMAIL_ENV} and {constants.ACCOUNT_PASSWORD_ENV} "
"in the environment (they must not be empty)"
)
return Credentials(email=email, password=password)
+38
View File
@@ -0,0 +1,38 @@
"""@file ports.py
@brief The ports the use cases need from the outside world.
"""
from pathlib import Path
from typing import Protocol
class GymPort(Protocol):
"""@brief The gym site as the use cases see it."""
def open_site(self) -> None:
"""@brief Open the home page and wait until it is ready.
@exception GymError The page did not load.
"""
...
def log_in(self, email: str, password: str) -> None:
"""@brief Log in and wait until the class schedule is shown.
@param email The account e-mail.
@param password The account password.
@exception LoginFailedError The site refused the login.
@exception GymError The page did not behave as expected.
"""
...
def save_screenshot(self, path: Path) -> None:
"""@brief Save a screenshot of the current page.
@param path The PNG file to write; its folders are created.
"""
...
def close(self) -> None:
"""@brief Close the browser."""
...
+21
View File
@@ -0,0 +1,21 @@
"""@file session.py
@brief Lecture step 2: the login of the bot.
"""
import logging
from snack_and_lift.application.credentials import Credentials
from snack_and_lift.application.ports import GymPort
logger = logging.getLogger(__name__)
def login(gym: GymPort, credentials: Credentials) -> None:
"""@brief Log in and wait until the class schedule is shown.
@param gym The gym site.
@param credentials The account to log in with.
@exception LoginFailedError The login failed; `retry` repeats it (lecture step 9).
"""
gym.log_in(credentials.email, credentials.password)
logger.info("✓ Logged in: the class schedule is shown")
+94 -8
View File
@@ -4,16 +4,40 @@
import argparse import argparse
import logging import logging
from collections.abc import Sequence import os
import sys
from collections.abc import Callable, Sequence
from pathlib import Path
from snack_and_lift.application.credentials import load_credentials
from snack_and_lift.application.ports import GymPort
from snack_and_lift.application.session import login
from snack_and_lift.domain.errors import LoginFailedError, MissingCredentialsError
from snack_and_lift.infrastructure.browser import create_driver, default_profile_dir
from snack_and_lift.infrastructure.selenium_gym import SeleniumGym
## Builds the gym site from (headless, profile folder, keep open).
GymFactory = Callable[[bool, Path, bool], GymPort]
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def selenium_gym_factory(headless: bool, profile_dir: Path, keep_open: bool) -> GymPort:
"""@brief Start Chrome and wrap it as the gym site.
@param headless True to run without a window.
@param profile_dir The Chrome user data folder.
@param keep_open True to leave the browser open when the program ends.
@return The gym site.
"""
return SeleniumGym(create_driver(headless, profile_dir, keep_open))
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
"""@brief Read the command line. """@brief Read the command line.
@param argv Arguments without the program name; None reads `sys.argv`. @param argv Arguments without the program name; None reads `sys.argv`.
@return The parsed options. @return The parsed options; `keep_open` is True unless `--headless` is given.
""" """
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="snack-and-lift", prog="snack-and-lift",
@@ -24,16 +48,78 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
action="store_true", action="store_true",
help="run Chrome without a window (the Docker image does this)", help="run Chrome without a window (the Docker image does this)",
) )
return parser.parse_args(argv) parser.add_argument(
"--keep-open",
action="store_true",
default=None,
help="leave the browser open at the end (default when not headless)",
)
parser.add_argument(
"--profile-dir",
type=Path,
default=None,
help="Chrome profile folder (default: ./chrome_profile)",
)
parser.add_argument(
"--screenshot-dir",
type=Path,
default=None,
help="save a screenshot after each step into this folder",
)
args = parser.parse_args(argv)
if args.keep_open is None:
args.keep_open = not args.headless
if args.profile_dir is None:
args.profile_dir = default_profile_dir()
return args
def main(argv: Sequence[str] | None = None) -> int: def _use_utf8_output() -> None:
"""@brief Start the bot. """@brief Make stdout UTF-8 so the check mark of the messages prints on Windows."""
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
reconfigure(encoding="utf-8")
def _save_step(gym: GymPort, screenshot_dir: Path | None, name: str) -> None:
"""@brief Save a screenshot when a folder was asked for.
@param gym The gym site.
@param screenshot_dir The folder, or None for no screenshots.
@param name The file name.
"""
if screenshot_dir is not None:
gym.save_screenshot(screenshot_dir / name)
def main(
argv: Sequence[str] | None = None,
gym_factory: GymFactory = selenium_gym_factory,
) -> int:
"""@brief Start the bot: open the site and log in.
@param argv Arguments without the program name; None reads `sys.argv`. @param argv Arguments without the program name; None reads `sys.argv`.
@return Process exit code. @param gym_factory Builds the gym site; tests pass a fake.
@return Process exit code: 0 done, 1 login failed, 2 credentials missing.
""" """
logging.basicConfig(level=logging.INFO, format="%(message)s") logging.basicConfig(level=logging.INFO, format="%(message)s")
parse_args(argv) _use_utf8_output()
logger.info("snack-and-lift: the lecture steps are added by the next milestones") args = parse_args(argv)
try:
credentials = load_credentials(os.environ)
except MissingCredentialsError as error:
logger.error("%s", error)
return 2
gym = gym_factory(args.headless, args.profile_dir, args.keep_open)
try:
gym.open_site()
_save_step(gym, args.screenshot_dir, "step-1.png")
login(gym, credentials)
_save_step(gym, args.screenshot_dir, "step-2.png")
except LoginFailedError as error:
logger.error("Login failed: %s", error)
return 1
finally:
if not args.keep_open:
gym.close()
return 0 return 0
+21
View File
@@ -24,6 +24,27 @@ TARGET_WEEKDAYS: Final[tuple[str, ...]] = ("Tue", "Thu")
## Id of the login button in the navigation bar of the site (the home page has no title). ## Id of the login button in the navigation bar of the site (the home page has no title).
NAV_LOGIN_BUTTON_ID: Final[str] = "login-button" NAV_LOGIN_BUTTON_ID: Final[str] = "login-button"
## Id of the e-mail field of the login form.
LOGIN_EMAIL_INPUT_ID: Final[str] = "email-input"
## Id of the password field of the login form.
LOGIN_PASSWORD_INPUT_ID: Final[str] = "password-input"
## Id of the submit button of the login form.
LOGIN_SUBMIT_BUTTON_ID: Final[str] = "submit-button"
## Id of the element that shows a login error.
ERROR_MESSAGE_ID: Final[str] = "error-message"
## Id of the class schedule page, shown after a successful login.
SCHEDULE_PAGE_ID: Final[str] = "schedule-page"
## Name of the environment variable that holds the account e-mail.
ACCOUNT_EMAIL_ENV: Final[str] = "SNACK_AND_LIFT_EMAIL"
## Name of the environment variable that holds the account password.
ACCOUNT_PASSWORD_ENV: Final[str] = "SNACK_AND_LIFT_PASSWORD"
## Folder where screenshots of a container run are written. ## Folder where screenshots of a container run are written.
SCREENSHOT_DIR: Final[str] = "docs/images" SCREENSHOT_DIR: Final[str] = "docs/images"
+15
View File
@@ -0,0 +1,15 @@
"""@file errors.py
@brief The errors of the bot.
"""
class GymError(Exception):
"""@brief The gym site did not behave as the bot expected (a page, element or timeout)."""
class LoginFailedError(GymError):
"""@brief The site refused the login; the message is the text the site shows."""
class MissingCredentialsError(Exception):
"""@brief The account e-mail or password is not set in the environment."""
+33 -6
View File
@@ -1,8 +1,9 @@
"""@file browser.py """@file browser.py
@brief Creates the Chrome driver, headless when asked. @brief Creates the Chrome driver with a persistent profile, headless when asked.
""" """
import os import os
from pathlib import Path
from selenium import webdriver from selenium import webdriver
from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.options import Options
@@ -11,23 +12,49 @@ from selenium.webdriver.chrome.service import Service
from snack_and_lift import constants from snack_and_lift import constants
def create_driver(headless: bool) -> webdriver.Chrome: def default_profile_dir() -> Path:
"""@brief Start Chrome. """@brief The folder where Chrome keeps the profile of the bot.
The binary and the driver come from `CHROME_BIN` and `CHROMEDRIVER_PATH` when @return `chrome_profile` below the working directory.
these are set (the Docker image sets them); otherwise Selenium finds them. """
return Path.cwd() / constants.PROFILE_DIR_NAME
def build_options(headless: bool, profile_dir: Path, keep_open: bool) -> Options:
"""@brief Build the Chrome options (no browser is started).
@param headless True to run without a window, as in the Docker container. @param headless True to run without a window, as in the Docker container.
@return A running Chrome driver. @param profile_dir The Chrome user data folder.
@param keep_open True to leave the browser open when the program ends.
@return The options.
""" """
options = Options() options = Options()
options.add_argument(f"--user-data-dir={profile_dir}")
options.add_argument(f"--window-size={constants.WINDOW_SIZE}") options.add_argument(f"--window-size={constants.WINDOW_SIZE}")
if keep_open:
options.add_experimental_option("detach", True)
if headless: if headless:
for argument in constants.HEADLESS_BROWSER_ARGUMENTS: for argument in constants.HEADLESS_BROWSER_ARGUMENTS:
options.add_argument(argument) options.add_argument(argument)
binary = os.environ.get(constants.CHROME_BIN_ENV) binary = os.environ.get(constants.CHROME_BIN_ENV)
if binary: if binary:
options.binary_location = binary options.binary_location = binary
return options
def create_driver(headless: bool, profile_dir: Path, keep_open: bool) -> webdriver.Chrome:
"""@brief Start Chrome.
The binary and the driver come from `CHROME_BIN` and `CHROMEDRIVER_PATH` when
these are set (the Docker image sets them); otherwise Selenium finds them.
@param headless True to run without a window, as in the Docker container.
@param profile_dir The Chrome user data folder; it is created when missing.
@param keep_open True to leave the browser open when the program ends.
@return A running Chrome driver.
"""
profile_dir.mkdir(parents=True, exist_ok=True)
options = build_options(headless, profile_dir, keep_open)
driver_path = os.environ.get(constants.CHROMEDRIVER_PATH_ENV) driver_path = os.environ.get(constants.CHROMEDRIVER_PATH_ENV)
service = Service(executable_path=driver_path) if driver_path else Service() service = Service(executable_path=driver_path) if driver_path else Service()
return webdriver.Chrome(service=service, options=options) return webdriver.Chrome(service=service, options=options)
@@ -0,0 +1,111 @@
"""@file selenium_gym.py
@brief The Selenium adapter of the GymPort.
"""
import functools
import logging
from collections.abc import Callable
from pathlib import Path
from typing import Concatenate
from selenium.common.exceptions import (
ElementClickInterceptedException,
NoSuchElementException,
StaleElementReferenceException,
TimeoutException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.ui import WebDriverWait
from snack_and_lift import constants
from snack_and_lift.domain.errors import GymError, LoginFailedError
logger = logging.getLogger(__name__)
_SELENIUM_ERRORS = (
TimeoutException,
NoSuchElementException,
StaleElementReferenceException,
ElementClickInterceptedException,
)
def _translate_errors[S, **P, R](
method: Callable[Concatenate[S, P], R],
) -> Callable[Concatenate[S, P], R]:
"""@brief Turn Selenium errors of a method into GymError.
@param method The adapter method to wrap.
@return The wrapped method.
"""
@functools.wraps(method)
def wrapper(self: S, *args: P.args, **kwargs: P.kwargs) -> R:
try:
return method(self, *args, **kwargs)
except _SELENIUM_ERRORS as error:
raise GymError(f"{method.__name__} failed: {type(error).__name__}") from error
return wrapper
class SeleniumGym:
"""@brief The gym site driven by a Selenium WebDriver."""
def __init__(self, driver: WebDriver, base_url: str = constants.GYM_URL) -> None:
"""@brief Wrap a driver.
@param driver A running WebDriver.
@param base_url The address of the home page.
"""
self._driver = driver
self._base_url = base_url
self._wait = WebDriverWait(driver, constants.WAIT_TIMEOUT_SECONDS)
@_translate_errors
def open_site(self) -> None:
"""@brief Open the home page and wait for the login button.
@exception GymError The page did not load.
"""
self._driver.get(self._base_url)
self._wait.until(ec.presence_of_element_located((By.ID, constants.NAV_LOGIN_BUTTON_ID)))
@_translate_errors
def log_in(self, email: str, password: str) -> None:
"""@brief Fill in the login form and wait for the schedule or an error.
@param email The account e-mail.
@param password The account password.
@exception LoginFailedError The site shows a login error.
@exception GymError The page did not behave as expected.
"""
self._wait.until(ec.element_to_be_clickable((By.ID, constants.NAV_LOGIN_BUTTON_ID))).click()
self._wait.until(
ec.presence_of_element_located((By.ID, constants.LOGIN_EMAIL_INPUT_ID))
).send_keys(email)
self._driver.find_element(By.ID, constants.LOGIN_PASSWORD_INPUT_ID).send_keys(password)
self._driver.find_element(By.ID, constants.LOGIN_SUBMIT_BUTTON_ID).click()
self._wait.until(
ec.any_of(
ec.presence_of_element_located((By.ID, constants.SCHEDULE_PAGE_ID)),
ec.presence_of_element_located((By.ID, constants.ERROR_MESSAGE_ID)),
)
)
errors = self._driver.find_elements(By.ID, constants.ERROR_MESSAGE_ID)
if errors:
raise LoginFailedError(errors[0].text)
def save_screenshot(self, path: Path) -> None:
"""@brief Save a screenshot of the current page.
@param path The PNG file to write; its folders are created.
"""
path.parent.mkdir(parents=True, exist_ok=True)
self._driver.save_screenshot(str(path))
def close(self) -> None:
"""@brief Quit the browser."""
self._driver.quit()
+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Fixture gym home</title></head>
<body>
<nav><a href="login.html"><button id="login-button">Login</button></a></nav>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Fixture gym login</title></head>
<body>
<form id="login-form">
<input id="email-input" type="email">
<input id="password-input" type="password">
<button id="submit-button" type="submit">Log in</button>
</form>
<div id="message-area"></div>
<script>
// Dummy account of this fixture only; it is not a real account.
const ACCEPTED_EMAIL = "fixture@example.invalid";
const ACCEPTED_PASSWORD = "fixture-password";
document.getElementById("login-form").addEventListener("submit", function (event) {
event.preventDefault();
const email = document.getElementById("email-input").value;
const password = document.getElementById("password-input").value;
if (email === ACCEPTED_EMAIL && password === ACCEPTED_PASSWORD) {
window.location.href = "schedule.html";
} else {
document.getElementById("message-area").innerHTML =
'<div id="error-message">Invalid email or password</div>';
}
});
</script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Fixture gym schedule</title></head>
<body>
<div id="schedule-page">Class schedule</div>
</body>
</html>
+3 -1
View File
@@ -3,6 +3,8 @@
""" """
import os import os
import tempfile
from pathlib import Path
import pytest import pytest
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
@@ -22,7 +24,7 @@ pytestmark = [
def test_gym_home_page_shows_the_login_button() -> None: def test_gym_home_page_shows_the_login_button() -> None:
driver = create_driver(headless=True) driver = create_driver(headless=True, profile_dir=Path(tempfile.mkdtemp()), keep_open=False)
try: try:
driver.get(constants.GYM_URL) driver.get(constants.GYM_URL)
button = WebDriverWait(driver, constants.WAIT_TIMEOUT_SECONDS).until( button = WebDriverWait(driver, constants.WAIT_TIMEOUT_SECONDS).until(
+57
View File
@@ -0,0 +1,57 @@
"""@file test_selenium_gym.py
@brief The Selenium adapter against local HTML fixtures; runs only in the Docker container.
"""
import os
from collections.abc import Iterator
from pathlib import Path
import pytest
from snack_and_lift.domain.errors import LoginFailedError
from snack_and_lift.infrastructure.browser import create_driver
from snack_and_lift.infrastructure.selenium_gym import SeleniumGym
pytestmark = [
pytest.mark.browser,
pytest.mark.skipif(
os.environ.get("SNACK_AND_LIFT_E2E") != "1",
reason="needs a real browser; run it in the Docker container",
),
]
FIXTURE_HOME = (Path(__file__).parent / "fixtures" / "home.html").as_uri()
# Dummy account that the fixture login.html accepts; it is not a real account.
FIXTURE_EMAIL = "fixture@example.invalid"
FIXTURE_PASSWORD = "fixture-password"
@pytest.fixture
def gym(tmp_path: Path) -> Iterator[SeleniumGym]:
driver = create_driver(headless=True, profile_dir=tmp_path / "profile", keep_open=False)
try:
yield SeleniumGym(driver, base_url=FIXTURE_HOME)
finally:
driver.quit()
def test_log_in_succeeds_with_accepted_credentials(gym: SeleniumGym) -> None:
gym.open_site()
gym.log_in(FIXTURE_EMAIL, FIXTURE_PASSWORD)
def test_log_in_raises_for_wrong_credentials(gym: SeleniumGym) -> None:
gym.open_site()
with pytest.raises(LoginFailedError, match="Invalid"):
gym.log_in(FIXTURE_EMAIL, "wrong-password")
def test_save_screenshot_creates_the_folder(gym: SeleniumGym, tmp_path: Path) -> None:
gym.open_site()
target = tmp_path / "shots" / "step-1.png"
gym.save_screenshot(target)
assert target.stat().st_size > 0
+35
View File
@@ -0,0 +1,35 @@
"""@file fakes.py
@brief A fake of the GymPort for the unit tests.
"""
from pathlib import Path
from snack_and_lift.domain.errors import LoginFailedError
class FakeGym:
"""@brief Records the calls; can be told to refuse the login."""
def __init__(self, login_error: str | None = None) -> None:
self.login_error = login_error
self.calls: list[str] = []
self.logins: list[tuple[str, str]] = []
self.screenshots: list[Path] = []
self.closed = False
def open_site(self) -> None:
self.calls.append("open_site")
def log_in(self, email: str, password: str) -> None:
self.calls.append("log_in")
self.logins.append((email, password))
if self.login_error is not None:
raise LoginFailedError(self.login_error)
def save_screenshot(self, path: Path) -> None:
self.calls.append("save_screenshot")
self.screenshots.append(path)
def close(self) -> None:
self.calls.append("close")
self.closed = True
+50
View File
@@ -0,0 +1,50 @@
"""@file test_browser_options.py
@brief The Chrome options are built correctly (no browser is started).
"""
from pathlib import Path
import pytest
from snack_and_lift import constants
from snack_and_lift.infrastructure.browser import build_options, default_profile_dir
def test_profile_dir_is_passed_to_chrome(tmp_path: Path) -> None:
options = build_options(False, tmp_path, False)
assert f"--user-data-dir={tmp_path}" in options.arguments
def test_default_profile_dir_is_below_the_working_directory() -> None:
assert default_profile_dir() == Path.cwd() / constants.PROFILE_DIR_NAME
def test_keep_open_detaches_the_browser(tmp_path: Path) -> None:
assert build_options(False, tmp_path, True).experimental_options["detach"] is True
def test_browser_is_not_detached_when_not_kept_open(tmp_path: Path) -> None:
assert "detach" not in build_options(False, tmp_path, False).experimental_options
def test_headless_adds_the_headless_arguments(tmp_path: Path) -> None:
options = build_options(True, tmp_path, False)
for argument in constants.HEADLESS_BROWSER_ARGUMENTS:
assert argument in options.arguments
def test_window_has_no_headless_arguments_by_default(tmp_path: Path) -> None:
options = build_options(False, tmp_path, False)
assert "--headless=new" not in options.arguments
assert f"--window-size={constants.WINDOW_SIZE}" in options.arguments
def test_chrome_binary_comes_from_the_environment(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv(constants.CHROME_BIN_ENV, "/opt/chrome")
assert build_options(False, tmp_path, False).binary_location == "/opt/chrome"
+110 -3
View File
@@ -1,8 +1,24 @@
"""@file test_cli.py """@file test_cli.py
@brief The command line accepts its options and the entry point returns 0. @brief The command line accepts its options and the entry point wires the use case.
""" """
from pathlib import Path
import pytest
from tests.unit.fakes import FakeGym
from snack_and_lift import constants
from snack_and_lift.cli import main, parse_args from snack_and_lift.cli import main, parse_args
from snack_and_lift.infrastructure.browser import default_profile_dir
DUMMY_EMAIL = "dummy@example.invalid"
DUMMY_PASSWORD = "dummy-password"
@pytest.fixture
def credentials_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL)
monkeypatch.setenv(constants.ACCOUNT_PASSWORD_ENV, DUMMY_PASSWORD)
def test_headless_is_off_by_default() -> None: def test_headless_is_off_by_default() -> None:
@@ -13,5 +29,96 @@ def test_headless_flag_is_read() -> None:
assert parse_args(["--headless"]).headless is True assert parse_args(["--headless"]).headless is True
def test_main_returns_zero() -> None: def test_browser_stays_open_by_default_when_not_headless() -> None:
assert main([]) == 0 assert parse_args([]).keep_open is True
def test_browser_is_closed_by_default_when_headless() -> None:
assert parse_args(["--headless"]).keep_open is False
def test_keep_open_flag_overrides_headless_default() -> None:
assert parse_args(["--headless", "--keep-open"]).keep_open is True
def test_profile_dir_defaults_to_working_directory() -> None:
assert parse_args([]).profile_dir == default_profile_dir()
def test_profile_dir_option_is_read(tmp_path: Path) -> None:
assert parse_args(["--profile-dir", str(tmp_path)]).profile_dir == tmp_path
def test_screenshot_dir_is_optional() -> None:
assert parse_args([]).screenshot_dir is None
@pytest.mark.usefixtures("credentials_env")
def test_main_saves_both_screenshots_on_success(tmp_path: Path) -> None:
gym = FakeGym()
code = main(["--screenshot-dir", str(tmp_path)], lambda *_: gym)
assert code == 0
assert gym.logins == [(DUMMY_EMAIL, DUMMY_PASSWORD)]
assert gym.screenshots == [tmp_path / "step-1.png", tmp_path / "step-2.png"]
assert gym.calls == ["open_site", "save_screenshot", "log_in", "save_screenshot"]
@pytest.mark.usefixtures("credentials_env")
def test_main_saves_no_screenshot_without_directory() -> None:
gym = FakeGym()
assert main([], lambda *_: gym) == 0
assert gym.screenshots == []
def test_main_returns_2_when_credentials_are_missing(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.delenv(constants.ACCOUNT_EMAIL_ENV, raising=False)
monkeypatch.delenv(constants.ACCOUNT_PASSWORD_ENV, raising=False)
gym = FakeGym()
code = main([], lambda *_: gym)
assert code == 2
assert gym.calls == []
assert constants.ACCOUNT_EMAIL_ENV in caplog.text
@pytest.mark.usefixtures("credentials_env")
def test_main_returns_1_when_login_fails() -> None:
gym = FakeGym(login_error="Invalid credentials")
assert main(["--headless"], lambda *_: gym) == 1
assert gym.closed is True
@pytest.mark.usefixtures("credentials_env")
def test_main_closes_the_gym_when_not_kept_open() -> None:
gym = FakeGym()
assert main(["--headless"], lambda *_: gym) == 0
assert gym.closed is True
@pytest.mark.usefixtures("credentials_env")
def test_main_keeps_the_gym_open_when_asked() -> None:
gym = FakeGym()
assert main([], lambda *_: gym) == 0
assert gym.closed is False
@pytest.mark.usefixtures("credentials_env")
def test_main_passes_options_to_the_factory(tmp_path: Path) -> None:
received: list[tuple[bool, Path, bool]] = []
def factory(headless: bool, profile_dir: Path, keep_open: bool) -> FakeGym:
received.append((headless, profile_dir, keep_open))
return FakeGym()
main(["--headless", "--profile-dir", str(tmp_path)], factory)
assert received == [(True, tmp_path, False)]
+14
View File
@@ -9,6 +9,20 @@ def test_gym_address_is_https() -> None:
assert constants.GYM_URL.startswith("https://") assert constants.GYM_URL.startswith("https://")
def test_credentials_are_read_from_named_environment_variables() -> None:
assert constants.ACCOUNT_EMAIL_ENV == "SNACK_AND_LIFT_EMAIL"
assert constants.ACCOUNT_PASSWORD_ENV == "SNACK_AND_LIFT_PASSWORD"
def test_login_element_ids() -> None:
assert constants.NAV_LOGIN_BUTTON_ID == "login-button"
assert constants.LOGIN_EMAIL_INPUT_ID == "email-input"
assert constants.LOGIN_PASSWORD_INPUT_ID == "password-input"
assert constants.LOGIN_SUBMIT_BUTTON_ID == "submit-button"
assert constants.ERROR_MESSAGE_ID == "error-message"
assert constants.SCHEDULE_PAGE_ID == "schedule-page"
def test_retry_count_is_seven() -> None: def test_retry_count_is_seven() -> None:
assert constants.RETRY_COUNT == 7 assert constants.RETRY_COUNT == 7
+44
View File
@@ -0,0 +1,44 @@
"""@file test_credentials.py
@brief Credentials come from the environment and never show the password.
"""
import pytest
from snack_and_lift import constants
from snack_and_lift.application.credentials import Credentials, load_credentials
from snack_and_lift.domain.errors import MissingCredentialsError
DUMMY_PASSWORD = "dummy-secret-value"
def test_load_credentials_reads_both_variables() -> None:
environment = {
constants.ACCOUNT_EMAIL_ENV: "dummy@example.invalid",
constants.ACCOUNT_PASSWORD_ENV: DUMMY_PASSWORD,
}
assert load_credentials(environment) == Credentials("dummy@example.invalid", DUMMY_PASSWORD)
@pytest.mark.parametrize(
"environment",
[
{},
{constants.ACCOUNT_EMAIL_ENV: "dummy@example.invalid"},
{constants.ACCOUNT_PASSWORD_ENV: DUMMY_PASSWORD},
{constants.ACCOUNT_EMAIL_ENV: "", constants.ACCOUNT_PASSWORD_ENV: DUMMY_PASSWORD},
{constants.ACCOUNT_EMAIL_ENV: "dummy@example.invalid", constants.ACCOUNT_PASSWORD_ENV: ""},
],
)
def test_load_credentials_rejects_missing_or_empty(environment: dict[str, str]) -> None:
with pytest.raises(MissingCredentialsError) as caught:
load_credentials(environment)
message = str(caught.value)
assert constants.ACCOUNT_EMAIL_ENV in message
assert constants.ACCOUNT_PASSWORD_ENV in message
assert DUMMY_PASSWORD not in message
def test_repr_hides_the_password() -> None:
assert DUMMY_PASSWORD not in repr(Credentials("dummy@example.invalid", DUMMY_PASSWORD))
+27
View File
@@ -0,0 +1,27 @@
"""@file test_session.py
@brief The login use case hands the credentials to the gym site.
"""
import pytest
from tests.unit.fakes import FakeGym
from snack_and_lift.application.credentials import Credentials
from snack_and_lift.application.session import login
from snack_and_lift.domain.errors import LoginFailedError
CREDENTIALS = Credentials("dummy@example.invalid", "dummy-password")
def test_login_passes_the_credentials_to_the_gym() -> None:
gym = FakeGym()
login(gym, CREDENTIALS)
assert gym.logins == [("dummy@example.invalid", "dummy-password")]
def test_login_propagates_a_refused_login() -> None:
gym = FakeGym(login_error="Invalid credentials")
with pytest.raises(LoginFailedError, match="Invalid credentials"):
login(gym, CREDENTIALS)