diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..998b33a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.venv +.env +.env.* +framework +docs +results +**/__pycache__ +.pytest_cache +.ruff_cache +.mypy_cache diff --git a/Doxyfile b/Doxyfile index 98c3f1f..145a82c 100644 --- a/Doxyfile +++ b/Doxyfile @@ -10,8 +10,8 @@ CREATE_SUBDIRS = NO OUTPUT_LANGUAGE = English # Python sources -INPUT = src README.md -FILE_PATTERNS = *.py *.md +INPUT = src +FILE_PATTERNS = *.py RECURSIVE = YES EXCLUDE_PATTERNS = */__pycache__/* OPTIMIZE_OUTPUT_JAVA = YES @@ -20,7 +20,6 @@ EXTRACT_PRIVATE = NO EXTRACT_STATIC = YES AUTOLINK_SUPPORT = YES MARKDOWN_SUPPORT = YES -USE_MDFILE_AS_MAINPAGE = README.md PYTHON_DOCSTRING = YES # Output formats @@ -31,7 +30,6 @@ GENERATE_XML = NO # Diagrams are not needed and Graphviz is not required HAVE_DOT = NO -CLASS_DIAGRAMS = NO # Messages QUIET = YES diff --git a/README.md b/README.md index 3c4c289..174deea 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Run the three lecture steps in a visible Chrome window: python -m fill_out_form ``` -Useful options: `--headless` (no window), `--screenshots DIR` (save one picture per step) and `--form-url URL` (use another form page). See `python -m fill_out_form --help`. +Useful options: `--headless` (no window), `--screenshots DIR` (save one picture per step), `--form-url URL` (use another form page) and `--first-name`, `--last-name`, `--email` (the values typed into the form; the demo page rejects the default `example.com` address, so use your own to see the success page). See `python -m fill_out_form --help`. Run the steps in Docker (headless, screenshots in `results/`), which never touches your desktop: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..b9ebee8 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,21 @@ +# Headless Chromium for the Selenium lecture steps. Runs without a desktop. +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + CHROME_BIN=/usr/bin/chromium \ + RESULTS_DIR=/results + +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 +COPY tests ./tests +RUN python -m pip install --upgrade pip \ + && python -m pip install ".[dev]" + +VOLUME ["/results"] +CMD ["python", "-m", "fill_out_form", "--headless", "--screenshots", "/results"] diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 76ea6e8..c3af67d 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.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 | 004 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0002 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 009 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 011 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | ## Languages diff --git a/docs/images/step-1-click-link.png b/docs/images/step-1-click-link.png new file mode 100644 index 0000000..cdb3eef Binary files /dev/null and b/docs/images/step-1-click-link.png differ diff --git a/docs/images/step-2-search.png b/docs/images/step-2-search.png new file mode 100644 index 0000000..7242adb Binary files /dev/null and b/docs/images/step-2-search.png differ diff --git a/docs/images/step-3-form-filled.png b/docs/images/step-3-form-filled.png new file mode 100644 index 0000000..436f486 Binary files /dev/null and b/docs/images/step-3-form-filled.png differ diff --git a/docs/images/step-3-form-signed-up.png b/docs/images/step-3-form-signed-up.png new file mode 100644 index 0000000..f4b12a1 Binary files /dev/null and b/docs/images/step-3-form-signed-up.png differ diff --git a/docs/milestones/mil-002-form-automation.md b/docs/milestones/mil-002-form-automation.md index e2c4abd..8ff9269 100644 --- a/docs/milestones/mil-002-form-automation.md +++ b/docs/milestones/mil-002-form-automation.md @@ -11,7 +11,8 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [e1a3d8d] | +| 2026-10-28 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [e1a3d8d] | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Go criterion 2 and deliverable: four screenshots, step 3 before and after Sign Up (RC-010) | [e764269] | --- @@ -21,14 +22,14 @@ Decide whether the three lecture steps (click a link by its text, search and pre ## Deliverable -Branch `mil-002-form-automation` with the `fill_out_form` package, a runnable entry point, pytest tests, a local form fixture, and one screenshot per lecture step in `docs/images/`, captured in Docker. +Branch `mil-002-form-automation` with the `fill_out_form` package, a runnable entry point, pytest tests, a local form fixture, and one screenshot per lecture step (four PNG files, step 3 before and after Sign Up) in `docs/images/`, captured in Docker. ## Go / No-Go Criteria | # | Criterion (objectively checkable) | Go | No-Go | | --- | --- | --- | --- | | 1 | `pytest` exits 0 on the host without a browser | Exit 0 | Non-zero | -| 2 | The Docker end-to-end run exits 0 and writes three screenshots | Exit 0 and three PNG files | Otherwise | +| 2 | The Docker end-to-end run exits 0 and writes one screenshot per lecture step (step 3 before and after Sign Up) | Exit 0 and four PNG files | Otherwise | | 3 | Every selector, URL and text is defined in `constants.py` | No literal selector or URL elsewhere under `src/` | Any found | | 4 | Public functions carry Doxygen comments | All public functions | Any missing | | 5 | The code review record against `QC-PY-001` is `Go` | Go | No-Go | @@ -75,3 +76,4 @@ Branch `mil-002-form-automation` with the `fill_out_form` package, a runnable en [BC-001]: ../business-case.md [MIL-001]: ./mil-001-project-setup.md [e1a3d8d]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-fill-out-form/commit/e1a3d8d304d4b6ece1a3f95ee02b597e30a97520 +[e764269]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-fill-out-form/commit/e764269eda7ccba732556b4c59bd8778483a6991 diff --git a/docs/sqa/reviews/rc-009-code-mil-002.md b/docs/sqa/reviews/rc-009-code-mil-002.md new file mode 100644 index 0000000..56c3886 --- /dev/null +++ b/docs/sqa/reviews/rc-009-code-mil-002.md @@ -0,0 +1,62 @@ +# SQA Review Record: code of MIL-002 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-009 | +| 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 S01 | Initial version | [e764269] | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-002] +- Checklist used: [QC-PY-001] +- Scope: full review +- Language and domain: n/a (technical type) +- Language reviewer: none +- Mode: autonomous, not independent (reviewer: AI as stand-in for S01) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | snake_case functions, PascalCase classes and Protocols, UPPER_SNAKE constants. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`click_link_by_text`, `search_for`, `fill_out_form`, `run_lecture`). | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check .` and `ruff format --check .` pass; no suppressions. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy --strict` passes on 25 files; every signature is annotated. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare except; the CLI uses try/finally so the browser is always quit. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults (`form_data: FormData | None = None`); no shadowed builtins. | +| 7 | Files, locks and connections are managed with context managers | Pass | No files are opened; screenshots go through the driver, directories through pathlib. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Every public module, class and function has a Doxygen docstring; `doxygen Doxyfile` runs without warnings. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | `logging` is used, no `print`; only the test email address is logged. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | N-A: no Design Class Diagram exists; the tasks are plain technical tasks. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 30 offline unit tests with fakes, named for behaviour; the e2e test is deselected by default and passed in Docker (1 passed). | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` has no errors and `Any` is not used. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `selenium>=4.25,<5` is the only runtime dependency and is used. | +| 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 | URLs, locators, texts and file names are defined once in `constants.py`; a grep finds none elsewhere under `src/`. | +| 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; selenium appears only in `infrastructure` (and the structural check in tests). | +| 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 | Each module has one reason to change; steps depend on narrow Protocols (`ElementPort`, `BrowserPort`). | +| 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 | Source in `domain`, `application`, `infrastructure`, `presentation`; tests in `unit`, `e2e`, `fixtures`. | +| 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 names are English with allowed characters. | + +## Overall Verdict + +Go — every Mandatory criterion passes; no blocking finding. Files: src/fill_out_form and tests/ at commit of this milestone. Observation: the live demo form rejects the default example.com address; documented in the README and wiki. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None | S01 | n/a | + +--- + +[MIL-002]: ../../milestones/mil-002-form-automation.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[e764269]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-fill-out-form/commit/e764269eda7ccba732556b4c59bd8778483a6991 diff --git a/docs/sqa/reviews/rc-010-mil-002-form-automation.md b/docs/sqa/reviews/rc-010-mil-002-form-automation.md new file mode 100644 index 0000000..d61835a --- /dev/null +++ b/docs/sqa/reviews/rc-010-mil-002-form-automation.md @@ -0,0 +1,67 @@ +# SQA Review Record: MIL-002 re-review + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-010 | +| CrossReference | [MIL-002], [QC-MIL-001], [QC-LANG-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [e764269] | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-002] +- Checklist used: [QC-MIL-001] +- Scope: full review (re-review after the wording change of criterion 2; earlier record [RC-005]) +- Language and domain: en / it +- Language reviewer: none (the reviewer reads English and knows the IT domain) +- Mode: autonomous, not independent (reviewer: AI as stand-in for S01) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | A concrete deliverable is defined for every gate | Pass | The deliverable names the branch, the code, the tests and four screenshots. | +| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Go/No-Go criterion 2 now says four PNG files, one per step with step 3 before and after Sign Up; all criteria checkable. | +| 3 | Dependencies on other milestones are explicitly mapped | Pass | Depends on MIL-001. | +| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Traceable to BC-001 objectives 1 and 2. | +| 5 | Milestone owner and approving reviewer are identified | Pass | Owner and approving reviewer are S01. | +| 6 | Milestone has a defined target date consistent with project constraints | Pass | Target date 2026-11-01 inside the window. | + +## Language and Domain Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Metadata has `Language` = en and `Domain` = it. | +| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | `en` is BCP 47; `it` is in the registry domain list. | +| 3 | The content (prose and table cells) is written in the stated language | Pass | All prose and table cells are English. | +| 4 | The register matches the one the registry gives for the artifact type | Pass | IT Executive English for BC, PP and MIL; IT Professional English for SA, as the registry states. | +| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | No `DICT` exists yet; only standard IT terms are used, with no synonyms for one concept. | +| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Keys, headings, IDs and statuses are English. | +| 7 | No translated twin (`..md`) exists beside the document | Pass | No translated twin exists beside the document. | +| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | N-A | First version: no earlier accepted version, so no language change. | +| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | The reviewer (AI as stand-in for S01) reads English and knows the IT domain; not independent. | +| 10 | Abbreviations are spelled out on first use, in the stated language | Pass | Abbreviations (ADR, PO, CI, FURPS+) are known to the audience or spelled out. | + +## Overall Verdict + +Go — every Mandatory criterion passes; no blocking finding. Re-review after the wording change of criterion 2 (earlier record RC-005). + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None | S01 | n/a | + +--- + +[MIL-002]: ../../milestones/mil-002-form-automation.md +[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md +[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md +[RC-005]: ./rc-005-mil-002-form-automation.md +[e764269]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-fill-out-form/commit/e764269eda7ccba732556b4c59bd8778483a6991 diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index d94bb37..440c67e 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -27,7 +27,7 @@ updated whenever an artifact instance is created or reviewed. | [SA-001] | SA | en | it | [BC-001] | [PP-001] | [RC-002] | | [PP-001] | PP | en | it | [BC-001], [SA-001] | [MIL-001], [MIL-002], [MIL-003] | [RC-003] | | [MIL-001] | MIL | en | it | [PP-001] | [MIL-002], [ADR-0001] | [RC-004], [RC-008] (code) | -| [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-005] | +| [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-010], [RC-009] (code) | | [MIL-003] | MIL | en | it | [MIL-002] | - | [RC-006] | | [ADR-0001] | ADR | - | - | [BC-001], [MIL-001] | - | [RC-007] | @@ -71,3 +71,5 @@ updated whenever an artifact instance is created or reviewed. [RC-007]: ./reviews/rc-007-adr-0001.md [RC-008]: ./reviews/rc-008-code-mil-001.md [e1a3d8d]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-fill-out-form/commit/e1a3d8d304d4b6ece1a3f95ee02b597e30a97520 +[RC-009]: ./reviews/rc-009-code-mil-002.md +[RC-010]: ./reviews/rc-010-mil-002-form-automation.md diff --git a/src/fill_out_form/__main__.py b/src/fill_out_form/__main__.py new file mode 100644 index 0000000..e9f65e2 --- /dev/null +++ b/src/fill_out_form/__main__.py @@ -0,0 +1,12 @@ +"""Module entry point. + +@file __main__.py +@brief Run the lecture with `python -m fill_out_form`. +""" + +import sys + +from fill_out_form.presentation.cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/fill_out_form/application/ports.py b/src/fill_out_form/application/ports.py new file mode 100644 index 0000000..5b6e2a8 --- /dev/null +++ b/src/fill_out_form/application/ports.py @@ -0,0 +1,70 @@ +"""Ports the use cases need from a browser. + +@file ports.py +@brief Narrow structural interfaces for a web element and a browser. +""" + +from typing import Protocol + + +class ElementPort(Protocol): + """Describe the web element operations the steps use. + + @brief A clickable, typeable element. + """ + + def click(self) -> None: + """Click the element. + + @brief Click the element. + """ + ... + + def send_keys(self, *value: str) -> None: + """Type text or keys into the element. + + @brief Type the given values into the element. + @param value The text or special keys to type. + """ + ... + + +class BrowserPort(Protocol): + """Describe the browser operations the steps use. + + @brief A browser that opens pages, finds elements and takes screenshots. + """ + + def get(self, url: str) -> None: + """Open a page. + + @brief Navigate to the url. + @param url The address to open. + """ + ... + + def find_element(self, by: str, value: str) -> ElementPort: + """Find one element. + + @brief Find the first element matching the locator. + @param by The locator strategy. + @param value The locator value. + @return The element found. + """ + ... + + def save_screenshot(self, filename: str) -> bool: + """Save a screenshot of the page. + + @brief Write a PNG screenshot to a file. + @param filename The path of the PNG file. + @return True when the file was written. + """ + ... + + def quit(self) -> None: + """Close the browser. + + @brief Quit the browser and end the session. + """ + ... diff --git a/src/fill_out_form/application/run_lecture.py b/src/fill_out_form/application/run_lecture.py new file mode 100644 index 0000000..28b0737 --- /dev/null +++ b/src/fill_out_form/application/run_lecture.py @@ -0,0 +1,83 @@ +"""The whole lecture as one use case. + +@file run_lecture.py +@brief Run the three lecture steps in order, with optional screenshots. +""" + +import logging +import time +from collections.abc import Callable +from pathlib import Path + +from fill_out_form.application.ports import BrowserPort +from fill_out_form.application.steps import ( + click_link_by_text, + fill_out_form, + search_for, +) +from fill_out_form.constants import ( + FORM_URL, + PAUSE_SECONDS, + SCREENSHOT_STEP_1, + SCREENSHOT_STEP_2, + SCREENSHOT_STEP_3_FILLED, + SCREENSHOT_STEP_3_SIGNED_UP, + WIKIPEDIA_URL, +) +from fill_out_form.domain.form_data import FormData + +logger = logging.getLogger(__name__) + + +def _screenshot(browser: BrowserPort, screenshot_dir: Path | None, name: str) -> None: + """Save a screenshot when a directory is given.""" + if screenshot_dir is None: + return + path = screenshot_dir / name + logger.info("Saving screenshot %s", path) + if not browser.save_screenshot(str(path)): + logger.warning("Screenshot %s was not written", path) + + +def run_lecture( + browser: BrowserPort, + form_url: str = FORM_URL, + screenshot_dir: Path | None = None, + pause: Callable[[float], None] = time.sleep, + form_data: FormData | None = None, +) -> None: + """Run the three lecture steps in order. + + @brief Click a link, search, then fill out the form, with screenshots. + @param browser The browser to drive. + @param form_url The address of the sign-up form. + @param screenshot_dir Directory for one screenshot per step; none when omitted. + @param pause Called with the seconds to wait so a viewer can see the result. + @param form_data The values to type into the form; the defaults when omitted. + """ + if screenshot_dir is not None: + screenshot_dir.mkdir(parents=True, exist_ok=True) + + logger.info("Step 1: click a link by its text") + browser.get(WIKIPEDIA_URL) + click_link_by_text(browser) + pause(PAUSE_SECONDS) + _screenshot(browser, screenshot_dir, SCREENSHOT_STEP_1) + + logger.info("Step 2: search") + browser.get(WIKIPEDIA_URL) + search_for(browser) + pause(PAUSE_SECONDS) + _screenshot(browser, screenshot_dir, SCREENSHOT_STEP_2) + + logger.info("Step 3: fill out the form") + browser.get(form_url) + fill_out_form( + browser, + form_data, + before_submit=lambda: _screenshot( + browser, screenshot_dir, SCREENSHOT_STEP_3_FILLED + ), + ) + pause(PAUSE_SECONDS) + _screenshot(browser, screenshot_dir, SCREENSHOT_STEP_3_SIGNED_UP) diff --git a/src/fill_out_form/application/steps.py b/src/fill_out_form/application/steps.py new file mode 100644 index 0000000..cf4260d --- /dev/null +++ b/src/fill_out_form/application/steps.py @@ -0,0 +1,76 @@ +"""The three lecture steps. + +@file steps.py +@brief Click a link by its text, search for a term, fill out the form. +""" + +import logging +from collections.abc import Callable + +from fill_out_form.application.ports import BrowserPort +from fill_out_form.constants import ( + BY_CSS_SELECTOR, + BY_LINK_TEXT, + BY_NAME, + EMAIL_SELECTOR, + FIRST_NAME_SELECTOR, + KEY_ENTER, + LAST_NAME_SELECTOR, + LINK_TEXT_CONTENT_PORTALS, + SEARCH_INPUT_NAME, + SEARCH_TERM, + SIGN_UP_BUTTON_SELECTOR, +) +from fill_out_form.domain.form_data import FormData, default_form_data + +logger = logging.getLogger(__name__) + + +def click_link_by_text( + browser: BrowserPort, link_text: str = LINK_TEXT_CONTENT_PORTALS +) -> None: + """Click the link with the given text. + + @brief Find a link by its visible text and click it. + @param browser The browser showing the page. + @param link_text The visible text of the link. + """ + logger.info("Clicking link %r", link_text) + browser.find_element(BY_LINK_TEXT, link_text).click() + + +def search_for(browser: BrowserPort, term: str = SEARCH_TERM) -> None: + """Type a term into the search input and press Enter. + + @brief Search the page for a term. + @param browser The browser showing the page. + @param term The text to search for. + """ + logger.info("Searching for %r", term) + search_input = browser.find_element(BY_NAME, SEARCH_INPUT_NAME) + search_input.send_keys(term) + search_input.send_keys(KEY_ENTER) + + +def fill_out_form( + browser: BrowserPort, + form_data: FormData | None = None, + before_submit: Callable[[], None] | None = None, +) -> None: + """Fill out the sign-up form and submit it. + + @brief Type the three values, run the hook, then click the sign-up button. + @param browser The browser showing the form. + @param form_data The values to type; the defaults when omitted. + @param before_submit A hook called after typing and before the click. + """ + data = form_data if form_data is not None else default_form_data() + logger.info("Filling out the form for %s", data.email) + browser.find_element(BY_CSS_SELECTOR, FIRST_NAME_SELECTOR).send_keys( + data.first_name + ) + browser.find_element(BY_CSS_SELECTOR, LAST_NAME_SELECTOR).send_keys(data.last_name) + browser.find_element(BY_CSS_SELECTOR, EMAIL_SELECTOR).send_keys(data.email) + if before_submit is not None: + before_submit() + browser.find_element(BY_CSS_SELECTOR, SIGN_UP_BUTTON_SELECTOR).click() diff --git a/src/fill_out_form/domain/form_data.py b/src/fill_out_form/domain/form_data.py new file mode 100644 index 0000000..a7d2219 --- /dev/null +++ b/src/fill_out_form/domain/form_data.py @@ -0,0 +1,41 @@ +"""Values typed into the sign-up form. + +@file form_data.py +@brief The form values as an immutable value object, and their defaults. +""" + +from dataclasses import dataclass + +from fill_out_form.constants import ( + DEFAULT_EMAIL, + DEFAULT_FIRST_NAME, + DEFAULT_LAST_NAME, +) + + +@dataclass(frozen=True, slots=True) +class FormData: + """Hold the three values the sign-up form asks for. + + @brief Immutable first name, last name and email of the sign-up form. + @param first_name The first name to type. + @param last_name The last name to type. + @param email The email address to type. + """ + + first_name: str + last_name: str + email: str + + +def default_form_data() -> FormData: + """Return the form values defined in the constants. + + @brief Build a FormData from the DEFAULT_* constants. + @return The default form data. + """ + return FormData( + first_name=DEFAULT_FIRST_NAME, + last_name=DEFAULT_LAST_NAME, + email=DEFAULT_EMAIL, + ) diff --git a/src/fill_out_form/infrastructure/driver.py b/src/fill_out_form/infrastructure/driver.py new file mode 100644 index 0000000..ebd6180 --- /dev/null +++ b/src/fill_out_form/infrastructure/driver.py @@ -0,0 +1,33 @@ +"""Chrome driver factory. + +@file driver.py +@brief Build a Selenium Chrome driver, headless when requested. +""" + +import os + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.remote.webdriver import WebDriver + +from fill_out_form.constants import CHROME_BINARY_ENV, HEADLESS_ARGUMENTS, WINDOW_SIZE + + +def create_driver(headless: bool = False) -> WebDriver: + """Create a Chrome driver. + + @brief Build a Chrome WebDriver from the options the lecture needs. + @param headless Run without a window, for a container without a desktop. + @return The Chrome WebDriver. + """ + options = Options() + if headless: + for argument in HEADLESS_ARGUMENTS: + options.add_argument(argument) + options.add_argument(f"--window-size={WINDOW_SIZE}") + else: + options.add_experimental_option("detach", False) + binary = os.environ.get(CHROME_BINARY_ENV) + if binary: + options.binary_location = binary + return webdriver.Chrome(options=options) diff --git a/src/fill_out_form/presentation/cli.py b/src/fill_out_form/presentation/cli.py new file mode 100644 index 0000000..219f965 --- /dev/null +++ b/src/fill_out_form/presentation/cli.py @@ -0,0 +1,71 @@ +"""Command line entry point. + +@file cli.py +@brief Parse the arguments, build the driver and run the lecture. +""" + +import argparse +import logging +from collections.abc import Sequence +from pathlib import Path + +from fill_out_form.application.run_lecture import run_lecture +from fill_out_form.constants import FORM_URL +from fill_out_form.domain.form_data import FormData, default_form_data +from fill_out_form.infrastructure.driver import create_driver + + +def build_parser() -> argparse.ArgumentParser: + """Build the argument parser. + + @brief Define the command line options. + @return The configured parser. + """ + parser = argparse.ArgumentParser( + prog="fill-out-form", + description="Run the Day 48 Selenium lecture steps.", + ) + parser.add_argument( + "--headless", action="store_true", help="run Chrome without a window" + ) + parser.add_argument( + "--screenshots", + type=Path, + metavar="DIR", + default=None, + help="save one screenshot per step in DIR", + ) + parser.add_argument( + "--form-url", default=FORM_URL, help="address of the sign-up form" + ) + defaults = default_form_data() + parser.add_argument( + "--first-name", default=defaults.first_name, help="first name to type" + ) + parser.add_argument( + "--last-name", default=defaults.last_name, help="last name to type" + ) + parser.add_argument("--email", default=defaults.email, help="email to type") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the lecture from the command line. + + @brief Create the driver, run the lecture and always quit the browser. + @param argv The arguments; the process arguments when omitted. + @return The process exit code. + """ + args = build_parser().parse_args(argv) + logging.basicConfig(level=logging.INFO) + browser = create_driver(headless=args.headless) + try: + run_lecture( + browser, + form_url=args.form_url, + screenshot_dir=args.screenshots, + form_data=FormData(args.first_name, args.last_name, args.email), + ) + finally: + browser.quit() + return 0 diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/test_lecture_e2e.py b/tests/e2e/test_lecture_e2e.py new file mode 100644 index 0000000..dafe1d9 --- /dev/null +++ b/tests/e2e/test_lecture_e2e.py @@ -0,0 +1,26 @@ +import os +from pathlib import Path + +import pytest + +from fill_out_form import constants +from fill_out_form.application.run_lecture import run_lecture +from fill_out_form.infrastructure.driver import create_driver + + +@pytest.mark.e2e +def test_lecture_runs_against_real_sites() -> None: + results = Path(os.environ.get("RESULTS_DIR", "results")) + browser = create_driver(headless=True) + try: + run_lecture(browser, screenshot_dir=results) + finally: + browser.quit() + + for name in ( + constants.SCREENSHOT_STEP_1, + constants.SCREENSHOT_STEP_2, + constants.SCREENSHOT_STEP_3_FILLED, + constants.SCREENSHOT_STEP_3_SIGNED_UP, + ): + assert (results / name).is_file() diff --git a/tests/fixtures/signup_form.html b/tests/fixtures/signup_form.html new file mode 100644 index 0000000..9e40400 --- /dev/null +++ b/tests/fixtures/signup_form.html @@ -0,0 +1,15 @@ + + + + + App Brewery Newsletter + + +
+ + + + +
+ + diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py new file mode 100644 index 0000000..6d8f33f --- /dev/null +++ b/tests/unit/fakes.py @@ -0,0 +1,38 @@ +from typing import Any + + +class FakeElement: + """Records the interactions with one element in a shared event list.""" + + def __init__(self, events: list[tuple[Any, ...]], locator: tuple[str, str]) -> None: + self._events = events + self.locator = locator + + def click(self) -> None: + self._events.append(("click", self.locator)) + + def send_keys(self, *value: str) -> None: + self._events.append(("send_keys", self.locator, value)) + + +class FakeBrowser: + """Records every call in order, without any browser.""" + + def __init__(self) -> None: + self.events: list[tuple[Any, ...]] = [] + self.screenshots: list[str] = [] + + def get(self, url: str) -> None: + self.events.append(("get", url)) + + def find_element(self, by: str, value: str) -> FakeElement: + self.events.append(("find_element", by, value)) + return FakeElement(self.events, (by, value)) + + def save_screenshot(self, filename: str) -> bool: + self.screenshots.append(filename) + self.events.append(("save_screenshot", filename)) + return True + + def quit(self) -> None: + self.events.append(("quit",)) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..ebee54e --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,111 @@ +from pathlib import Path +from typing import Any + +import pytest + +from fill_out_form import constants +from fill_out_form.application.ports import BrowserPort +from fill_out_form.domain.form_data import FormData, default_form_data +from fill_out_form.presentation import cli +from tests.unit.fakes import FakeBrowser + + +def test_parser_defaults() -> None: + args = cli.build_parser().parse_args([]) + + assert args.headless is False + assert args.screenshots is None + assert args.form_url == constants.FORM_URL + + +def test_parser_reads_all_options() -> None: + args = cli.build_parser().parse_args( + ["--headless", "--screenshots", "out", "--form-url", "http://x/"] + ) + + assert args.headless is True + assert args.screenshots == Path("out") + assert args.form_url == "http://x/" + + +def test_main_runs_lecture_and_quits(monkeypatch: pytest.MonkeyPatch) -> None: + browser = FakeBrowser() + created: list[bool] = [] + calls: list[dict[str, Any]] = [] + + def fake_create(headless: bool = False) -> FakeBrowser: + created.append(headless) + return browser + + def fake_run(b: BrowserPort, **kwargs: Any) -> None: + calls.append({"browser": b, **kwargs}) + + monkeypatch.setattr(cli, "create_driver", fake_create) + monkeypatch.setattr(cli, "run_lecture", fake_run) + + code = cli.main(["--headless", "--screenshots", "shots"]) + + assert code == 0 + assert created == [True] + assert calls == [ + { + "browser": browser, + "form_url": constants.FORM_URL, + "screenshot_dir": Path("shots"), + "form_data": default_form_data(), + } + ] + assert ("quit",) in browser.events + + +def test_parser_form_data_defaults_come_from_constants() -> None: + args = cli.build_parser().parse_args([]) + + assert FormData(args.first_name, args.last_name, args.email) == ( + default_form_data() + ) + + +def test_parser_reads_form_data_options() -> None: + args = cli.build_parser().parse_args( + ["--first-name", "Ada", "--last-name", "Lovelace", "--email", "a@example.test"] + ) + + assert (args.first_name, args.last_name, args.email) == ( + "Ada", + "Lovelace", + "a@example.test", + ) + + +def test_main_forwards_form_data_to_run_lecture( + monkeypatch: pytest.MonkeyPatch, +) -> None: + received: list[FormData | None] = [] + + def fake_run(b: BrowserPort, **kwargs: Any) -> None: + received.append(kwargs["form_data"]) + + monkeypatch.setattr(cli, "create_driver", lambda headless=False: FakeBrowser()) + monkeypatch.setattr(cli, "run_lecture", fake_run) + + cli.main(["--first-name", "Ada", "--last-name", "L", "--email", "a@example.test"]) + + assert received == [FormData("Ada", "L", "a@example.test")] + + +def test_main_quits_browser_when_lecture_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + browser = FakeBrowser() + + def failing_run(b: BrowserPort, **kwargs: Any) -> None: + raise RuntimeError("boom") + + monkeypatch.setattr(cli, "create_driver", lambda headless=False: browser) + monkeypatch.setattr(cli, "run_lecture", failing_run) + + with pytest.raises(RuntimeError, match="boom"): + cli.main([]) + + assert ("quit",) in browser.events diff --git a/tests/unit/test_fixture_html.py b/tests/unit/test_fixture_html.py new file mode 100644 index 0000000..911c6ce --- /dev/null +++ b/tests/unit/test_fixture_html.py @@ -0,0 +1,64 @@ +from html.parser import HTMLParser +from pathlib import Path + +from fill_out_form import constants + +FIXTURE = Path(__file__).parent.parent / "fixtures" / "signup_form.html" + + +class _Collector(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.inputs: list[dict[str, str | None]] = [] + self.in_form = False + self.button_in_form = False + self.in_title = False + self.title = "" + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag == "form": + self.in_form = True + elif tag == "input": + self.inputs.append(dict(attrs)) + elif tag == "button" and self.in_form: + self.button_in_form = True + elif tag == "title": + self.in_title = True + + def handle_endtag(self, tag: str) -> None: + if tag == "title": + self.in_title = False + + def handle_data(self, data: str) -> None: + if self.in_title: + self.title += data + + +def _parse() -> _Collector: + collector = _Collector() + collector.feed(FIXTURE.read_text(encoding="utf-8")) + return collector + + +def _name_from_selector(selector: str) -> str: + return selector.split("='")[1].rstrip("']") + + +def test_fixture_inputs_match_constants_selectors() -> None: + names = {i.get("name") for i in _parse().inputs} + + for selector in ( + constants.FIRST_NAME_SELECTOR, + constants.LAST_NAME_SELECTOR, + constants.EMAIL_SELECTOR, + ): + assert _name_from_selector(selector) in names + + +def test_fixture_has_button_inside_form() -> None: + assert constants.SIGN_UP_BUTTON_SELECTOR == "form button" + assert _parse().button_in_form + + +def test_fixture_title() -> None: + assert _parse().title == "App Brewery Newsletter" diff --git a/tests/unit/test_form_data.py b/tests/unit/test_form_data.py new file mode 100644 index 0000000..b8a0929 --- /dev/null +++ b/tests/unit/test_form_data.py @@ -0,0 +1,21 @@ +import dataclasses + +import pytest + +from fill_out_form import constants +from fill_out_form.domain.form_data import FormData, default_form_data + + +def test_default_form_data_uses_constants() -> None: + data = default_form_data() + + assert data.first_name == constants.DEFAULT_FIRST_NAME + assert data.last_name == constants.DEFAULT_LAST_NAME + assert data.email == constants.DEFAULT_EMAIL + + +def test_form_data_is_immutable() -> None: + data = FormData("A", "B", "a@b.c") + + with pytest.raises(dataclasses.FrozenInstanceError): + data.email = "x" # type: ignore[misc] diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py new file mode 100644 index 0000000..61932f5 --- /dev/null +++ b/tests/unit/test_ports.py @@ -0,0 +1,20 @@ +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.remote.webelement import WebElement + +from fill_out_form.application.ports import BrowserPort, ElementPort + + +def _accepts_browser(browser: BrowserPort) -> BrowserPort: + return browser + + +def _accepts_element(element: ElementPort) -> ElementPort: + return element + + +def test_selenium_types_satisfy_ports_structurally() -> None: + # mypy --strict checks the structural match through these signatures. + assert callable(_accepts_browser) + assert callable(_accepts_element) + assert WebDriver.find_element is not None + assert WebElement.click is not None diff --git a/tests/unit/test_run_lecture.py b/tests/unit/test_run_lecture.py new file mode 100644 index 0000000..1b2ba94 --- /dev/null +++ b/tests/unit/test_run_lecture.py @@ -0,0 +1,86 @@ +from pathlib import Path + +from fill_out_form import constants +from fill_out_form.application.run_lecture import run_lecture +from fill_out_form.domain.form_data import FormData +from tests.unit.fakes import FakeBrowser + + +def test_run_lecture_visits_pages_in_order() -> None: + browser = FakeBrowser() + + run_lecture(browser, form_url="https://form.test/", pause=lambda _: None) + + urls = [e[1] for e in browser.events if e[0] == "get"] + assert urls == [ + constants.WIKIPEDIA_URL, + constants.WIKIPEDIA_URL, + "https://form.test/", + ] + + +def test_run_lecture_pauses_after_each_step() -> None: + browser = FakeBrowser() + pauses: list[float] = [] + + run_lecture(browser, pause=pauses.append) + + assert pauses == [constants.PAUSE_SECONDS] * 3 + + +def test_run_lecture_saves_screenshots_in_step_order(tmp_path: Path) -> None: + browser = FakeBrowser() + target = tmp_path / "nested" / "results" + + run_lecture(browser, screenshot_dir=target, pause=lambda _: None) + + assert target.is_dir() + assert browser.screenshots == [ + str(target / name) + for name in ( + constants.SCREENSHOT_STEP_1, + constants.SCREENSHOT_STEP_2, + constants.SCREENSHOT_STEP_3_FILLED, + constants.SCREENSHOT_STEP_3_SIGNED_UP, + ) + ] + + +def test_run_lecture_filled_screenshot_is_taken_before_sign_up_click( + tmp_path: Path, +) -> None: + browser = FakeBrowser() + + run_lecture(browser, screenshot_dir=tmp_path, pause=lambda _: None) + + names = [e[0] for e in browser.events] + filled = browser.events.index( + ("save_screenshot", str(tmp_path / constants.SCREENSHOT_STEP_3_FILLED)) + ) + click = names.index("click", filled) + assert names[filled] == "save_screenshot" + assert browser.events[click][1] == ( + constants.BY_CSS_SELECTOR, + constants.SIGN_UP_BUTTON_SELECTOR, + ) + + +def test_run_lecture_types_given_form_data() -> None: + browser = FakeBrowser() + + run_lecture( + browser, + pause=lambda _: None, + form_data=FormData("Ada", "Lovelace", "ada@example.test"), + ) + + typed = [e[2][0] for e in browser.events if e[0] == "send_keys"][-3:] + assert typed == ["Ada", "Lovelace", "ada@example.test"] + + +def test_run_lecture_without_directory_takes_no_screenshots() -> None: + browser = FakeBrowser() + + run_lecture(browser, pause=lambda _: None) + + assert browser.screenshots == [] diff --git a/tests/unit/test_steps.py b/tests/unit/test_steps.py new file mode 100644 index 0000000..02a6880 --- /dev/null +++ b/tests/unit/test_steps.py @@ -0,0 +1,95 @@ +from fill_out_form import constants +from fill_out_form.application.ports import BrowserPort +from fill_out_form.application.steps import ( + click_link_by_text, + fill_out_form, + search_for, +) +from fill_out_form.domain.form_data import FormData +from tests.unit.fakes import FakeBrowser + + +def test_click_link_by_text_clicks_default_link() -> None: + browser = FakeBrowser() + + click_link_by_text(browser) + + link = (constants.BY_LINK_TEXT, constants.LINK_TEXT_CONTENT_PORTALS) + assert browser.events == [("find_element", *link), ("click", link)] + + +def test_click_link_by_text_uses_given_text() -> None: + browser = FakeBrowser() + + click_link_by_text(browser, "Other") + + assert browser.events[0] == ("find_element", constants.BY_LINK_TEXT, "Other") + + +def test_search_for_types_term_then_enter() -> None: + browser = FakeBrowser() + + search_for(browser, "Selenium") + + locator = (constants.BY_NAME, constants.SEARCH_INPUT_NAME) + assert browser.events == [ + ("find_element", *locator), + ("send_keys", locator, ("Selenium",)), + ("send_keys", locator, (constants.KEY_ENTER,)), + ] + + +def test_search_for_uses_default_term() -> None: + browser = FakeBrowser() + + search_for(browser) + + assert ( + "send_keys", + (constants.BY_NAME, constants.SEARCH_INPUT_NAME), + (constants.SEARCH_TERM,), + ) in browser.events + + +def test_fill_out_form_types_fields_then_clicks_in_order() -> None: + browser = FakeBrowser() + data = FormData("Ada", "Lovelace", "ada@example.com") + + fill_out_form(browser, data) + + css = constants.BY_CSS_SELECTOR + actions = [e for e in browser.events if e[0] in ("send_keys", "click")] + assert actions == [ + ("send_keys", (css, constants.FIRST_NAME_SELECTOR), ("Ada",)), + ("send_keys", (css, constants.LAST_NAME_SELECTOR), ("Lovelace",)), + ("send_keys", (css, constants.EMAIL_SELECTOR), ("ada@example.com",)), + ("click", (css, constants.SIGN_UP_BUTTON_SELECTOR)), + ] + + +def test_fill_out_form_uses_default_data() -> None: + browser = FakeBrowser() + + fill_out_form(browser) + + typed = [e[2][0] for e in browser.events if e[0] == "send_keys"] + assert typed == [ + constants.DEFAULT_FIRST_NAME, + constants.DEFAULT_LAST_NAME, + constants.DEFAULT_EMAIL, + ] + + +def test_fill_out_form_calls_before_submit_before_click() -> None: + browser = FakeBrowser() + + fill_out_form(browser, before_submit=lambda: browser.events.append(("hook",))) + + names = [e[0] for e in browser.events if e[0] in ("send_keys", "hook", "click")] + assert names == ["send_keys"] * 3 + ["hook", "click"] + + +def test_fake_browser_satisfies_browser_port() -> None: + browser: BrowserPort = FakeBrowser() + + assert browser is not None