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
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| 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 |
@@ -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) | [e764269] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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 |
|
||||||
@@ -75,3 +76,4 @@ Branch `mil-002-form-automation` with the `fill_out_form` package, a runnable en
|
|||||||
[BC-001]: ../business-case.md
|
[BC-001]: ../business-case.md
|
||||||
[MIL-001]: ./mil-001-project-setup.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
|
[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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 (`<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
|
||||||
|
[e764269]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-fill-out-form/commit/e764269eda7ccba732556b4c59bd8778483a6991
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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