MIL-003: Add Docker end to end run and lecture step screenshots #25
@@ -0,0 +1,16 @@
|
||||
# Secrets and local state never go into the image.
|
||||
.env
|
||||
.env.*
|
||||
.venv/
|
||||
.git/
|
||||
.gitmodules
|
||||
framework/
|
||||
results/
|
||||
docs/ai-prompts/
|
||||
docs/doxygen/
|
||||
docs/wiki/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
**/__pycache__/
|
||||
*.egg-info/
|
||||
@@ -0,0 +1,24 @@
|
||||
# End to end run of the Wikipedia scraper in headless Chromium, and the screenshots of the
|
||||
# lecture steps. Build and run: see "Tests" in README.md.
|
||||
FROM python:3.13-slim-bookworm
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
CHROME_BINARY=/usr/bin/chromium \
|
||||
CHROMEDRIVER_PATH=/usr/bin/chromedriver
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends chromium chromium-driver fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash runner
|
||||
WORKDIR /app
|
||||
COPY --chown=runner:runner . /app
|
||||
RUN python -m pip install --upgrade pip \
|
||||
&& python -m pip install ".[dev]"
|
||||
|
||||
RUN mkdir /results && chown runner:runner /results
|
||||
USER runner
|
||||
VOLUME ["/results"]
|
||||
|
||||
CMD ["bash", "tests/e2e/run_e2e.sh", "/results"]
|
||||
@@ -0,0 +1,59 @@
|
||||
# ADR-0002 Article count selector
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | ADR-0002 |
|
||||
| CrossReference | [BC-001], [ADR-0001], [MIL-003] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Accepted | Claude (AI) | AI as stand-in for S01 | Initial version; accepted after a Go review (RC-011) | [085a66e] |
|
||||
|
||||
Allowed Status values: `Proposed`, `Accepted`, `Rejected`, `Deprecated`, `Superseded by ADR-NNNN`.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The lecture finds the article count with the CSS selector `#articlecount a`. The end to end run in Docker on 2026-10-28 showed that the Wikipedia main page now has two list items in the `articlecount` div: the first link is the number of active editors (269,023), the second list item holds the article count (7,256,081). `find_element` returns the first match, so the lecture selector now prints the active editors, not the articles. [BC-001] lists this as a risk and says the selector lives in `constants.py`.
|
||||
|
||||
Options:
|
||||
|
||||
1. Keep the lecture selector `#articlecount a` in the program and document that it prints the active editors.
|
||||
2. Use a selector for the article link, `#articlecount li:nth-of-type(2) a`, in the program, and keep the lecture selector as a constant that the lecture step scripts and screenshots show.
|
||||
3. Use `find_elements` and pick by text.
|
||||
|
||||
Forces: the program is named for the article count (S02, S01 concern CN-001); S02 compares code with the lecture; the end to end run is the check that notices a page change.
|
||||
|
||||
## Decision
|
||||
|
||||
The program uses `ARTICLE_COUNT_SELECTOR = "#articlecount li:nth-of-type(2) a"` and `constants.py` also keeps `LECTURE_SELECTOR = "#articlecount a"`, which the lecture step scripts show. The end to end test asserts the printed number is at least one million, so the active editors count cannot pass for the article count.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- The program prints the article count again and the lecture's selector stays visible for comparison.
|
||||
- The wiki can teach that a page changes and a selector must be checked against the page.
|
||||
- The end to end test notices the same defect next time.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- The program differs from the lecture code in one string.
|
||||
- The selector depends on the order of the list items and breaks if Wikipedia reorders them.
|
||||
- The one-million threshold must be revisited if the count ever falls below it (very unlikely).
|
||||
|
||||
## Affected Artifacts
|
||||
|
||||
- [BC-001] — risk 1 materialised; the mitigation (selector in `constants.py`, end to end run as the check) worked
|
||||
- [ADR-0001] — the flat layout is unchanged; `constants.py` gains one constant
|
||||
- [MIL-003] — the screenshots of steps 5 to 7 show both selectors
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ../business-case.md
|
||||
[ADR-0001]: ./adr-0001-flat-package-layout.md
|
||||
[MIL-003]: ../milestones/mil-003-screenshots-in-docker.md
|
||||
[085a66e]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-scrapping-wiki/commit/085a66eb52299961a02cb57553e5ccf850e9d27f
|
||||
@@ -15,8 +15,8 @@ document of a type. `Primary File` may contain a glob (e.g.
|
||||
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 006 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 011 |
|
||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0002 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 013 |
|
||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0003 |
|
||||
|
||||
## Languages
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1,2 @@
|
||||
# interaction.py
|
||||
# Step 1: create a new Python file named interaction.py.
|
||||
@@ -0,0 +1,4 @@
|
||||
# interaction.py
|
||||
# Step 2: import the Selenium package.
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
@@ -0,0 +1,6 @@
|
||||
# interaction.py
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
# Step 3: initialise the Chrome driver; it controls the browser.
|
||||
driver = webdriver.Chrome()
|
||||
@@ -0,0 +1,8 @@
|
||||
# interaction.py
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
driver = webdriver.Chrome()
|
||||
|
||||
# Step 4: navigate to the main page of Wikipedia with get().
|
||||
driver.get("https://en.wikipedia.org/wiki/Main_Page")
|
||||
@@ -0,0 +1,10 @@
|
||||
# interaction.py
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
driver = webdriver.Chrome()
|
||||
driver.get("https://en.wikipedia.org/wiki/Main_Page")
|
||||
|
||||
# Step 5: identify the element. The article count is the anchor (a)
|
||||
# inside the div whose id is "articlecount", so the CSS selector is:
|
||||
# #articlecount a
|
||||
@@ -0,0 +1,12 @@
|
||||
# interaction.py
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
driver = webdriver.Chrome()
|
||||
driver.get("https://en.wikipedia.org/wiki/Main_Page")
|
||||
|
||||
# Step 6: find the element with find_element() and print its text.
|
||||
article_count = driver.find_element(By.CSS_SELECTOR, "#articlecount a")
|
||||
print(article_count.text)
|
||||
|
||||
driver.quit()
|
||||
@@ -0,0 +1,15 @@
|
||||
# interaction.py
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
driver = webdriver.Chrome()
|
||||
driver.get("https://en.wikipedia.org/wiki/Main_Page")
|
||||
|
||||
article_count = driver.find_element(By.CSS_SELECTOR, "#articlecount a")
|
||||
print(article_count.text)
|
||||
|
||||
# Step 7: the next lectures interact with the page:
|
||||
# article_count.click() # click a link
|
||||
# search = driver.find_element(By.NAME, "search") # type into a search field
|
||||
|
||||
driver.quit()
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 294 KiB |
@@ -0,0 +1,55 @@
|
||||
# RC-011 Review of ADR-0002
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-011 |
|
||||
| CrossReference | [ADR-0002], [QC-ADR-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Accepted | Claude (AI) | AI as stand-in for S01 | Initial version: Go | [085a66e] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [ADR-0002]
|
||||
- Checklist used: [QC-ADR-001]
|
||||
- Scope: full review
|
||||
- Language and domain: n/a (technical type)
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | The ID (`ADR-NNNN`, 4-digit) matches the filename number and is unique and sequential | Pass | ID ADR-0002 matches the file name adr-0002 and follows ADR-0001. |
|
||||
| 2 | Context states the problem, the forces (cost, risk, constraints) and the options evaluated | Pass | Context states the problem (two list items, wrong number), the forces and three options. |
|
||||
| 3 | At least two options were considered, or the absence of alternatives is justified | Pass | Three options were considered. |
|
||||
| 4 | Decision is stated in one or two unhedged sentences and matches one of the evaluated options | Pass | Decision is two unhedged sentences and is option 2. |
|
||||
| 5 | Consequences list both Positive and Negative outcomes | Pass | Three positive and three negative consequences. |
|
||||
| 6 | Quality-attribute impacts (performance, security, portability, etc.) are named where the decision affects them | Pass | Maintainability and reliability impacts are named (order dependence, test threshold). |
|
||||
| 7 | Affected Artifacts are listed as links, and each is also in CrossReference | Pass | Affected Artifacts BC-001, ADR-0001 and MIL-003 are links and are in CrossReference. |
|
||||
| 8 | Status changes are recorded as new Version History rows with a Change summary and Commit link; retained rows are never rewritten | Pass | First version; Version History has one row. |
|
||||
| 9 | A superseded or deprecated ADR names its successor, and the successor links back | Pass | Not superseded or deprecated; passes by absence. |
|
||||
| 10 | Written in business or technical language of the domain, with no unexplained abbreviations | Pass | Plain technical language. |
|
||||
| 11 | Status is one of `Proposed`, `Accepted`, `Rejected`, `Deprecated` or `Superseded by ADR-NNNN`, and every Version History row uses only these | Pass | Status is Accepted, an allowed value. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — Every Mandatory criterion of the checklist passes; no Fail. Stand-in review as `AI as stand-in for S01`, not independent: S01 can challenge it in the end-of-run summary. Evidence for the context: the Docker run of 2026-10-28.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | S01 | n/a |
|
||||
|
||||
---
|
||||
|
||||
[ADR-0002]: ../../adr/adr-0002-article-count-selector.md
|
||||
[QC-ADR-001]: ../../../framework/qc/artifacts/qc-adr.md
|
||||
[085a66e]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-scrapping-wiki/commit/085a66eb52299961a02cb57553e5ccf850e9d27f
|
||||
@@ -0,0 +1,62 @@
|
||||
# RC-012 Code review of MIL-003 (e2e and selector change)
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-012 |
|
||||
| CrossReference | [MIL-003], [QC-PY-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Accepted | Claude (AI) | AI as stand-in for S01 | Initial version: Go | [085a66e] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-003]
|
||||
- Checklist used: [QC-PY-001]
|
||||
- Scope: full review
|
||||
- Language and domain: n/a (technical type)
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (the reviewer is `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, variables and modules; UPPER_SNAKE constants. |
|
||||
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | create_driver, open_main_page, read_article_count state their purpose; no abbreviations or one-letter names. |
|
||||
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | ruff format and ruff check (E, F, I, B, UP) are clean; no noqa suppressions. |
|
||||
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every function, test and fake is annotated, including -> None; mypy --strict is clean. |
|
||||
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No except clause; main uses try/finally so the driver is always quit; errors propagate (tested). |
|
||||
| 6 | No mutable default arguments and no shadowed builtins | Pass | The only default is the immutable `headless: bool = False`; no builtins are shadowed. |
|
||||
| 7 | Files, locks and connections are managed with context managers | Pass | The browser is closed with try/finally and driver.quit(); kept close to the lecture instead of a with block (judgement). |
|
||||
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public functions of src and of tests/e2e/capture_screenshots.py have Doxygen @brief/@param/@return; doxygen Doxyfile builds clean on src. |
|
||||
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | print is the program's console output, which the lecture asks for, not logging; no secrets are handled (judgement). |
|
||||
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No classes and no Design Class Diagram; the test fakes are not production classes (judgement; passes by absence). |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 10 unit tests need no browser, network or order. The e2e test and the capture script use the network by design; they live in tests/e2e, are ignored by the default pytest run and run only in the Docker container (judgement, open to challenge). |
|
||||
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | mypy --strict is clean; the six type: ignore comments mark fakes standing in for WebDriver; no Any. |
|
||||
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | selenium>=4.20 is the only runtime dependency; dev tools are pytest, ruff, mypy; lower bounds rather than exact pins. |
|
||||
| 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 | URL, both selectors, Chrome options and env names are defined once in constants.py; the e2e files import them. |
|
||||
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | interaction imports constants and selenium; constants imports nothing; no cycle. |
|
||||
| 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 and function has one reason to change; no type tests. |
|
||||
| 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 | Flat package by ADR-0001; tests/unit and tests/e2e follow the test folder layout; ADR-0002 records the selector change. |
|
||||
| 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, snake_case, no spaces. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — Every Mandatory criterion of the checklist passes; no Fail. Stand-in review as `AI as stand-in for S01`, not independent: S01 can challenge it in the end-of-run summary. Reviewed: constants.py, interaction.py, tests/unit, tests/e2e. Verified: pytest 10 passed, ruff, mypy strict clean, Docker e2e exit code 0 and 7 screenshots.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | S01 | n/a |
|
||||
|
||||
---
|
||||
|
||||
[MIL-003]: ../../milestones/mil-003-screenshots-in-docker.md
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[085a66e]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-scrapping-wiki/commit/085a66eb52299961a02cb57553e5ccf850e9d27f
|
||||
@@ -5,8 +5,13 @@
|
||||
## @brief The Wikipedia main page that the scraper opens.
|
||||
WIKIPEDIA_MAIN_PAGE_URL: str = "https://en.wikipedia.org/wiki/Main_Page"
|
||||
|
||||
## @brief CSS selector of the anchor inside the div whose id is `articlecount`.
|
||||
ARTICLE_COUNT_SELECTOR: str = "#articlecount a"
|
||||
## @brief The CSS selector shown in the lecture. It matched the article count when the lecture
|
||||
# was recorded; today it matches the first link, the active editors.
|
||||
LECTURE_SELECTOR: str = "#articlecount a"
|
||||
|
||||
## @brief CSS selector of the article count link: the first anchor of the second list item
|
||||
# inside the div whose id is `articlecount`.
|
||||
ARTICLE_COUNT_SELECTOR: str = "#articlecount li:nth-of-type(2) a"
|
||||
|
||||
## @brief Chrome option that runs the browser without a window.
|
||||
HEADLESS_ARGUMENT: str = "--headless=new"
|
||||
|
||||
@@ -56,10 +56,11 @@ def open_main_page(driver: WebDriver) -> None:
|
||||
|
||||
|
||||
def read_article_count(driver: WebDriver) -> str:
|
||||
"""@brief Find the article count link with a CSS selector and return its text.
|
||||
"""@brief Find the article count link (first link of the second list item) and return its text.
|
||||
|
||||
@param driver A driver that has the Wikipedia main page open.
|
||||
@return The text of the anchor inside the `articlecount` div, for example `7,000,000`.
|
||||
@return The text of the first link of the second list item in the `articlecount` div,
|
||||
for example `7,000,000`.
|
||||
"""
|
||||
article_count = driver.find_element(By.CSS_SELECTOR, ARTICLE_COUNT_SELECTOR)
|
||||
return article_count.text
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
"""@file
|
||||
@brief Capture one screenshot per lecture step with headless Chromium.
|
||||
|
||||
Usage: `python tests/e2e/capture_screenshots.py <output_dir>`. Runs only inside the Docker
|
||||
container (see tests/e2e/run_e2e.sh), never on the host desktop.
|
||||
"""
|
||||
|
||||
import html
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
from scraping_wiki.constants import (
|
||||
ARTICLE_COUNT_SELECTOR,
|
||||
LECTURE_SELECTOR,
|
||||
WIKIPEDIA_MAIN_PAGE_URL,
|
||||
)
|
||||
from scraping_wiki.interaction import create_driver, open_main_page, read_article_count
|
||||
|
||||
## @brief Folder with the lecture step scripts, relative to the repository root.
|
||||
LECTURE_STEPS_FOLDER: Path = Path("docs") / "lecture-steps"
|
||||
|
||||
## @brief The repository root, two folders above this file (tests/e2e/).
|
||||
REPOSITORY_ROOT: Path = Path(__file__).resolve().parents[2]
|
||||
|
||||
## @brief Step number, file name prefix of the lecture script, screenshot name and title.
|
||||
STEPS: dict[int, tuple[str, str, str]] = {
|
||||
1: ("step_1_", "step-1-create-the-file.png", "Create the file"),
|
||||
2: ("step_2_", "step-2-import-selenium.png", "Import Selenium"),
|
||||
3: ("step_3_", "step-3-initialise-chrome-driver.png", "Initialise the Chrome driver"),
|
||||
4: ("step_4_", "step-4-navigate-with-get.png", "Navigate with get()"),
|
||||
5: ("step_5_", "step-5-identify-the-element.png", "Identify the element"),
|
||||
6: ("step_6_", "step-6-find-element-and-print.png", "Find the element and print its text"),
|
||||
7: ("step_7_", "step-7-future-interactions.png", "Future interactions"),
|
||||
}
|
||||
|
||||
## @brief Title of the editor window in the rendered source pages.
|
||||
EDITOR_FILE_NAME: str = "interaction.py"
|
||||
|
||||
## @brief The command shown in the terminal panel of step 6.
|
||||
TERMINAL_COMMAND: str = "$ python interaction.py"
|
||||
|
||||
## @brief Pattern the article count text must match.
|
||||
ARTICLE_COUNT_PATTERN: str = r"^[\d,]+$"
|
||||
|
||||
## @brief CSS selector of the Wikipedia search field, and its fallback.
|
||||
SEARCH_SELECTOR: str = 'input[name="search"]'
|
||||
SEARCH_FALLBACK_SELECTOR: str = 'input[type="search"]'
|
||||
|
||||
## @brief Banner texts of the steps that annotate the real Wikipedia page.
|
||||
STEP_4_BANNER: str = f'Step 4: driver.get("{WIKIPEDIA_MAIN_PAGE_URL}")'
|
||||
STEP_5_BANNER: str = (
|
||||
f"Step 5: {LECTURE_SELECTOR} (red). "
|
||||
f"Today the articles link is the 2nd: {ARTICLE_COUNT_SELECTOR} (green)"
|
||||
)
|
||||
STEP_7_BANNER: str = "Step 7: next lectures: click links and type into search fields"
|
||||
|
||||
## @brief Style of the highlighted elements: the lecture selector red, the article link green.
|
||||
LECTURE_HIGHLIGHT_COLOR: str = "red"
|
||||
ARTICLE_HIGHLIGHT_COLOR: str = "green"
|
||||
HIGHLIGHT_WIDTH: str = "4px solid "
|
||||
HIGHLIGHT_BACKGROUND: str = "lightyellow"
|
||||
|
||||
## @brief Second line of the terminal panel in step 6, before the real article count.
|
||||
TODAYS_COUNT_PREFIX: str = "# today's article count: "
|
||||
|
||||
## @brief Style of the banner at the bottom of the Wikipedia page; long text wraps.
|
||||
BANNER_STYLE: str = (
|
||||
"position:fixed;bottom:0;left:0;right:0;white-space:normal;word-wrap:break-word;"
|
||||
"z-index:2147483647;padding:12px 20px;"
|
||||
"background:#1e1e1e;color:#fff;font:bold 20px 'DejaVu Sans Mono',monospace;"
|
||||
)
|
||||
|
||||
## @brief Style sheet shared by the rendered source pages.
|
||||
PAGE_STYLE: str = """
|
||||
body { margin: 0; background: #1e1e1e; color: #d4d4d4;
|
||||
font: 20px/1.5 'DejaVu Sans Mono', monospace; }
|
||||
.title-bar { background: #333; color: #fff; padding: 8px 16px; font-size: 18px; }
|
||||
h1 { margin: 16px; font-size: 28px; color: #4ec9b0; }
|
||||
.layout { display: flex; }
|
||||
.editor { flex: 1; padding: 0 16px; }
|
||||
.terminal { flex: 1; margin: 0 16px; padding: 16px; background: #000; color: #0f0;
|
||||
min-height: 200px; }
|
||||
.line { white-space: pre; }
|
||||
.number { display: inline-block; width: 3em; color: #858585; text-align: right;
|
||||
margin-right: 1.5em; }
|
||||
"""
|
||||
|
||||
## @brief Seconds to wait until the images in the first view are loaded.
|
||||
IMAGE_WAIT_SECONDS: int = 15
|
||||
|
||||
## @brief Where a scrolled element lands in the window.
|
||||
SCROLL_ELEMENT_BLOCK: str = "center"
|
||||
|
||||
|
||||
def find_step_source(step_number: int) -> str:
|
||||
"""@brief Read the lecture script of one step.
|
||||
|
||||
@param step_number The step, 1 to 7.
|
||||
@return The source text of docs/lecture-steps/step_N_*.py.
|
||||
@raises FileNotFoundError When the script of the step does not exist.
|
||||
"""
|
||||
prefix = STEPS[step_number][0]
|
||||
folder = REPOSITORY_ROOT / LECTURE_STEPS_FOLDER
|
||||
matches = sorted(folder.glob(f"{prefix}*.py"))
|
||||
if not matches:
|
||||
raise FileNotFoundError(f"No lecture script {prefix}*.py in {folder}")
|
||||
return matches[0].read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def render_source_lines(source: str) -> str:
|
||||
"""@brief Render source text as numbered, HTML-escaped lines.
|
||||
|
||||
@param source The source text.
|
||||
@return HTML of the lines, each with its line number.
|
||||
"""
|
||||
lines = source.rstrip("\n").split("\n")
|
||||
return "".join(
|
||||
f'<div class="line"><span class="number">{number}</span>{html.escape(line)}</div>'
|
||||
for number, line in enumerate(lines, start=1)
|
||||
)
|
||||
|
||||
|
||||
def render_page(step_number: int, source: str, terminal_text: str | None = None) -> str:
|
||||
"""@brief Render the source of a step as an editor-style HTML page.
|
||||
|
||||
@param step_number The step, 1 to 7.
|
||||
@param source The source text of the step.
|
||||
@param terminal_text When given, a terminal panel with this text is shown on the right.
|
||||
@return The HTML page.
|
||||
"""
|
||||
title = STEPS[step_number][2]
|
||||
terminal = ""
|
||||
if terminal_text is not None:
|
||||
terminal_lines = "<br>".join(html.escape(line) for line in terminal_text.split("\n"))
|
||||
terminal = f'<div class="terminal">{terminal_lines}</div>'
|
||||
return (
|
||||
f"<!DOCTYPE html><html><head><meta charset='utf-8'><style>{PAGE_STYLE}</style></head>"
|
||||
f"<body><div class='title-bar'>{html.escape(EDITOR_FILE_NAME)}</div>"
|
||||
f"<h1>Step {step_number}: {html.escape(title)}</h1>"
|
||||
f"<div class='layout'><div class='editor'>{render_source_lines(source)}</div>"
|
||||
f"{terminal}</div></body></html>"
|
||||
)
|
||||
|
||||
|
||||
def screenshot_html(driver: WebDriver, page: str, target: Path) -> None:
|
||||
"""@brief Load an HTML page from a temporary file and save a screenshot of it.
|
||||
|
||||
@param driver The driver that controls the browser.
|
||||
@param page The HTML page.
|
||||
@param target The PNG file to write.
|
||||
"""
|
||||
with TemporaryDirectory() as folder:
|
||||
page_file = Path(folder) / "page.html"
|
||||
page_file.write_text(page, encoding="utf-8", newline="\n")
|
||||
driver.get(page_file.as_uri())
|
||||
save_screenshot(driver, target)
|
||||
|
||||
|
||||
def save_screenshot(driver: WebDriver, target: Path) -> None:
|
||||
"""@brief Save the current view of the browser as a PNG and report it.
|
||||
|
||||
@param driver The driver that controls the browser.
|
||||
@param target The PNG file to write.
|
||||
@raises RuntimeError When the browser cannot save the file.
|
||||
"""
|
||||
if not driver.save_screenshot(str(target)):
|
||||
raise RuntimeError(f"Could not save screenshot {target}")
|
||||
print(f"saved {target}")
|
||||
|
||||
|
||||
def add_banner(driver: WebDriver, text: str) -> None:
|
||||
"""@brief Show a fixed banner at the bottom of the current page.
|
||||
|
||||
@param driver The driver that controls the browser.
|
||||
@param text The banner text.
|
||||
"""
|
||||
driver.execute_script(
|
||||
"const b = document.createElement('div'); b.textContent = arguments[0];"
|
||||
"b.style.cssText = arguments[1]; document.body.appendChild(b);",
|
||||
text,
|
||||
BANNER_STYLE,
|
||||
)
|
||||
|
||||
|
||||
def highlight(driver: WebDriver, element: WebElement, color: str) -> None:
|
||||
"""@brief Outline an element in a colour on a light yellow background.
|
||||
|
||||
@param driver The driver that controls the browser.
|
||||
@param element The element to highlight.
|
||||
@param color The outline colour.
|
||||
"""
|
||||
driver.execute_script(
|
||||
"arguments[0].style.outline = arguments[1]; arguments[0].style.background = arguments[2];",
|
||||
element,
|
||||
HIGHLIGHT_WIDTH + color,
|
||||
HIGHLIGHT_BACKGROUND,
|
||||
)
|
||||
|
||||
|
||||
def scroll_to_center(driver: WebDriver, element: WebElement) -> None:
|
||||
"""@brief Scroll an element to the middle of the window, clear of the banner.
|
||||
|
||||
@param driver The driver that controls the browser.
|
||||
@param element The element to show.
|
||||
"""
|
||||
driver.execute_script(
|
||||
"arguments[0].scrollIntoView({block: arguments[1]});", element, SCROLL_ELEMENT_BLOCK
|
||||
)
|
||||
|
||||
|
||||
def find_required(driver: WebDriver, selector: str) -> WebElement:
|
||||
"""@brief Find an element by CSS selector.
|
||||
|
||||
@param driver The driver that controls the browser.
|
||||
@param selector The CSS selector.
|
||||
@return The element.
|
||||
@raises LookupError When the selector finds nothing.
|
||||
"""
|
||||
elements = driver.find_elements(By.CSS_SELECTOR, selector)
|
||||
if not elements:
|
||||
raise LookupError(f"Selector {selector!r} found nothing on {driver.current_url}")
|
||||
return elements[0]
|
||||
|
||||
|
||||
def find_search_field(driver: WebDriver) -> WebElement:
|
||||
"""@brief Find the Wikipedia search input, with a fallback to any search input.
|
||||
|
||||
@param driver The driver that controls the browser.
|
||||
@return The search input.
|
||||
@raises LookupError When neither selector finds an element.
|
||||
"""
|
||||
for selector in (SEARCH_SELECTOR, SEARCH_FALLBACK_SELECTOR):
|
||||
elements = driver.find_elements(By.CSS_SELECTOR, selector)
|
||||
if elements:
|
||||
return elements[0]
|
||||
raise LookupError(f"Neither {SEARCH_SELECTOR!r} nor {SEARCH_FALLBACK_SELECTOR!r} found")
|
||||
|
||||
|
||||
def read_checked_article_count(driver: WebDriver) -> str:
|
||||
"""@brief Read the article count and check that it looks like a number.
|
||||
|
||||
@param driver A driver that has the Wikipedia main page open.
|
||||
@return The article count text.
|
||||
@raises LookupError When the text does not match the number pattern.
|
||||
"""
|
||||
find_required(driver, ARTICLE_COUNT_SELECTOR)
|
||||
count = read_article_count(driver)
|
||||
if not re.fullmatch(ARTICLE_COUNT_PATTERN, count):
|
||||
raise LookupError(f"Article count {count!r} does not match {ARTICLE_COUNT_PATTERN!r}")
|
||||
return count
|
||||
|
||||
|
||||
def open_loaded_main_page(driver: WebDriver) -> None:
|
||||
"""@brief Open the main page and wait until the images in the first view are loaded.
|
||||
|
||||
@param driver The driver that controls the browser.
|
||||
@raises selenium.common.exceptions.TimeoutException When the images do not load in time.
|
||||
"""
|
||||
open_main_page(driver)
|
||||
WebDriverWait(driver, IMAGE_WAIT_SECONDS).until(
|
||||
lambda d: d.execute_script(
|
||||
"return Array.from(document.images)"
|
||||
".filter(i => i.getBoundingClientRect().top < window.innerHeight)"
|
||||
".every(i => i.complete);"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def capture_source_steps(driver: WebDriver, output: Path) -> None:
|
||||
"""@brief Capture steps 1 to 3: the source of the step in an editor-style page.
|
||||
|
||||
@param driver The driver that controls the browser.
|
||||
@param output The folder for the PNG files.
|
||||
"""
|
||||
for step_number in (1, 2, 3):
|
||||
page = render_page(step_number, find_step_source(step_number))
|
||||
screenshot_html(driver, page, output / STEPS[step_number][1])
|
||||
|
||||
|
||||
def capture_page_steps(driver: WebDriver, output: Path) -> None:
|
||||
"""@brief Capture steps 4, 5 and 7: the real Wikipedia page with a banner.
|
||||
|
||||
@param driver The driver that controls the browser.
|
||||
@param output The folder for the PNG files.
|
||||
"""
|
||||
open_loaded_main_page(driver)
|
||||
add_banner(driver, STEP_4_BANNER)
|
||||
save_screenshot(driver, output / STEPS[4][1])
|
||||
|
||||
open_loaded_main_page(driver)
|
||||
lecture_match = find_required(driver, LECTURE_SELECTOR)
|
||||
article_count = find_required(driver, ARTICLE_COUNT_SELECTOR)
|
||||
scroll_to_center(driver, article_count)
|
||||
highlight(driver, lecture_match, LECTURE_HIGHLIGHT_COLOR)
|
||||
highlight(driver, article_count, ARTICLE_HIGHLIGHT_COLOR)
|
||||
add_banner(driver, STEP_5_BANNER)
|
||||
save_screenshot(driver, output / STEPS[5][1])
|
||||
|
||||
open_loaded_main_page(driver)
|
||||
article_count = find_required(driver, ARTICLE_COUNT_SELECTOR)
|
||||
search = find_search_field(driver)
|
||||
# The input's own outline is clipped by its wrapper, so outline the wrapper.
|
||||
highlight(driver, search.find_element(By.XPATH, ".."), LECTURE_HIGHLIGHT_COLOR)
|
||||
highlight(driver, article_count, ARTICLE_HIGHLIGHT_COLOR)
|
||||
driver.execute_script("window.scrollTo(0, 0);")
|
||||
add_banner(driver, STEP_7_BANNER)
|
||||
save_screenshot(driver, output / STEPS[7][1])
|
||||
|
||||
|
||||
def capture_print_step(driver: WebDriver, output: Path) -> None:
|
||||
"""@brief Capture step 6: the source and a terminal with the real article count.
|
||||
|
||||
@param driver The driver that controls the browser.
|
||||
@param output The folder for the PNG files.
|
||||
"""
|
||||
open_main_page(driver)
|
||||
count = read_checked_article_count(driver)
|
||||
lecture_text = find_required(driver, LECTURE_SELECTOR).text
|
||||
terminal_text = f"{TERMINAL_COMMAND}\n{lecture_text}\n{TODAYS_COUNT_PREFIX}{count}"
|
||||
page = render_page(6, find_step_source(6), terminal_text)
|
||||
screenshot_html(driver, page, output / STEPS[6][1])
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""@brief Capture the seven screenshots into a folder.
|
||||
|
||||
@param argv Command line arguments: the output folder.
|
||||
@return The exit code, 0 when all seven files were saved, 2 for a usage error.
|
||||
"""
|
||||
arguments = list(sys.argv[1:] if argv is None else argv)
|
||||
if len(arguments) != 1:
|
||||
print("usage: capture_screenshots.py <output_dir>", file=sys.stderr)
|
||||
return 2
|
||||
output = Path(arguments[0])
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
driver = create_driver(headless=True)
|
||||
try:
|
||||
capture_source_steps(driver, output)
|
||||
capture_page_steps(driver, output)
|
||||
capture_print_step(driver, output)
|
||||
finally:
|
||||
driver.quit()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# End to end run, inside the Docker container: run the e2e test, then capture the
|
||||
# screenshots of the seven lecture steps. Results are files in the folder given as $1:
|
||||
# e2e.log the combined output
|
||||
# exit-code.txt 0 when both parts passed
|
||||
# screenshots/ one PNG per lecture step
|
||||
set -uo pipefail
|
||||
|
||||
results="${1:-/results}"
|
||||
mkdir -p "$results/screenshots"
|
||||
status=0
|
||||
|
||||
{
|
||||
python -m pytest tests/e2e -p no:cacheprovider --override-ini="addopts=" -q || status=$?
|
||||
python tests/e2e/capture_screenshots.py "$results/screenshots" || status=$?
|
||||
} >"$results/e2e.log" 2>&1
|
||||
|
||||
cat "$results/e2e.log"
|
||||
echo "$status" >"$results/exit-code.txt"
|
||||
exit "$status"
|
||||
@@ -0,0 +1,33 @@
|
||||
"""@file
|
||||
@brief End to end test of the lecture script: real Chromium, real network.
|
||||
|
||||
It runs only inside the Docker container (see tests/e2e/run_e2e.sh), never on the host desktop.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
## @brief Seconds the script may take before the test fails.
|
||||
SCRIPT_TIMEOUT_SECONDS: int = 120
|
||||
|
||||
## @brief The article count is in the millions; the active editors count is not.
|
||||
MINIMUM_ARTICLE_COUNT: int = 1_000_000
|
||||
|
||||
## @brief One line with a thousands-separated number, for example `7,123,456`.
|
||||
ARTICLE_COUNT_LINE_PATTERN: str = r"^\d{1,3}(,\d{3})+\n$"
|
||||
|
||||
|
||||
def test_interaction_prints_article_count_when_run_headless() -> None:
|
||||
"""@brief The script exits with 0 and prints the article count as one line."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "scraping_wiki.interaction", "--headless"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=SCRIPT_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert re.fullmatch(ARTICLE_COUNT_LINE_PATTERN, result.stdout), repr(result.stdout)
|
||||
assert int(result.stdout.strip().replace(",", "")) >= MINIMUM_ARTICLE_COUNT, result.stdout
|
||||
@@ -7,5 +7,9 @@ def test_url_is_the_wikipedia_main_page() -> None:
|
||||
assert constants.WIKIPEDIA_MAIN_PAGE_URL == "https://en.wikipedia.org/wiki/Main_Page"
|
||||
|
||||
|
||||
def test_selector_targets_the_anchor_in_the_article_count_div() -> None:
|
||||
assert constants.ARTICLE_COUNT_SELECTOR == "#articlecount a"
|
||||
def test_lecture_selector_targets_the_first_anchor_in_the_article_count_div() -> None:
|
||||
assert constants.LECTURE_SELECTOR == "#articlecount a"
|
||||
|
||||
|
||||
def test_article_count_selector_targets_the_link_of_the_second_list_item() -> None:
|
||||
assert constants.ARTICLE_COUNT_SELECTOR == "#articlecount li:nth-of-type(2) a"
|
||||
|
||||