Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
044ea148b9 | ||
|
|
7fdc11a0e7 | ||
|
|
0c81b034dc |
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.venv
|
||||
.env
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
docs/doxygen
|
||||
framework
|
||||
.claude
|
||||
.agents
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# Headless Chromium for real-browser runs and screenshots (ADR-0003).
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
CHROME_BIN=/usr/bin/chromium \
|
||||
CHROMEDRIVER_PATH=/usr/bin/chromedriver \
|
||||
SNACK_AND_LIFT_E2E=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
COPY src ./src
|
||||
RUN python -m pip install --upgrade pip && python -m pip install -e ".[dev]"
|
||||
COPY tests ./tests
|
||||
|
||||
ENTRYPOINT ["python", "-m", "snack_and_lift"]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Doxygen configuration for the Snack and Lift booking bot. Run: doxygen Doxyfile
|
||||
PROJECT_NAME = "Snack and Lift Booking Bot"
|
||||
PROJECT_BRIEF = "Selenium bot that books classes at the Snack and Lift practice gym (Udemy 100 Days of Code, day 49)"
|
||||
OUTPUT_DIRECTORY = docs/doxygen
|
||||
INPUT = src
|
||||
FILE_PATTERNS = *.py
|
||||
RECURSIVE = YES
|
||||
EXTRACT_ALL = YES
|
||||
OPTIMIZE_OUTPUT_JAVA = YES
|
||||
PYTHON_DOCSTRING = YES
|
||||
AUTOLINK_SUPPORT = YES
|
||||
GENERATE_HTML = YES
|
||||
GENERATE_LATEX = NO
|
||||
QUIET = YES
|
||||
WARN_IF_UNDOCUMENTED = YES
|
||||
WARN_AS_ERROR = NO
|
||||
@@ -1,2 +1,154 @@
|
||||
# 049-snack-and-lift
|
||||
# 🏋️ Snack and Lift Booking Bot
|
||||
|
||||
A Selenium bot that books classes at the fictional gym "Snack and Lift" for you: it logs in, books every Tuesday and Thursday 6pm class, joins the waitlist when a class is full, verifies the result on the "My Bookings" page and survives a site that fails half of its requests. It is the solution to day 49 of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*, written for course participants who want to compare solutions and for visitors who browse for ideas.
|
||||
|
||||
## 📚 Table of Contents
|
||||
|
||||
- [Overview](#-overview)
|
||||
- [Requirements](#-requirements)
|
||||
- [Setup](#-setup)
|
||||
- [Run](#-run)
|
||||
- [Tests](#-tests)
|
||||
- [License](#-license)
|
||||
- [Links](#-links)
|
||||
- [Appendix: Project documents](#-appendix-project-documents)
|
||||
|
||||
## 🧭 Overview
|
||||
|
||||
The bot follows the nine steps of the lecture on the practice site [Snack and Lift][gym]:
|
||||
|
||||
1. Setup, Chrome profile and basic navigation.
|
||||
2. Automated login.
|
||||
3. Book the upcoming Tuesday class.
|
||||
4. Check if a class is already booked.
|
||||
5. Add counters and a summary.
|
||||
6. Book every Tuesday and Thursday class.
|
||||
7. Verify the classes on the "My Bookings" page.
|
||||
8. Time travel quality assurance.
|
||||
9. Add resilience against network failures (`retry`, `login`, `book_class`, `get_my_bookings`).
|
||||
|
||||
The repository is organised like this:
|
||||
|
||||
| Folder or file | What it is for |
|
||||
| --- | --- |
|
||||
| `src/snack_and_lift/` | The bot. `domain/` holds the rules with no Selenium, `application/` holds `retry`, `login`, `book_class` and `get_my_bookings`, `infrastructure/` drives the browser, `constants.py` holds every constant |
|
||||
| `tests/` | `pytest` tests; the unit tests need no browser |
|
||||
| `docs/` | Project documents, wiki pages (English, Danish, Bahasa Malaysia) and the screenshots of the lecture steps |
|
||||
| `Dockerfile` | A Linux image with headless Chromium for real-browser runs and screenshots |
|
||||
| `Doxyfile` | Doxygen configuration for the source comments |
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
- Python 3.13 or newer.
|
||||
- Selenium (the only runtime dependency; it is installed by `pip` and finds the browser driver itself).
|
||||
- Google Chrome or Chromium to run the bot on your own machine.
|
||||
- `pytest` and `pytest-cov` for the tests (development extra only).
|
||||
- Docker, only if you want to run the bot or take screenshots without opening a window on your desktop.
|
||||
- Doxygen, only to build the source documentation.
|
||||
|
||||
## 🛠️ Setup
|
||||
|
||||
Create a local virtual environment, upgrade `pip` and install the project with the test tools:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
```
|
||||
|
||||
Activate it:
|
||||
|
||||
```bash
|
||||
# Linux and macOS
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
.venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Leave the environment with `deactivate`. The folder `.venv` is ignored by git.
|
||||
|
||||
## ▶️ Run
|
||||
|
||||
With the environment active:
|
||||
|
||||
```bash
|
||||
snack-and-lift
|
||||
```
|
||||
|
||||
or, without installing the command:
|
||||
|
||||
```bash
|
||||
python -m snack_and_lift
|
||||
```
|
||||
|
||||
To keep your desktop free, run it in the Docker container (headless Chromium):
|
||||
|
||||
```bash
|
||||
docker build -t snack-and-lift .
|
||||
docker run --rm snack-and-lift
|
||||
```
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
Coverage of the logic modules is checked with `python -m pytest --cov`. Tests that need a real browser are marked `browser` and run in the Docker container; all other tests need neither a browser nor the network.
|
||||
|
||||
Build the source documentation with Doxygen:
|
||||
|
||||
```bash
|
||||
doxygen Doxyfile
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
GNU Affero General Public License v3.0, see [LICENSE][license].
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- [Repository][repository]
|
||||
- [Documentation][documentation]
|
||||
- [Issue tracker][issues]
|
||||
|
||||
## 📎 Appendix: Project documents
|
||||
|
||||
The documents a newcomer needs, in reading order.
|
||||
|
||||
| Document | What it tells you |
|
||||
| --- | --- |
|
||||
| [AGENTS.md][agents] | The rules AI agents follow in this repository |
|
||||
| [Business Case][BC-001] | Why the project exists, its scope and success criteria |
|
||||
| [Stakeholder Analysis][SA-001] | Who is involved; the stakeholder IDs used for owners and reviewers |
|
||||
| [Project Plan][PP-001] | The gateways and their schedule |
|
||||
| [Milestones][milestones] | One document per gateway: Go/No-Go criteria and tasks |
|
||||
| [Artifact registry][registry] | Where each artifact type lives, the next version, the PO language and domain |
|
||||
| [Domain dictionary][DICT-001] | The PO terms and the IT terms |
|
||||
| [Traceability matrix][TM-001] | How the artifacts link and when each was last reviewed |
|
||||
| [Framework README][framework-readme] | The framework this project uses |
|
||||
| [Framework changelog][framework-changelog] | What changed in each framework release |
|
||||
|
||||
---
|
||||
|
||||
[gym]: https://appbrewery.github.io/gym/
|
||||
[license]: ./LICENSE
|
||||
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift
|
||||
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki
|
||||
[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/issues
|
||||
[agents]: ./AGENTS.md
|
||||
[BC-001]: ./docs/business-case.md
|
||||
[SA-001]: ./docs/stakeholder-analysis.md
|
||||
[PP-001]: ./docs/project-plan.md
|
||||
[milestones]: ./docs/milestones/
|
||||
[registry]: ./docs/artifact-registry.md
|
||||
[DICT-001]: ./docs/dictionary.md
|
||||
[TM-001]: ./docs/sqa/traceability-matrix.md
|
||||
[framework-readme]: ./framework/README.md
|
||||
[framework-changelog]: ./framework/CHANGELOG.md
|
||||
|
||||
@@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 009 |
|
||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 016 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 017 |
|
||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-016 |
|
||||
| CrossReference | [MIL-002], [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-002 | [7fdc11a] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-002]
|
||||
- Checklist used: [QC-PY-001] (`QC-PY-001`)
|
||||
- Scope: full review of the source and tests of MIL-002
|
||||
- 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; the entry point logs 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 | Unit tests cover the constants and the command line; the e2e smoke test is skipped outside the container and passed in it; no test depends on order or the network. |
|
||||
| 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 — the code of MIL-002 meets every Mandatory criterion of QC-PY-001. No blocking finding. 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.
|
||||
|
||||
Gate evidence for MIL-002: `pip install -e .[dev]` and `pytest` pass (6 passed, 1 skipped outside the container); `doxygen Doxyfile` reports no warning; the container image builds and the e2e smoke test passes in it (the home page has no title, so the test reads the login button instead of a page title). `check-readme.sh` reports one finding about the framework's own README (`qc-project-plan.md` is not named); the project README has none. The finding is upstream, outside this project, and is listed in the end-of-run summary.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | S01 | - |
|
||||
|
||||
---
|
||||
|
||||
[MIL-002]: ../../milestones/mil-002-scaffold.md
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[7fdc11a]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/7fdc11a0e7a8d2d1db63676c73c59b8fe999e0b4
|
||||
@@ -36,6 +36,7 @@ Tracks backward and forward links between artifact instances so that the Busines
|
||||
| [MIL-006] | Milestone | en | it | [BC-001], [PP-001] | [MIL-007] | RC-013 |
|
||||
| [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | [MIL-008] | RC-014 |
|
||||
| [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 |
|
||||
|
||||
## Concern Coverage
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=75"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "snack-and-lift-bot"
|
||||
version = "0.1.0"
|
||||
description = "Selenium bot that books classes at the Snack and Lift practice gym (Udemy 100 Days of Code, day 49)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"selenium>=4.25",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"pytest-cov>=6.0",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
snack-and-lift = "snack_and_lift.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-ra --strict-markers"
|
||||
pythonpath = ["src"]
|
||||
markers = [
|
||||
"browser: needs a real browser (run in the Docker container)",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py313"
|
||||
src = ["src", "tests"]
|
||||
extend-exclude = ["framework", "chrome_profile"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "ANN"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["snack_and_lift"]
|
||||
omit = ["*/infrastructure/*", "*/cli.py", "*/__main__.py"]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 80
|
||||
show_missing = true
|
||||
@@ -0,0 +1,3 @@
|
||||
"""@file __init__.py
|
||||
@brief Snack and Lift booking bot: books gym classes with Selenium.
|
||||
"""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""@file __main__.py
|
||||
@brief Entry point for `python -m snack_and_lift`.
|
||||
"""
|
||||
|
||||
from snack_and_lift.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
"""@file __init__.py
|
||||
@brief Use of the rules through ports: login, booking, verification and retry.
|
||||
"""
|
||||
@@ -0,0 +1,39 @@
|
||||
"""@file cli.py
|
||||
@brief Command-line entry point (the composition root of the bot).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
"""@brief Read the command line.
|
||||
|
||||
@param argv Arguments without the program name; None reads `sys.argv`.
|
||||
@return The parsed options.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="snack-and-lift",
|
||||
description="Books the Tuesday and Thursday 6pm classes at the Snack and Lift gym.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--headless",
|
||||
action="store_true",
|
||||
help="run Chrome without a window (the Docker image does this)",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""@brief Start the bot.
|
||||
|
||||
@param argv Arguments without the program name; None reads `sys.argv`.
|
||||
@return Process exit code.
|
||||
"""
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
parse_args(argv)
|
||||
logger.info("snack-and-lift: the lecture steps are added by the next milestones")
|
||||
return 0
|
||||
@@ -0,0 +1,45 @@
|
||||
"""@file constants.py
|
||||
@brief Every constant of the bot: the site address, selectors, timings and messages.
|
||||
|
||||
Selectors of the gym pages are added here by the milestone that first uses them.
|
||||
"""
|
||||
|
||||
from typing import Final
|
||||
|
||||
## Address of the Snack and Lift practice site on GitHub Pages (appbrewery/gym).
|
||||
GYM_URL: Final[str] = "https://appbrewery.github.io/gym/"
|
||||
|
||||
## Folder, below the working directory, where Chrome keeps the profile of the bot.
|
||||
PROFILE_DIR_NAME: Final[str] = "chrome_profile"
|
||||
|
||||
## Longest time to wait for an element of the page, in seconds.
|
||||
WAIT_TIMEOUT_SECONDS: Final[int] = 10
|
||||
|
||||
## Attempts of the `retry` wrapper before it gives up (lecture step 9).
|
||||
RETRY_COUNT: Final[int] = 7
|
||||
|
||||
## Weekdays of the classes the bot books, as the site abbreviates them.
|
||||
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).
|
||||
NAV_LOGIN_BUTTON_ID: Final[str] = "login-button"
|
||||
|
||||
## Folder where screenshots of a container run are written.
|
||||
SCREENSHOT_DIR: Final[str] = "docs/images"
|
||||
|
||||
## Name of the environment variable that holds the path of the browser binary.
|
||||
CHROME_BIN_ENV: Final[str] = "CHROME_BIN"
|
||||
|
||||
## Name of the environment variable that holds the path of the browser driver.
|
||||
CHROMEDRIVER_PATH_ENV: Final[str] = "CHROMEDRIVER_PATH"
|
||||
|
||||
## Window size of the browser, so screenshots look the same on every machine.
|
||||
WINDOW_SIZE: Final[str] = "1280,1600"
|
||||
|
||||
## Chromium arguments that a container needs to run without a display.
|
||||
HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = (
|
||||
"--headless=new",
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""@file __init__.py
|
||||
@brief Booking rules and plain data; imports no Selenium.
|
||||
"""
|
||||
@@ -0,0 +1,3 @@
|
||||
"""@file __init__.py
|
||||
@brief Selenium adapter: the only package that imports Selenium.
|
||||
"""
|
||||
@@ -0,0 +1,33 @@
|
||||
"""@file browser.py
|
||||
@brief Creates the Chrome driver, headless when asked.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
|
||||
from snack_and_lift import constants
|
||||
|
||||
|
||||
def create_driver(headless: 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.
|
||||
@return A running Chrome driver.
|
||||
"""
|
||||
options = Options()
|
||||
options.add_argument(f"--window-size={constants.WINDOW_SIZE}")
|
||||
if headless:
|
||||
for argument in constants.HEADLESS_BROWSER_ARGUMENTS:
|
||||
options.add_argument(argument)
|
||||
binary = os.environ.get(constants.CHROME_BIN_ENV)
|
||||
if binary:
|
||||
options.binary_location = binary
|
||||
driver_path = os.environ.get(constants.CHROMEDRIVER_PATH_ENV)
|
||||
service = Service(executable_path=driver_path) if driver_path else Service()
|
||||
return webdriver.Chrome(service=service, options=options)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""@file test_browser_smoke.py
|
||||
@brief Real-browser smoke test; runs only in the Docker container (SNACK_AND_LIFT_E2E=1).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from selenium.webdriver.common.by import By
|
||||
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.infrastructure.browser import create_driver
|
||||
|
||||
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",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_gym_home_page_shows_the_login_button() -> None:
|
||||
driver = create_driver(headless=True)
|
||||
try:
|
||||
driver.get(constants.GYM_URL)
|
||||
button = WebDriverWait(driver, constants.WAIT_TIMEOUT_SECONDS).until(
|
||||
ec.presence_of_element_located((By.ID, constants.NAV_LOGIN_BUTTON_ID))
|
||||
)
|
||||
assert button.text == "Login"
|
||||
finally:
|
||||
driver.quit()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""@file test_cli.py
|
||||
@brief The command line accepts its options and the entry point returns 0.
|
||||
"""
|
||||
|
||||
from snack_and_lift.cli import main, parse_args
|
||||
|
||||
|
||||
def test_headless_is_off_by_default() -> None:
|
||||
assert parse_args([]).headless is False
|
||||
|
||||
|
||||
def test_headless_flag_is_read() -> None:
|
||||
assert parse_args(["--headless"]).headless is True
|
||||
|
||||
|
||||
def test_main_returns_zero() -> None:
|
||||
assert main([]) == 0
|
||||
@@ -0,0 +1,17 @@
|
||||
"""@file test_constants.py
|
||||
@brief Smoke test: the assignment's values are the ones in constants.py.
|
||||
"""
|
||||
|
||||
from snack_and_lift import constants
|
||||
|
||||
|
||||
def test_gym_address_is_https() -> None:
|
||||
assert constants.GYM_URL.startswith("https://")
|
||||
|
||||
|
||||
def test_retry_count_is_seven() -> None:
|
||||
assert constants.RETRY_COUNT == 7
|
||||
|
||||
|
||||
def test_bot_books_tuesday_and_thursday() -> None:
|
||||
assert constants.TARGET_WEEKDAYS == ("Tue", "Thu")
|
||||
Reference in New Issue
Block a user