MIL-002 Form automation #22
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.venv
|
||||
.env
|
||||
.env.*
|
||||
framework
|
||||
docs
|
||||
results
|
||||
**/__pycache__
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
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 |
@@ -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) | pending |
|
||||
|
||||
---
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Vendored
+15
@@ -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>
|
||||
@@ -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",))
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user