MIL-002 Form automation #22

Merged
Tirsvad merged 2 commits from mil-002-form-automation into main 2026-10-28 09:22:50 +01:00
31 changed files with 1032 additions and 10 deletions
Showing only changes of commit e764269eda - Show all commits
+11
View File
@@ -0,0 +1,11 @@
.git
.venv
.env
.env.*
framework
docs
results
**/__pycache__
.pytest_cache
.ruff_cache
.mypy_cache
+2 -4
View File
@@ -10,8 +10,8 @@ CREATE_SUBDIRS = NO
OUTPUT_LANGUAGE = English OUTPUT_LANGUAGE = English
# Python sources # Python sources
INPUT = src README.md INPUT = src
FILE_PATTERNS = *.py *.md FILE_PATTERNS = *.py
RECURSIVE = YES RECURSIVE = YES
EXCLUDE_PATTERNS = */__pycache__/* EXCLUDE_PATTERNS = */__pycache__/*
OPTIMIZE_OUTPUT_JAVA = YES OPTIMIZE_OUTPUT_JAVA = YES
@@ -20,7 +20,6 @@ EXTRACT_PRIVATE = NO
EXTRACT_STATIC = YES EXTRACT_STATIC = YES
AUTOLINK_SUPPORT = YES AUTOLINK_SUPPORT = YES
MARKDOWN_SUPPORT = YES MARKDOWN_SUPPORT = YES
USE_MDFILE_AS_MAINPAGE = README.md
PYTHON_DOCSTRING = YES PYTHON_DOCSTRING = YES
# Output formats # Output formats
@@ -31,7 +30,6 @@ GENERATE_XML = NO
# Diagrams are not needed and Graphviz is not required # Diagrams are not needed and Graphviz is not required
HAVE_DOT = NO HAVE_DOT = NO
CLASS_DIAGRAMS = NO
# Messages # Messages
QUIET = YES QUIET = YES
+1 -1
View File
@@ -75,7 +75,7 @@ Run the three lecture steps in a visible Chrome window:
python -m fill_out_form 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: Run the steps in Docker (headless, screenshots in `results/`), which never touches your desktop:
+21
View File
@@ -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"]
+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 | 004 | | MIL | Milestone / Gateway | docs/milestones/*.md | 004 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0002 | | 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 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
## Languages ## Languages
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+4 -3
View File
@@ -11,7 +11,8 @@
## Version History ## Version History
| Date | Status | Author | Reviewer | Change | Commit | | 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) | pending |
--- ---
@@ -21,14 +22,14 @@ Decide whether the three lecture steps (click a link by its text, search and pre
## Deliverable ## 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 ## Go / No-Go Criteria
| # | Criterion (objectively checkable) | Go | No-Go | | # | Criterion (objectively checkable) | Go | No-Go |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 1 | `pytest` exits 0 on the host without a browser | Exit 0 | Non-zero | | 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 | | 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 | | 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 | | 5 | The code review record against `QC-PY-001` is `Go` | Go | No-Go |
+61
View File
@@ -0,0 +1,61 @@
# 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 | pending |
---
## 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
@@ -0,0 +1,66 @@
# 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 | pending |
---
## 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 (`<name>.<language>.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
+3 -1
View File
@@ -27,7 +27,7 @@ updated whenever an artifact instance is created or reviewed.
| [SA-001] | SA | en | it | [BC-001] | [PP-001] | [RC-002] | | [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] | | [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-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] | | [MIL-003] | MIL | en | it | [MIL-002] | - | [RC-006] |
| [ADR-0001] | ADR | - | - | [BC-001], [MIL-001] | - | [RC-007] | | [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-007]: ./reviews/rc-007-adr-0001.md
[RC-008]: ./reviews/rc-008-code-mil-001.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 [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
+12
View File
@@ -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())
+70
View File
@@ -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.
"""
...
@@ -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)
+76
View File
@@ -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()
+41
View File
@@ -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,
)
@@ -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)
+71
View File
@@ -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
View File
+26
View File
@@ -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()
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>App Brewery Newsletter</title>
</head>
<body>
<form action="#" method="post">
<input type="text" name="fName" placeholder="First Name">
<input type="text" name="lName" placeholder="Last Name">
<input type="email" name="email" placeholder="Email">
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign Up</button>
</form>
</body>
</html>
View File
+38
View File
@@ -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",))
+111
View File
@@ -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
+64
View File
@@ -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"
+21
View File
@@ -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]
+20
View File
@@ -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
+86
View File
@@ -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 == []
+95
View File
@@ -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